From f0bc170113f3f914063add9ca1ed8b4a2293d892 Mon Sep 17 00:00:00 2001 From: rxwatcher Date: Fri, 17 Jul 2026 08:53:27 +0200 Subject: [PATCH] feat(admin): clarify AI service configuration --- internal/api/handlers/admin.go | 3 + .../api/handlers/admin_settings_checks.go | 181 +++- .../handlers/admin_settings_checks_test.go | 272 +++++ .../admin/ConnectionCheckAction.test.tsx | 18 + .../admin/ConnectionCheckAction.tsx | 6 +- web/src/lib/adminSettingsSearch.ts | 6 + .../AIServicesSettings.test.tsx | 116 +++ .../admin-settings/AIServicesSettings.tsx | 958 ++++++++++-------- 8 files changed, 1142 insertions(+), 418 deletions(-) create mode 100644 web/src/components/admin/ConnectionCheckAction.test.tsx create mode 100644 web/src/pages/admin-settings/AIServicesSettings.test.tsx diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index 0f6ac304..1ada9797 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -2586,7 +2586,10 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques return } req.Value = normalized + case "ai.base_url", "ai.chat_model", "ai.asr_model": + req.Value = strings.TrimSpace(req.Value) case "ai.asr_base_url": + req.Value = strings.TrimSpace(req.Value) if llm.IsChatOnlyGateway(req.Value) { writeError(w, http.StatusBadRequest, "bad_request", "This endpoint cannot produce timestamped transcriptions (chat-only gateway). "+ diff --git a/internal/api/handlers/admin_settings_checks.go b/internal/api/handlers/admin_settings_checks.go index 8bdb57e3..fe2db011 100644 --- a/internal/api/handlers/admin_settings_checks.go +++ b/internal/api/handlers/admin_settings_checks.go @@ -3,16 +3,19 @@ package handlers import ( "bytes" "context" + "encoding/binary" "encoding/json" "errors" "fmt" "net/http" + "net/url" "strings" "time" "github.com/go-chi/chi/v5" "github.com/redis/go-redis/v9" + "github.com/Silo-Server/silo-server/internal/ai/llm" "github.com/Silo-Server/silo-server/internal/cache" "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/config" @@ -51,6 +54,11 @@ type mdblistSettingsCheckClient interface { Check(ctx context.Context) error } +type aiSettingsCheckClient interface { + Chat(ctx context.Context, messages []llm.Message, jsonObject bool) (string, error) + Transcribe(ctx context.Context, req llm.TranscribeRequest) (*llm.Transcription, error) +} + type redisSettingsCheckAdapter struct { client *redis.Client } @@ -88,6 +96,10 @@ var newAdminMDBListSettingsCheckClient = func(apiKey string) mdblistSettingsChec return mdblist.NewClient(apiKey, nil) } +var newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient { + return llm.NewClient(cfg) +} + func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *http.Request) { if h.SettingsRepo == nil { writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured") @@ -109,7 +121,7 @@ func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *h req.Values = map[string]string{} } - effectiveSettings, err := h.effectiveSettingsForConnectionCheck(r.Context(), req) + effectiveSettings, err := h.effectiveSettingsForConnectionCheck(r.Context(), kind, req) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load settings") return @@ -131,6 +143,10 @@ func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *h response = checkRedisConnection(r.Context(), cfg) case "recommendations_embedding": response = checkRecommendationsEmbeddingConnection(r.Context(), cfg) + case "ai_chat": + response = checkAIChatConnection(r.Context(), cfg) + case "ai_transcription": + response = checkAITranscriptionConnection(r.Context(), cfg) case "meilisearch": response = checkMeilisearchConnection(r.Context(), effectiveSettings) case "mdblist": @@ -155,6 +171,105 @@ func checkMDBListConnection(ctx context.Context, cfg *config.Config) connectionC return connectionCheckResponse{Success: true, Message: "MDBList API key verified."} } +func aiClientConfig(cfg *config.Config) llm.Config { + return llm.Config{ + BaseURL: strings.TrimSpace(cfg.AI.BaseURL), + APIKey: cfg.AI.APIKey, + ChatModel: strings.TrimSpace(cfg.AI.ChatModel), + ASRBaseURL: strings.TrimSpace(cfg.AI.ASRBaseURL), + ASRAPIKey: cfg.AI.ASRAPIKey, + ASRModel: strings.TrimSpace(cfg.AI.ASRModel), + } +} + +func checkAIChatConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse { + if strings.TrimSpace(cfg.AI.BaseURL) == "" { + return connectionCheckResponse{Success: false, Message: "Text AI base URL is required."} + } + if strings.TrimSpace(cfg.AI.ChatModel) == "" { + return connectionCheckResponse{Success: false, Message: "Chat model is required."} + } + + client := newAdminAISettingsCheckClient(aiClientConfig(cfg)) + checkCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if _, err := client.Chat(checkCtx, []llm.Message{ + {Role: "system", Content: "Return a JSON object with status set to ok."}, + {Role: "user", Content: "Check this Silo text translation connection."}, + }, true); err != nil { + return connectionCheckResponse{ + Success: false, + Message: fmt.Sprintf("Text AI connection check failed: %v", err), + } + } + + return connectionCheckResponse{Success: true, Message: "Text AI connection successful."} +} + +func checkAITranscriptionConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse { + effectiveBaseURL := strings.TrimSpace(cfg.AI.ASRBaseURL) + if effectiveBaseURL == "" { + effectiveBaseURL = strings.TrimSpace(cfg.AI.BaseURL) + } + if effectiveBaseURL == "" { + return connectionCheckResponse{ + Success: false, + Message: "Speech-to-text base URL is required.", + } + } + if strings.TrimSpace(cfg.AI.ASRModel) == "" { + return connectionCheckResponse{ + Success: false, + Message: "Transcription model is required.", + } + } + + client := newAdminAISettingsCheckClient(aiClientConfig(cfg)) + checkCtx, cancel := context.WithTimeout(ctx, 35*time.Second) + defer cancel() + if _, err := client.Transcribe(checkCtx, llm.TranscribeRequest{ + Filename: "silo-connection-check.wav", + Audio: silenceWAV(), + Timeout: 30 * time.Second, + }); err != nil { + return connectionCheckResponse{ + Success: false, + Message: fmt.Sprintf("Speech-to-text connection check failed: %v", err), + } + } + + return connectionCheckResponse{ + Success: true, + Message: "Speech-to-text connection successful.", + } +} + +// silenceWAV returns 250 ms of 16 kHz mono PCM. It is long enough for +// transcription providers to parse while keeping connection checks cheap. +func silenceWAV() []byte { + const ( + sampleRate = 16_000 + bitsPerSample = 16 + sampleCount = sampleRate / 4 + dataSize = sampleCount * bitsPerSample / 8 + ) + wav := make([]byte, 44+dataSize) + copy(wav[0:4], "RIFF") + binary.LittleEndian.PutUint32(wav[4:8], uint32(36+dataSize)) + copy(wav[8:12], "WAVE") + copy(wav[12:16], "fmt ") + binary.LittleEndian.PutUint32(wav[16:20], 16) + binary.LittleEndian.PutUint16(wav[20:22], 1) + binary.LittleEndian.PutUint16(wav[22:24], 1) + binary.LittleEndian.PutUint32(wav[24:28], sampleRate) + binary.LittleEndian.PutUint32(wav[28:32], sampleRate*bitsPerSample/8) + binary.LittleEndian.PutUint16(wav[32:34], bitsPerSample/8) + binary.LittleEndian.PutUint16(wav[34:36], bitsPerSample) + copy(wav[36:40], "data") + binary.LittleEndian.PutUint32(wav[40:44], dataSize) + return wav +} + func checkMeilisearchConnection(ctx context.Context, settings map[string]string) connectionCheckResponse { searchSettings, err := catalog.CatalogSearchSettingsFromMap(settings) if err != nil { @@ -173,6 +288,7 @@ func checkMeilisearchConnection(ctx context.Context, settings map[string]string) func (h *AdminHandler) effectiveSettingsForConnectionCheck( ctx context.Context, + kind string, req adminSettingsConnectionCheckRequest, ) (map[string]string, error) { settings, err := h.SettingsRepo.GetAll(ctx) @@ -190,13 +306,76 @@ func (h *AdminHandler) effectiveSettingsForConnectionCheck( } merged[key] = value } + + var storedAIConfig *config.Config + if kind == "ai_chat" || kind == "ai_transcription" { + storedAIConfig, err = config.LoadFromDB(merged) + if err != nil { + return nil, err + } + } for _, key := range req.DirtyKeys { merged[key] = req.Values[key] } + if storedAIConfig != nil { + draftAIConfig, loadErr := config.LoadFromDB(merged) + if loadErr != nil { + return nil, loadErr + } + protectAIConnectionCheckSecrets(kind, req, storedAIConfig, draftAIConfig, merged) + } return merged, nil } +func protectAIConnectionCheckSecrets( + kind string, + req adminSettingsConnectionCheckRequest, + storedCfg *config.Config, + draftCfg *config.Config, + settings map[string]string, +) { + storedEndpoint := storedCfg.AI.BaseURL + draftEndpoint := draftCfg.AI.BaseURL + if kind == "ai_transcription" { + if strings.TrimSpace(storedCfg.AI.ASRBaseURL) != "" { + storedEndpoint = storedCfg.AI.ASRBaseURL + } + if strings.TrimSpace(draftCfg.AI.ASRBaseURL) != "" { + draftEndpoint = draftCfg.AI.ASRBaseURL + } + } + if endpointAuthority(storedEndpoint) == endpointAuthority(draftEndpoint) { + return + } + + if kind == "ai_transcription" && !hasExplicitDraftSecret(req, "ai.asr_api_key") { + settings["ai.asr_api_key"] = "" + } + if !hasExplicitDraftSecret(req, "ai.api_key") { + settings["ai.api_key"] = "" + settings["subtitle_ai.api_key"] = "" + } +} + +func hasExplicitDraftSecret(req adminSettingsConnectionCheckRequest, key string) bool { + for _, dirtyKey := range req.DirtyKeys { + if dirtyKey == key { + return strings.TrimSpace(req.Values[key]) != "" + } + } + return false +} + +func endpointAuthority(rawURL string) string { + trimmed := strings.TrimSpace(rawURL) + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return strings.ToLower(trimmed) + } + return strings.ToLower(parsed.Scheme + "://" + parsed.Host) +} + func checkS3PublicConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse { if strings.TrimSpace(cfg.S3.Public.Endpoint) == "" { return connectionCheckResponse{Success: false, Message: "S3 endpoint is required."} diff --git a/internal/api/handlers/admin_settings_checks_test.go b/internal/api/handlers/admin_settings_checks_test.go index 92df0bd4..6afc7881 100644 --- a/internal/api/handlers/admin_settings_checks_test.go +++ b/internal/api/handlers/admin_settings_checks_test.go @@ -14,6 +14,7 @@ import ( "github.com/go-chi/chi/v5" + "github.com/Silo-Server/silo-server/internal/ai/llm" "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/config" "github.com/Silo-Server/silo-server/internal/diagnostics" @@ -886,6 +887,239 @@ func (f *fakeEmbeddingsSettingsCheckClient) Embed( return [][]float32{{0.1, 0.2}}, nil } +type fakeAISettingsCheckClient struct { + chat func(ctx context.Context, messages []llm.Message, jsonObject bool) (string, error) + transcribe func(ctx context.Context, req llm.TranscribeRequest) (*llm.Transcription, error) +} + +func (f *fakeAISettingsCheckClient) Chat( + ctx context.Context, + messages []llm.Message, + jsonObject bool, +) (string, error) { + if f.chat != nil { + return f.chat(ctx, messages, jsonObject) + } + return `{"status":"ok"}`, nil +} + +func (f *fakeAISettingsCheckClient) Transcribe( + ctx context.Context, + req llm.TranscribeRequest, +) (*llm.Transcription, error) { + if f.transcribe != nil { + return f.transcribe(ctx, req) + } + return &llm.Transcription{}, nil +} + +func TestHandleCheckSettingsConnectionAIChatDoesNotSendStoredKeyToDraftEndpoint(t *testing.T) { + originalFactory := newAdminAISettingsCheckClient + t.Cleanup(func() { + newAdminAISettingsCheckClient = originalFactory + }) + + var captured llm.Config + var chatCalled bool + newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient { + captured = cfg + return &fakeAISettingsCheckClient{ + chat: func(_ context.Context, _ []llm.Message, jsonObject bool) (string, error) { + chatCalled = true + if !jsonObject { + t.Fatal("chat connection check did not request a JSON response") + } + return `{"status":"ok"}`, nil + }, + } + } + + handler := &AdminHandler{ + SettingsRepo: &fakeServerSettingsStore{ + values: map[string]string{ + "ai.base_url": "https://persisted.example.test", + "ai.chat_model": "persisted-model", + "ai.api_key": "persisted-secret", + }, + }, + } + + rec := performSettingsCheckRequest( + t, + handler, + "/admin/settings/check/ai_chat", + map[string]any{ + "values": map[string]string{ + "ai.base_url": "https://draft.example.test", + "ai.chat_model": "draft-model", + "ai.api_key": "", + }, + "dirty_keys": []string{"ai.base_url", "ai.chat_model"}, + }, + ) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + var response connectionCheckResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("Decode() returned error: %v", err) + } + if !response.Success || !chatCalled { + t.Fatalf("response = %+v, chatCalled = %v", response, chatCalled) + } + if captured.BaseURL != "https://draft.example.test" || captured.ChatModel != "draft-model" { + t.Fatalf("captured config = %+v, want draft endpoint and model", captured) + } + if captured.APIKey != "" { + t.Fatalf("captured API key = %q, want no stored secret for a changed endpoint", captured.APIKey) + } +} + +func TestHandleCheckSettingsConnectionAIChatReusesStoredKeyForSameEndpoint(t *testing.T) { + originalFactory := newAdminAISettingsCheckClient + t.Cleanup(func() { + newAdminAISettingsCheckClient = originalFactory + }) + + var captured llm.Config + newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient { + captured = cfg + return &fakeAISettingsCheckClient{} + } + + handler := &AdminHandler{ + SettingsRepo: &fakeServerSettingsStore{ + values: map[string]string{ + "ai.base_url": "https://persisted.example.test/v1", + "ai.chat_model": "persisted-model", + "ai.api_key": "persisted-secret", + }, + }, + } + + rec := performSettingsCheckRequest( + t, + handler, + "/admin/settings/check/ai_chat", + map[string]any{ + "values": map[string]string{ + "ai.base_url": "https://persisted.example.test/other-path", + "ai.chat_model": "draft-model", + }, + "dirty_keys": []string{"ai.base_url", "ai.chat_model"}, + }, + ) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if captured.APIKey != "persisted-secret" { + t.Fatalf("captured API key = %q, want stored secret for unchanged authority", captured.APIKey) + } +} + +func TestHandleCheckSettingsConnectionAITranscriptionUsesDedicatedASR(t *testing.T) { + originalFactory := newAdminAISettingsCheckClient + t.Cleanup(func() { + newAdminAISettingsCheckClient = originalFactory + }) + + var captured llm.Config + var request llm.TranscribeRequest + newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient { + captured = cfg + return &fakeAISettingsCheckClient{ + transcribe: func(_ context.Context, req llm.TranscribeRequest) (*llm.Transcription, error) { + request = req + return &llm.Transcription{}, nil + }, + } + } + + handler := &AdminHandler{ + SettingsRepo: &fakeServerSettingsStore{ + values: map[string]string{ + "ai.base_url": "https://chat.example.test", + "ai.api_key": "chat-secret", + "ai.chat_model": "chat-model", + "ai.asr_base_url": "https://whisper.example.test", + "ai.asr_api_key": "asr-secret", + "ai.asr_model": "whisper-model", + }, + }, + } + + rec := performSettingsCheckRequest( + t, + handler, + "/admin/settings/check/ai_transcription", + map[string]any{"values": map[string]string{}, "dirty_keys": []string{}}, + ) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + var response connectionCheckResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("Decode() returned error: %v", err) + } + if !response.Success { + t.Fatalf("response.Success = false, message = %q", response.Message) + } + if captured.ASRBaseURL != "https://whisper.example.test" || + captured.ASRAPIKey != "asr-secret" || + captured.ASRModel != "whisper-model" { + t.Fatalf("captured ASR config = %+v", captured) + } + if len(request.Audio) == 0 || request.Filename == "" || request.Timeout <= 0 { + t.Fatalf("transcription probe request = %+v, want bounded WAV probe", request) + } +} + +func TestHandleCheckSettingsConnectionAITranscriptionDoesNotSendStoredKeysToDraftEndpoint(t *testing.T) { + originalFactory := newAdminAISettingsCheckClient + t.Cleanup(func() { + newAdminAISettingsCheckClient = originalFactory + }) + + var captured llm.Config + newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient { + captured = cfg + return &fakeAISettingsCheckClient{} + } + + handler := &AdminHandler{ + SettingsRepo: &fakeServerSettingsStore{ + values: map[string]string{ + "ai.base_url": "https://chat.example.test", + "ai.api_key": "chat-secret", + "ai.chat_model": "chat-model", + "ai.asr_base_url": "https://stored-whisper.example.test", + "ai.asr_api_key": "asr-secret", + "ai.asr_model": "whisper-model", + }, + }, + } + + rec := performSettingsCheckRequest( + t, + handler, + "/admin/settings/check/ai_transcription", + map[string]any{ + "values": map[string]string{ + "ai.asr_base_url": "https://draft-whisper.example.test", + }, + "dirty_keys": []string{"ai.asr_base_url"}, + }, + ) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if captured.ASRAPIKey != "" || captured.APIKey != "" { + t.Fatalf("captured config = %+v, want no stored ASR or inherited chat secret", captured) + } +} + func TestHandleCheckSettingsConnectionS3UsesPersistedSensitiveValues(t *testing.T) { originalFactory := newAdminS3SettingsCheckClient t.Cleanup(func() { @@ -1238,6 +1472,44 @@ func TestAdminUpdateSettingReportsRestartRequired(t *testing.T) { } } +func TestAdminUpdateSettingNormalizesAIEndpointAndModelValues(t *testing.T) { + for _, tc := range []struct { + key string + value string + want string + }{ + {key: "ai.base_url", value: " https://text.example.test/v1 ", want: "https://text.example.test/v1"}, + {key: "ai.chat_model", value: " chat-model ", want: "chat-model"}, + {key: "ai.asr_base_url", value: " https://speech.example.test ", want: "https://speech.example.test"}, + {key: "ai.asr_model", value: " whisper-model ", want: "whisper-model"}, + } { + t.Run(tc.key, func(t *testing.T) { + settings := &fakeServerSettingsStore{} + handler := &AdminHandler{SettingsRepo: settings} + body, err := json.Marshal(updateSettingRequest{Value: tc.value}) + if err != nil { + t.Fatalf("Marshal() returned error: %v", err) + } + req := httptest.NewRequest( + http.MethodPut, + "/admin/settings/"+tc.key, + bytes.NewReader(body), + ) + req = withChiParam(req, "key", tc.key) + rec := httptest.NewRecorder() + + handler.HandleUpdateSetting(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if settings.values[tc.key] != tc.want { + t.Fatalf("stored value = %q, want %q", settings.values[tc.key], tc.want) + } + }) + } +} + func TestAdminUpdatePolicyEditorEnabledValidation(t *testing.T) { settings := &fakeServerSettingsStore{} handler := &AdminHandler{SettingsRepo: settings} diff --git a/web/src/components/admin/ConnectionCheckAction.test.tsx b/web/src/components/admin/ConnectionCheckAction.test.tsx new file mode 100644 index 00000000..beab9c62 --- /dev/null +++ b/web/src/components/admin/ConnectionCheckAction.test.tsx @@ -0,0 +1,18 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { ConnectionCheckAction } from "./ConnectionCheckAction"; + +describe("ConnectionCheckAction", () => { + it("announces connection results", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('role="status"'); + expect(markup).toContain('aria-live="polite"'); + }); +}); diff --git a/web/src/components/admin/ConnectionCheckAction.tsx b/web/src/components/admin/ConnectionCheckAction.tsx index 9c295b21..44e76387 100644 --- a/web/src/components/admin/ConnectionCheckAction.tsx +++ b/web/src/components/admin/ConnectionCheckAction.tsx @@ -30,7 +30,11 @@ export function ConnectionCheckAction({ {isPending ? pendingLabel : label} {result ? ( - + {result.message} ) : null} diff --git a/web/src/lib/adminSettingsSearch.ts b/web/src/lib/adminSettingsSearch.ts index 7ffbc30e..99dff5db 100644 --- a/web/src/lib/adminSettingsSearch.ts +++ b/web/src/lib/adminSettingsSearch.ts @@ -218,12 +218,17 @@ export const ADMIN_SETTINGS_GROUPS: AdminSettingsSearchGroup[] = [ "quota", ], settings: settingIndex( + "Text translation", "Base URL", "Chat model", "API Key", + "Test Text AI", + "Speech-to-text", "Transcription model", "Transcription base URL", "Transcription API key", + "Test Speech-to-Text", + "Features", "Max concurrent jobs", "Subtitle translation", "Subtitle generation from audio", @@ -234,6 +239,7 @@ export const ADMIN_SETTINGS_GROUPS: AdminSettingsSearchGroup[] = [ "Transcription chunk length (seconds)", "Transcription limit per account", "Transcription limit period", + "Advanced", ), icon: Sparkles, }, diff --git a/web/src/pages/admin-settings/AIServicesSettings.test.tsx b/web/src/pages/admin-settings/AIServicesSettings.test.tsx new file mode 100644 index 00000000..20ef43a2 --- /dev/null +++ b/web/src/pages/admin-settings/AIServicesSettings.test.tsx @@ -0,0 +1,116 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import AIServicesSettings from "./AIServicesSettings"; + +const values: Record = { + "ai.base_url": "https://text.example.test", + "ai.chat_model": "chat-model", + "ai.asr_base_url": "", + "ai.asr_model": "whisper-model", + "ai.max_concurrent_jobs": "2", + "subtitle_ai.base_url": "https://legacy.example.test", + "subtitle_ai.chat_model": "legacy-chat-model", + "subtitle_ai.max_concurrent_jobs": "3", + "subtitle_ai.enabled": "true", + "subtitle_ai.transcribe_enabled": "false", + "subtitle_ai.batch_size": "40", + "subtitle_ai.context_neighbors": "2", + "subtitle_ai.asr_chunk_seconds": "600", + "subtitle_ai.transcribe_quota_jobs": "0", + "subtitle_ai.transcribe_quota_period": "day", + "metadata_ai.enabled": "false", + "metadata_ai.on_view": "button", +}; + +const useSettingsFormMock = vi.fn((_options?: { keys: string[] }) => ({ + isLoading: false, + getValue: (key: string) => values[key] ?? "", + setValue: vi.fn(), + dirtyCount: 0, + dirtyKeys: [], + isDirty: vi.fn(() => false), + save: vi.fn(), + discard: vi.fn(), + isSaving: false, + restartRequired: false, + sensitiveConfigured: ["subtitle_ai.api_key"], + sensitiveManagedByEnv: [], + buildConnectionCheckRequest: vi.fn(() => ({ values: {}, dirty_keys: [] })), +})); + +vi.mock("@/hooks/useSettingsForm", () => ({ + useSettingsForm: (options: { keys: string[] }) => useSettingsFormMock(options), +})); + +vi.mock("@/hooks/queries/admin/settings", () => ({ + useAdminServerSettings: () => ({ data: values }), + useAdminSensitiveStatus: () => ({ data: { configured: ["ai.api_key"] } }), + useUpdateServerSetting: () => ({ mutateAsync: vi.fn(), isPending: false }), + useCheckAdminSettingsConnection: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +describe("AIServicesSettings", () => { + it("separates text translation from speech-to-text configuration", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain("Text translation"); + expect(markup).toContain("Speech-to-text"); + expect(markup).toContain("Test Text AI"); + expect(markup).toContain("Test Speech-to-Text"); + expect(markup).toContain("Uses the Text translation endpoint"); + }); + + it("shows effective legacy endpoint values until modern keys are saved", () => { + const currentBaseURL = values["ai.base_url"]!; + const currentChatModel = values["ai.chat_model"]!; + values["ai.base_url"] = ""; + values["ai.chat_model"] = ""; + + try { + const markup = renderToStaticMarkup(); + + expect(markup).toContain("https://legacy.example.test"); + expect(markup).toContain("legacy-chat-model"); + } finally { + values["ai.base_url"] = currentBaseURL; + values["ai.chat_model"] = currentChatModel; + } + }); + + it("marks known chat-only fallback endpoints as incompatible with speech-to-text", () => { + const currentBaseURL = values["ai.base_url"]!; + values["ai.base_url"] = "https://openrouter.ai/api"; + + try { + const markup = renderToStaticMarkup(); + + expect(markup).toContain("Incompatible endpoint"); + } finally { + values["ai.base_url"] = currentBaseURL; + } + }); + + it("exposes transcription preset selection to assistive technology", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('aria-pressed="false"'); + }); + + it("explains feature dependencies and keeps advanced tuning secondary", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain("Text AI required"); + expect(markup).toContain("Speech-to-text required"); + expect(markup).toContain("Inactive until Description translation is enabled"); + expect(markup).toContain("Advanced"); + }); + + it("points recommendation embeddings to their separate configuration", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain("Recommendation embeddings are configured separately"); + expect(markup).toContain('href="/admin/recommendations"'); + expect(markup).not.toContain("Changes take effect after a server restart"); + }); +}); diff --git a/web/src/pages/admin-settings/AIServicesSettings.tsx b/web/src/pages/admin-settings/AIServicesSettings.tsx index 24aac8cc..596dc0a5 100644 --- a/web/src/pages/admin-settings/AIServicesSettings.tsx +++ b/web/src/pages/admin-settings/AIServicesSettings.tsx @@ -1,471 +1,597 @@ -import { useState, useEffect } from "react"; -import { toast } from "sonner"; +import { useState } from "react"; import { - useAdminSensitiveStatus, - useAdminServerSettings, - useUpdateServerSettings, -} from "@/hooks/queries/admin/settings"; -import { AlertTriangle } from "lucide-react"; + AudioLines, + ChevronDown, + CircleAlert, + CircleCheck, + ExternalLink, + Languages, +} from "lucide-react"; +import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; +import type { ConnectionCheckResponse } from "@/api/types"; +import { ConnectionCheckAction } from "@/components/admin/ConnectionCheckAction"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useCheckAdminSettingsConnection } from "@/hooks/queries/admin/settings"; +import { useSettingsForm } from "@/hooks/useSettingsForm"; import { QUOTA_PERIODS, QUOTA_PERIOD_WINDOW_LABELS } from "@/lib/quotaPeriods"; -import { CredentialStatus } from "./CredentialStatus"; -import { RestartServerButton } from "./RestartServerButton"; +import { cn } from "@/lib/utils"; + +import { SaveBar } from "./SaveBar"; import { SettingField } from "./SettingField"; -// Connection settings live under the ai.* keys; reads fall back to the legacy -// subtitle_ai.* rows (mirroring the server's loader) so an existing setup -// shows its effective values, while saves always write the new keys. +const TEXT_AI_KEYS = ["ai.base_url", "ai.chat_model", "ai.api_key"] as const; +const SPEECH_AI_KEYS = [ + "ai.base_url", + "ai.api_key", + "ai.asr_base_url", + "ai.asr_model", + "ai.asr_api_key", +] as const; +const LEGACY_AI_KEYS = [ + "subtitle_ai.base_url", + "subtitle_ai.api_key", + "subtitle_ai.chat_model", + "subtitle_ai.max_concurrent_jobs", +] as const; +const KEYS: string[] = [ + ...TEXT_AI_KEYS, + ...LEGACY_AI_KEYS, + "ai.asr_base_url", + "ai.asr_model", + "ai.asr_api_key", + "ai.max_concurrent_jobs", + "subtitle_ai.enabled", + "subtitle_ai.transcribe_enabled", + "subtitle_ai.batch_size", + "subtitle_ai.context_neighbors", + "subtitle_ai.asr_chunk_seconds", + "subtitle_ai.transcribe_quota_jobs", + "subtitle_ai.transcribe_quota_period", + "metadata_ai.enabled", + "metadata_ai.on_view", +]; -// Chat-only gateways have no timestamped transcription API; the server -// rejects them for the transcription URL — mirror that check for instant -// feedback. Keep in sync with llm.IsChatOnlyGateway. -const CHAT_ONLY_GATEWAY_HOSTS = ["openrouter.ai"]; - -function isChatOnlyGateway(rawUrl: string): boolean { - const trimmed = rawUrl.trim(); - if (!trimmed) return false; - try { - const host = new URL( - trimmed.includes("://") ? trimmed : `https://${trimmed}`, - ).hostname.toLowerCase(); - return CHAT_ONLY_GATEWAY_HOSTS.some((g) => host === g || host.endsWith(`.${g}`)); - } catch { - return false; - } -} - -// Recommended transcription endpoints, fastest path first. Clicking a preset -// fills the URL + model; the API key still comes from the operator. -const TRANSCRIPTION_PRESETS: { - id: string; - label: string; - description: string; - baseUrl: string; - model: string; -}[] = [ +const TRANSCRIPTION_PRESETS = [ { - id: "local", - label: "Self-hosted · recommended", + id: "self-hosted", + label: "Self-hosted", description: - "A speaches/faster-whisper server on your own hardware — private, free, and no rate limits. Adjust the URL to where it runs; no API key needed.", - baseUrl: "http://localhost:8000", + "Speaches or faster-whisper on your network. Replace the hostname with one reachable from the Silo container.", + baseUrl: "http://speaches:8000", model: "deepdml/faster-whisper-large-v3-turbo-ct2", }, { id: "groq-turbo", - label: "Groq · hosted fallback", - description: - "whisper-large-v3-turbo on Groq — fastest hosted option, very low cost (free tier covers ~2 audio-hours per hour). Needs a Groq API key in the transcription key field.", + label: "Groq - fast", + description: "Hosted whisper-large-v3-turbo. Requires a Groq API key.", baseUrl: "https://api.groq.com/openai", model: "whisper-large-v3-turbo", }, { id: "groq-accurate", - label: "Groq · most accurate", - description: - "whisper-large-v3 on Groq — best multilingual accuracy among hosted options, slightly slower and pricier than turbo.", + label: "Groq - accurate", + description: "Hosted whisper-large-v3. Requires a Groq API key.", baseUrl: "https://api.groq.com/openai", model: "whisper-large-v3", }, { id: "openai", label: "OpenAI", - description: - "whisper-1 on OpenAI — solid quality, higher cost than Groq. Uses the main API key if the transcription key is blank.", + description: "Hosted whisper-1. The transcription key can inherit the Text AI key.", baseUrl: "https://api.openai.com", model: "whisper-1", }, -]; +] as const; -function AIConnectionCard() { - const { data: settings } = useAdminServerSettings(); - const { data: sensitive } = useAdminSensitiveStatus(); - const updateSettings = useUpdateServerSettings(); +const CHAT_ONLY_GATEWAY_HOSTS = ["openrouter.ai"]; - const configuredKeys = new Set(sensitive?.configured ?? []); - const apiKeyConfigured = - configuredKeys.has("ai.api_key") || configuredKeys.has("subtitle_ai.api_key"); - const asrApiKeyConfigured = configuredKeys.has("ai.asr_api_key"); - - const [baseUrl, setBaseUrl] = useState(""); - const [chatModel, setChatModel] = useState(""); - const [apiKey, setApiKey] = useState(""); - const [asrModel, setAsrModel] = useState(""); - const [asrBaseUrl, setAsrBaseUrl] = useState(""); - const [asrApiKey, setAsrApiKey] = useState(""); - const [maxConcurrent, setMaxConcurrent] = useState("2"); - const [restartRequired, setRestartRequired] = useState(false); - - useEffect(() => { - if (!settings) return; - setBaseUrl( - settings["ai.base_url"] ?? settings["subtitle_ai.base_url"] ?? "https://api.openai.com", +function isChatOnlyGateway(rawURL: string): boolean { + const trimmed = rawURL.trim(); + if (!trimmed) return false; + try { + const host = new URL( + trimmed.includes("://") ? trimmed : `https://${trimmed}`, + ).hostname.toLowerCase(); + return CHAT_ONLY_GATEWAY_HOSTS.some( + (gateway) => host === gateway || host.endsWith(`.${gateway}`), ); - setChatModel(settings["ai.chat_model"] ?? settings["subtitle_ai.chat_model"] ?? "gpt-4o-mini"); - setAsrModel(settings["ai.asr_model"] ?? "whisper-1"); - setAsrBaseUrl(settings["ai.asr_base_url"] ?? ""); - setMaxConcurrent( - settings["ai.max_concurrent_jobs"] ?? settings["subtitle_ai.max_concurrent_jobs"] ?? "2", - ); - }, [settings]); - - async function save() { - const trimmedBaseUrl = baseUrl.trim(); - const trimmedChatModel = chatModel.trim(); - const parsedMaxConcurrent = Number.parseInt(maxConcurrent, 10); - - if (trimmedBaseUrl === "" || trimmedChatModel === "") { - toast.error("Base URL and chat model are required."); - return; - } - if (!Number.isInteger(parsedMaxConcurrent) || parsedMaxConcurrent < 1) { - toast.error("Max concurrent jobs must be a positive whole number."); - return; - } - if (isChatOnlyGateway(asrBaseUrl)) { - toast.error( - "That endpoint can't produce timestamped transcriptions (chat-only gateway). Pick a transcription preset below or use a Whisper-capable server.", - ); - return; - } - - const candidates: Record = { - "ai.base_url": trimmedBaseUrl, - "ai.chat_model": trimmedChatModel, - "ai.asr_model": asrModel.trim(), - "ai.asr_base_url": asrBaseUrl.trim(), - "ai.max_concurrent_jobs": String(parsedMaxConcurrent), - }; - const updates = Object.fromEntries( - Object.entries(candidates).filter(([key, value]) => settings?.[key] !== value), - ); - if (apiKey.trim() !== "") { - updates["ai.api_key"] = apiKey; - } - if (asrApiKey.trim() !== "") { - updates["ai.asr_api_key"] = asrApiKey; - } - if (Object.keys(updates).length === 0) { - toast.info("No endpoint settings changed."); - return; - } - try { - const result = await updateSettings.mutateAsync(updates); - setApiKey(""); - setAsrApiKey(""); - setRestartRequired((current) => current || result.restart_required); - toast.success("AI endpoint settings saved"); - } catch { - // The mutation reports the API error. - } + } catch { + return false; } +} +function SectionHeading({ + icon: Icon, + title, + description, + status, + statusTone = "neutral", +}: { + icon: typeof Languages; + title: string; + description: string; + status: string; + statusTone?: "ready" | "warning" | "neutral"; +}) { return ( -
-
-
-

Endpoint

-

- One OpenAI-compatible endpoint shared by every AI feature (OpenAI, Groq, a local Ollama - server, …). Transcription can use a separate Whisper-compatible server. +

+
+
+ +
+
+

{title}

+

+ {description}

-
- - - -
-

Transcription

-

- Subtitle generation needs a Whisper endpoint that returns segment timestamps. Pick a - preset or configure your own: -

-
- {TRANSCRIPTION_PRESETS.map((preset) => { - const active = asrBaseUrl.trim() === preset.baseUrl && asrModel.trim() === preset.model; - return ( - - ); - })} -
-
- - - - -
- -

- Endpoint, model, and credential changes apply live. Changing the concurrency cap requires - a restart. -

- {restartRequired && ( -
- - - Restart required to resize the AI job pool. - - -
+ -
+ > + {statusTone === "ready" ? ( + + ) : statusTone === "warning" ? ( + + ) : null} + {status} + + ); } -function AIFeaturesCard() { - const { data: settings } = useAdminServerSettings(); - const updateSettings = useUpdateServerSettings(); - - const [subtitleTranslate, setSubtitleTranslate] = useState("false"); - const [transcribe, setTranscribe] = useState("false"); - const [metadataTranslate, setMetadataTranslate] = useState("false"); - const [onView, setOnView] = useState("off"); - const [batchSize, setBatchSize] = useState("40"); - const [contextNeighbors, setContextNeighbors] = useState("2"); - const [asrChunkSeconds, setAsrChunkSeconds] = useState("600"); - const [transcribeQuotaJobs, setTranscribeQuotaJobs] = useState("0"); - const [transcribeQuotaPeriod, setTranscribeQuotaPeriod] = useState("day"); - - useEffect(() => { - if (!settings) return; - setSubtitleTranslate(settings["subtitle_ai.enabled"] ?? "false"); - setTranscribe(settings["subtitle_ai.transcribe_enabled"] ?? "false"); - setMetadataTranslate(settings["metadata_ai.enabled"] ?? "false"); - setOnView(settings["metadata_ai.on_view"] ?? "off"); - setBatchSize(settings["subtitle_ai.batch_size"] ?? "40"); - setContextNeighbors(settings["subtitle_ai.context_neighbors"] ?? "2"); - setAsrChunkSeconds(settings["subtitle_ai.asr_chunk_seconds"] ?? "600"); - setTranscribeQuotaJobs(settings["subtitle_ai.transcribe_quota_jobs"] ?? "0"); - setTranscribeQuotaPeriod(settings["subtitle_ai.transcribe_quota_period"] ?? "day"); - }, [settings]); - - async function save() { - const parsedBatch = Number.parseInt(batchSize, 10); - const parsedNeighbors = Number.parseInt(contextNeighbors, 10); - if (!Number.isInteger(parsedBatch) || parsedBatch < 1) { - toast.error("Batch size must be a positive whole number."); - return; - } - if (!Number.isInteger(parsedNeighbors) || parsedNeighbors < 0) { - toast.error("Context lines must be zero or a positive whole number."); - return; - } - const parsedChunkSeconds = Number.parseInt(asrChunkSeconds, 10); - if ( - !Number.isInteger(parsedChunkSeconds) || - parsedChunkSeconds < 60 || - parsedChunkSeconds > 600 - ) { - toast.error("Transcription chunk length must be between 60 and 600 seconds."); - return; - } - const parsedQuotaJobs = Number.parseInt(transcribeQuotaJobs, 10); - if (!Number.isInteger(parsedQuotaJobs) || parsedQuotaJobs < 0) { - toast.error("Transcription limit must be zero (unlimited) or a positive whole number."); - return; - } - const candidates: Record = { - "subtitle_ai.enabled": subtitleTranslate, - "subtitle_ai.transcribe_enabled": transcribe, - "metadata_ai.enabled": metadataTranslate, - "metadata_ai.on_view": onView, - "subtitle_ai.batch_size": String(parsedBatch), - "subtitle_ai.context_neighbors": String(parsedNeighbors), - "subtitle_ai.asr_chunk_seconds": String(parsedChunkSeconds), - "subtitle_ai.transcribe_quota_jobs": String(parsedQuotaJobs), - "subtitle_ai.transcribe_quota_period": transcribeQuotaPeriod, - }; - const updates = Object.fromEntries( - Object.entries(candidates).filter(([key, value]) => settings?.[key] !== value), - ); - if (Object.keys(updates).length === 0) { - toast.info("No feature settings changed."); - return; - } - try { - await updateSettings.mutateAsync(updates); - toast.success("AI feature settings saved"); - } catch { - // The mutation reports the API error. - } - } - +function RequirementNote({ + label, + ready, + detail, +}: { + label: string; + ready: boolean; + detail: string; +}) { return ( -
-
-

Features

-

- Everything runs once on the server and is served to every client through the normal - subtitle and metadata pipelines. -

-
- - - - - - - - - ({ - value: p, - label: `Per ${p} (rolling ${QUOTA_PERIOD_WINDOW_LABELS[p]})`, - }))} - hint="Rolling window the transcription limit counts against." - /> -
- -

Feature changes apply live.

-
-
+
+ {ready ? ( + + ) : ( + + )} + + {label} - {detail} + +
); } export default function AIServicesSettings() { + const form = useSettingsForm({ keys: KEYS }); + const textCheck = useCheckAdminSettingsConnection(); + const speechCheck = useCheckAdminSettingsConnection(); + const [textResult, setTextResult] = useState(null); + const [speechResult, setSpeechResult] = useState(null); + + if (form.isLoading) { + return ( +
+ + + + + Loading AI settings +
+ ); + } + + const value = (key: string, fallback = "") => form.getValue(key) || fallback; + const effectiveValue = (key: string, legacyKey: string, fallback: string) => + value(key, value(legacyKey, fallback)); + const textBaseURL = effectiveValue( + "ai.base_url", + "subtitle_ai.base_url", + "https://api.openai.com", + ); + const chatModel = effectiveValue("ai.chat_model", "subtitle_ai.chat_model", "gpt-4o-mini"); + const asrBaseURL = value("ai.asr_base_url"); + const asrModel = value("ai.asr_model", "whisper-1"); + const textReady = textBaseURL.trim() !== "" && chatModel.trim() !== ""; + const speechUsesTextEndpoint = asrBaseURL.trim() === ""; + const speechCheckable = + (asrBaseURL.trim() !== "" || textBaseURL.trim() !== "") && asrModel.trim() !== ""; + const speechCompatible = !isChatOnlyGateway(speechUsesTextEndpoint ? textBaseURL : asrBaseURL); + const speechReady = speechCheckable && speechCompatible; + const descriptionEnabled = value("metadata_ai.enabled", "false") === "true"; + + function setValue(key: string, nextValue: string) { + form.setValue(key, nextValue); + if (TEXT_AI_KEYS.includes(key as (typeof TEXT_AI_KEYS)[number])) { + setTextResult(null); + } + if (SPEECH_AI_KEYS.includes(key as (typeof SPEECH_AI_KEYS)[number])) { + setSpeechResult(null); + } + } + + async function checkTextConnection() { + try { + setTextResult( + await textCheck.mutateAsync({ + kind: "ai_chat", + body: form.buildConnectionCheckRequest([...TEXT_AI_KEYS]), + }), + ); + } catch (error) { + setTextResult({ + success: false, + message: error instanceof Error ? error.message : "Text AI connection check failed.", + }); + } + } + + async function checkSpeechConnection() { + try { + setSpeechResult( + await speechCheck.mutateAsync({ + kind: "ai_transcription", + body: form.buildConnectionCheckRequest([...SPEECH_AI_KEYS]), + }), + ); + } catch (error) { + setSpeechResult({ + success: false, + message: error instanceof Error ? error.message : "Speech-to-text connection check failed.", + }); + } + } + + async function save() { + const batchSize = Number.parseInt(value("subtitle_ai.batch_size", "40"), 10); + const contextLines = Number.parseInt(value("subtitle_ai.context_neighbors", "2"), 10); + const chunkSeconds = Number.parseInt(value("subtitle_ai.asr_chunk_seconds", "600"), 10); + const quotaJobs = Number.parseInt(value("subtitle_ai.transcribe_quota_jobs", "0"), 10); + const maxConcurrent = Number.parseInt( + effectiveValue("ai.max_concurrent_jobs", "subtitle_ai.max_concurrent_jobs", "2"), + 10, + ); + + if (!textReady) { + toast.error("Text AI base URL and chat model are required."); + return; + } + if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1) { + toast.error("Max concurrent jobs must be a positive whole number."); + return; + } + if (!Number.isInteger(batchSize) || batchSize < 1) { + toast.error("Subtitle batch size must be a positive whole number."); + return; + } + if (!Number.isInteger(contextLines) || contextLines < 0) { + toast.error("Subtitle context lines must be zero or a positive whole number."); + return; + } + if (!Number.isInteger(chunkSeconds) || chunkSeconds < 60 || chunkSeconds > 600) { + toast.error("Transcription chunk length must be between 60 and 600 seconds."); + return; + } + if (!Number.isInteger(quotaJobs) || quotaJobs < 0) { + toast.error("Transcription limit must be zero or a positive whole number."); + return; + } + await form.save(); + } + return ( -
+

AI Services

- Shared AI endpoint and feature toggles for subtitle translation, subtitle generation from - audio, and description translation. + Configure text translation and speech-to-text independently, then enable only the features + that use them.

-
- - +
+
+ +
+ setValue("ai.base_url", next)} + hint="https://api.openai.com" + /> + setValue("ai.chat_model", next)} + hint="gpt-4o-mini, gemini-flash-latest, llama3.1" + /> + setValue("ai.api_key", next)} + sensitiveConfigured={ + form.sensitiveConfigured.includes("ai.api_key") || + form.sensitiveConfigured.includes("subtitle_ai.api_key") + } + hint="Optional for keyless local endpoints. Saved keys are reused for tests only when the endpoint host is unchanged." + /> + +
+
+ +
+ +
+
+ {TRANSCRIPTION_PRESETS.map((preset) => { + const active = asrBaseURL === preset.baseUrl && asrModel === preset.model; + return ( + + ); + })} +
+ setValue("ai.asr_base_url", next)} + hint="http://speaches:8000 or https://api.groq.com/openai" + /> + {speechUsesTextEndpoint && ( +
+ + + Uses the Text translation endpoint and API key. This only works when that provider + implements OpenAI-compatible /audio/transcriptions with timestamped + segments. Test it before enabling audio generation. + +
+ )} + setValue("ai.asr_model", next)} + hint="whisper-large-v3-turbo or whisper-1" + /> + setValue("ai.asr_api_key", next)} + sensitiveConfigured={form.sensitiveConfigured.includes("ai.asr_api_key")} + hint="Optional. A saved or inherited key is reused for tests only when the endpoint host is unchanged." + /> +

