# STT and Audio-LLM Split — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Refactor `internal/ai/` into a `stt/` (deterministic transcription) and `audiollm/` (multimodal audio LLM) package pair, port behavior from the current `openai.go` and `gemini.go`, refactor the `Transcribe` API handler to dispatch by provider type, then update proto comments and frontend i18n. End state preserves current end-to-end behavior; the response field for Gemini failures is more informative (`FinishReason`). **Architecture:** Two new packages under `internal/ai/`. Each has a thin interface in the parent (`stt.Transcriber`, `audiollm.Model`), a factory that switches on `ai.ProviderType`, and a sub-package per provider (`stt/openai/`, `audiollm/gemini/`). Application-layer dispatch in the gRPC handler decides which interface to invoke. No proto field changes — only two comment updates. **Tech Stack:** Go 1.x (existing Memos toolchain), `github.com/openai/openai-go/v3`, `google.golang.org/genai`, `github.com/pkg/errors`, `github.com/stretchr/testify`. Proto: buf v2. Frontend: TypeScript + bufbuild/es. **Spec:** `docs/superpowers/specs/2026-05-02-stt-audiollm-split-design.md` — read it before starting; this plan implements §6–§7 of the spec. **Worktree:** Run from a dedicated worktree (e.g., `git worktree add ../memos-stt-split`). Each stage is a separate commit; the branch should be PR-ready when all 7 stages are done. --- ## File Manifest **Created (new files):** - `internal/ai/stt/stt.go` - `internal/ai/stt/options.go` - `internal/ai/stt/factory.go` - `internal/ai/stt/openai/openai.go` - `internal/ai/stt/openai/openai_test.go` - `internal/ai/audiollm/audiollm.go` - `internal/ai/audiollm/options.go` - `internal/ai/audiollm/factory.go` - `internal/ai/audiollm/gemini/gemini.go` - `internal/ai/audiollm/gemini/gemini_test.go` **Modified:** - `internal/ai/errors.go` (add 2 sentinel errors) - `proto/store/instance_setting.proto` (2 field-comment blocks) - `proto/gen/store/instance_setting.pb.go` (regenerated) - `web/src/types/proto/store/instance_setting_pb.ts` (regenerated) - `server/router/api/v1/ai_service.go` (refactor `Transcribe` handler) - `web/src/locales/en.json` (3 strings) **Deleted:** - `internal/ai/transcription.go` - `internal/ai/client.go` - `internal/ai/openai.go` - `internal/ai/openai_test.go` - `internal/ai/gemini.go` - `internal/ai/gemini_test.go` **Untouched:** - `internal/ai/ai.go`, `resolver.go`, `models.go` - `internal/ai/audio/webm.go`, `audio/webm_test.go` - `web/src/components/Settings/AISection.tsx` (no structural change; only the i18n strings it consumes change) --- ## Stage A: Scaffold new packages and add sentinel errors **Files:** - Modify: `internal/ai/errors.go` - Create: `internal/ai/stt/stt.go` - Create: `internal/ai/stt/options.go` - Create: `internal/ai/stt/factory.go` - Create: `internal/ai/audiollm/audiollm.go` - Create: `internal/ai/audiollm/options.go` - Create: `internal/ai/audiollm/factory.go` This stage adds types and stubs only. No real provider implementation yet — both factories return errors for every provider type. We verify the project still builds. - [ ] **A.1: Add sentinel errors** Open `internal/ai/errors.go`, read the existing file. Append (or replace if you find a clearer location) so the file ends with these errors in addition to whatever is already there: ```go // ErrSTTNotSupported indicates that the provider does not have a dedicated // speech-to-text endpoint. Use the audiollm package for multimodal audio // understanding when this is returned. var ErrSTTNotSupported = errors.New("provider does not support speech-to-text capability") // ErrAudioLLMNotSupported indicates that the provider does not have a // multimodal-audio LLM available in this codebase. var ErrAudioLLMNotSupported = errors.New("provider does not support multimodal audio capability") ``` If `errors.go` does not yet import `errors`, add: ```go import "errors" ``` to its imports block. Use the standard library `errors`, not `github.com/pkg/errors` — `errors.New` from stdlib is what `errors.Is` uses for sentinel comparison. - [ ] **A.2: Create `internal/ai/stt/stt.go`** ```go // Package stt defines the speech-to-text capability for AI providers. // Implementations call dedicated STT endpoints (e.g. OpenAI /audio/transcriptions) // and return deterministic transcription output. For multimodal LLMs that // happen to accept audio input, see internal/ai/audiollm. package stt import ( "context" "io" ) // Transcriber transcribes audio to text using a provider's dedicated STT endpoint. type Transcriber interface { Transcribe(ctx context.Context, req Request) (*Response, error) } // Request is the input to a transcription call. 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 } // Response is the output of a transcription call. type Response struct { Text string Language string // empty if provider did not return it Segments []Segment // empty unless provider returned timestamps } // Segment is a timestamped portion of the transcript. type Segment struct { Text string Start float64 End float64 Speaker string // empty unless using a diarization-capable model } ``` - [ ] **A.3: Create `internal/ai/stt/options.go`** ```go package stt import ( "net/http" "time" ) const defaultHTTPTimeout = 2 * time.Minute // Options is the resolved option set passed to provider implementations. type Options struct { HTTPClient *http.Client } // TranscriberOption customizes a Transcriber. type TranscriberOption func(*Options) // WithHTTPClient overrides the HTTP client used by the transcriber. func WithHTTPClient(client *http.Client) TranscriberOption { return func(o *Options) { if client != nil { o.HTTPClient = client } } } // ApplyOptions resolves a TranscriberOption slice into Options with defaults. func ApplyOptions(opts []TranscriberOption) Options { resolved := Options{HTTPClient: &http.Client{Timeout: defaultHTTPTimeout}} for _, apply := range opts { apply(&resolved) } return resolved } ``` - [ ] **A.4: Create `internal/ai/stt/factory.go`** ```go package stt import ( "github.com/pkg/errors" "github.com/usememos/memos/internal/ai" ) // NewTranscriber returns a Transcriber for the given provider, or an error if // the provider type does not have a dedicated STT endpoint. func NewTranscriber(cfg ai.ProviderConfig, opts ...TranscriberOption) (Transcriber, error) { switch cfg.Type { case ai.ProviderOpenAI: // Implemented in stage B; keep the stub return so the package compiles. return nil, errors.New("stt/openai not implemented yet") case ai.ProviderGemini: return nil, errors.Wrap(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) } } ``` - [ ] **A.5: Create `internal/ai/audiollm/audiollm.go`** ```go // Package audiollm defines the multimodal-audio capability for AI providers. // Implementations call chat-completions or generate-content style APIs that // accept audio as input. For deterministic transcription, prefer internal/ai/stt // where a dedicated STT endpoint exists. package audiollm import ( "context" "io" ) // Model invokes a multimodal LLM with audio input. type Model interface { GenerateFromAudio(ctx context.Context, req Request) (*Response, error) } // Request is the input to a multimodal-audio call. 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 the provider default in place } // Response is the output of a multimodal-audio call. type Response struct { Text string FinishReason FinishReason } // FinishReason describes why the model stopped generating. 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, including unknown ) ``` - [ ] **A.6: Create `internal/ai/audiollm/options.go`** ```go package audiollm import ( "net/http" "time" ) const defaultHTTPTimeout = 2 * time.Minute // Options is the resolved option set passed to provider implementations. type Options struct { HTTPClient *http.Client } // ModelOption customizes a Model. type ModelOption func(*Options) // WithHTTPClient overrides the HTTP client used by the model. func WithHTTPClient(client *http.Client) ModelOption { return func(o *Options) { if client != nil { o.HTTPClient = client } } } // ApplyOptions resolves a ModelOption slice into Options with defaults. func ApplyOptions(opts []ModelOption) Options { resolved := Options{HTTPClient: &http.Client{Timeout: defaultHTTPTimeout}} for _, apply := range opts { apply(&resolved) } return resolved } ``` - [ ] **A.7: Create `internal/ai/audiollm/factory.go`** ```go package audiollm import ( "github.com/pkg/errors" "github.com/usememos/memos/internal/ai" ) // NewModel returns a Model for the given provider, or an error if the provider // type does not expose a multimodal-audio capability in this codebase. func NewModel(cfg ai.ProviderConfig, opts ...ModelOption) (Model, error) { switch cfg.Type { case ai.ProviderGemini: // Implemented in stage C; keep the stub return so the package compiles. return nil, errors.New("audiollm/gemini not implemented yet") case ai.ProviderOpenAI: // gpt-4o-audio-preview support is intentionally out of scope (see spec §2). return nil, errors.Wrap(ai.ErrAudioLLMNotSupported, "OpenAI multimodal audio (gpt-4o-audio) is not implemented") default: return nil, errors.Wrapf(ai.ErrCapabilityUnsupported, "provider type %q", cfg.Type) } } ``` - [ ] **A.8: Verify the project still builds** ```bash go build ./... ``` Expected: exits 0, no output. (`internal/ai/transcription.go`, `client.go`, `openai.go`, `gemini.go` are still in place from before this plan; the new packages compile alongside them.) - [ ] **A.9: Commit** ```bash git add internal/ai/errors.go \ internal/ai/stt \ internal/ai/audiollm git commit -m "$(cat <<'EOF' feat(ai): scaffold stt and audiollm packages Adds two new sub-packages under internal/ai/ that will replace the single Transcriber abstraction: - stt/ — deterministic speech-to-text (dedicated STT endpoints) - audiollm/ — multimodal audio LLMs (chat-completions / generateContent) This commit only adds the interface types, factories with stub returns, and two sentinel errors. Provider implementations land in the next two commits; until then the existing internal/ai/transcription.go path is still the live code path. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Stage B: Implement `stt/openai` **Files:** - Create: `internal/ai/stt/openai/openai.go` - Create: `internal/ai/stt/openai/openai_test.go` - Modify: `internal/ai/stt/factory.go` Port the wire behavior of `internal/ai/openai.go::openAITranscriber.Transcribe` into the new package. TDD: write the test (adapted from `internal/ai/openai_test.go`) first. - [ ] **B.1: Write the failing test** Create `internal/ai/stt/openai/openai_test.go`: ```go package openai_test import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/stretchr/testify/require" "github.com/usememos/memos/internal/ai" "github.com/usememos/memos/internal/ai/stt" sttopenai "github.com/usememos/memos/internal/ai/stt/openai" ) func TestTranscribe(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodPost, r.Method) require.Equal(t, "/audio/transcriptions", r.URL.Path) require.Equal(t, "Bearer test-key", r.Header.Get("Authorization")) require.NoError(t, r.ParseMultipartForm(10<<20)) require.Equal(t, "gpt-4o-transcribe", r.FormValue("model")) require.Equal(t, "json", r.FormValue("response_format")) require.Equal(t, "domain words", r.FormValue("prompt")) require.Equal(t, "en", r.FormValue("language")) file, header, err := r.FormFile("file") require.NoError(t, err) defer file.Close() require.Equal(t, "voice.wav", header.Filename) require.Equal(t, "audio/wav", header.Header.Get("Content-Type")) w.Header().Set("Content-Type", "application/json") require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "text": "hello world", "language": "en", "duration": 1.5, })) })) defer server.Close() transcriber, err := sttopenai.New(ai.ProviderConfig{ Type: ai.ProviderOpenAI, Endpoint: server.URL, APIKey: "test-key", }, stt.ApplyOptions(nil)) require.NoError(t, err) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() response, err := transcriber.Transcribe(ctx, stt.Request{ Model: "gpt-4o-transcribe", Filename: "voice.wav", ContentType: "audio/wav", Audio: strings.NewReader("RIFF"), Prompt: "domain words", Language: "en", }) require.NoError(t, err) require.Equal(t, "hello world", response.Text) require.Equal(t, "en", response.Language) // Note: Duration intentionally omitted from stt.Response — not exposed in the new contract. } ``` - [ ] **B.2: Run the test to verify it fails** ```bash go test ./internal/ai/stt/openai/... ``` Expected: build failure with "undefined: openai.New" or similar — the implementation file does not exist yet. - [ ] **B.3: Create `internal/ai/stt/openai/openai.go`** Port from `internal/ai/openai.go`. The body is structurally identical to today's `openAITranscriber.Transcribe`, only the Request/Response types and package boundary change. ```go // Package openai implements stt.Transcriber against the OpenAI // /audio/transcriptions endpoint (and any compatible third-party endpoint // such as Groq Whisper, faster-whisper self-hosted, or Azure Whisper). package openai import ( "context" "mime" "net/url" "strings" openaisdk "github.com/openai/openai-go/v3" openaioption "github.com/openai/openai-go/v3/option" "github.com/pkg/errors" "github.com/usememos/memos/internal/ai" "github.com/usememos/memos/internal/ai/stt" ) const defaultEndpoint = "https://api.openai.com/v1" // Transcriber implements stt.Transcriber for OpenAI-compatible STT endpoints. type Transcriber struct { client openaisdk.Client } // New constructs a Transcriber from a provider config. func New(cfg ai.ProviderConfig, options stt.Options) (*Transcriber, error) { endpoint, err := normalizeEndpoint(cfg.Endpoint) if err != nil { return nil, err } if cfg.APIKey == "" { return nil, errors.New("OpenAI API key is required") } return &Transcriber{ client: openaisdk.NewClient( openaioption.WithAPIKey(cfg.APIKey), openaioption.WithBaseURL(endpoint), openaioption.WithHTTPClient(options.HTTPClient), ), }, nil } // Transcribe sends the audio to /audio/transcriptions. func (t *Transcriber) Transcribe(ctx context.Context, req stt.Request) (*stt.Response, error) { if strings.TrimSpace(req.Model) == "" { return nil, errors.New("model is required") } if req.Audio == nil { return nil, errors.New("audio is required") } filename, contentType, err := normalizeAudioMetadata(req) if err != nil { return nil, err } params := openaisdk.AudioTranscriptionNewParams{ File: openaisdk.File(req.Audio, filename, contentType), Model: openaisdk.AudioModel(req.Model), ResponseFormat: openaisdk.AudioResponseFormatJSON, } if req.Prompt != "" { params.Prompt = openaisdk.String(req.Prompt) } if req.Language != "" { params.Language = openaisdk.String(req.Language) } resp, err := t.client.Audio.Transcriptions.New(ctx, params) if err != nil { return nil, errors.Wrap(err, "failed to send OpenAI transcription request") } return &stt.Response{ Text: resp.Text, Language: resp.Language, }, nil } func normalizeEndpoint(endpoint string) (string, error) { endpoint = strings.TrimSpace(endpoint) if endpoint == "" { endpoint = defaultEndpoint } if _, err := url.ParseRequestURI(endpoint); err != nil { return "", errors.Wrap(err, "invalid OpenAI endpoint") } return strings.TrimRight(endpoint, "/"), nil } func normalizeAudioMetadata(req stt.Request) (string, string, error) { filename := strings.TrimSpace(req.Filename) if filename == "" { filename = "audio" } contentType := strings.TrimSpace(req.ContentType) if contentType == "" { contentType = "application/octet-stream" } else { mediaType, _, err := mime.ParseMediaType(contentType) if err != nil { return "", "", errors.Wrap(err, "invalid audio content type") } contentType = mediaType } return sanitizeFilename(filename), contentType, nil } func sanitizeFilename(filename string) string { filename = strings.NewReplacer("\r", "_", "\n", "_").Replace(filename) if strings.TrimSpace(filename) == "" { return "audio" } return filename } ``` - [ ] **B.4: Wire the factory to use the new implementation** Replace the stub branch in `internal/ai/stt/factory.go`: ```go package stt import ( "github.com/pkg/errors" "github.com/usememos/memos/internal/ai" sttopenai "github.com/usememos/memos/internal/ai/stt/openai" ) func NewTranscriber(cfg ai.ProviderConfig, opts ...TranscriberOption) (Transcriber, error) { switch cfg.Type { case ai.ProviderOpenAI: return sttopenai.New(cfg, ApplyOptions(opts)) case ai.ProviderGemini: return nil, errors.Wrap(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) } } ``` - [ ] **B.5: Run the test to verify it passes** ```bash go test ./internal/ai/stt/openai/... -v ``` Expected: `--- PASS: TestTranscribe`. Exit 0. - [ ] **B.6: Verify the whole project still builds** ```bash go build ./... ``` Expected: exits 0, no output. - [ ] **B.7: Commit** ```bash git add internal/ai/stt git commit -m "$(cat <<'EOF' feat(ai/stt): implement OpenAI-compatible Transcriber Ports the wire behavior of internal/ai/openai.go into the new internal/ai/stt/openai package. Behavior is identical: same SDK, same endpoint, same request shape; only the contract types (stt.Request, stt.Response) change. Duration is intentionally dropped from the response shape (audio file metadata, not transcription metadata). The legacy internal/ai/openai.go is still in place — it will be removed once the handler is refactored to call into the new stack. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Stage C: Implement `audiollm/gemini` **Files:** - Create: `internal/ai/audiollm/gemini/gemini.go` - Create: `internal/ai/audiollm/gemini/gemini_test.go` - Modify: `internal/ai/audiollm/factory.go` Port the wire behavior of `internal/ai/gemini.go::geminiTranscriber.Transcribe` with two semantic upgrades: 1. The transcription instruction is no longer hardcoded in this package — the caller passes `Request.Instructions`. 2. The Gemini API's `FinishReason` is mapped to `audiollm.FinishReason` and surfaced in the response, instead of being collapsed into a generic error. - [ ] **C.1: Write the failing tests** Create `internal/ai/audiollm/gemini/gemini_test.go`. Two tests: happy path and unsupported-content-type rejection. The happy-path test asserts that the caller-supplied `Instructions` string flows through to the request and that `FinishStop` is returned. We also verify that WebM input is transcoded to WAV by re-using `internal/ai/audio.WebMOpusToWAV`. ```go package gemini_test import ( "context" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/stretchr/testify/require" "github.com/usememos/memos/internal/ai" "github.com/usememos/memos/internal/ai/audiollm" audiollmgemini "github.com/usememos/memos/internal/ai/audiollm/gemini" ) func TestGenerateFromAudio(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodPost, r.Method) require.Equal(t, "/v1beta/models/gemini-2.5-flash:generateContent", r.URL.Path) require.Equal(t, "test-key", r.Header.Get("x-goog-api-key")) require.Equal(t, "application/json", r.Header.Get("Content-Type")) var request struct { Contents []struct { Parts []struct { Text string `json:"text"` InlineData *struct { MIMEType string `json:"mimeType"` Data string `json:"data"` } `json:"inlineData"` } `json:"parts"` } `json:"contents"` GenerationConfig map[string]json.Number `json:"generationConfig"` } require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) require.Len(t, request.Contents, 1) require.Len(t, request.Contents[0].Parts, 2) require.NotNil(t, request.Contents[0].Parts[0].InlineData) require.Equal(t, "audio/mp3", request.Contents[0].Parts[0].InlineData.MIMEType) audio, err := base64.StdEncoding.DecodeString(request.Contents[0].Parts[0].InlineData.Data) require.NoError(t, err) require.Equal(t, "audio bytes", string(audio)) require.Equal(t, "transcribe please", request.Contents[0].Parts[1].Text) require.Equal(t, json.Number("0"), request.GenerationConfig["temperature"]) w.Header().Set("Content-Type", "application/json") require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "candidates": []map[string]any{ { "finishReason": "STOP", "content": map[string]any{ "parts": []map[string]string{{"text": "hello from gemini"}}, }, }, }, })) })) defer server.Close() model, err := audiollmgemini.New(ai.ProviderConfig{ Type: ai.ProviderGemini, Endpoint: server.URL + "/v1beta", APIKey: "test-key", }, audiollm.ApplyOptions(nil)) require.NoError(t, err) temp := float32(0) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() resp, err := model.GenerateFromAudio(ctx, audiollm.Request{ Model: "models/gemini-2.5-flash", ContentType: "audio/mpeg", Audio: strings.NewReader("audio bytes"), Instructions: "transcribe please", Temperature: &temp, }) require.NoError(t, err) require.Equal(t, "hello from gemini", resp.Text) require.Equal(t, audiollm.FinishStop, resp.FinishReason) } func TestGenerateFromAudioRejectsUnsupportedContentType(t *testing.T) { t.Parallel() model, err := audiollmgemini.New(ai.ProviderConfig{ Type: ai.ProviderGemini, Endpoint: "https://example.com/v1beta", APIKey: "test-key", }, audiollm.ApplyOptions(nil)) require.NoError(t, err) _, err = model.GenerateFromAudio(context.Background(), audiollm.Request{ Model: "gemini-2.5-flash", ContentType: "video/mp4", Audio: strings.NewReader("video bytes"), Instructions: "transcribe please", }) require.Error(t, err) require.Contains(t, err.Error(), "not supported by Gemini") } ``` - [ ] **C.2: Run the tests to verify they fail** ```bash go test ./internal/ai/audiollm/gemini/... ``` Expected: build failure ("undefined: gemini.New" or similar). - [ ] **C.3: Create `internal/ai/audiollm/gemini/gemini.go`** Port from `internal/ai/gemini.go`. The structure mirrors the existing file; the differences are: - No hardcoded transcription prompt (caller passes `Instructions`). - `Temperature` comes from the request, not from a package constant. - Response includes a mapped `FinishReason`, and we no longer treat empty text as an error here — that decision moves to the caller (the handler), which knows whether `FinishStop` is required. ```go // Package gemini implements audiollm.Model against the Gemini generateContent // endpoint. Used by Memos transcription when the user picks a Gemini provider: // the handler issues a transcription instruction via audiollm.Request.Instructions. package gemini import ( "context" "io" "mime" "net/url" "strings" "github.com/pkg/errors" "google.golang.org/genai" "github.com/usememos/memos/internal/ai" "github.com/usememos/memos/internal/ai/audio" "github.com/usememos/memos/internal/ai/audiollm" ) const ( defaultEndpoint = "https://generativelanguage.googleapis.com/v1beta" defaultAPIVersion = "v1beta" maxInlineSize = 14 * 1024 * 1024 providerName = "Gemini" ) var supportedContentTypes = map[string]string{ "audio/wav": "audio/wav", "audio/x-wav": "audio/wav", "audio/mp3": "audio/mp3", "audio/mpeg": "audio/mp3", "audio/aiff": "audio/aiff", "audio/aac": "audio/aac", "audio/ogg": "audio/ogg", "audio/flac": "audio/flac", "audio/x-flac": "audio/flac", } // Model implements audiollm.Model for Gemini generateContent. type Model struct { client *genai.Client } // New constructs a Model from a provider config. func New(cfg ai.ProviderConfig, options audiollm.Options) (*Model, error) { endpoint, err := normalizeEndpoint(cfg.Endpoint) if err != nil { return nil, err } if cfg.APIKey == "" { return nil, errors.Errorf("%s API key is required", providerName) } baseURL, apiVersion, err := splitEndpoint(endpoint) if err != nil { return nil, err } httpOptions := genai.HTTPOptions{BaseURL: baseURL, APIVersion: apiVersion} if options.HTTPClient != nil && options.HTTPClient.Timeout > 0 { timeout := options.HTTPClient.Timeout httpOptions.Timeout = &timeout } client, err := genai.NewClient(context.Background(), &genai.ClientConfig{ APIKey: cfg.APIKey, Backend: genai.BackendGeminiAPI, HTTPClient: options.HTTPClient, HTTPOptions: httpOptions, }) if err != nil { return nil, errors.Wrap(err, "failed to create Gemini client") } return &Model{client: client}, nil } // GenerateFromAudio calls Gemini generateContent with the audio attached. func (m *Model) GenerateFromAudio(ctx context.Context, req audiollm.Request) (*audiollm.Response, error) { if strings.TrimSpace(req.Model) == "" { return nil, errors.New("model is required") } if req.Audio == nil { return nil, errors.New("audio is required") } if strings.TrimSpace(req.Instructions) == "" { return nil, errors.New("instructions are required") } audioBytes, err := io.ReadAll(req.Audio) if err != nil { return nil, errors.Wrap(err, "failed to read audio") } if len(audioBytes) == 0 { return nil, errors.New("audio is required") } contentType := req.ContentType if audio.IsWebMContentType(contentType) { wav, err := audio.WebMOpusToWAV(audioBytes) if err != nil { return nil, errors.Wrap(err, "failed to transcode webm audio for Gemini") } audioBytes = wav contentType = "audio/wav" } if len(audioBytes) > maxInlineSize { return nil, errors.Errorf("audio is too large for Gemini inline request; maximum size is %d bytes", maxInlineSize) } contentType, err = normalizeContentType(contentType) if err != nil { return nil, err } cfg := &genai.GenerateContentConfig{} if req.Temperature != nil { t := *req.Temperature cfg.Temperature = &t } resp, err := m.client.Models.GenerateContent(ctx, normalizeModelName(req.Model), []*genai.Content{ genai.NewContentFromParts([]*genai.Part{ genai.NewPartFromBytes(audioBytes, contentType), genai.NewPartFromText(req.Instructions), }, genai.RoleUser), }, cfg) if err != nil { return nil, errors.Wrap(err, "failed to send Gemini request") } return &audiollm.Response{ Text: strings.TrimSpace(resp.Text()), FinishReason: mapFinishReason(resp), }, nil } func mapFinishReason(resp *genai.GenerateContentResponse) audiollm.FinishReason { if resp == nil || len(resp.Candidates) == 0 { return audiollm.FinishOther } switch resp.Candidates[0].FinishReason { case genai.FinishReasonStop: return audiollm.FinishStop case genai.FinishReasonMaxTokens: return audiollm.FinishLength case genai.FinishReasonSafety: return audiollm.FinishSafety default: return audiollm.FinishOther } } func normalizeEndpoint(endpoint string) (string, error) { endpoint = strings.TrimSpace(endpoint) if endpoint == "" { endpoint = defaultEndpoint } if _, err := url.ParseRequestURI(endpoint); err != nil { return "", errors.Wrapf(err, "invalid %s endpoint", providerName) } return strings.TrimRight(endpoint, "/"), nil } func splitEndpoint(endpoint string) (string, string, error) { parsed, err := url.Parse(endpoint) if err != nil { return "", "", errors.Wrap(err, "invalid Gemini endpoint") } path := strings.TrimRight(parsed.Path, "/") apiVersion := defaultAPIVersion for _, supported := range []string{"v1alpha", "v1beta", "v1"} { if path == "/"+supported || strings.HasSuffix(path, "/"+supported) { apiVersion = supported parsed.Path = strings.TrimSuffix(path, "/"+supported) break } } return strings.TrimRight(parsed.String(), "/"), apiVersion, nil } func normalizeContentType(contentType string) (string, error) { mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(contentType)) if err != nil { return "", errors.Wrap(err, "invalid audio content type") } mediaType = strings.ToLower(mediaType) normalized, ok := supportedContentTypes[mediaType] if !ok { return "", errors.Errorf("audio content type %q is not supported by Gemini", mediaType) } return normalized, nil } func normalizeModelName(model string) string { return strings.TrimPrefix(strings.TrimSpace(model), "models/") } ``` - [ ] **C.4: Wire the factory to use the new implementation** Replace the stub branch in `internal/ai/audiollm/factory.go`: ```go package audiollm import ( "github.com/pkg/errors" "github.com/usememos/memos/internal/ai" audiollmgemini "github.com/usememos/memos/internal/ai/audiollm/gemini" ) func NewModel(cfg ai.ProviderConfig, opts ...ModelOption) (Model, error) { switch cfg.Type { case ai.ProviderGemini: return audiollmgemini.New(cfg, ApplyOptions(opts)) case ai.ProviderOpenAI: return nil, errors.Wrap(ai.ErrAudioLLMNotSupported, "OpenAI multimodal audio (gpt-4o-audio) is not implemented") default: return nil, errors.Wrapf(ai.ErrCapabilityUnsupported, "provider type %q", cfg.Type) } } ``` - [ ] **C.5: Run the tests to verify they pass** ```bash go test ./internal/ai/audiollm/gemini/... -v ``` Expected: `--- PASS: TestGenerateFromAudio` and `--- PASS: TestGenerateFromAudioRejectsUnsupportedContentType`. If `TestGenerateFromAudio` fails because the genai SDK constant for "STOP" is named differently from `genai.FinishReasonStop`, look up the exact constant name in `google.golang.org/genai` and adjust `mapFinishReason`. The behavioral expectation is unchanged: a JSON `"finishReason": "STOP"` from the server must map to `audiollm.FinishStop`. - [ ] **C.6: Verify the whole project still builds** ```bash go build ./... ``` Expected: exits 0. - [ ] **C.7: Commit** ```bash git add internal/ai/audiollm git commit -m "$(cat <<'EOF' feat(ai/audiollm): implement Gemini multimodal audio Model Ports internal/ai/gemini.go into the new internal/ai/audiollm/gemini package. Two behavior changes from the previous code: - Transcription instructions are now caller-supplied via audiollm.Request.Instructions instead of being hardcoded in the package. This frees the audiollm/gemini package to serve future non-transcription use cases (audio summarization, etc.) without changes. - The genai FinishReason is mapped to audiollm.FinishReason and surfaced on the response, replacing the previous "did not include text" error that collapsed safety-filter, max-tokens, and other failure modes. The legacy internal/ai/gemini.go is still in place and still wired to the handler — that switches in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Stage D: Refactor the Transcribe handler to dispatch by provider type **Files:** - Modify: `server/router/api/v1/ai_service.go` The handler currently calls `ai.NewTranscriber(provider)` and `transcriber.Transcribe(...)`. We replace that core block with a `switch provider.Type` that calls either `stt.NewTranscriber` or `audiollm.NewModel`. All input validation and provider resolution above it stays the same. - [ ] **D.1: Replace the core call block** In `server/router/api/v1/ai_service.go`, locate the existing call sequence inside the `Transcribe` method: ```go transcriber, err := ai.NewTranscriber(provider) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "failed to create AI transcriber: %v", err) } transcription, err := transcriber.Transcribe(ctx, ai.TranscribeRequest{ Model: model, Filename: filename, ContentType: contentType, Audio: bytes.NewReader(content), Size: int64(len(content)), Prompt: persisted.GetPrompt(), Language: persisted.GetLanguage(), }) if err != nil { return nil, status.Errorf(codes.Internal, "failed to transcribe audio: %v", err) } return &v1pb.TranscribeResponse{ Text: transcription.Text, }, nil ``` Replace that entire sequence (everything from `transcriber, err := ai.NewTranscriber(provider)` through the closing `}, nil` of the return statement) with: ```go var text string switch provider.Type { case ai.ProviderOpenAI: text, err = s.transcribeViaSTT(ctx, provider, persisted, model, content, filename, contentType) case ai.ProviderGemini: text, err = s.transcribeViaAudioLLM(ctx, provider, persisted, model, content, contentType) default: return nil, status.Errorf(codes.FailedPrecondition, "provider type %q is not supported for transcription", provider.Type) } if err != nil { return nil, status.Errorf(codes.Internal, "failed to transcribe audio: %v", err) } return &v1pb.TranscribeResponse{Text: text}, nil } ``` Remove the now-unused import of `bytes` if Go points it out — `bytes.NewReader(content)` moves into the helpers below. - [ ] **D.2: Add the two helpers and the instruction builder** Append to the same file, after the `Transcribe` function: ```go func (*APIV1Service) transcribeViaSTT( ctx context.Context, provider ai.ProviderConfig, persisted *storepb.TranscriptionConfig, model string, content []byte, filename string, contentType string, ) (string, error) { transcriber, err := stt.NewTranscriber(provider) if err != nil { return "", errors.Wrap(err, "failed to create STT transcriber") } resp, err := transcriber.Transcribe(ctx, stt.Request{ Audio: bytes.NewReader(content), Size: int64(len(content)), Filename: filename, ContentType: contentType, Model: model, Prompt: persisted.GetPrompt(), Language: persisted.GetLanguage(), }) if err != nil { return "", err } return resp.Text, nil } func (*APIV1Service) transcribeViaAudioLLM( ctx context.Context, provider ai.ProviderConfig, persisted *storepb.TranscriptionConfig, model string, content []byte, contentType string, ) (string, error) { m, err := audiollm.NewModel(provider) if err != nil { return "", errors.Wrap(err, "failed to create audio LLM") } resp, err := m.GenerateFromAudio(ctx, audiollm.Request{ Audio: bytes.NewReader(content), Size: int64(len(content)), ContentType: contentType, Model: model, Instructions: buildTranscriptionInstructions(persisted.GetPrompt(), persisted.GetLanguage()), }) if err != nil { return "", err } if resp.FinishReason != audiollm.FinishStop { return "", errors.Errorf("transcription incomplete (finish reason: %s)", resp.FinishReason) } if strings.TrimSpace(resp.Text) == "" { return "", errors.New("transcription response did not include text") } return resp.Text, nil } 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 = strings.TrimSpace(language); language != "" { parts = append(parts, "The input language is "+language+".") } if prompt = strings.TrimSpace(prompt); prompt != "" { parts = append(parts, "Context and spelling hints:\n"+prompt) } return strings.Join(parts, "\n\n") } ``` - [ ] **D.3: Update the imports** The imports block of `ai_service.go` needs: ```go import ( "bytes" "context" "mime" "net/http" "strings" "github.com/pkg/errors" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "github.com/usememos/memos/internal/ai" "github.com/usememos/memos/internal/ai/audiollm" "github.com/usememos/memos/internal/ai/stt" v1pb "github.com/usememos/memos/proto/gen/api/v1" storepb "github.com/usememos/memos/proto/gen/store" ) ``` (`bytes` and `mime` were already there; `errors`, `audiollm`, and `stt` are new.) - [ ] **D.4: Verify the project builds** ```bash go build ./... ``` Expected: exits 0. - [ ] **D.5: Run the existing handler tests** ```bash go test ./server/router/api/v1/... -run Transcrib -v ``` Expected: any existing tests touching `Transcribe` still pass. If there are no Transcribe tests yet, the command will print `no tests to run` — that is fine. - [ ] **D.6: Run the full Go test suite** ```bash go test ./... ``` Expected: exits 0. Watch for any test that imports `internal/ai.Transcriber`, `internal/ai.NewTranscriber`, `internal/ai.TranscribeRequest`, or `internal/ai.TranscribeResponse` — if such a test exists outside the files we delete in stage E, it must be migrated now. - [ ] **D.7: Commit** ```bash git add server/router/api/v1/ai_service.go git commit -m "$(cat <<'EOF' refactor(ai_service): dispatch transcription by provider type The Transcribe handler now switches on the resolved provider's Type and calls into either internal/ai/stt (OpenAI-compatible) or internal/ai/audiollm (Gemini multimodal). buildTranscriptionInstructions centralizes the literal instruction sent to multimodal LLMs, replacing the previously hardcoded prompt inside internal/ai/gemini.go. The legacy internal/ai package is no longer called by the handler. It is still on disk; it gets removed in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Stage E: Delete legacy internal/ai files **Files (deleted):** - `internal/ai/transcription.go` - `internal/ai/client.go` - `internal/ai/openai.go` - `internal/ai/openai_test.go` - `internal/ai/gemini.go` - `internal/ai/gemini_test.go` After the handler refactor in stage D, nothing references the legacy `Transcriber` interface or its implementations. We delete them. - [ ] **E.1: Confirm no callers remain** ```bash git grep -nE 'ai\.(NewTranscriber|TranscribeRequest|TranscribeResponse|Transcriber)\b' -- ':(exclude)internal/ai/transcription.go' ':(exclude)internal/ai/client.go' ':(exclude)internal/ai/openai.go' ':(exclude)internal/ai/openai_test.go' ':(exclude)internal/ai/gemini.go' ':(exclude)internal/ai/gemini_test.go' ``` Expected: no output (no remaining external callers). If anything matches, fix it before deleting. - [ ] **E.2: Delete the files** ```bash git rm internal/ai/transcription.go \ internal/ai/client.go \ internal/ai/openai.go \ internal/ai/openai_test.go \ internal/ai/gemini.go \ internal/ai/gemini_test.go ``` - [ ] **E.3: Verify the project still builds** ```bash go build ./... ``` Expected: exits 0. - [ ] **E.4: Run the full Go test suite** ```bash go test ./... ``` Expected: exits 0. - [ ] **E.5: Confirm `internal/ai/` only has the surviving files** ```bash ls internal/ai/ ``` Expected output (alphabetical, may include `audio/`): ``` ai.go audio audiollm errors.go models.go resolver.go stt ``` (`audio/` is the existing webm package, untouched.) - [ ] **E.6: Commit** ```bash git commit -m "$(cat <<'EOF' refactor(ai): remove legacy Transcriber implementations The Transcribe handler now uses internal/ai/stt and internal/ai/audiollm exclusively. Removes the obsolete single-interface Transcriber, its factory, and both provider implementations. internal/ai/ now contains only shared primitives (ProviderConfig, errors, default models, provider resolver) plus the per-capability sub-packages. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Stage F: Update proto comments and regenerate bindings **Files:** - Modify: `proto/store/instance_setting.proto` - Regenerate: `proto/gen/store/instance_setting.pb.go` - Regenerate: `web/src/types/proto/store/instance_setting_pb.ts` Documentation-only proto change; no field tags moved or renamed. - [ ] **F.1: Verify `buf` is available** ```bash buf --version ``` Expected: prints a version. If absent: `brew install bufbuild/buf/buf`. - [ ] **F.2: Update the `model` field comment** Edit `proto/store/instance_setting.proto`. Find the existing block (around lines 179–182): ```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; ``` Replace 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; ``` - [ ] **F.3: Update the `prompt` field comment** In the same file, find (around 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; ``` Replace 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; ``` - [ ] **F.4: Format and regenerate** ```bash cd proto && buf format -w && buf generate && cd .. ``` Expected: each command exits 0; `proto/gen/store/instance_setting.pb.go` and `web/src/types/proto/store/instance_setting_pb.ts` are rewritten in place. - [ ] **F.5: Verify the generated Go file picked up both comments** ```bash grep -A 9 "model is the provider-specific model identifier" proto/gen/store/instance_setting.pb.go grep -A 4 "prompt is a default spelling/vocabulary hint" proto/gen/store/instance_setting.pb.go ``` Expected: both blocks contain the new wording (model: 9 lines including the OPENAI/GEMINI examples and `Model string ...`; prompt: 4 lines of comment + the `Prompt string ...` line). - [ ] **F.6: Verify the generated TypeScript file picked up both comments** ```bash grep -A 9 "model is the provider-specific model identifier" web/src/types/proto/store/instance_setting_pb.ts grep -A 4 "prompt is a default spelling/vocabulary hint" web/src/types/proto/store/instance_setting_pb.ts ``` Expected: both blocks contain the new wording (TS comments are JSDoc-formatted with leading `*` per line). - [ ] **F.7: Verify only three files changed** ```bash git status --short ``` Expected: exactly: ``` M proto/store/instance_setting.proto M proto/gen/store/instance_setting.pb.go M web/src/types/proto/store/instance_setting_pb.ts ``` If other generated files appear modified, the regen touched something unrelated — inspect with `git diff --stat` and stop. - [ ] **F.8: Verify Go still builds** ```bash go build ./... ``` Expected: exits 0. (Comment-only proto changes cannot break the build, but this catches accidental edits that bled into adjacent fields.) - [ ] **F.9: Commit** ```bash git add proto/store/instance_setting.proto \ proto/gen/store/instance_setting.pb.go \ web/src/types/proto/store/instance_setting_pb.ts git commit -m "$(cat <<'EOF' docs(proto): clarify TranscriptionConfig model and prompt fields - model: list current OpenAI (whisper-1, gpt-4o-transcribe family, gpt-4o-transcribe-diarize) and Gemini (2.5-flash, 2.5-pro) examples. The /audio/transcriptions endpoint is no longer Whisper-only. - prompt: note that OpenAI Whisper treats it as a soft hint while Gemini folds it literally into the generation prompt. Documentation-only; no field changes. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Stage G: Update frontend i18n strings **Files:** - Modify: `web/src/locales/en.json` The `AISection.tsx` component already conditionally swaps the model placeholder by provider type and renders the prompt help string. Only the underlying strings need updating. - [ ] **G.1: Update three strings in `web/src/locales/en.json`** Find these three keys (they currently live under `setting.ai.*`, around lines 441–449): ```json "transcription-model-placeholder-gemini": "gemini-2.5-flash", "transcription-model-placeholder-openai": "whisper-1", "transcription-prompt-help": "Improves spelling of proper nouns and jargon. Whisper limit is roughly 224 tokens.", ``` Replace with: ```json "transcription-model-placeholder-gemini": "gemini-2.5-flash, gemini-2.5-pro", "transcription-model-placeholder-openai": "whisper-1, gpt-4o-transcribe, gpt-4o-mini-transcribe, gpt-4o-transcribe-diarize", "transcription-prompt-help": "Improves spelling of proper nouns and jargon. OpenAI Whisper treats this as a soft hint (Whisper limit is roughly 224 tokens). Gemini treats it as a literal instruction inside the generation prompt.", ``` - [ ] **G.2: Verify the JSON file is still valid** ```bash python3 -m json.tool web/src/locales/en.json > /dev/null ``` Expected: exits 0, no output. - [ ] **G.3: Verify the frontend build still succeeds** ```bash cd web && pnpm build && cd .. ``` (Use `npm run build` if pnpm is not the project's tool — check `web/package.json` for the canonical command.) Expected: build succeeds with no TypeScript errors. - [ ] **G.4: Commit** ```bash git add web/src/locales/en.json git commit -m "$(cat <<'EOF' i18n(en): expand transcription model and prompt help text - Model placeholders list the full set of supported transcription models for OpenAI (whisper-1 plus the gpt-4o-transcribe family) and Gemini. - Prompt help text notes the cross-provider semantic difference: Whisper treats the field as a soft hint, Gemini treats it as a literal instruction. Mirrors the proto field comments. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Final validation After Stage G commits, run a full validation pass: - [ ] **V.1: Full Go test suite** ```bash go test ./... ``` Expected: exits 0. - [ ] **V.2: Full Go build** ```bash go build ./... ``` Expected: exits 0. - [ ] **V.3: Frontend build** ```bash cd web && pnpm build && cd .. ``` Expected: exits 0. - [ ] **V.4: Smoke-test the Transcribe endpoint manually** If a local Memos instance is convenient, configure one OpenAI provider and one Gemini provider via the AI settings UI, then upload a short audio file via the voice-input feature for each. The end-to-end behavior should be identical to before this refactor (same transcript text). For Gemini specifically: a deliberately bad audio (e.g., silence) should now produce a clearer error message when `FinishReason != FinishStop`. - [ ] **V.5: Inspect the commit log** ```bash git log --oneline -8 ``` Expected (most-recent-first): ``` i18n(en): expand transcription model and prompt help text docs(proto): clarify TranscriptionConfig model and prompt fields refactor(ai): remove legacy Transcriber implementations refactor(ai_service): dispatch transcription by provider type feat(ai/audiollm): implement Gemini multimodal audio Model feat(ai/stt): implement OpenAI-compatible Transcriber feat(ai): scaffold stt and audiollm packages ``` Each commit is independently revertable.