From f10888c0dd4146f06ef23bf163c0bb3cd7e61df9 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Tue, 6 Oct 2026 21:55:46 +0800 Subject: [PATCH 1/2] feat(ui): add cloud transcription settings (MEET-12) --- packages/ui/src/components/AiSettings.tsx | 113 ++++++++++++++-- .../components/__tests__/aiSettings.test.tsx | 127 ++++++++++++++++++ packages/ui/src/i18n/messages/de.ts | 8 +- packages/ui/src/i18n/messages/en.ts | 8 +- packages/ui/src/i18n/messages/ja.ts | 8 +- packages/ui/src/i18n/messages/zh.ts | 8 +- 6 files changed, 256 insertions(+), 16 deletions(-) create mode 100644 packages/ui/src/components/__tests__/aiSettings.test.tsx diff --git a/packages/ui/src/components/AiSettings.tsx b/packages/ui/src/components/AiSettings.tsx index 88f3e421..f51813f8 100644 --- a/packages/ui/src/components/AiSettings.tsx +++ b/packages/ui/src/components/AiSettings.tsx @@ -1,6 +1,6 @@ import {useCallback, useEffect, useState, type ReactNode} from 'react'; import {ChevronDown, ChevronRight, Trash2} from 'lucide-react'; -import {providerSettings, type AiConfig, type AiEffort, type AiProvider, type AiProviderSettings, type AiSkill, type AiStatus} from '@book.dev/sdk'; +import {providerSettings, type AiConfig, type AiEffort, type AiProvider, type AiProviderSettings, type AiSkill, type AiStatus, type AiTranscriptionConfig} from '@book.dev/sdk'; import {ScopeChip, SettingsField, SettingsScreen, SettingsSection, SettingsToggle, SETTINGS_CONTROL_CLASS} from '@/components/settings/primitives'; import {Button} from '@/components/ui/button'; import {Select} from '@/components/ui/select'; @@ -92,8 +92,11 @@ export default function AiSettings() { setBusy(true); try { await client.aiSetConfig(config); - setDraft(config); - await refresh(); + // Re-read the redacted config: neither new keys nor explicit-clear nulls + // should be replayed by a later save in the other settings section. + const next = await client.aiStatus(); + setStatus(next); + setDraft(normalize(next.config)); } finally { setBusy(false); } @@ -141,6 +144,22 @@ export default function AiSettings() { }); }); }; + const audio: AiTranscriptionConfig = draft.transcription ?? {provider: 'local'}; + const setAudio = (patch: Partial): AiConfig => ({ + ...draft, + transcription: {...audio, ...patch}, + }); + const saveAudioKey = (): void => { + const typed = audio.apiKey; + void apply(draft).then(() => { + const stored = typeof typed === 'string' && typed.trim().length > 0; + setDraft((d) => d?.transcription ? { + ...d, + transcription: {...d.transcription, apiKey: undefined, apiKeySet: stored || (d.transcription.apiKeySet ?? false)}, + } : d); + }); + }; + const showAudioKeySet = Boolean(audio.apiKeySet) && audio.apiKey == null; const modelInput = (p: AiProvider, placeholder: string, hint: string) => ( - {status?.transcription && ( - -

{t(status.transcription.modelPresent ? 'ai.transcription.modelPresent' : 'ai.transcription.modelAbsent')} {t(status.transcription.ready ? 'ai.transcription.ready' : status.transcription.runtimeAvailable ? 'ai.transcription.modelMissing' : 'ai.transcription.runtimeMissing')}

- - {download?.url === status.transcription.downloadUrl && download.error &&

{download.error}

} -
- )} + + + + +

+ {t('ai.transcription.localHint')}{' '} + + {t('ai.transcription.localDocs')} + +