+ For self-hosted services, use a hostname or IP reachable from the Silo container. + localhost + points back to Silo itself. +

+ +
+
+ +
+
+

Features

+

+ Generated subtitles and translated metadata are saved once and served to every client + through Silo's normal pipelines. +

+
+ +
+
+ setValue("subtitle_ai.enabled", next)} + hint="Text AI required - Translates an existing text subtitle track. Whisper is not used." + /> + +
+
+ setValue("subtitle_ai.transcribe_enabled", next)} + hint="Speech-to-text required - Uses Whisper to create timed subtitles from the selected audio track." + /> + +
+
+ setValue("metadata_ai.enabled", next)} + hint="Text AI required - Translates overviews and taglines from the metadata editor or library refresh." + /> + +
+
+ setValue("metadata_ai.on_view", next)} + disabled={!descriptionEnabled} + options={[ + { value: "off", label: "Off" }, + { value: "button", label: "Translate button on detail pages" }, + { value: "auto", label: "Automatic on view" }, + ]} + hint={ + descriptionEnabled + ? "Controls viewer-triggered description translation." + : "Inactive until Description translation is enabled." + } + /> +
+
+
+ +
+
+ +
+

Advanced

+

+ Job concurrency, translation batching, transcription chunks, and account quotas. +

+
+ +
+
+ setValue("ai.max_concurrent_jobs", next)} + hint="Shared by subtitle translation, speech-to-text, and description translation. Changing this value requires a server restart." + /> + setValue("subtitle_ai.batch_size", next)} + hint="Text cues sent in each translation request." + /> + setValue("subtitle_ai.context_neighbors", next)} + hint="Previous source cues included for scene continuity." + /> + setValue("subtitle_ai.asr_chunk_seconds", next)} + hint="60-600. Shorter chunks reduce timestamp drift but make more requests." + /> + setValue("subtitle_ai.transcribe_quota_jobs", next)} + hint="0 = unlimited. Profiles share their account's limit." + /> + setValue("subtitle_ai.transcribe_quota_period", next)} + options={QUOTA_PERIODS.map((period) => ({ + value: period, + label: `Per ${period} (rolling ${QUOTA_PERIOD_WINDOW_LABELS[period]})`, + }))} + hint="Rolling window used for the account limit." + /> +
+
+
+ +
+
+

Recommendation embeddings are configured separately

+

+ Search vectors and recommendations do not use the translation or speech endpoints above. +

+
+ + Open Recommendations + + +
+ + void save()} + onDiscard={form.discard} + isSaving={form.isSaving} + restartRequired={form.restartRequired} + />
); }