0.29.1原版
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
# ActivityCalendar: Honor `timeBasis` (Create vs Update)
|
||||
|
||||
## Problem
|
||||
|
||||
The ActivityCalendar in `web/src/components/ActivityCalendar/` always aggregates by memo creation time, regardless of how the surrounding memo list is sorted.
|
||||
|
||||
The application already supports a global "time basis" toggle (`web/src/contexts/ViewContext.tsx:3`):
|
||||
|
||||
```ts
|
||||
export type MemoTimeBasis = "create_time" | "update_time";
|
||||
```
|
||||
|
||||
The toggle is persisted in `localStorage` and drives memo list ordering across the app. When a user switches the list to `update_time`, the heatmap below it continues to show creation counts — the two views literally disagree about what "today" means.
|
||||
|
||||
This is the user-visible bug we are fixing.
|
||||
|
||||
## Non-goals
|
||||
|
||||
The following were considered and explicitly excluded:
|
||||
|
||||
- **Tracking every individual edit event.** This would require resurrecting the `activity` table that was deliberately dropped in migration `0.27/03__drop_activity.sql`. The cost (write-path instrumentation, storage growth, privacy review) is not justified by this UI bug.
|
||||
- **Tracking archive / restore / delete events.** Housekeeping actions, not contributions; would also leak private behavior on public Profile/Explore pages.
|
||||
- **Adding comments or reactions to the heatmap count.** A reasonable separate feature, but a different decision (event-type expansion). Out of scope for this spec — one ticket, one problem.
|
||||
- **Renaming `ActivityCalendar` to `ContributionCalendar`.** Out of scope.
|
||||
|
||||
## Design
|
||||
|
||||
### Semantics
|
||||
|
||||
The heatmap aggregates one timestamp per memo:
|
||||
|
||||
- When `timeBasis === "create_time"`: use `memo.created_ts` (current behavior).
|
||||
- When `timeBasis === "update_time"`: use `memo.updated_ts`.
|
||||
|
||||
Each memo contributes exactly one cell of color, on the day of its chosen timestamp. This matches the list view's semantics exactly: in `update_time` mode, a memo edited on 5/1 and again on 5/2 appears once at 5/2 in the list, and the heatmap will show +1 on 5/2 and nothing on 5/1. The "lossiness" is identical to the lossiness already accepted by the list view — so by definition, the two are consistent.
|
||||
|
||||
### Backend
|
||||
|
||||
`UserStats` (proto/api/v1/user_service.proto) gains one field:
|
||||
|
||||
```proto
|
||||
// The latest update timestamps of the user's memos.
|
||||
repeated google.protobuf.Timestamp memo_updated_timestamps = 8;
|
||||
```
|
||||
|
||||
The implementation mirrors `memo_created_timestamps` in `server/router/api/v1/user_service_stats.go`: in the same loop that appends `memo.CreatedTs` (line 115), also append `memo.UpdatedTs` to a parallel slice. The same `FindMemo` filters apply automatically: `RowStatus: NORMAL` (archived excluded), `ExcludeComments: true`, and the viewer-based visibility filter. Both `GetUserStats` and `ListAllUserStats` paths must be updated symmetrically.
|
||||
|
||||
No DB migration. No new tables. No new write paths.
|
||||
|
||||
### Frontend
|
||||
|
||||
`web/src/hooks/useFilteredMemoStats.ts` reads `useView().timeBasis` and switches its data source:
|
||||
|
||||
- `create_time` → `userStats.memoCreatedTimestamps` (today's behavior, untouched)
|
||||
- `update_time` → `userStats.memoUpdatedTimestamps`
|
||||
|
||||
The `explore` context branch (which derives stats from the in-memory memo list rather than `userStats`) applies the same switch using `memo.createTime` vs `memo.updateTime` from the cached memos.
|
||||
|
||||
The `MonthCalendar` / `YearCalendar` components themselves require no changes — they receive an opaque `Record<date, count>` and render it. The change is confined to the data-source layer.
|
||||
|
||||
### Tooltip / labeling
|
||||
|
||||
A small but necessary clarification for the user: the cell tooltip should reflect which basis is active, e.g.
|
||||
|
||||
- `create_time` mode: "3 memos on May 2"
|
||||
- `update_time` mode: "3 memos updated on May 2"
|
||||
|
||||
This belongs in `ActivityCalendar/utils.ts:getTooltipText`, which already takes a `t` translator. Add a `timeBasis` argument and pick the right i18n key.
|
||||
|
||||
## Components
|
||||
|
||||
| Unit | Responsibility | Depends on |
|
||||
|---|---|---|
|
||||
| `UserStats` proto | Carry both timestamp arrays | — |
|
||||
| `GetUserStats` server impl | Populate both arrays from `memo` table | store |
|
||||
| `useFilteredMemoStats` | Pick the correct array based on `timeBasis`; aggregate by day | `useView`, `useUserStats`, `useMemos` |
|
||||
| `getTooltipText` | Render basis-aware tooltip | i18n |
|
||||
| `MonthCalendar` / `YearCalendar` | Unchanged — render `Record<date, count>` | — |
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
ViewContext.timeBasis ──┐
|
||||
▼
|
||||
useFilteredMemoStats ── pick array ── countBy(day) ── Record<date,count> ── MonthCalendar
|
||||
▲
|
||||
userStats ───────────┤ (memo_created_timestamps OR memo_updated_timestamps)
|
||||
│
|
||||
memos cache ─────────┘ (createTime OR updateTime — explore context only)
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
No new failure modes.
|
||||
|
||||
`protobuf-es` generates `repeated` fields as non-optional `T[]`, so an older server that doesn't populate the new field deserializes it as `[]` (never `undefined`). Naïvely treating empty as "no data" would be wrong, because a user with zero memos also gets `[]`. Detection uses **length divergence**: since `memo.updated_ts` is initialized to `created_ts` at row creation, the two arrays are the same length whenever there are any memos. So:
|
||||
|
||||
- `created.length === 0 && updated.length === 0` — user has no memos, render empty.
|
||||
- `created.length > 0 && updated.length === created.length` — new server, normal path.
|
||||
- `created.length > 0 && updated.length === 0` — old server, fall back to `memoCreatedTimestamps` regardless of `timeBasis`, with a one-line `console.warn`.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit test `useFilteredMemoStats`: given a fixed `userStats`, switching `timeBasis` returns aggregations matching the expected source array.
|
||||
- Unit test the new `getTooltipText` branch.
|
||||
- Manual verification: in dev, toggle the global time basis and confirm:
|
||||
- Heatmap recomputes
|
||||
- A memo edited yesterday but created last week shows up "yesterday" in update mode and "last week" in create mode
|
||||
- Tooltip text reflects the basis
|
||||
|
||||
## Migration / compatibility
|
||||
|
||||
- Proto field is additive (tag 8 is unused; tag 2 is `reserved`).
|
||||
- Old clients ignore the new field.
|
||||
- New clients tolerate old servers via the fallback above.
|
||||
- No DB migration.
|
||||
- No data backfill — `updated_ts` already exists on every memo row.
|
||||
|
||||
## Out-of-scope follow-ups (not part of this work)
|
||||
|
||||
These came up during brainstorming and are tracked here only so they aren't lost:
|
||||
|
||||
1. Adding comment / reaction event types to the heatmap count.
|
||||
2. A "Memo History / Versions" feature (per-edit snapshots, diffs, optional commit messages). If pursued, the heatmap would become a downstream consumer of that history, and the field added here may be revisited.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Create memo on selected calendar date — design
|
||||
|
||||
**Date:** 2026-05-02
|
||||
**Scope:** Frontend-only.
|
||||
|
||||
## Problem
|
||||
|
||||
Clicking a date in the activity calendar filters the memo list to that date but does nothing for the inline editor. To create a memo dated for the selected day, the user must (1) create with today's timestamp, then (2) open the timestamp popover on the saved memo and edit `createTime`. This is a two-step retro-fill that defeats the calendar selection. Empty dates are also not clickable today, so the user cannot start the first memo for an empty day from the calendar at all.
|
||||
|
||||
## Goal
|
||||
|
||||
When a user picks a calendar date and immediately writes in the home editor, the resulting memo is created on that date — single-step.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Backend changes. The API already accepts custom `createTime` and `updateTime`.
|
||||
- Changes to the comment/reply editor or the edit-mode editor.
|
||||
- Changes outside the home page's editor render site (Explore/Archived/Profile pages have no editor).
|
||||
- Reworking the timestamp popover UI itself.
|
||||
- Empty-state copy changes on non-Home pages when an empty date is selected.
|
||||
|
||||
## User-visible behavior
|
||||
|
||||
1. **Empty calendar dates are clickable.** Clicking a date with zero memos sets the `displayTime` filter the same way a populated date does. Tooltip and selection ring still work.
|
||||
2. **When the home editor renders with an active `displayTime` filter:**
|
||||
- The `TimestampPopover` (already used in edit mode) appears in create mode, pre-populated with the selected date.
|
||||
- The draft's `createTime` is set to **selected local date + current local hh:mm:ss** (e.g., picking May 1 at 14:32 → `2025-05-01 14:32`).
|
||||
- The draft's `updateTime` is set to the same value, to avoid the saved memo immediately reading "updated today" relative to a back-dated `createTime`.
|
||||
- The user can adjust either field via the popover before saving.
|
||||
3. **When no `displayTime` filter is active**, the editor is identical to today: no popover in create mode, no override, server stamps with "now".
|
||||
4. **Live derivation.** If the filter changes while a draft is in progress, the editor's prefilled timestamps re-sync to the new date. The popover stays visible so the change is observable. (Manual popover edits before the next filter change are overwritten — chosen tradeoff.)
|
||||
5. **Future dates are allowed** (e.g., May 15 when today is May 2). Backend already accepts future timestamps.
|
||||
6. **Other contexts** (Explore/Archived/Profile) gain empty-date clickability for navigation consistency, but have no editor and so no prefill behavior.
|
||||
|
||||
## Architecture
|
||||
|
||||
Four touch points, all in `web/src/`:
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `components/ActivityCalendar/CalendarCell.tsx` | Drop `day.count > 0` gate so empty in-month cells are clickable. |
|
||||
| `components/MemoEditor/index.tsx` | Accept `defaultCreateTime?: Date` prop; render `TimestampPopover` in create mode when set; sync state on prop change. |
|
||||
| `components/MemoEditor/utils/deriveDefaultCreateTime.ts` (new) | Pure helper: `(filters, now?) => Date \| undefined` derived from any `displayTime` filter. |
|
||||
| `components/PagedMemoList/PagedMemoList.tsx` | At the home-editor render site (line 155), read `MemoFilterContext`, compute `defaultCreateTime`, pass as prop. |
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
CalendarCell click
|
||||
→ useDateFilterNavigation
|
||||
→ URL ?filter=displayTime:YYYY-MM-DD
|
||||
→ MemoFilterContext re-renders
|
||||
→ PagedMemoList recomputes defaultCreateTime via deriveDefaultCreateTimeFromFilters(filters)
|
||||
→ <MemoEditor defaultCreateTime={...}> re-renders
|
||||
→ editor reducer syncs state.timestamps (create + update) and renders TimestampPopover
|
||||
→ save → memoService.ts:111 sends createTime/updateTime to API
|
||||
```
|
||||
|
||||
## Component contracts
|
||||
|
||||
### `MemoEditor` — new prop
|
||||
|
||||
```ts
|
||||
interface MemoEditorProps {
|
||||
// ...existing props
|
||||
/**
|
||||
* When set in create mode (no `memo` prop), seeds the draft's
|
||||
* createTime/updateTime and reveals the TimestampPopover so the
|
||||
* user can adjust. Tracked live: changes after mount re-sync state.
|
||||
* Ignored in edit mode (when `memo` is set).
|
||||
*/
|
||||
defaultCreateTime?: Date;
|
||||
}
|
||||
```
|
||||
|
||||
Internal behavior:
|
||||
- On `INIT_MEMO` for create mode, if `defaultCreateTime` is set, payload `timestamps` is `{ createTime: defaultCreateTime, updateTime: defaultCreateTime }`.
|
||||
- A `useEffect` keyed on `[defaultCreateTime?.getTime(), memo]` dispatches `SET_TIMESTAMPS` whenever the prop changes in create mode.
|
||||
- Popover render condition becomes `memoName || (!memo && state.timestamps.createTime)`.
|
||||
|
||||
### `deriveDefaultCreateTimeFromFilters` — pure helper
|
||||
|
||||
```ts
|
||||
// web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts
|
||||
export function deriveDefaultCreateTimeFromFilters(
|
||||
filters: MemoFilter[],
|
||||
now: Date = new Date(),
|
||||
): Date | undefined {
|
||||
const dateFilter = filters.find((f) => f.factor === "displayTime");
|
||||
if (!dateFilter) return undefined;
|
||||
const [y, m, d] = dateFilter.value.split("-").map(Number);
|
||||
if (!y || !m || !d) return undefined;
|
||||
return new Date(y, m - 1, d, now.getHours(), now.getMinutes(), now.getSeconds());
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Defensive parse — returns `undefined` for malformed values rather than throwing.
|
||||
- `now` is injectable for deterministic tests.
|
||||
- Multiple `displayTime` filters are not produced by current UI; `find` ignores extras safely.
|
||||
|
||||
### `PagedMemoList.tsx` — call-site change
|
||||
|
||||
```tsx
|
||||
const { filters } = useMemoFilterContext();
|
||||
const defaultCreateTime = useMemo(
|
||||
() => deriveDefaultCreateTimeFromFilters(filters),
|
||||
[filters],
|
||||
);
|
||||
// ...
|
||||
{showMemoEditor ? (
|
||||
<MemoEditor
|
||||
className="mb-2"
|
||||
cacheKey="home-memo-editor"
|
||||
placeholder={t("editor.any-thoughts")}
|
||||
defaultCreateTime={defaultCreateTime}
|
||||
/>
|
||||
) : null}
|
||||
```
|
||||
|
||||
`useMemo` keyed on `filters` keeps the reference stable when the filter doesn't change, avoiding unnecessary editor re-syncs. `now` is captured once per filter change — matches "the local time when you picked the date".
|
||||
|
||||
### `CalendarCell.tsx` — empty-cell clickability
|
||||
|
||||
- `handleClick`: drop the `day.count > 0` check; just call `onClick(day.date)` if `onClick` is provided.
|
||||
- `isInteractive`: `Boolean(onClick)`.
|
||||
- `tabIndex` / `aria-disabled` / hover-cursor classes follow the new `isInteractive`.
|
||||
- `shouldShowTooltip`: drop the `day.count > 0` gate; tooltip text already conveys the count.
|
||||
- Out-of-month cells (existing early return) stay unclickable.
|
||||
- The `selected` ring already works on count=0 cells. Visual contrast on the lowest-intensity background may need a small ring-weight bump in light theme; eyeball during implementation.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **No filter / filter cleared:** `defaultCreateTime` becomes `undefined`; editor falls back to current behavior.
|
||||
- **User edits draft, then re-picks date:** live-derived; editor's `createTime` updates, popover reflects new value.
|
||||
- **User manually edits via popover, then changes filter:** prop sync overwrites manual edit. Acceptable per design choice; popover keeps the change observable.
|
||||
- **Draft cache (`cacheKey="home-memo-editor"`):** caches `content`, not `timestamps`. Reload restores text but `createTime` is freshly derived from current filter — consistent.
|
||||
- **Future dates:** allowed. No clamp.
|
||||
- **DST / timezone:** date arithmetic uses local time (`new Date(y, m-1, d, h, mi, s)`), matching `useDateFilterNavigation`'s local-date convention. Server receives an absolute `Timestamp`.
|
||||
- **Comment editor (`MemoCommentSection`):** doesn't pass `defaultCreateTime` → no behavior change.
|
||||
- **Edit mode (`memo` prop set):** prop is ignored; existing edit-mode popover is unchanged.
|
||||
- **Empty-date click on Explore/Archived/Profile:** filters to empty date → "no memos" empty state. Acceptable.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit (Vitest)** for `deriveDefaultCreateTimeFromFilters`:
|
||||
- no `displayTime` filter → `undefined`
|
||||
- valid `displayTime:2025-05-01` + injected `now=14:32:10` → `2025-05-01 14:32:10` local
|
||||
- malformed value (`"not-a-date"`, `"2025-13-40"`) → `undefined`
|
||||
- extra non-`displayTime` filters present → still works
|
||||
- **Component (React Testing Library) for `CalendarCell`:** count=0 in-month cell is clickable, has correct `tabIndex`/`aria-disabled`, fires `onClick` with date.
|
||||
- **Component for `MemoEditor`:** with `defaultCreateTime` prop, popover renders in create mode and `state.timestamps.createTime` matches; without prop, no popover; changing the prop re-syncs state.
|
||||
- **Manual smoke (per CLAUDE.md UI-changes rule):** `pnpm dev`, click a non-today date (with and without existing memos), type a memo, save, confirm it appears under that date. Clear the filter chip; confirm a new memo posts to today.
|
||||
|
||||
## Risks
|
||||
|
||||
- The `useEffect` re-sync overwriting an in-progress popover edit is a *chosen* behavior. If users later complain, a "manual override sticky" flag is the natural follow-up. Not pre-built.
|
||||
- Selection-ring contrast on the lowest-intensity background may need a small visual tweak; flagged for implementation.
|
||||
|
||||
## Out of scope (explicit)
|
||||
|
||||
- Sticky manual-override semantics for the popover.
|
||||
- New empty-state copy on Explore/Archived/Profile when filtering to a date with zero memos.
|
||||
- Any backend/API change.
|
||||
- Any change to the comment editor, edit mode, or non-Home editor sites.
|
||||
@@ -0,0 +1,511 @@
|
||||
# STT and Audio-LLM Split — Design Spec
|
||||
|
||||
**Date:** 2026-05-02
|
||||
**Status:** Draft, pending user review
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Refactor `internal/ai/` to split **speech-to-text (STT)** and **audio-multimodal-LLM (Audio-LLM)** into two separate Go interfaces, aligning with mainstream OSS conventions (Vercel AI SDK, LiteLLM, the Go AI ecosystem). Update the public API handler to dispatch to the right interface based on provider type. Make two small comment improvements to `proto/store/instance_setting.proto` and the generated bindings; **no proto field changes**.
|
||||
|
||||
## 2. Non-Goals
|
||||
|
||||
The following are intentionally **out of scope** for this design:
|
||||
|
||||
- **`enabled` boolean field on `TranscriptionConfig`** (improvement #1 from the brainstorming) — keep using `provider_id == ""` as the disabled signal.
|
||||
- **Direction C (audio → structured note pipeline)** — auto-summarization / tag extraction. Independent future feature.
|
||||
- **Multi-provider STT with default-model selector** (Dify-style) — Memos has a single transcription config; that stays.
|
||||
- **Per-model credential overrides**, **load balancing**, **capability YAML schemas** — Dify-style enterprise complexity.
|
||||
- **Streaming transcription, retry policy, OpenAI Translations endpoint** — YAGNI.
|
||||
- **`gpt-4o-audio-preview` user-facing support** — the `audiollm/openai` package will be implementable after this refactor, but UI support is a follow-up.
|
||||
- **TTS** (text-to-speech) — different concern, not affected.
|
||||
|
||||
## 3. Background
|
||||
|
||||
The current `internal/ai/` has one `Transcriber` interface with two implementations: `openAITranscriber` (calls `/audio/transcriptions`, a real STT endpoint) and `geminiTranscriber` (calls `generateContent`, a multimodal-LLM endpoint dressed up to act like STT). This conflation has caused real symptoms:
|
||||
|
||||
- `TranscribeResponse.Language` and `Duration` are silently empty for Gemini (Gemini multimodal doesn't return them).
|
||||
- The `Prompt` field has different semantics across providers — Whisper treats it as a soft hint that may be ignored; Gemini treats it as a literal instruction.
|
||||
- Gemini's multimodal failure modes (safety filter, token-truncation, refusals) are flattened to a single "did not include text" error.
|
||||
- Gemini-specific code (WebM transcoding, `maxGeminiInlineAudioSize`, `genai` SDK) lives in the same package as the OpenAI Whisper integration.
|
||||
|
||||
The brainstorming session (this conversation, 2026-05-02) ran two rounds of OSS research to validate the corrective direction.
|
||||
|
||||
## 4. Research Findings Summary
|
||||
|
||||
Detailed findings in conversation history; abridged here for design accountability.
|
||||
|
||||
### 4.1 SDK-Layer Research
|
||||
|
||||
| Source | Key Decision |
|
||||
|---|---|
|
||||
| **Vercel AI SDK** (`vercel/ai`) | `TranscriptionModelV3` is implemented **only** by providers with a dedicated STT endpoint (OpenAI Whisper/gpt-4o-transcribe, Deepgram, ElevenLabs, AssemblyAI). **Google provider deliberately does not implement it** — Gemini audio rides through `generateText` with `FilePart`. Two completely separate code paths. No "source" discriminator. Provider id is `vendor.modality` (`openai.transcription`); model is a free string. |
|
||||
| **LiteLLM** (`BerriAI/litellm`) | `litellm.transcription()` only routes to providers with `/audio/transcriptions`-style endpoints. **Gemini is absent** from the transcription router (`litellm/llms/gemini/` has no `audio_transcription/` subdirectory). Multimodal audio rides through `litellm.completion()` with `{"type":"input_audio"}` content parts. Response is `text + usage`, no provider discriminator. |
|
||||
| **Go AI SDKs** (`cloudwego/eino`, `tmc/langchaingo`, `sashabaranov/go-openai`) | One package per provider; provider identity = import path; **no provider enum**. `Model` is opaque string. OpenAI-compatible endpoints handled via `BaseURL` config field, never via separate package. go-openai's `audio.go` is structurally separate from `chat.go`. |
|
||||
|
||||
**Convergent finding:** All three ecosystems split STT and multimodal-audio into separate interfaces. None expose a "this came from a multimodal LLM" discriminator. None encode wire-format into the provider type enum.
|
||||
|
||||
### 4.2 Application-Layer Research
|
||||
|
||||
| Source | STT-Storage Design |
|
||||
|---|---|
|
||||
| **Open WebUI** | STT is a **flat singleton config block** (`audio.stt.*` namespace), completely separate from chat providers (`openai.*` namespace). `STT_ENGINE` enum dispatches; per-engine credentials side-by-side in one config. |
|
||||
| **LobeChat** | STT is a **separate global user setting** (`UserTTSConfig`). But credentials silently piggyback on the `openai` chat provider's `keyVaults` — author has marked the helper `@deprecated`. |
|
||||
| **Dify** | `ProviderEntity.supported_model_types` declares capabilities; STT is the `SPEECH2TEXT` enum value. STT info lives in a **separate "system model" config row** (`tenant_default_models(model_type='speech2text', provider_name, model_name)`) that **references** an existing provider. |
|
||||
|
||||
**Convergent finding:** Zero apps add STT-specific fields onto the AI provider entity. All three keep providers capability-agnostic and put STT config in a separate place.
|
||||
|
||||
### 4.3 Proto Schema Assessment
|
||||
|
||||
The current `proto/store/instance_setting.proto` `InstanceAISetting` + `TranscriptionConfig` is **already aligned with the mainstream pattern**:
|
||||
|
||||
- ✅ `AIProviderConfig` carries no STT-specific field (capability-agnostic)
|
||||
- ✅ `TranscriptionConfig` is a separate pointer (`provider_id` references a provider)
|
||||
- ✅ `AIProviderType` is vendor-level (`OPENAI`, `GEMINI`) — no wire-format suffix
|
||||
- ✅ `model` is a free string
|
||||
- ✅ Comments already document Whisper vs Gemini prompt semantics (though could be clearer)
|
||||
|
||||
The proto schema requires **no field changes**. Only two comment improvements (§7 below).
|
||||
|
||||
## 5. Current State (Files Touched)
|
||||
|
||||
```
|
||||
internal/ai/
|
||||
ai.go # ProviderType, ProviderConfig, errors
|
||||
client.go # NewTranscriber factory, transcriberOptions, normalizeEndpoint, requireAPIKey
|
||||
transcription.go # Transcriber interface, TranscribeRequest, TranscribeResponse
|
||||
openai.go # openAITranscriber → /audio/transcriptions
|
||||
openai_test.go
|
||||
gemini.go # geminiTranscriber → generateContent (multimodal)
|
||||
gemini_test.go
|
||||
models.go # DefaultOpenAITranscriptionModel, DefaultGeminiTranscriptionModel
|
||||
resolver.go # FindProvider
|
||||
errors.go # ErrProviderNotFound, ErrCapabilityUnsupported
|
||||
audio/
|
||||
webm.go # IsWebMContentType, WebMOpusToWAV (used by Gemini path)
|
||||
webm_test.go
|
||||
|
||||
server/router/api/v1/
|
||||
ai_service.go # Transcribe handler (lines 42–123)
|
||||
|
||||
proto/store/
|
||||
instance_setting.proto # InstanceAISetting, AIProviderConfig, AIProviderType, TranscriptionConfig
|
||||
|
||||
web/src/components/Settings/
|
||||
AISection.tsx # Provider list UI, TranscriptionForm
|
||||
```
|
||||
|
||||
The handler at `server/router/api/v1/ai_service.go:42` is the **single integration point** between proto config and the `internal/ai/` SDK. It already discards Language/Duration (returns `{Text}` only), so the response narrowing is already in place.
|
||||
|
||||
## 6. Target Design
|
||||
|
||||
### 6.1 Package Structure
|
||||
|
||||
```
|
||||
internal/ai/
|
||||
ai.go # ProviderType (unchanged: OPENAI, GEMINI), ProviderConfig (unchanged)
|
||||
resolver.go # FindProvider (unchanged)
|
||||
errors.go # add ErrSTTNotSupported, ErrAudioLLMNotSupported
|
||||
audio/
|
||||
webm.go # unchanged — moves with audiollm/gemini consumer
|
||||
webm_test.go
|
||||
|
||||
stt/
|
||||
stt.go # Transcriber interface, Request, Response, Segment
|
||||
factory.go # NewTranscriber(cfg ai.ProviderConfig, opts...) (Transcriber, error)
|
||||
options.go # TranscriberOption, WithHTTPClient
|
||||
openai/
|
||||
openai.go # openAITranscriber → POST /audio/transcriptions
|
||||
openai_test.go
|
||||
|
||||
audiollm/
|
||||
audiollm.go # Model interface, Request, Response, FinishReason
|
||||
factory.go # NewModel(cfg ai.ProviderConfig, opts...) (Model, error)
|
||||
options.go # ModelOption, WithHTTPClient
|
||||
gemini/
|
||||
gemini.go # geminiModel → POST :generateContent (multimodal audio)
|
||||
gemini_test.go
|
||||
# openai/ — NOT created in this refactor; reserved for future gpt-4o-audio support
|
||||
```
|
||||
|
||||
**Rationale (Go-ecosystem convention, per §4.1):** one package per provider; provider identity is import path; capability is implied by which umbrella package (`stt` vs `audiollm`) you import from. The runtime dispatch (factory) is the only place that translates `ProviderConfig.Type` enum → concrete implementation.
|
||||
|
||||
### 6.2 Interfaces
|
||||
|
||||
#### `internal/ai/stt/stt.go`
|
||||
|
||||
```go
|
||||
package stt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Transcriber transcribes audio into text using a provider's dedicated STT endpoint
|
||||
// (e.g. OpenAI /audio/transcriptions). Implementations are deterministic STT —
|
||||
// they are NOT for multimodal LLMs that happen to accept audio input. For
|
||||
// multimodal audio understanding, see internal/ai/audiollm.
|
||||
type Transcriber interface {
|
||||
Transcribe(ctx context.Context, req Request) (*Response, error)
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Audio io.Reader
|
||||
Size int64
|
||||
Filename string
|
||||
ContentType string // IANA media type, e.g. "audio/wav"
|
||||
Model string // provider-specific model id (e.g. "whisper-1", "gpt-4o-transcribe")
|
||||
Prompt string // soft spelling/vocabulary hint (Whisper "prompt" parameter)
|
||||
Language string // ISO 639-1, optional
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Text string
|
||||
Language string // empty if provider did not return it (best-effort)
|
||||
Segments []Segment // empty unless provider returned timestamps
|
||||
}
|
||||
|
||||
type Segment struct {
|
||||
Text string
|
||||
Start float64
|
||||
End float64
|
||||
Speaker string // empty unless using a diarization-capable model (e.g. gpt-4o-transcribe-diarize)
|
||||
}
|
||||
```
|
||||
|
||||
#### `internal/ai/audiollm/audiollm.go`
|
||||
|
||||
```go
|
||||
package audiollm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Model invokes a multimodal LLM with audio input. Implementations call
|
||||
// chat-completions or generate-content style APIs that happen to accept audio.
|
||||
// They are NOT deterministic STT — outputs may be refused, truncated, or
|
||||
// rephrased per the LLM's behavior. For pure transcription, prefer
|
||||
// internal/ai/stt where available.
|
||||
type Model interface {
|
||||
GenerateFromAudio(ctx context.Context, req Request) (*Response, error)
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Audio io.Reader
|
||||
Size int64
|
||||
ContentType string
|
||||
Model string
|
||||
Instructions string // literal instruction the model is expected to follow
|
||||
Temperature *float32 // optional; nil leaves provider default
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Text string
|
||||
FinishReason FinishReason
|
||||
}
|
||||
|
||||
type FinishReason string
|
||||
|
||||
const (
|
||||
FinishStop FinishReason = "stop" // model finished normally
|
||||
FinishLength FinishReason = "length" // truncated by max-tokens
|
||||
FinishSafety FinishReason = "safety" // safety filter blocked output
|
||||
FinishOther FinishReason = "other" // anything else (incl. unknown)
|
||||
)
|
||||
```
|
||||
|
||||
#### Factory dispatch
|
||||
|
||||
```go
|
||||
// internal/ai/stt/factory.go
|
||||
package stt
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/internal/ai"
|
||||
"github.com/usememos/memos/internal/ai/stt/openai"
|
||||
)
|
||||
|
||||
func NewTranscriber(cfg ai.ProviderConfig, opts ...TranscriberOption) (Transcriber, error) {
|
||||
switch cfg.Type {
|
||||
case ai.ProviderOpenAI:
|
||||
return openai.New(cfg, applyOptions(opts...))
|
||||
case ai.ProviderGemini:
|
||||
return nil, errors.Wrapf(ai.ErrSTTNotSupported,
|
||||
"Gemini does not provide a dedicated STT endpoint; use audiollm.NewModel instead")
|
||||
default:
|
||||
return nil, errors.Wrapf(ai.ErrCapabilityUnsupported, "provider type %q", cfg.Type)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// internal/ai/audiollm/factory.go
|
||||
package audiollm
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/internal/ai"
|
||||
"github.com/usememos/memos/internal/ai/audiollm/gemini"
|
||||
)
|
||||
|
||||
func NewModel(cfg ai.ProviderConfig, opts ...ModelOption) (Model, error) {
|
||||
switch cfg.Type {
|
||||
case ai.ProviderGemini:
|
||||
return gemini.New(cfg, applyOptions(opts...))
|
||||
case ai.ProviderOpenAI:
|
||||
// NOTE: gpt-4o-audio-preview support belongs here but is out of scope;
|
||||
// see §2 (Non-Goals).
|
||||
return nil, errors.Wrapf(ai.ErrAudioLLMNotSupported,
|
||||
"OpenAI multimodal audio (gpt-4o-audio) is not yet implemented in this codebase")
|
||||
default:
|
||||
return nil, errors.Wrapf(ai.ErrCapabilityUnsupported, "provider type %q", cfg.Type)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Backend Handler Dispatch
|
||||
|
||||
The handler at `server/router/api/v1/ai_service.go:Transcribe` dispatches based on `provider.Type`:
|
||||
|
||||
```go
|
||||
func (s *APIV1Service) Transcribe(ctx context.Context, request *v1pb.TranscribeRequest) (*v1pb.TranscribeResponse, error) {
|
||||
// ... existing config loading, provider resolution, audio reading ...
|
||||
|
||||
switch provider.Type {
|
||||
case ai.ProviderOpenAI:
|
||||
text, err := s.transcribeViaSTT(ctx, provider, transcriptionCfg, audio, contentType)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to transcribe: %v", err)
|
||||
}
|
||||
return &v1pb.TranscribeResponse{Text: text}, nil
|
||||
|
||||
case ai.ProviderGemini:
|
||||
text, err := s.transcribeViaAudioLLM(ctx, provider, transcriptionCfg, audio, contentType)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to transcribe: %v", err)
|
||||
}
|
||||
return &v1pb.TranscribeResponse{Text: text}, nil
|
||||
|
||||
default:
|
||||
return nil, status.Errorf(codes.FailedPrecondition,
|
||||
"provider type %q is not supported for transcription", provider.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIV1Service) transcribeViaSTT(ctx context.Context, provider ai.ProviderConfig,
|
||||
cfg *storepb.TranscriptionConfig,
|
||||
audio io.Reader, contentType string) (string, error) {
|
||||
t, err := stt.NewTranscriber(provider)
|
||||
if err != nil { return "", err }
|
||||
resp, err := t.Transcribe(ctx, stt.Request{
|
||||
Audio: audio,
|
||||
Filename: "audio",
|
||||
ContentType: contentType,
|
||||
Model: resolveModel(provider, cfg.Model),
|
||||
Prompt: cfg.Prompt, // Whisper: soft hint, may be ignored
|
||||
Language: cfg.Language,
|
||||
})
|
||||
if err != nil { return "", err }
|
||||
return resp.Text, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) transcribeViaAudioLLM(ctx context.Context, provider ai.ProviderConfig,
|
||||
cfg *storepb.TranscriptionConfig,
|
||||
audio io.Reader, contentType string) (string, error) {
|
||||
m, err := audiollm.NewModel(provider)
|
||||
if err != nil { return "", err }
|
||||
resp, err := m.GenerateFromAudio(ctx, audiollm.Request{
|
||||
Audio: audio,
|
||||
ContentType: contentType,
|
||||
Model: resolveModel(provider, cfg.Model),
|
||||
Instructions: buildTranscriptionInstructions(cfg.Prompt, cfg.Language),
|
||||
})
|
||||
if err != nil { return "", err }
|
||||
if resp.FinishReason != audiollm.FinishStop {
|
||||
return "", errors.Errorf("transcription incomplete (finish reason: %s)", resp.FinishReason)
|
||||
}
|
||||
return resp.Text, nil
|
||||
}
|
||||
```
|
||||
|
||||
`buildTranscriptionInstructions` lives next to the handler and centralizes the literal instruction sent to multimodal LLMs:
|
||||
|
||||
```go
|
||||
func buildTranscriptionInstructions(prompt, language string) string {
|
||||
parts := []string{
|
||||
"Transcribe the audio accurately. Return only the transcript text. " +
|
||||
"Do not summarize, explain, or add content that is not spoken.",
|
||||
}
|
||||
if language != "" {
|
||||
parts = append(parts, fmt.Sprintf("The input language is %s.", language))
|
||||
}
|
||||
if prompt != "" {
|
||||
parts = append(parts, "Context and spelling hints:\n"+prompt)
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
```
|
||||
|
||||
`resolveModel` returns `cfg.Model` if non-empty, else the per-provider default from `ai/models.go` (unchanged from today).
|
||||
|
||||
### 6.4 Implementation Notes Per Package
|
||||
|
||||
#### `internal/ai/stt/openai/openai.go`
|
||||
|
||||
- Identical wire behavior to current `internal/ai/openai.go::openAITranscriber.Transcribe`.
|
||||
- Uses `github.com/openai/openai-go/v3` SDK (already a dep).
|
||||
- Defaults `endpoint` to `https://api.openai.com/v1`. Trims trailing slash. Validates URL.
|
||||
- Honors `cfg.Endpoint` to support OpenAI-compatible providers (Groq Whisper, faster-whisper self-hosted, Azure Whisper deployments). The user simply adds another `AIProviderConfig` row with `Type=OPENAI` and a different `Endpoint`.
|
||||
- Supports any model the underlying endpoint accepts: `whisper-1`, `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, `gpt-4o-transcribe-diarize`, etc. The model string is opaque.
|
||||
- Returns `Response.Language` and `Response.Segments` populated when the API returns them; otherwise empty.
|
||||
|
||||
#### `internal/ai/audiollm/gemini/gemini.go`
|
||||
|
||||
- Identical wire behavior to current `internal/ai/gemini.go::geminiTranscriber.Transcribe`, EXCEPT:
|
||||
- Reads `Instructions` from the caller (not hardcoded inside the package).
|
||||
- Maps `genai.FinishReason` to `audiollm.FinishReason` (`STOP→FinishStop`, `MAX_TOKENS→FinishLength`, `SAFETY→FinishSafety`, anything else → `FinishOther`).
|
||||
- Returns `Response{Text, FinishReason}` instead of swallowing the finish reason into a generic error.
|
||||
- Continues to use `internal/ai/audio.WebMOpusToWAV` for WebM transcoding (Gemini doesn't accept WebM).
|
||||
- Continues to enforce `maxGeminiInlineAudioSize` (14 MiB) — File API support is out of scope.
|
||||
- Uses `google.golang.org/genai` SDK (already a dep).
|
||||
|
||||
#### `internal/ai/errors.go`
|
||||
|
||||
Add:
|
||||
```go
|
||||
var ErrSTTNotSupported = errors.New("provider does not support speech-to-text capability")
|
||||
var ErrAudioLLMNotSupported = errors.New("provider does not support multimodal audio capability")
|
||||
```
|
||||
Keep existing `ErrProviderNotFound` and `ErrCapabilityUnsupported`.
|
||||
|
||||
### 6.5 Proto Schema Changes
|
||||
|
||||
**Two comment-only updates. No field additions, no field renames, no breaking changes.**
|
||||
|
||||
#### Improvement #2 — `TranscriptionConfig.model` comment
|
||||
|
||||
Replace lines 179–181 of `proto/store/instance_setting.proto`:
|
||||
|
||||
```proto
|
||||
// model is the provider-specific model identifier.
|
||||
// Empty string falls back to the engine default
|
||||
// (whisper-1 for OPENAI providers, gemini-2.5-flash for GEMINI providers).
|
||||
string model = 2;
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```proto
|
||||
// model is the provider-specific model identifier.
|
||||
// Empty string falls back to the engine default.
|
||||
// OPENAI examples:
|
||||
// - whisper-1 (legacy, lower cost)
|
||||
// - gpt-4o-transcribe, gpt-4o-mini-transcribe (higher quality)
|
||||
// - gpt-4o-transcribe-diarize (includes speaker labels)
|
||||
// GEMINI examples:
|
||||
// - gemini-2.5-flash (default, multimodal call)
|
||||
// - gemini-2.5-pro
|
||||
string model = 2;
|
||||
```
|
||||
|
||||
**Rationale:** OpenAI's `/audio/transcriptions` endpoint now supports the `gpt-4o-transcribe` family in addition to `whisper-1`. The current comment is misleading — it implies Whisper is the only OpenAI option.
|
||||
|
||||
#### Improvement #3 — `TranscriptionConfig.prompt` comment
|
||||
|
||||
Replace lines 188–191:
|
||||
|
||||
```proto
|
||||
// prompt is a default spelling/vocabulary hint passed to the provider.
|
||||
// Used as the OpenAI Whisper "prompt" parameter and folded into the Gemini
|
||||
// generation prompt as a "Context and spelling hints" block.
|
||||
string prompt = 4;
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```proto
|
||||
// prompt is a default spelling/vocabulary hint passed to the provider.
|
||||
// Used as the OpenAI Whisper "prompt" parameter (a soft hint that the model
|
||||
// may ignore) and folded into the Gemini generation prompt as a "Context and
|
||||
// spelling hints" block (which the LLM will treat more literally).
|
||||
string prompt = 4;
|
||||
```
|
||||
|
||||
**Rationale:** Same field, two semantically different behaviors. Surfacing this in the schema documentation (which propagates to generated Go and TypeScript via JSDoc) makes the cross-provider variability explicit for any caller reading the bindings cold.
|
||||
|
||||
After editing the proto, regenerate via `cd proto && buf format -w && buf generate`. The two regenerated files are:
|
||||
- `proto/gen/store/instance_setting.pb.go`
|
||||
- `web/src/types/proto/store/instance_setting_pb.ts`
|
||||
|
||||
#### Why NOT add an `enabled` field (improvement #1)
|
||||
|
||||
Out of scope per §2. Doing it would add a new field that the frontend, backend, and migration logic all need to handle, for the sole benefit of letting users "disable but keep the config." The current `provider_id == ""` semantics work; the cost of the change exceeds the benefit at this moment.
|
||||
|
||||
### 6.6 Frontend Impact
|
||||
|
||||
Minimal. `web/src/components/Settings/AISection.tsx` already:
|
||||
|
||||
- Switches the model placeholder per provider (`placeholderForProvider` at line 371, using `setting.ai.transcription-model-placeholder-gemini` / `-openai`).
|
||||
- Disables the form when `providerId == ""`.
|
||||
- Validates that the referenced provider exists.
|
||||
|
||||
Recommended adjustments (in scope):
|
||||
|
||||
1. **Update i18n model placeholder strings** in `web/src/locales/en.json` to reflect the new model examples:
|
||||
- `setting.ai.transcription-model-placeholder-openai`: include `gpt-4o-transcribe` family alongside `whisper-1`.
|
||||
- `setting.ai.transcription-model-placeholder-gemini`: confirm `gemini-2.5-flash` is the listed example.
|
||||
2. **Update the prompt help text** (`setting.ai.transcription-prompt-help`) to note the cross-provider semantic difference, mirroring the new proto comment in user-facing language.
|
||||
|
||||
No structural component changes. No new fields. No state-shape changes.
|
||||
|
||||
### 6.7 What Stays Identical
|
||||
|
||||
- Database storage (`InstanceSetting` rows, `AISetting` blob) — proto field tags unchanged.
|
||||
- API surface (`TranscribeRequest`, `TranscribeResponse` messages) — unchanged.
|
||||
- gRPC/Connect endpoint paths — unchanged.
|
||||
- Frontend state shape (`LocalTranscription`) — unchanged.
|
||||
- All existing tests semantically unchanged (will be ported to new package paths).
|
||||
|
||||
## 7. Migration Path
|
||||
|
||||
The refactor is internal to the Go server. End-to-end behavior is preserved. Migration is staged so each stage is independently buildable, testable, and revertable.
|
||||
|
||||
| Stage | What | Compiles? | Tests pass? |
|
||||
|---|---|---|---|
|
||||
| A | Add `internal/ai/stt/` and `internal/ai/audiollm/` with new interfaces and (empty) factories. Add new errors. | ✅ | ✅ (no callers yet) |
|
||||
| B | Implement `internal/ai/stt/openai/` — port behavior from current `openai.go::openAITranscriber`. Port tests to `stt/openai/openai_test.go`. | ✅ | ✅ |
|
||||
| C | Implement `internal/ai/audiollm/gemini/` — port behavior from current `gemini.go::geminiTranscriber`, but: lift instructions out into the caller, return `FinishReason` instead of swallowing it. Port tests. | ✅ | ✅ |
|
||||
| D | Refactor `server/router/api/v1/ai_service.go::Transcribe` to dispatch via the new factories. Add `transcribeViaSTT` and `transcribeViaAudioLLM`. Add `buildTranscriptionInstructions`. | ✅ | ✅ |
|
||||
| E | Delete old files: `internal/ai/transcription.go`, `client.go`, `openai.go`, `openai_test.go`, `gemini.go`, `gemini_test.go`. | ✅ | ✅ |
|
||||
| F | Update proto comments (#2 and #3), run `buf format -w && buf generate`. | ✅ | ✅ |
|
||||
| G | Update `web/src/locales/en.json` strings for model placeholders and prompt help. | ✅ | ✅ |
|
||||
|
||||
Each stage is one commit. Reverting any single stage leaves the system in a working state.
|
||||
|
||||
## 8. Anti-Patterns Avoided (and Why)
|
||||
|
||||
| Anti-pattern | Where it would have come from | Why we're avoiding it |
|
||||
|---|---|---|
|
||||
| `ProviderType` enum with wire-format suffix (`OPENAI_TRANSCRIPTIONS`, `OPENAI_CHAT_AUDIO`) | Earlier brainstorming draft | Vercel/LiteLLM/Go ecosystem all use vendor-level identity; capability is implied by which interface you call. |
|
||||
| `Response.Source` enum (`NativeSTT`, `MultimodalLLM`) | Earlier brainstorming draft | None of the three SDKs surveyed has this. It re-introduces the "pretend STT" smell at a different layer. |
|
||||
| Adapter wrapping `audiollm.Model` as `stt.Transcriber` | Earlier brainstorming draft | Adapter would re-create the conflation we're trying to remove. Application-layer dispatch is honest. |
|
||||
| Adding `transcription_*` fields to `AIProviderConfig` | Naive instinct | Three of three OSS apps surveyed (Open WebUI, LobeChat, Dify) do **not** do this. Pollutes the provider entity; repeats with every new capability. |
|
||||
| Silently reusing chat provider credentials for STT (LobeChat's deprecated pattern) | LobeChat-style shortcut | LobeChat's own author marked the helper `@deprecated`. Memos's existing `provider_id` reference is more flexible (user can configure a different OpenAI-compatible endpoint, e.g. Groq, just for STT). |
|
||||
| Per-model credential overrides, capability YAML, load balancing | Dify | Enterprise complexity that doesn't fit Memos's scope. |
|
||||
| Auto-fallback from STT failure to multimodal-LLM transcription | Plausible "smart" idea | LiteLLM doesn't do this; failure modes and cost differ enough that fallback would surprise users. Explicit dispatch by provider type is what LiteLLM ships. |
|
||||
|
||||
## 9. Open Decisions
|
||||
|
||||
All resolved during brainstorming. None remain open. For the record:
|
||||
|
||||
1. **Direction A (split STT/Audio-LLM into separate interfaces) over Direction B (capability-flag system) over Direction C (audio-to-structured-note pipeline).** Resolved: A. Rationale: most honest abstraction, matches mainstream SDKs, leaves the door open to C as a future addition without rework.
|
||||
2. **Provider type naming: vendor-level (`openai`/`gemini`) over wire-format-encoded.** Resolved: vendor-level. Rationale: matches Vercel/LiteLLM/Go convention; new OpenAI transcription model snapshots require zero schema or code changes.
|
||||
3. **`TranscriptionConfig.Duration` field decision.** Not present in current proto; not added. Audio duration belongs to resource metadata (computed from the file at upload time), not to the transcription response.
|
||||
4. **Multimodal failure-mode surface.** Resolved: expose `FinishReason` from `audiollm.Model` to the application layer; the Transcribe handler converts non-`Stop` reasons into informative errors.
|
||||
|
||||
## 10. Implementation Plan Pointer
|
||||
|
||||
Once this spec is approved, the implementation plan will be created at `docs/superpowers/plans/2026-05-02-stt-audiollm-split.md` covering Stages A–G from §7 above as discrete, bite-sized tasks with TDD steps and per-stage commits.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Transcription (STT) settings — design
|
||||
|
||||
**Date:** 2026-05-02
|
||||
**Scope:** Backend + frontend. Schema-additive (no migration required).
|
||||
|
||||
## Problem
|
||||
|
||||
Memos has one AI feature today: audio transcription (speech-to-text). The current design has three concrete problems:
|
||||
|
||||
1. **Model is hard-coded per provider type.** `internal/ai/models.go` pins OpenAI to `gpt-4o-transcribe` and Gemini to `gemini-2.5-flash`. Users who want `whisper-1` (cheaper, often more accurate for non-English) or third-party Whisper-compatible endpoints (Groq's `whisper-large-v3-turbo`, self-hosted whisper.cpp / Speaches via OpenAI-compatible URL) cannot configure them at all.
|
||||
2. **No explicit transcription configuration.** `InstanceAISetting.providers` is a generic credentials list. The frontend (`MemoEditor/index.tsx:65`) implicitly picks "the first provider with an API key whose type is in TRANSCRIPTION_PROVIDER_TYPES." Users cannot:
|
||||
- Choose which provider runs transcription when they have multiple.
|
||||
- Set a default language (Whisper API supports it but it is never sent).
|
||||
- Set a `prompt` hint to bias spelling of proper nouns / jargon (a documented Whisper feature, surfaced by every other STT product).
|
||||
3. **Gemini fails for browser-recorded audio.** `internal/ai/gemini.go:23` does not list `audio/webm` in `geminiSupportedContentTypes`, but `MediaRecorder` in browsers defaults to `audio/webm`. So selecting a Gemini provider for in-editor recording produces a content-type error every time.
|
||||
|
||||
## Goal
|
||||
|
||||
Let the operator configure transcription explicitly: which provider, which model, default language, and a spelling-hint prompt. Make the OpenAI provider work as a universal "OpenAI-compatible" engine so Groq / self-hosted Whisper / Speaches are reachable through endpoint override.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Adding STT engines beyond OpenAI and Gemini (Azure, Deepgram, AWS Transcribe — out of scope; the schema admits them later via `AIProviderType` enum).
|
||||
- Other AI features (summarization, embeddings, tag suggestion). The schema is shaped so they fit later, but none are designed here.
|
||||
- Per-call provider override at recording time. Research across all surveyed products (OpenWebUI, LibreChat, Whisper Memos, Superwhisper, etc.) confirms STT engine is a global preference, not an action-time choice. We follow the same pattern.
|
||||
- Server-side audio transcoding (e.g., webm → wav for Gemini). See "Gemini webm" below for the chosen mitigation.
|
||||
- Multi-user or per-user override of admin defaults. Memos' STT setting is instance-scoped, like every other instance setting.
|
||||
|
||||
## Naming
|
||||
|
||||
Field and message names follow cross-platform STT conventions, not Memos-internal shorthand:
|
||||
|
||||
| Concept | Chosen name | Rationale |
|
||||
|---|---|---|
|
||||
| Config message | `TranscriptionConfig` | AssemblyAI uses this exact identifier; matches OpenAI's `CreateTranscription*` verb family and Memos' existing `Transcribe` RPC. The `STT` acronym is not used as a type name in any major STT API. |
|
||||
| Provider reference | `provider_id` (string) | Plain protobuf convention for a string-ID reference (`field_id`, `user_id` style). `engine` was rejected as an OpenWebUI-only term; typed message refs are not needed since providers are addressed by string ID. |
|
||||
| Model | `model` | Unanimous across OpenAI, Google v2, Deepgram, OpenWebUI, LibreChat. Not `model_id`. |
|
||||
| Default language | `language` | Bare `language` is the modern convention (OpenAI, Whisper family, Deepgram, Wyoming). `language_code` is the older Google/AWS form; we accept ISO 639-1 short codes the same way OpenAI does. |
|
||||
| Spelling hint | `prompt` | OpenAI's public API field name and AssemblyAI's. Whisper's internal name is `initial_prompt`, but `prompt` is what users of `audio.transcriptions.create` recognize. |
|
||||
|
||||
A note on the message name collision: `proto/api/v1/ai_service.proto` already declares a `TranscriptionConfig` for **per-call** prompt/language overrides. The new store-level `TranscriptionConfig` lives in package `memos.store`, so the two compile cleanly. Memos already uses parallel `api.v1.X` / `store.X` message pairs (e.g. `User`, `Memo`); this matches that pattern.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Schema (additive)
|
||||
|
||||
`proto/store/instance_setting.proto`:
|
||||
|
||||
```proto
|
||||
message InstanceAISetting {
|
||||
repeated AIProviderConfig providers = 1; // unchanged — credential pool
|
||||
TranscriptionConfig transcription = 2; // NEW — feature config
|
||||
}
|
||||
|
||||
message TranscriptionConfig {
|
||||
// References an entry in providers[].id. Empty string = transcription disabled.
|
||||
string provider_id = 1;
|
||||
// Free text. Empty string = engine default (whisper-1 for OPENAI, gemini-2.5-flash for GEMINI).
|
||||
string model = 2;
|
||||
// ISO 639-1 short code. Empty string = auto-detect.
|
||||
string language = 3;
|
||||
// Up to ~200 tokens. Used as the OpenAI Whisper `prompt` parameter and as
|
||||
// a "Context and spelling hints:" block in the Gemini prompt.
|
||||
string prompt = 4;
|
||||
}
|
||||
```
|
||||
|
||||
`proto/api/v1/ai_service.proto`:
|
||||
|
||||
- `TranscribeRequest.provider_id` becomes optional. When omitted, the server resolves the provider from `InstanceAISetting.transcription.provider_id`.
|
||||
- `TranscribeRequest.config` (per-call `TranscriptionConfig` with `prompt` / `language`) is kept for advanced overrides but its fields, when empty, fall back to the persisted defaults from `InstanceAISetting.transcription`.
|
||||
|
||||
### Backend changes
|
||||
|
||||
1. **`internal/ai/models.go`** — `DefaultTranscriptionModel` already exists; reuse it as the fallback when `TranscriptionConfig.model` is empty. No new code, just used from a new call site.
|
||||
2. **`server/router/api/v1/ai_service.go`**:
|
||||
- Read `InstanceAISetting.transcription` at the start of `Transcribe`.
|
||||
- Resolve `provider_id` from request → fall back to `transcription.provider_id`. If both empty, return `FailedPrecondition` with a clear "transcription not configured" message.
|
||||
- Resolve `model` similarly: request override → `transcription.model` → engine default via `DefaultTranscriptionModel`.
|
||||
- Merge `language` and `prompt`: per-call overrides win; otherwise fall through to persisted defaults.
|
||||
3. **`internal/ai/gemini.go`** — out of scope to fix the webm content-type list here. See mitigation below.
|
||||
|
||||
### Frontend changes
|
||||
|
||||
`web/src/components/Settings/AISection.tsx` is restructured into two settings groups inside the existing `SettingSection`:
|
||||
|
||||
1. **AI Integrations** (renamed from "Providers" — current behavior): list of credential entries (id, title, type, endpoint, api key). No functional changes; the rename communicates that this section is just credentials.
|
||||
2. **Transcription** (new): three-segment form
|
||||
- **Provider** — Select dropdown listing entries from group 1 by `title`. First option is "None — transcription disabled". Disabled with a hint "Add an AI integration first ↑" when group 1 is empty.
|
||||
- **Model** — text input. Placeholder updates dynamically based on the selected provider's type (`whisper-1` for OPENAI, `gemini-2.5-flash` for GEMINI). Help text below: "Free text. Use the provider's model identifier — e.g., whisper-1, gpt-4o-transcribe, whisper-large-v3-turbo."
|
||||
- **Default language** — text input, ISO 639-1 placeholder, empty = auto.
|
||||
- **Prompt hints** — textarea, ~200 token soft limit, help text "Improves spelling of proper nouns and jargon. Whisper limit is ~224 tokens."
|
||||
|
||||
`web/src/components/MemoEditor/index.tsx:65` changes:
|
||||
|
||||
- Replace the "first provider with apiKey in TRANSCRIPTION_PROVIDER_TYPES" lookup with this enable rule: transcribe button shows iff `aiSetting.transcription.providerId` is non-empty AND the referenced provider exists in `aiSetting.providers` AND that provider has `apiKeySet === true`.
|
||||
- The editor no longer needs to know the provider object itself for the call — see service change below.
|
||||
|
||||
`web/src/components/MemoEditor/services/transcriptionService.ts` is simplified: it stops accepting a `provider` argument and simply omits `provider_id` from the request. The server resolves the provider, model, language, and prompt from `InstanceAISetting.transcription`. (No override path is exposed at the editor layer; advanced callers can still pass `provider_id` directly via the proto if needed in the future.)
|
||||
|
||||
### How "OpenAI-compatible" backends work
|
||||
|
||||
To use Groq, Speaches, or self-hosted whisper.cpp:
|
||||
|
||||
1. In **AI Integrations**, add a provider with type `OPENAI`, set `endpoint` to e.g. `https://api.groq.com/openai/v1` or `http://speaches:8000/v1`, set the API key, give it a recognizable title ("Groq", "Self-hosted Whisper").
|
||||
2. In **Transcription**, select that provider and set `model` to the backend's model identifier (`whisper-large-v3-turbo`, `Systran/faster-distil-whisper-large-v3`, etc.).
|
||||
|
||||
This is the universal escape hatch confirmed across OpenWebUI, LibreChat, and Whisper Obsidian plugin: don't enumerate every backend — let the OpenAI engine be a transport, not a brand.
|
||||
|
||||
## Gemini webm mitigation
|
||||
|
||||
The Gemini `audio/webm` failure is a real user-blocking bug but separate from the settings redesign. Three options were considered:
|
||||
|
||||
- **(a) Server-side transcode** with ffmpeg. Adds a heavy runtime dep; rejected as YAGNI.
|
||||
- **(b) Switch MediaRecorder format** when STT engine is Gemini. Browser support for `audio/mp4` and `audio/wav` in `MediaRecorder` is patchy across Firefox / Safari / Chrome; rejected as fragile.
|
||||
- **(c) Inline hint + accept the limitation.** Selected. The Transcription section shows a small warning under the model field when the chosen provider type is `GEMINI`: "Gemini does not accept browser-recorded `audio/webm`. For in-editor recording, use an OpenAI-compatible provider."
|
||||
|
||||
Server-side transcoding can be revisited later as a self-contained change if Gemini demand grows.
|
||||
|
||||
## Validation
|
||||
|
||||
Server validation (`server/router/api/v1/ai_service.go`):
|
||||
|
||||
- `transcription.provider_id`, when set, must reference an existing entry in `providers[]`. On `UpdateInstanceSetting` for the AI key, reject with `InvalidArgument` if it doesn't.
|
||||
- `transcription.model` length cap: 256 chars (covers `Systran/faster-distil-whisper-large-v3`-style names with margin).
|
||||
- `transcription.language` length cap: 32 chars (existing constant `maxTranscriptionLanguageLength`).
|
||||
- `transcription.prompt` length cap: 4096 chars (existing constant `maxTranscriptionPromptLength`).
|
||||
|
||||
Frontend validation in `AISection.tsx`:
|
||||
|
||||
- "Save" disabled if `transcription.providerId` is set but the referenced provider was just deleted from the integrations list (in the same unsaved edit).
|
||||
- Inline warning shown (but Save still allowed) if the referenced provider exists but has `apiKeySet === false` — surfacing the broken state so the operator can fix it without blocking unrelated edits to other settings.
|
||||
|
||||
## Backwards compatibility
|
||||
|
||||
The schema change is purely additive. Existing instances with `providers` configured but no `transcription` field default to `provider_id = ""`, which means transcription is disabled until the operator visits the new Transcription section and selects a provider.
|
||||
|
||||
This is a small UX regression for instances that were relying on the implicit "first provider wins" behavior — they now must make a one-click selection. Acceptable trade-off because:
|
||||
|
||||
- It makes the choice explicit (the implicit pick was the source of confusion when users had multiple providers).
|
||||
- A one-time migration that auto-fills `transcription.provider_id` with the first STT-capable provider is feasible but adds complexity for a one-line user action. Skip the migration; document the change in the release notes.
|
||||
|
||||
## Testing
|
||||
|
||||
- `internal/ai/transcription_test.go` (existing) covers the transcribe RPC. Add cases for: empty `provider_id` falls back to setting; empty `model` falls back to `DefaultTranscriptionModel`; per-call overrides win over settings.
|
||||
- `server/router/api/v1/test/ai_service_test.go` (existing) covers the API service. Add cases for the validation rules above (unknown provider_id, oversized model/language/prompt).
|
||||
- Frontend: manual verification via the dev server (`pnpm dev` in `web/`) — load Settings, add a provider, configure transcription, verify the home editor's record button enables/disables based on `provider_id`. No new component tests required (existing AISection has none).
|
||||
|
||||
## Out of scope, explicitly
|
||||
|
||||
- Multiple transcription configurations / per-tag or per-user routing.
|
||||
- Per-call provider override exposed in the editor UI.
|
||||
- Test-transcription button in settings (worth doing later; deferred to keep this scope tight).
|
||||
- Glossary / vocabulary list as a separate field — folded into `prompt` for now (Joplin/Superwhisper split this; we can add later if users ask).
|
||||
- TTS settings. Memos has none today and none planned.
|
||||
@@ -0,0 +1,113 @@
|
||||
# First Screen Lazy Heavy Dependencies Design
|
||||
|
||||
## Context
|
||||
|
||||
The auth and signup pages currently fetch JavaScript and CSS assets for features that are not used on the first screen, including Mermaid, KaTeX, Leaflet, and React Leaflet. The current routing already uses lazy route components, so the remaining problem is eager imports from shared app entry points and feature modules.
|
||||
|
||||
The goal is to reduce first screen load time, especially for `/auth` and `/auth/signup`, without changing memo rendering, map behavior, or authenticated workflows.
|
||||
|
||||
## Goals
|
||||
|
||||
- Prevent Mermaid and Leaflet vendor chunks from loading on auth/signup before they are needed.
|
||||
- Prevent Leaflet and KaTeX CSS from loading globally at app startup.
|
||||
- Preserve current behavior when users view Mermaid diagrams, math content, memo location previews, profile maps, or location pickers.
|
||||
- Keep fallbacks small and consistent with existing async rendering patterns.
|
||||
- Verify the production build and confirm auth/signup network requests no longer include Mermaid or Leaflet chunks during initial render.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Rewriting the markdown rendering pipeline.
|
||||
- Removing support for Mermaid, KaTeX, Leaflet, or React Leaflet.
|
||||
- Optimizing every authenticated route in this change.
|
||||
- Changing server behavior, route guards, or authentication flow.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
Use feature-level lazy loading for heavy optional features. Keep the app shell and auth routes free of diagram, math styling, and map dependencies. Load those dependencies from the feature boundary where the user actually needs them.
|
||||
|
||||
This approach has the best balance of impact and risk because it removes known eager imports while preserving existing route structure and feature internals.
|
||||
|
||||
## Architecture
|
||||
|
||||
### App Entry
|
||||
|
||||
`web/src/main.tsx` should no longer import:
|
||||
|
||||
- `leaflet/dist/leaflet.css`
|
||||
- `katex/dist/katex.min.css`
|
||||
|
||||
These styles are feature-specific and should be loaded from the map and markdown rendering paths.
|
||||
|
||||
### Mermaid
|
||||
|
||||
`web/src/components/MemoContent/MermaidBlock.tsx` should replace the static `import mermaid from "mermaid"` with an async `import("mermaid")` inside the render effect.
|
||||
|
||||
The component should keep its current behavior:
|
||||
|
||||
- initialize Mermaid with the resolved app theme;
|
||||
- render when code content or theme changes;
|
||||
- show the existing error fallback when rendering fails.
|
||||
|
||||
The Mermaid chunk should only be requested when a memo actually renders a Mermaid code block.
|
||||
|
||||
### KaTeX
|
||||
|
||||
KaTeX CSS should load from the memo markdown rendering path instead of the app entry. Since `rehype-katex` is only useful when memo markdown is rendered, loading the stylesheet near `MemoMarkdownRenderer` keeps auth/signup free of KaTeX CSS while preserving math output styling.
|
||||
|
||||
This change does not need content-level math detection. Loading KaTeX CSS with memo markdown is simpler and still removes it from the first auth/signup screen.
|
||||
|
||||
### Leaflet Maps
|
||||
|
||||
Leaflet-dependent UI should be moved behind lazy component boundaries:
|
||||
|
||||
- `UserMemoMap` should be lazy-loaded by the user profile route or by a small wrapper component.
|
||||
- `LocationPicker` should be lazy-loaded where location UI is opened or displayed.
|
||||
|
||||
The underlying map implementations can continue using Leaflet, React Leaflet, marker clustering, and their current helpers. The key boundary is that parent components must not statically import the map implementation if that parent can be pulled into non-map first-screen chunks.
|
||||
|
||||
Leaflet CSS and marker cluster CSS should load inside the lazy map implementation path, not from `main.tsx`.
|
||||
|
||||
### Type Imports
|
||||
|
||||
Any imports from `leaflet` that are used only as TypeScript types should use `import type`. Runtime construction such as `new LatLng(...)` should be avoided in parent components that are meant to stay Leaflet-free; pass plain latitude/longitude data into lazy map wrappers and construct Leaflet objects inside the lazy implementation.
|
||||
|
||||
## Data Flow
|
||||
|
||||
Auth/signup initial render:
|
||||
|
||||
1. App entry initializes theme, locale, providers, auth, and instance data.
|
||||
2. Router loads only the auth/signup route component and shared app shell dependencies.
|
||||
3. Mermaid, Leaflet, React Leaflet, marker cluster, and feature CSS are not requested.
|
||||
|
||||
Memo markdown render:
|
||||
|
||||
1. Memo content renders with the existing markdown renderer.
|
||||
2. KaTeX CSS loads with the markdown rendering path.
|
||||
3. If a code block language is `mermaid`, `MermaidBlock` dynamically imports Mermaid and renders the diagram.
|
||||
|
||||
Map render:
|
||||
|
||||
1. A map feature mounts through a lazy boundary.
|
||||
2. The lazy implementation imports Leaflet, React Leaflet, and required map CSS.
|
||||
3. Existing map interactions and display behavior continue inside the loaded implementation.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Mermaid import or render failures should use the existing Mermaid error UI with the original code content visible.
|
||||
- Lazy map boundaries should use minimal fallbacks sized like the eventual map container to avoid layout shift.
|
||||
- Chunk load failures should continue to use the existing router chunk reload behavior where applicable.
|
||||
|
||||
## Testing
|
||||
|
||||
- Run `pnpm build` in `web`.
|
||||
- Run `pnpm lint` in `web`.
|
||||
- Inspect production build output to ensure Mermaid and Leaflet remain split chunks.
|
||||
- Use a production preview or equivalent browser check for `/auth/signup` and confirm initial network requests do not include Mermaid or Leaflet JavaScript chunks.
|
||||
- Smoke test memo content with Mermaid and math.
|
||||
- Smoke test location picker, location popover, and user profile map.
|
||||
|
||||
## Risks
|
||||
|
||||
- Loading CSS from lazy paths can cause a small style delay the first time a map or math content appears. Use map-sized fallbacks and keep CSS imports in the feature implementation to minimize visible shifts.
|
||||
- Moving Leaflet runtime types out of parent components may require small prop shape changes.
|
||||
- Dynamic Mermaid import needs effect cancellation to avoid setting state after unmount.
|
||||
@@ -0,0 +1,282 @@
|
||||
# Placeholder Component Design
|
||||
|
||||
## Context
|
||||
|
||||
The web frontend currently renders empty states with a single `Empty.tsx` component that displays a lucide `BirdIcon`. It is used in exactly one place (`web/src/pages/Inboxes.tsx`) and offers no support for other state varieties — loading, no-search-results, or 404 pages — each of which is currently handled inconsistently or not at all.
|
||||
|
||||
The goal is to replace `Empty.tsx` with a single reusable `<Placeholder>` component that renders a hand-curated ASCII bird illustration plus a short muted message, supports four distinct variants, and ships with the architectural seam needed to grow a randomized pool of ASCII pieces per variant in future work.
|
||||
|
||||
The visual register is "cozy and minimal": muted colors, monospace throughout, gentle motion that respects `prefers-reduced-motion`. The ASCII art itself is drawn from Joan Stark's (jgs) classic ASCII bird collection (https://github.com/oldcompcz/jgs), preserving the `jgs` signature and date as a visible credit beneath each piece.
|
||||
|
||||
## Goals
|
||||
|
||||
- Provide a single `<Placeholder variant="…">` component covering empty, loading, no-results, and 404 states.
|
||||
- Render real, recognizable ASCII bird art (not freehand sketches), preserving Joan Stark's attribution.
|
||||
- Apply subtle CSS-only animation (bob for perched birds, flutter for in-flight birds, fade-in on the message).
|
||||
- Respect `prefers-reduced-motion` — no animation when the user opts out.
|
||||
- Provide a pool-shaped data file so future PRs can drop in additional ASCII pieces per variant without component changes.
|
||||
- Replace the existing `Empty.tsx` in `Inboxes.tsx` as the proof of integration.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Adding additional ASCII pieces beyond the four initial picks (one per variant). The pool architecture supports growth, but seeding more pieces is a follow-up.
|
||||
- Wiring the component into search results, the 404 route, or Suspense fallbacks. Each is a candidate; each is a separate PR.
|
||||
- Translating the default messages. The file structure leaves a seam for `i18next`, but the initial PR ships plain strings.
|
||||
- Adding a JS animation library (Framer Motion, react-spring, etc.). Three keyframe animations do not justify a new dependency.
|
||||
- Visual regression testing infrastructure.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
Build a single `<Placeholder>` component with a `variant` prop that selects a randomized ASCII piece from a co-located pool data file. Animation is CSS-only, with motion presets keyed off each piece's `motion` field. Accessibility uses `aria-hidden` on the decorative ASCII and a semantic `<p>` for the message.
|
||||
|
||||
This approach has the best balance of present-day simplicity and future extensibility: the initial pool contains one piece per variant, so the component is deterministic today, but the picker function and pool shape impose no constraints on how many pieces are added later.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Public Component
|
||||
|
||||
**Path:** `web/src/components/Placeholder/index.tsx`
|
||||
|
||||
```tsx
|
||||
import { useMemo } from "react";
|
||||
import clsx from "clsx";
|
||||
import { pickPiece, MotionStyle, PlaceholderVariant } from "./ascii-pool";
|
||||
import { DEFAULT_MESSAGES } from "./messages";
|
||||
import "./Placeholder.css";
|
||||
|
||||
interface PlaceholderProps {
|
||||
variant: PlaceholderVariant;
|
||||
message?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MOTION_CLASS: Record<MotionStyle, string> = {
|
||||
bob: "placeholder-motion-bob",
|
||||
flutter: "placeholder-motion-flutter",
|
||||
none: "",
|
||||
};
|
||||
|
||||
export function Placeholder({ variant, message, children, className }: PlaceholderProps) {
|
||||
const piece = useMemo(() => pickPiece(variant), [variant]);
|
||||
const resolvedMessage = message ?? DEFAULT_MESSAGES[variant];
|
||||
const isLoading = variant === "loading";
|
||||
|
||||
return (
|
||||
<div
|
||||
role={isLoading ? "status" : undefined}
|
||||
aria-live={isLoading ? "polite" : undefined}
|
||||
className={clsx("flex flex-col items-center justify-center max-w-md mx-auto px-4 py-8", className)}
|
||||
>
|
||||
<pre
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
"font-mono text-xs sm:text-sm leading-tight text-muted-foreground whitespace-pre",
|
||||
MOTION_CLASS[piece.motion],
|
||||
)}
|
||||
>
|
||||
{piece.ascii}
|
||||
</pre>
|
||||
<p className="mt-3 font-mono text-sm text-muted-foreground placeholder-fade-in">
|
||||
{resolvedMessage}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-[10px] text-muted-foreground/60">{piece.credit}</p>
|
||||
{children && <div className="mt-4">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `useMemo` keyed on `variant` ensures the piece is stable for the mount but re-rolls if the variant prop changes (rare in practice). Re-mounting re-rolls naturally.
|
||||
|
||||
### ASCII Pool
|
||||
|
||||
**Path:** `web/src/components/Placeholder/ascii-pool.ts`
|
||||
|
||||
```ts
|
||||
export type PlaceholderVariant = "empty" | "loading" | "noResults" | "notFound";
|
||||
export type MotionStyle = "bob" | "flutter" | "none";
|
||||
|
||||
export interface AsciiPiece {
|
||||
id: string;
|
||||
variant: PlaceholderVariant;
|
||||
ascii: string;
|
||||
credit: string;
|
||||
motion: MotionStyle;
|
||||
}
|
||||
|
||||
export const ASCII_POOL: AsciiPiece[] = [
|
||||
{
|
||||
id: "jgs-crested-parrot",
|
||||
variant: "empty",
|
||||
credit: "jgs · 4/97",
|
||||
motion: "bob",
|
||||
ascii: ` .---.
|
||||
/ 6_6
|
||||
\\_ (__\\
|
||||
// \\\\
|
||||
(( ))
|
||||
=====""===""=====
|
||||
|||
|
||||
|`,
|
||||
},
|
||||
{
|
||||
id: "jgs-hummingbird-sm",
|
||||
variant: "loading",
|
||||
credit: "jgs · 7/98",
|
||||
motion: "flutter",
|
||||
ascii: ` , _
|
||||
{ \\/\`o;====-
|
||||
.----'-/\`-/
|
||||
\`'-..-| /
|
||||
/\\/\\
|
||||
\`--\``,
|
||||
},
|
||||
{
|
||||
id: "jgs-wide-eyed-owl",
|
||||
variant: "noResults",
|
||||
credit: "jgs · 2/01",
|
||||
motion: "bob",
|
||||
ascii: ` __ __
|
||||
\\ \`-'"'-\` /
|
||||
/ \\_ _/ \\
|
||||
| d\\_/b |
|
||||
.'\\ V /'.
|
||||
/ '-...-' \\
|
||||
| / \\ |
|
||||
\\/\\ /\\/
|
||||
==(||)---(||)==`,
|
||||
},
|
||||
{
|
||||
id: "jgs-bird-flown-away",
|
||||
variant: "notFound",
|
||||
credit: "jgs · 7/96",
|
||||
motion: "flutter",
|
||||
ascii: ` ___
|
||||
_,-' ______
|
||||
.' .-' ____7
|
||||
/ / ___7
|
||||
_| / ___7
|
||||
>(')\\ | ___7
|
||||
\\\\/ \\_______
|
||||
' _======>
|
||||
\`'----\\\\\``,
|
||||
},
|
||||
];
|
||||
|
||||
export function pickPiece(variant: PlaceholderVariant): AsciiPiece {
|
||||
const matches = ASCII_POOL.filter(p => p.variant === variant);
|
||||
return matches[Math.floor(Math.random() * matches.length)];
|
||||
}
|
||||
```
|
||||
|
||||
> Each `ascii` value is a template literal; backslashes and backticks are escaped per JS rules. The art shown here is verbatim from the brainstorm sign-off; implementation must preserve every space.
|
||||
|
||||
The `ascii` field of each entry holds the verbatim ASCII string. The four initial pieces are the ones validated during brainstorming:
|
||||
|
||||
- **empty** — Joan Stark's "early-bird" parrot with crest, perched on a branch
|
||||
- **loading** — Joan Stark's compact hummingbird (mid-flight, fluttering)
|
||||
- **noResults** — Joan Stark's wide-eyed two-feathered owl
|
||||
- **notFound** — Joan Stark's flying-away bird with motion trails
|
||||
|
||||
Each piece's `ascii` is committed as a template literal preserving exact whitespace; the file is the source of truth and is small enough (~5–10 lines of art per piece) to keep inline rather than splitting into per-piece files.
|
||||
|
||||
### Default Messages
|
||||
|
||||
**Path:** `web/src/components/Placeholder/messages.ts`
|
||||
|
||||
```ts
|
||||
export const DEFAULT_MESSAGES: Record<PlaceholderVariant, string> = {
|
||||
empty: "No memos yet",
|
||||
loading: "Loading…",
|
||||
noResults: "Nothing matches that search",
|
||||
notFound: "This page flew the coop",
|
||||
};
|
||||
```
|
||||
|
||||
Plain strings for the initial PR. A future i18n pass swaps these for `t("placeholder.empty")` etc. without touching the component.
|
||||
|
||||
### Animation
|
||||
|
||||
CSS keyframes live in a small co-located stylesheet (`Placeholder.css`) imported by `index.tsx`, scoped via a `.placeholder-…` class prefix to avoid leakage. The codebase uses Tailwind v4 plus plain CSS imports elsewhere; this matches the existing pattern.
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.placeholder-motion-bob { animation: placeholder-bob 3.4s ease-in-out infinite; }
|
||||
.placeholder-motion-flutter { animation: placeholder-flutter 0.7s ease-in-out infinite; }
|
||||
.placeholder-fade-in { animation: placeholder-fade 1s ease-out 0.3s both; opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes placeholder-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
@keyframes placeholder-flutter {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(2px, -1px); }
|
||||
}
|
||||
|
||||
@keyframes placeholder-fade {
|
||||
to { opacity: 1; }
|
||||
}
|
||||
```
|
||||
|
||||
The reduced-motion guard wraps all three rules. When the user prefers reduced motion, the bird is static and the message appears without fading.
|
||||
|
||||
### Accessibility
|
||||
|
||||
- ASCII `<pre>` carries `aria-hidden="true"`; screen readers do not announce it character-by-character.
|
||||
- The message is a semantic `<p>`. It is the accessible name of the placeholder for assistive tech.
|
||||
- For `variant="loading"` only, the wrapper has `role="status"` and `aria-live="polite"` so the loading message is announced when the placeholder appears.
|
||||
- The credit line (`jgs · 4/97`) is visible-but-small below the message. It is not aria-hidden because it is intentionally part of the visual presentation.
|
||||
- The component itself is not focusable. Anything passed as `children` (e.g. a "Go home" button on 404) participates normally in tab order.
|
||||
- Color contrast relies on the existing `text-muted-foreground` token, which already meets WCAG AA in the project's theme.
|
||||
|
||||
### File Layout
|
||||
|
||||
```
|
||||
web/src/components/Placeholder/
|
||||
index.tsx # the <Placeholder> component (public export)
|
||||
Placeholder.css # keyframes + .placeholder-motion-* classes
|
||||
ascii-pool.ts # AsciiPiece type, ASCII_POOL array, pickPiece()
|
||||
messages.ts # DEFAULT_MESSAGES map (i18n-ready seam)
|
||||
CREDITS.md # Joan Stark attribution + link to oldcompcz/jgs
|
||||
```
|
||||
|
||||
### Integration
|
||||
|
||||
- **Delete:** `web/src/components/Empty.tsx`.
|
||||
- **Modify:** `web/src/pages/Inboxes.tsx` — replace the `<Empty />` import and usage with `<Placeholder variant="empty" />`.
|
||||
|
||||
Other potential call sites (search results page, router 404 catch-all, Suspense fallbacks) are explicitly out of scope for this PR. They are noted in the PR description as follow-up opportunities.
|
||||
|
||||
### Credits
|
||||
|
||||
**Path:** `web/src/components/Placeholder/CREDITS.md`
|
||||
|
||||
A short Markdown file pointing to https://github.com/oldcompcz/jgs and acknowledging Joan Stark's work. This survives even if the per-piece `credit` field is ever cleaned up by a refactor.
|
||||
|
||||
## Testing
|
||||
|
||||
A small Vitest suite covers:
|
||||
|
||||
- Each variant renders without throwing.
|
||||
- The chosen `<pre>` content contains text from the matching pool entry.
|
||||
- `aria-hidden="true"` on the `<pre>`.
|
||||
- `role="status"` only present when `variant="loading"`.
|
||||
- A custom `message` prop overrides the default.
|
||||
- The credit text is present in the DOM.
|
||||
|
||||
No visual regression testing is added.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. All design decisions were validated during the brainstorming session — animation register (cozy), bird selections (jgs collection), naming (`Placeholder`, no "bird" in the name), text treatment (plain muted monospace), and integration scope (replace `Empty.tsx`, do not wire other sites yet).
|
||||
|
||||
## References
|
||||
|
||||
- Joan Stark's ASCII Art Gallery (jgs collection): https://github.com/oldcompcz/jgs
|
||||
- Existing component being replaced: `web/src/components/Empty.tsx`
|
||||
- Existing call site: `web/src/pages/Inboxes.tsx`
|
||||
- Visual brainstorm artifacts: `.superpowers/brainstorm/1991-1778593581/content/` (mascot-approach, animation-vibe, bird-shapes-v2, jgs-bird-set, text-treatment-c)
|
||||
@@ -0,0 +1,82 @@
|
||||
# Remove react-use Design
|
||||
|
||||
## Context
|
||||
|
||||
The frontend has a direct dependency on `react-use@17.6.0`. Current source usage is limited to six imports:
|
||||
|
||||
- `usePrevious` in `web/src/layouts/RootLayout.tsx`
|
||||
- `useLocalStorage` in `web/src/components/MemoExplorer/TagsSection.tsx`
|
||||
- `useWindowScroll` in `web/src/components/MobileHeader.tsx`
|
||||
- `useToggle` in `web/src/components/TagTree.tsx`
|
||||
- `useDebounce` in `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`
|
||||
- `useDebounce` in `web/src/components/MemoEditor/hooks/useLinkMemo.ts`
|
||||
|
||||
`react-use` pulls in `js-cookie@2.2.1` transitively. A direct `js-cookie@3.0.7` dependency does not remove that vulnerable transitive copy, so the better remediation is to remove `react-use` rather than adding another copy of `js-cookie`.
|
||||
|
||||
## Goals
|
||||
|
||||
- Remove `react-use` from `web/package.json` and `web/pnpm-lock.yaml`.
|
||||
- Remove all source imports from `react-use` and `react-use/lib/*`.
|
||||
- Prefer built-in React hooks directly where the replacement is simple.
|
||||
- Add local hooks only where reuse or browser-side-effect cleanup makes the code clearer and safer.
|
||||
- Confirm `react-use` and `js-cookie` are no longer present in the frontend dependency graph.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not introduce another general-purpose React hooks library.
|
||||
- Do not refactor unrelated frontend state management.
|
||||
- Do not change user-visible behavior of tag preferences, tag tree expansion, scroll shadow behavior, route filter clearing, memo link search, or reverse geocoding debounce.
|
||||
|
||||
## Approach
|
||||
|
||||
Use native React hooks for one-off, simple behavior:
|
||||
|
||||
- Replace `useToggle` in `TagTree` with `useState(false)` plus local callbacks.
|
||||
- Replace `usePrevious` in `RootLayout` with local `useRef` and `useEffect` while preserving the existing previous-path comparison.
|
||||
- Replace `useWindowScroll` in `MobileHeader` with local `useState` and `useEffect` for the scroll listener.
|
||||
|
||||
Create focused local hooks where repeated behavior or cleanup consistency matters:
|
||||
|
||||
- Add `useDebouncedEffect` under `web/src/hooks/` for the two debounce call sites. It schedules a callback after the configured delay and clears the timeout when dependencies change or the component unmounts.
|
||||
- Add a typed `useLocalStorage` under `web/src/hooks/` for tag display settings. It reads the stored value on initialization, falls back to the provided default, writes updates to `localStorage`, and tolerates unavailable or malformed storage by using the default.
|
||||
|
||||
Update imports to use `@/hooks/...` for local hooks. Keep hook APIs small and shaped around current usage rather than cloning the full `react-use` API.
|
||||
|
||||
## Data Flow And Behavior
|
||||
|
||||
Tag view settings continue to persist in `localStorage` under the same keys:
|
||||
|
||||
- `tag-view-as-tree`
|
||||
- `tag-tree-auto-expand`
|
||||
|
||||
Debounced effects continue to defer:
|
||||
|
||||
- reverse-geocoding position updates in the memo editor insert menu by 1000 ms
|
||||
- memo link search requests by 300 ms
|
||||
|
||||
The route filter clearing logic continues to run only when navigation changes route and the URL has no `filter` parameter.
|
||||
|
||||
The mobile header continues to show its shadow when the window has scrolled below the top.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The local storage hook catches read and write failures. On read failure or malformed JSON, it returns the default value. On write failure, it keeps React state updated so the current UI interaction still works, while avoiding a thrown render or event-handler error.
|
||||
|
||||
Debounced effects do not swallow errors inside the user callback. Existing call sites already handle their own asynchronous errors where needed.
|
||||
|
||||
## Testing And Verification
|
||||
|
||||
Run frontend validation after implementation:
|
||||
|
||||
- `pnpm install --lockfile-only` from `web/` to regenerate `pnpm-lock.yaml`
|
||||
- `pnpm why react-use js-cookie` from `web/` and confirm neither dependency remains
|
||||
- `pnpm lint` from `web/`
|
||||
- `pnpm build` from `web/`
|
||||
|
||||
Manual checks should cover:
|
||||
|
||||
- toggling tag tree mode and auto-expand persists across reload
|
||||
- opening tag tree nodes still works
|
||||
- mobile header shadow appears after scrolling
|
||||
- memo link search still debounces and updates results
|
||||
- location reverse geocoding still waits for position changes before lookup
|
||||
Reference in New Issue
Block a user