+ {audio.provider === 'openai-compat' && ( + <> +

{t('ai.transcription.privacy')}

+ + setDraft(setAudio({baseUrl: e.target.value}))} + onBlur={() => void apply(draft)} + /> + + + setDraft(setAudio({model: e.target.value}))} + onBlur={() => void apply(draft)} + /> + + +
+ setDraft(setAudio({apiKey: e.target.value}))} + onBlur={saveAudioKey} + /> + {showAudioKeySet && ( + + )} +
+ {showAudioKeySet && ( +

+ {t('ai.apiKeySetStatus')} · {t('ai.apiKeyKeepHint')} +

+ )} +
+ + )} + {status?.transcription && ( + <> +

{t(status.transcription.modelPresent ? 'ai.transcription.modelPresent' : 'ai.transcription.modelAbsent')} {t(status.transcription.ready ? 'ai.transcription.ready' : status.transcription.runtimeAvailable ? 'ai.transcription.modelMissing' : 'ai.transcription.runtimeMissing')}

+ + {download?.url === status.transcription.downloadUrl && download.error &&

{download.error}

} + + )} +
{providers.map((p) => ( diff --git a/packages/ui/src/components/__tests__/aiSettings.test.tsx b/packages/ui/src/components/__tests__/aiSettings.test.tsx new file mode 100644 index 00000000..14ab83fd --- /dev/null +++ b/packages/ui/src/components/__tests__/aiSettings.test.tsx @@ -0,0 +1,127 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react'; +import type {AiConfig, AiStatus, DataClient} from '@book.dev/sdk'; +import AiSettings from '../AiSettings'; +import {DataProvider} from '@/data/DataProvider'; +import {ConfirmProvider, I18nProvider, PreferencesProvider} from '@/providers'; + +afterEach(cleanup); + +const chat: AiConfig = {provider: 'claude', providers: {claude: {model: 'chat-model', apiKeySet: true}}, effort: 'high', thinking: false}; +const cloud: AiConfig['transcription'] = {provider: 'openai-compat', baseUrl: 'https://audio.example', model: 'audio-model', apiKeySet: true}; + +function setup(initial: AiConfig = chat) { + let config = structuredClone(initial); + const aiStatus = vi.fn(async (): Promise => ({ + config: structuredClone(config), ready: false, embeddings: false, index: {pages: 0, builtAt: null}, + transcription: {model: 'base', modelPresent: false, runtimeAvailable: false, ready: false, downloadUrl: 'https://example.com/base.bin'}, + })); + const aiSetConfig = vi.fn(async (next: AiConfig) => { + // Simulate the server's redaction and preserve/set/clear key contract. + const saved = structuredClone(next); + const redact = (s: {apiKey?: string | null; apiKeySet?: boolean}, previous?: {apiKeySet?: boolean}) => { + s.apiKeySet = s.apiKey === null ? false : Boolean(s.apiKey?.trim()) || Boolean(previous?.apiKeySet); + delete s.apiKey; + }; + if (saved.transcription) redact(saved.transcription, config.transcription); + for (const [p, settings] of Object.entries(saved.providers ?? {})) { + redact(settings, config.providers?.[p as keyof NonNullable]); + } + config = saved; + return structuredClone(config); + }); + const client = {aiStatus, aiSetConfig, aiSkills: async () => []} as unknown as DataClient; + const mount = () => render( + + + , + ); + const view = mount(); + return {aiStatus, aiSetConfig, view, mount}; +} + +async function section() { + const picker = await screen.findByLabelText('Transcription provider') as HTMLButtonElement; + return {picker, audio: within(picker.closest('section')!)}; +} + +describe('AI transcription settings', () => { + it('defaults to local, shows cloud fields only for cloud, and round-trips all picker choices through status', async () => { + const {aiSetConfig, view, mount} = setup(); + let {picker, audio} = await section(); + expect(picker.dataset.value).toBe('local'); + expect(audio.queryByLabelText('API key')).toBeNull(); + expect(audio.getByRole('link', {name: 'Local transcription setup'}).getAttribute('href')).toContain('docs/local-transcription.md'); + for (const provider of ['openai-compat', 'off', 'local']) { + fireEvent.click(picker); + fireEvent.click(await screen.findByRole('option', {name: provider === 'local' ? 'Default (local)' : provider === 'off' ? 'Off' : 'Cloud (OpenAI-compatible)'})); + await waitFor(() => expect(picker.disabled).toBe(false)); + expect(aiSetConfig.mock.lastCall?.[0]).toEqual({...chat, transcription: expect.objectContaining({provider})}); + expect(Boolean(audio.queryByLabelText('API key'))).toBe(provider === 'openai-compat'); + if (provider === 'openai-compat') { + expect((audio.getByLabelText('Server URL') as HTMLInputElement).value).toBe('https://api.openai.com'); + expect((audio.getByLabelText('Model') as HTMLInputElement).value).toBe('whisper-1'); + expect(audio.getByText(/sends meeting audio to the configured endpoint/)).toBeTruthy(); + } + } + view.unmount(); + const mounted = mount(); + ({picker, audio} = await section()); + expect(picker.dataset.value).toBe('local'); + mounted.unmount(); + }); + + it.each(['', ' ', 'replacement-secret'])('keeps keys write-only on blur (%j)', async (typed) => { + const {aiSetConfig} = setup({...chat, transcription: cloud}); + const {audio, picker} = await section(); + const input = audio.getByLabelText('API key') as HTMLInputElement; + expect(input.value).toBe(''); + expect(input.getAttribute('aria-describedby')).toBe('ai-transcription-apikey-status'); + expect(audio.getByText(/Leave blank to keep the current key/)).toBeTruthy(); + fireEvent.change(input, {target: {value: typed}}); + fireEvent.blur(input); + await waitFor(() => expect(picker.disabled).toBe(false)); + expect(aiSetConfig.mock.lastCall?.[0].transcription?.apiKey ?? '').toBe(typed); + expect(input.value).toBe(''); + expect(audio.getByRole('button', {name: 'Clear key'})).toBeTruthy(); + }); + + it('clears only with explicit null and never replays the clear on subsequent saves', async () => { + const {aiSetConfig} = setup({...chat, transcription: cloud}); + const {audio, picker} = await section(); + fireEvent.click(audio.getByRole('button', {name: 'Clear key'})); + await waitFor(() => expect(picker.disabled).toBe(false)); + expect(aiSetConfig.mock.lastCall?.[0]).toEqual({...chat, transcription: {...cloud, apiKey: null, apiKeySet: false}}); + expect(audio.queryByRole('button', {name: 'Clear key'})).toBeNull(); + fireEvent.click(picker); + fireEvent.click(await screen.findByRole('option', {name: 'Off'})); + await waitFor(() => expect(picker.disabled).toBe(false)); + expect(aiSetConfig.mock.lastCall?.[0].transcription?.apiKey).toBeUndefined(); + }); + + it('preserves chat config in transcription saves and transcription config in chat saves', async () => { + const {aiSetConfig, view, mount} = setup({...chat, transcription: cloud}); + const {audio, picker} = await section(); + const url = audio.getByLabelText('Server URL'); + fireEvent.change(url, {target: {value: 'https://new.example'}}); + fireEvent.blur(url); + await waitFor(() => expect(picker.disabled).toBe(false)); + const updated = {...cloud, baseUrl: 'https://new.example'}; + expect(aiSetConfig.mock.lastCall?.[0]).toEqual({...chat, transcription: updated}); + const model = audio.getByLabelText('Model'); + fireEvent.change(model, {target: {value: 'new-model'}}); + fireEvent.blur(model); + await waitFor(() => expect(picker.disabled).toBe(false)); + updated.model = 'new-model'; + expect(aiSetConfig.mock.lastCall?.[0]).toEqual({...chat, transcription: updated}); + fireEvent.click(screen.getByRole('radio', {name: /Off No model/})); + await waitFor(() => expect(picker.disabled).toBe(false)); + expect(aiSetConfig.mock.lastCall?.[0]).toEqual({...chat, provider: 'off', transcription: updated}); + view.unmount(); + mount(); + const reloaded = await section(); + expect(reloaded.picker.dataset.value).toBe('openai-compat'); + expect((reloaded.audio.getByLabelText('Server URL') as HTMLInputElement).value).toBe(updated.baseUrl); + expect((reloaded.audio.getByLabelText('Model') as HTMLInputElement).value).toBe(updated.model); + }); +}); diff --git a/packages/ui/src/i18n/messages/de.ts b/packages/ui/src/i18n/messages/de.ts index 4dea7274..5d480286 100644 --- a/packages/ui/src/i18n/messages/de.ts +++ b/packages/ui/src/i18n/messages/de.ts @@ -533,7 +533,13 @@ export const de: PartialMessages = { }, ai: { transcription: { - title: 'Lokale Transkription', + title: 'Transkription', + provider: 'Transkriptionsanbieter', + local: 'Standard (lokal)', + cloud: 'Cloud (OpenAI-kompatibel)', + localHint: 'Standardmäßig erfolgt die Transkription lokal und benötigt die Whisper-Laufzeit auf dem Server.', + localDocs: 'Lokale Transkription einrichten', + privacy: 'Die Cloud-Transkription sendet Meeting-Audio an den konfigurierten Endpunkt. Aktiviere sie nur, wenn du diesem Dienst deine Aufnahmen anvertrauen möchtest.', description: 'Aufnahmen werden standardmäßig lokal mit Whisper transkribiert, ohne Cloud-Schlüssel. Whisper base ist mehrsprachig (~142 MiB).', modelPresent: 'Modell heruntergeladen.', modelAbsent: 'Modell nicht heruntergeladen.', diff --git a/packages/ui/src/i18n/messages/en.ts b/packages/ui/src/i18n/messages/en.ts index b5168a7a..6bb1339d 100644 --- a/packages/ui/src/i18n/messages/en.ts +++ b/packages/ui/src/i18n/messages/en.ts @@ -847,7 +847,13 @@ export const en = { }, ai: { transcription: { - title: 'Local transcription', + title: 'Transcription', + provider: 'Transcription provider', + local: 'Default (local)', + cloud: 'Cloud (OpenAI-compatible)', + localHint: 'Local is the default and requires the Whisper runtime on the server.', + localDocs: 'Local transcription setup', + privacy: 'Cloud transcription sends meeting audio to the configured endpoint. Only enable it if you trust that service with your recordings.', description: 'Recordings use Whisper locally by default, with no cloud key. Whisper base is multilingual (~142 MiB).', modelPresent: 'Model downloaded.', modelAbsent: 'Model not downloaded.', diff --git a/packages/ui/src/i18n/messages/ja.ts b/packages/ui/src/i18n/messages/ja.ts index 6891c3c9..f2e1239f 100644 --- a/packages/ui/src/i18n/messages/ja.ts +++ b/packages/ui/src/i18n/messages/ja.ts @@ -528,7 +528,13 @@ export const ja: PartialMessages = { }, ai: { transcription: { - title: 'ローカル文字起こし', + title: '文字起こし', + provider: '文字起こしプロバイダー', + local: 'デフォルト(ローカル)', + cloud: 'クラウド(OpenAI互換)', + localHint: 'デフォルトはローカル処理です。サーバーにWhisperランタイムが必要です。', + localDocs: 'ローカル文字起こしの設定', + privacy: 'クラウド文字起こしでは会議の音声を設定したエンドポイントに送信します。録音を預けられる信頼できるサービスの場合のみ有効にしてください。', description: '録音は標準でWhisperを使ってローカルで文字起こしされます。クラウドのAPIキーは不要です。Whisper baseは多言語対応です(約142 MiB)。', modelPresent: 'モデルをダウンロード済みです。', modelAbsent: 'モデルが未ダウンロードです。', diff --git a/packages/ui/src/i18n/messages/zh.ts b/packages/ui/src/i18n/messages/zh.ts index 396d748b..6c7b111c 100644 --- a/packages/ui/src/i18n/messages/zh.ts +++ b/packages/ui/src/i18n/messages/zh.ts @@ -527,7 +527,13 @@ export const zh: PartialMessages = { }, ai: { transcription: { - title: '本地转录', + title: '转录', + provider: '转录提供商', + local: '默认(本地)', + cloud: '云端(兼容 OpenAI)', + localHint: '默认使用本地转录,需要在服务器上安装 Whisper 运行时。', + localDocs: '本地转录设置', + privacy: '云端转录会将会议音频发送到配置的端点。仅在信任该服务能够处理您的录音时启用。', description: '录音默认使用 Whisper 在本地转录,无需云端密钥。Whisper base 支持多种语言(约142 MiB)。', modelPresent: '模型已下载。', modelAbsent: '模型尚未下载。', From 76b0f395999f86a168e9a85ea07e1c67babbd40d Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Tue, 6 Oct 2026 22:06:11 +0800 Subject: [PATCH 2/2] fix(ui): surface transcription config errors + settings polish (MEET-12) --- packages/ui/src/components/AiSettings.tsx | 67 ++++++------- ...iSettings.test.tsx => AiSettings.test.tsx} | 97 +++++++++++++++++-- .../ui/src/components/settings/primitives.tsx | 3 + packages/ui/src/i18n/messages/de.ts | 1 + packages/ui/src/i18n/messages/en.ts | 1 + packages/ui/src/i18n/messages/ja.ts | 1 + packages/ui/src/i18n/messages/zh.ts | 3 +- 7 files changed, 128 insertions(+), 45 deletions(-) rename packages/ui/src/components/__tests__/{aiSettings.test.tsx => AiSettings.test.tsx} (55%) diff --git a/packages/ui/src/components/AiSettings.tsx b/packages/ui/src/components/AiSettings.tsx index f51813f8..a1933476 100644 --- a/packages/ui/src/components/AiSettings.tsx +++ b/packages/ui/src/components/AiSettings.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useState, type ReactNode} from 'react'; +import {useCallback, useEffect, useRef, useState, type ReactNode} from 'react'; import {ChevronDown, ChevronRight, Trash2} from 'lucide-react'; import {providerSettings, type AiConfig, type AiEffort, type AiProvider, type AiProviderSettings, type AiSkill, type AiStatus, type AiTranscriptionConfig} from '@book.dev/sdk'; import {ScopeChip, SettingsField, SettingsScreen, SettingsSection, SettingsToggle, SETTINGS_CONTROL_CLASS} from '@/components/settings/primitives'; @@ -44,6 +44,10 @@ export default function AiSettings() { const [status, setStatus] = useState(null); const [draft, setDraft] = useState(null); const [busy, setBusy] = useState(false); + const [saveError, setSaveError] = useState(null); + const saving = useRef(false); + const lastGoodConfig = useRef(null); + const transcriptionPicker = useRef(null); const [indexing, setIndexing] = useState(false); const [skills, setSkills] = useState([]); // Which provider accordions are expanded. The default provider's opens @@ -53,6 +57,7 @@ export default function AiSettings() { const refresh = useCallback(async () => { try { const next = await client.aiStatus(); + lastGoodConfig.current = normalize(next.config); setStatus(next); setDraft((d) => d ?? normalize(next.config)); } catch { @@ -89,15 +94,25 @@ export default function AiSettings() { }, [status, refresh]); const apply = async (config: AiConfig): Promise => { + if (saving.current) return; + saving.current = true; setBusy(true); + setSaveError(null); try { await client.aiSetConfig(config); // Re-read the redacted config: neither new keys nor explicit-clear nulls // should be replayed by a later save in the other settings section. const next = await client.aiStatus(); + lastGoodConfig.current = normalize(next.config); setStatus(next); - setDraft(normalize(next.config)); + setDraft(lastGoodConfig.current); + } catch (error) { + setSaveError(error instanceof Error ? error.message : String(error)); + // Roll back rejected edits and scrub typed keys even when the post-save + // status read fails. Only server-redacted, last-known-good state survives. + setDraft(lastGoodConfig.current); } finally { + saving.current = false; setBusy(false); } }; @@ -129,36 +144,11 @@ export default function AiSettings() { ...draft, providers: {...draft.providers, [p]: {...providerSettings(draft, p), ...patch}}, }); - // Persist a write-only API-key field on blur, then SCRUB the raw entry from - // local state so the secret never lingers in React: a non-empty entry is now - // stored on the server (surface it as a masked "key set"), while a blank is a - // no-op that leaves the stored key untouched. Explicit removal uses "Clear key". - const saveKey = (p: AiProvider): void => { - const typed = providerSettings(draft, p).apiKey; - void apply(draft).then(() => { - const stored = typeof typed === 'string' && typed.trim().length > 0; - setDraft((d) => { - if (!d) return d; - const cur = providerSettings(d, p); - return {...d, providers: {...d.providers, [p]: {...cur, apiKey: undefined, apiKeySet: stored || (cur.apiKeySet ?? false)}}}; - }); - }); - }; const audio: AiTranscriptionConfig = draft.transcription ?? {provider: 'local'}; const setAudio = (patch: Partial): AiConfig => ({ ...draft, transcription: {...audio, ...patch}, }); - const saveAudioKey = (): void => { - const typed = audio.apiKey; - void apply(draft).then(() => { - const stored = typeof typed === 'string' && typed.trim().length > 0; - setDraft((d) => d?.transcription ? { - ...d, - transcription: {...d.transcription, apiKey: undefined, apiKeySet: stored || (d.transcription.apiKeySet ?? false)}, - } : d); - }); - }; const showAudioKeySet = Boolean(audio.apiKeySet) && audio.apiKey == null; const modelInput = (p: AiProvider, placeholder: string, hint: string) => ( @@ -259,7 +249,7 @@ export default function AiSettings() { value={typed} placeholder={showKeySet ? t('ai.apiKeySet') : 'sk-ant-…'} onChange={(e) => setDraft(set(p, {apiKey: e.target.value}))} - onBlur={() => saveKey(p)} + onBlur={() => void apply(draft)} /> {showKeySet && (