diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4bfc5..7f8b6de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,13 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [0.9.0] - 2026-09-02 + ### Changed +- Rebuilt Settings as a full-height categorized workspace with automatic + saving for ordinary preferences, dedicated Provider and AI-template + list/detail managers, and a non-blocking first-run checklist. - Hotword lists now accept optional `:weight` suffixes with full-width-colon compatibility, save-time normalization, structured generation snapshots, DashScope weights `1–5` and super-hotword `50`, and explicit disclosure when @@ -353,7 +358,8 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht - Added GitHub Actions workflows for continuous integration and tagged Windows releases. - Added English and Simplified Chinese README documentation. -[Unreleased]: https://github.com/kwp-lab/nota/compare/v0.8.0...HEAD +[Unreleased]: https://github.com/kwp-lab/nota/compare/v0.9.0...HEAD +[0.9.0]: https://github.com/kwp-lab/nota/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/kwp-lab/nota/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/kwp-lab/nota/compare/069d067d016282ae7a9ca24a360fcc64a7b4155b...v0.7.0 [0.6.0]: https://github.com/kwp-lab/nota/compare/9dca2a84e556bf813f98beb0bab82ab5c5eb6295...069d067d016282ae7a9ca24a360fcc64a7b4155b diff --git a/README.md b/README.md index 8738dbc..fc301fa 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@

Platform: Windows 11 x64 - Version: 0.8.0 + Version: 0.9.0 Status: early preview Privacy: local first Telemetry: none diff --git a/README.zh-CN.md b/README.zh-CN.md index 027b595..26a1ad4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -19,7 +19,7 @@

平台:Windows 11 x64 - 版本:0.8.0 + 版本:0.9.0 状态:早期预览 隐私:本地优先 遥测:无 diff --git a/THIRD_PARTY_NOTICES.txt b/THIRD_PARTY_NOTICES.txt index dddf01e..c5768db 100644 --- a/THIRD_PARTY_NOTICES.txt +++ b/THIRD_PARTY_NOTICES.txt @@ -4242,7 +4242,7 @@ developed by Chen Jiaju, licensed under the MIT License and the Apache License 2 ------------------------------------------------------------------------------- MIT License (MIT) Used by: -- nota 0.8.0 +- nota 0.9.0 - brotli-decompressor 5.0.3 - cargo_toml 0.22.3 - dpi 0.1.2 diff --git a/bom.cyclonedx.json b/bom.cyclonedx.json index 24057fb..b98ec48 100644 --- a/bom.cyclonedx.json +++ b/bom.cyclonedx.json @@ -1,14 +1,14 @@ { "bomFormat": "CycloneDX", "specVersion": "1.6", - "serialNumber": "urn:uuid:62a7911f-9473-4929-ac10-00652eef1252", + "serialNumber": "urn:uuid:1a4f4ce6-ac43-4dee-a45d-8d03c8231185", "version": 1, "metadata": { "component": { "type": "application", "bom-ref": "pkg:github/kwp-lab/nota", "name": "Nota", - "version": "0.8.0", + "version": "0.9.0", "licenses": [ { "license": { diff --git a/docs/architecture.md b/docs/architecture.md index 4f95b42..62e5303 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,7 +1,7 @@ # Nota Client Architecture - Status: Accepted -- Last updated: 2026-08-22 +- Last updated: 2026-08-27 - Owners: Nota desktop maintainers - Related code: `src/`, `src-tauri/src/controller.rs`, `src-tauri/src/audio/`, `src-tauri/src/storage.rs`, `src-tauri/src/asr.rs`, @@ -76,6 +76,52 @@ flowchart LR | Configured ASR service | Model inference and server-side processing | Local recording ownership | | Configured LLM provider | Explicitly requested text generation | Local Markdown, SQLite, or recording ownership | +### Settings Ownership and Persistence + +`App.tsx` owns the canonical settings and Provider summaries shared with the +rest of the application, the current settings route for this application +session, and the one global leave guard for complex editors. The settings +workspace owns category navigation and page composition. Individual pages +receive one aggregated model/actions interface rather than a collection of IPC +callbacks. + +`useSettingsController` is the single frontend owner of settings IPC, directory +selection, Provider/template refresh, ordinary-preference autosave, local +feedback, and complex-editor dirty state. ASR Provider, LLM Provider, and AI +template managers use the controller but own their type-specific drafts and +validation. They save explicitly and must confirm before discarding a changed +draft. Ordinary preference pages never contribute to the global leave guard. + +Ordinary settings use optimistic rendering and a serialized, latest-snapshot +queue. The controller sends only one settings write at a time, coalesces newer +intent behind the in-flight write, and rolls back to the last confirmed +snapshot if persistence fails. Default ASR/LLM changes remain specialized IPC +commands so backend validation and automatic-transcription rules stay +authoritative. Shortcut text is a local paired draft and is submitted on blur +or Enter. The Rust controller re-registers global shortcuts only when either +shortcut field changed and restores the previous registration if persistence +fails. + +```mermaid +stateDiagram-v2 + [*] --> Confirmed + Confirmed --> Queued: user changes an ordinary preference + Queued --> Saving: no settings write is in flight + Saving --> Queued: newer target snapshot replaces queued target + Saving --> Confirmed: backend accepts the latest saved snapshot + Saving --> RolledBack: backend rejects registration or persistence + RolledBack --> Confirmed: restore the last confirmed snapshot + Confirmed --> DraftEditor: open Provider or template editor + DraftEditor --> Confirmed: explicit save or discard confirmed + DraftEditor --> DraftEditor: navigation blocked until user decides +``` + +The first-run checklist is a route inside this same workspace. Only its +**Finish** and **Set up later** actions may set `firstRunComplete`; unrelated +autosaves must preserve that field. After first run, the last settings route is +remembered only for the current process and defaults to recording settings on +the next launch. + ## Non-Negotiable Invariants - Recording must work without network access. diff --git a/docs/design-system.md b/docs/design-system.md index 09b3e91..2d7dfd7 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -1,7 +1,7 @@ # Nota Visual Design System - Status: Accepted -- Last updated: 2026-08-11 +- Last updated: 2026-08-27 - Owners: Nota maintainers This specification is the source of truth for Nota's in-product visual @@ -155,6 +155,37 @@ page-specific CSS. and must appear above scroll containers without being clipped. - Keyboard focus must be visible. Hover-only behavior is not sufficient. +### Settings Center + +Settings is a full-height workspace inside the application shell. The global +application sidebar remains visible, while the normal page header and footer +are removed so the settings hierarchy owns the available height. + +- The settings category navigation is a persistent `200 px` grid track. It + never collapses or hides, including on Provider and template management + routes. A selected management route highlights its parent category. +- Ordinary preference pages use one readable column of stacked + `SettingsCard` rows. A card contains one icon, a primary label, optional + supporting text, and one trailing control or navigation affordance. It must + not become a second nested navigation system. +- Provider and template management pages use three conceptual columns: the + persistent category navigation, a `260 px` resource list, and a scrollable + detail editor. ASR and LLM editors share this shell and its feedback/action + treatments, but keep type-specific fields and validation. +- Explicit-save editors keep their actions in a stable detail footer. Built-in + templates use the same detail region in a read-only state; custom templates + may be edited, saved, or archived. +- At the `980 px` minimum application width, the category and resource tracks + reduce to approximately `168 px` and `220 px`. Multi-column forms become one + column. The category navigation remains visible and no settings route may + introduce horizontal page overflow. +- The category list, resource list, and detail region own independent vertical + scrolling where needed. Chinese long text may wrap in descriptions, while + paths and compact resource metadata use tokenized truncation treatments. + +Settings uses existing semantic surface, border, status, spacing, and focus +roles. The workspace geometry does not introduce a new visual token role. + ## Change and Review Checklist For every visual change: diff --git a/docs/testing.md b/docs/testing.md index 7734f88..e9ae65b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,7 +1,7 @@ # Testing and Acceptance - Status: Accepted -- Last updated: 2026-08-22 +- Last updated: 2026-08-27 - Owners: Nota maintainers - Related configuration: `package.json`, `src-tauri/Cargo.toml`, `.github/workflows/ci.yml`, `.github/workflows/frontend.yml`, @@ -80,6 +80,7 @@ required devices or environment variables in the test name and ignore message. | Area | Primary tests | |---|---| | React behavior and typed rendering | `src/*.test.tsx`, `src/components/*.test.tsx` | +| Settings navigation, autosave, Provider/template editors, and first run | `src/components/settings/*.test.tsx` and `src/App.test.tsx` | | IPC serialization | `src-tauri/src/models.rs` tests | | SQLite schema and migrations | `src-tauri/src/storage.rs` tests | | ASR parsing, capability checks, request construction, chunk merging | `src-tauri/src/asr.rs` tests | @@ -192,6 +193,38 @@ Server-side automated coverage must include: - duration, upload-size, disk-space, and retention limits; - exact `verbose_json 1.0` result compatibility. +## Settings Center Regression Matrix + +Automated coverage for Settings must include: + +- category navigation, parent-category highlighting on management routes, + management-page back navigation, session-only route restoration, and + first-run routing precedence; +- full-height settings mode retaining the application sidebar while omitting + the normal global header/footer, including the active-recording notice; +- serialized ordinary-settings autosave, newest-snapshot coalescing, success + feedback, persistence rollback, directory-picker cancellation, specialized + default Provider commands, and no leave prompt for confirmed ordinary + settings; +- paired shortcut draft submission on blur or Enter, whitespace trimming, + conflict feedback without replacing the effective pair, disabled shortcuts, + and no shortcut re-registration for unrelated settings writes; +- ASR and LLM Provider API-key keep/replace/clear, unsaved-draft connection + testing, model discovery and manual fallback, explicit save/delete, default + cleanup and first-valid default selection; +- DashScope fixed fields, no model discovery, and temporary upload disclosure; +- built-in template read-only behavior and copying, custom-template create, + save, revision update, archive, load failures, and dirty-editor navigation + protection; +- the first-run checklist allowing non-blocking category and Provider + navigation, Provider round trips, and only **Finish** or **Set up later** + persisting completion for the next startup. + +Manual visual acceptance covers `1280×800` and `980×640`, Windows `125%` +scaling, Chinese long text, keyboard navigation and focus, plus disabled, +loading, empty-list, and error states. The category navigation must remain +visible and the workspace must not scroll horizontally. + ## Diagnostic Logging Regression Matrix Automated coverage for local diagnostics must include: @@ -199,8 +232,8 @@ Automated coverage for local diagnostics must include: - UTC RFC3339 timestamps, one-line records, JSON escaping, and bounded text; - the typed field allowlist rejecting sensitive field names; - one active log plus two archives after rotation; -- Settings rendering and the open-directory command path without changing - settings dirty state; +- Settings rendering and the open-directory command path without enqueueing an + unrelated settings save or changing complex-editor dirty state; - lifecycle metadata for recording, import, ASR, and LLM work, including correlation IDs, coarse phases, HTTP status, elapsed time, estimates, and actual token usage where available; diff --git a/package-lock.json b/package-lock.json index d989646..822a34f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nota", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nota", - "version": "0.8.0", + "version": "0.9.0", "license": "MIT", "dependencies": { "@radix-ui/react-tooltip": "^1.2.16", diff --git a/package.json b/package.json index b1e5fce..dbea148 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nota", "private": true, - "version": "0.8.0", + "version": "0.9.0", "license": "MIT", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 352a54f..1644d5c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2299,7 +2299,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nota" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anyhow", "arboard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e03786c..8a2c204 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nota" -version = "0.8.0" +version = "0.9.0" description = "A private, local-first Windows meeting recorder" authors = ["Nota Contributors"] license = "MIT" diff --git a/src-tauri/src/asr.rs b/src-tauri/src/asr.rs index c82879d..f4fbf00 100644 --- a/src-tauri/src/asr.rs +++ b/src-tauri/src/asr.rs @@ -2539,7 +2539,7 @@ impl OggPcm16Reader { decoded.drain(..skip); self.pre_skip -= skip; } - for source in decoded.chunks_exact(3) { + for source in decoded.as_chunks::<3>().0 { let value = ((source[0] + source[1] + source[2]) / 3.0).clamp(-1.0, 1.0); self.pending .push_back((value * if value < 0.0 { 32_768.0 } else { 32_767.0 }).round() as i16); diff --git a/src-tauri/src/controller.rs b/src-tauri/src/controller.rs index 61738b4..9be0608 100644 --- a/src-tauri/src/controller.rs +++ b/src-tauri/src/controller.rs @@ -1345,6 +1345,12 @@ fn get_settings(state: State) -> std::result::Result bool { + previous.shortcuts_enabled != next.shortcuts_enabled + || previous.toggle_shortcut != next.toggle_shortcut + || previous.stop_shortcut != next.stop_shortcut +} + #[tauri::command] fn save_settings( app: AppHandle, @@ -1353,11 +1359,17 @@ fn save_settings( ) -> std::result::Result<(), String> { command_result((|| { let previous = state.storage.settings()?; - if let Err(error) = register_shortcuts(&app, &settings) { + let shortcuts_changed = shortcut_settings_changed(&previous, &settings); + if shortcuts_changed && let Err(error) = register_shortcuts(&app, &settings) { let _ = register_shortcuts(&app, &previous); bail!("快捷键注册失败,可能与其他应用冲突:{error}"); } - state.storage.save_settings(&settings)?; + if let Err(error) = state.storage.save_settings(&settings) { + if shortcuts_changed { + let _ = register_shortcuts(&app, &previous); + } + return Err(error); + } logging::info("settings", "saved", &[]); Ok(()) })()) @@ -3152,6 +3164,57 @@ pub fn run_app() { }); } +#[cfg(test)] +mod settings_tests { + use super::*; + + fn settings() -> AppSettings { + AppSettings { + output_directory: "C:\\Recordings".into(), + ai_documents_directory: "C:\\Documents".into(), + aec_mode: AecMode::Auto, + microphone_enabled: true, + first_run_complete: true, + shortcuts_enabled: true, + toggle_shortcut: "Ctrl+Alt+F9".into(), + stop_shortcut: "Ctrl+Alt+F10".into(), + active_asr_provider_id: None, + voiceprint_provider_id: None, + auto_transcribe: false, + auto_transcribe_hotword_list_id: None, + active_llm_provider_id: None, + } + } + + #[test] + fn unrelated_settings_do_not_require_shortcut_registration() { + let previous = settings(); + let mut next = previous.clone(); + next.output_directory = "D:\\Meetings".into(); + next.aec_mode = AecMode::On; + next.auto_transcribe = true; + + assert!(!shortcut_settings_changed(&previous, &next)); + } + + #[test] + fn every_shortcut_field_requires_registration() { + let previous = settings(); + + let mut disabled = previous.clone(); + disabled.shortcuts_enabled = false; + assert!(shortcut_settings_changed(&previous, &disabled)); + + let mut toggle = previous.clone(); + toggle.toggle_shortcut = "Ctrl+Alt+F7".into(); + assert!(shortcut_settings_changed(&previous, &toggle)); + + let mut stop = previous.clone(); + stop.stop_shortcut = "Ctrl+Alt+F8".into(); + assert!(shortcut_settings_changed(&previous, &stop)); + } +} + #[cfg(test)] mod transcript_export_tests { use super::*; diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index 850a903..f95a5d8 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -2922,8 +2922,10 @@ fn decode_embedding(bytes: &[u8], dimension: usize) -> Option> { return None; } let values = bytes - .chunks_exact(4) - .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) + .as_chunks::<4>() + .0 + .iter() + .map(|chunk| f32::from_le_bytes(*chunk)) .collect::>(); values .iter() diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a44da6f..4c1842c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Nota", - "version": "0.8.0", + "version": "0.9.0", "identifier": "com.local.meetingnote", "build": { "beforeDevCommand": "npm run dev:web", diff --git a/src/App.test.tsx b/src/App.test.tsx index 6d29713..a60bd01 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -564,7 +564,8 @@ describe("Nota UI states", () => { it("shows the application version in settings", async () => { render(); fireEvent.click(await screen.findByRole("button", { name: "设置" })); - expect(await screen.findByText("关于此应用")).toBeInTheDocument(); + fireEvent.click(await screen.findByRole("button", { name: "关于" })); + expect(await screen.findByRole("heading", { name: "关于" })).toBeInTheDocument(); expect(screen.getByText("v0.2.0")).toBeInTheDocument(); expect(screen.queryByRole("dialog", { name: "设置" })).not.toBeInTheDocument(); }); @@ -585,7 +586,8 @@ describe("Nota UI states", () => { ]; render(); fireEvent.click(await screen.findByRole("button", { name: "设置" })); - fireEvent.click(await screen.findByRole("button", { name: /LAN FunASR/ })); + fireEvent.click(await screen.findByRole("button", { name: "语音转写" })); + fireEvent.click(await screen.findByRole("button", { name: /管理转写服务/ })); const key = screen.getByLabelText("API Key"); expect(key).toHaveAttribute("type", "password"); expect(key).toHaveValue("••••••••"); @@ -795,39 +797,28 @@ describe("Nota UI states", () => { expect(vi.mocked(api.revealTranscriptExport)).toHaveBeenCalledWith(exportPath); }); - it("uses the settings workspace, saves explicitly, and guards dirty navigation", async () => { - const confirm = vi.spyOn(window, "confirm").mockReturnValueOnce(false).mockReturnValueOnce(true); + it("uses the full-screen settings workspace and auto-saves ordinary preferences", async () => { + const confirm = vi.spyOn(window, "confirm"); render(); fireEvent.click(await screen.findByRole("button", { name: "设置" })); const aec = await screen.findByRole("combobox", { name: "回声消除" }); fireEvent.change(aec, { target: { value: "off" } }); - expect(screen.getByText("有未保存的更改")).toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "录音" })); - expect(confirm).toHaveBeenCalledTimes(1); - expect(screen.getByRole("heading", { name: "设置" })).toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "录音" })); - expect(confirm).toHaveBeenCalledTimes(2); - expect(await screen.findByText("准备好记录会议")).toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "设置" })); - fireEvent.change(screen.getByRole("combobox", { name: "回声消除" }), { - target: { value: "off" }, - }); - fireEvent.click(screen.getByRole("button", { name: "保存设置" })); await waitFor(() => expect(vi.mocked(api.saveSettings)).toHaveBeenCalledWith( expect.objectContaining({ aecMode: "off", firstRunComplete: true }), ), ); - expect(await screen.findByText("所有普通设置均已保存")).toBeInTheDocument(); + expect(screen.queryByText("保存设置")).not.toBeInTheDocument(); + expect(screen.queryByText("本地优先")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "录音" })); + expect(confirm).not.toHaveBeenCalled(); + expect(await screen.findByText("准备好记录会议")).toBeInTheDocument(); }); it("opens first run as a non-blocking settings page and can skip it", async () => { testState.firstRunComplete = false; render(); - expect(await screen.findByText("欢迎使用 Nota")).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "开始使用 Nota" })).toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "录音" })).toBeEnabled(); fireEvent.click(screen.getByRole("button", { name: "稍后设置" })); @@ -844,7 +835,7 @@ describe("Nota UI states", () => { render(); fireEvent.click(await screen.findByRole("button", { name: "设置" })); expect( - await screen.findByText(/当前录音不会被设置页操作中断/), + await screen.findByText(/当前录音不会被设置操作中断/), ).toBeInTheDocument(); }); diff --git a/src/App.tsx b/src/App.tsx index d52ab7e..555b20c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,7 +27,10 @@ import { import { LevelMeter } from "./components/LevelMeter"; import { AppTooltip } from "./components/AppTooltip"; import { RecordingsWorkspace } from "./components/RecordingsWorkspace"; -import { SettingsWorkspace } from "./components/SettingsWorkspace"; +import { + SettingsWorkspace, + type SettingsRoute, +} from "./components/settings"; import { VoiceprintsWorkspace } from "./components/VoiceprintsWorkspace"; import { HotwordLibraryWorkspace } from "./components/HotwordLibraryWorkspace"; import { @@ -40,7 +43,6 @@ import type { AecMode, AppSettings, AsrProvider, - AsrProviderProbeRequest, AudioDevice, AudioImportBatchSnapshot, CaptureSelection, @@ -48,7 +50,6 @@ import type { DeviceSelection, LevelEvent, LlmProvider, - LlmProviderProbeRequest, HotwordListSummary, ParticipantProfile, RecordingItem, @@ -152,7 +153,8 @@ export default function App() { const [recordingDeleteBusy, setRecordingDeleteBusy] = useState(false); const [toasts, setToasts] = useState([]); const [appVersion, setAppVersion] = useState("…"); - const [draftSettings, setDraftSettings] = useState(defaultSettings); + const [settingsRoute, setSettingsRoute] = useState("recording"); + const [settingsEditorDirty, setSettingsEditorDirty] = useState(false); const targetIdRef = useRef(""); const targetPreferenceRef = useRef(null); const refreshTargetsPromiseRef = useRef | null>(null); @@ -162,10 +164,6 @@ export default function App() { const selectedRecordingIdRef = useRef(null); const nextToastIdRef = useRef(1); const seenFaultKeysRef = useRef(new Set()); - const settingsDirty = useMemo( - () => JSON.stringify(draftSettings) !== JSON.stringify(settings), - [draftSettings, settings], - ); const autoHotwordList = hotwordLists.find( (list) => list.id === settings.autoTranscribeHotwordListId, ); @@ -290,12 +288,6 @@ export default function App() { .catch(showError); }, [refreshLibrary, showError, showToast]); - const refreshProviders = useCallback(async () => { - const next = await api.listAsrProviders(); - setProviders(next); - return next; - }, []); - const refreshHotwordLists = useCallback(async () => { const [next, savedSettings] = await Promise.all([ api.listHotwordLists(), @@ -303,7 +295,6 @@ export default function App() { ]); setHotwordLists(next); setSettings(savedSettings); - setDraftSettings(savedSettings); }, []); useEffect(() => { @@ -316,12 +307,6 @@ export default function App() { .catch(() => setActiveTranscriptionOptions(null)); }, [settings.activeAsrProviderId, settings.autoTranscribeHotwordListId]); - const refreshLlmProviders = useCallback(async () => { - const next = await api.listLlmProviders(); - setLlmProviders(next); - return next; - }, []); - const refreshParticipants = useCallback(async () => { setParticipantsLoading(true); try { @@ -399,8 +384,8 @@ export default function App() { applyCaptureTargets(targetList); setDevices(deviceList); setSettings(savedSettings); - setDraftSettings(savedSettings); if (!savedSettings.firstRunComplete) { + setSettingsRoute("setup"); setPage("settings"); } if (current.state === "completed") { @@ -698,157 +683,29 @@ export default function App() { } }; - const chooseDraftOutput = async () => { - try { - const selected = await open({ directory: true, multiple: false }); - if (typeof selected === "string") { - setDraftSettings((current) => ({ - ...current, - outputDirectory: selected, - })); - } - } catch (error) { - showError(error); - } - }; - - const chooseDraftAiDocuments = async () => { - try { - const selected = await open({ directory: true, multiple: false }); - if (typeof selected === "string") { - setDraftSettings((current) => ({ - ...current, - aiDocumentsDirectory: selected, - })); - } - } catch (error) { - showError(error); - } - }; - const navigateTo = (nextPage: AppPage) => { if (nextPage === page) return; if ( page === "settings" && - settingsDirty && - !confirm("设置尚未保存。放弃这些更改并离开设置页吗?") + settingsEditorDirty && + !confirm("当前编辑内容尚未保存。放弃这些更改并离开设置页吗?") ) { return; } - if (page === "settings" && settingsDirty) { - setDraftSettings(settings); - } + if (page === "settings") setSettingsEditorDirty(false); if ( page === "hotwords" && hotwordLibraryDirty && !confirm("热词列表尚未保存。放弃这些更改并离开热词库吗?") ) return; - if (nextPage === "settings") { - setDraftSettings(settings); - } setPage(nextPage); }; - const saveDraftSettings = async () => { - const next = { - ...draftSettings, - firstRunComplete: true, - toggleShortcut: draftSettings.toggleShortcut.trim(), - stopShortcut: draftSettings.stopShortcut.trim(), - }; - const previousProviderId = settings.activeAsrProviderId; - const nextProvider = providers.find( - (provider) => provider.id === next.activeAsrProviderId, - ); - try { - await api.saveSettings(next); - setSettings(next); - setDraftSettings(next); - showToast("success", "设置已保存"); - if (nextProvider?.kind === "dashScope" && previousProviderId !== nextProvider.id) { - showToast( - "info", - "已切换到千问云转写:新任务会上传完整录音,支持匿名说话人分离,但不支持 Nota 声纹分析。", - ); - } - } catch (error) { - showError(error); - } - }; - - const skipFirstRun = async () => { - const next = { - ...settings, - firstRunComplete: true, - }; - try { - await api.saveSettings(next); - setSettings(next); - setDraftSettings(next); - setPage("recorder"); - showToast("info", "已跳过首次设置,可以随时从侧边栏返回。"); - } catch (error) { - showError(error); - } - }; - - const saveProvider = async (request: Parameters[0]) => { - const saved = await api.saveAsrProvider(request); - const [nextSettings] = await Promise.all([api.getSettings(), refreshProviders()]); - setSettings(nextSettings); - setDraftSettings((current) => ({ - ...current, - voiceprintProviderId: nextSettings.voiceprintProviderId, - })); - return saved; - }; - - const deleteProvider = async (id: string) => { - await api.deleteAsrProvider(id); - const [nextSettings] = await Promise.all([api.getSettings(), refreshProviders()]); - setSettings(nextSettings); - setDraftSettings((current) => ({ - ...current, - activeAsrProviderId: - current.activeAsrProviderId === id - ? nextSettings.activeAsrProviderId - : current.activeAsrProviderId, - voiceprintProviderId: - current.voiceprintProviderId === id - ? nextSettings.voiceprintProviderId - : current.voiceprintProviderId, - autoTranscribe: - current.activeAsrProviderId === id - ? nextSettings.autoTranscribe - : current.autoTranscribe, - })); - }; - - const saveLlmProvider = async (request: Parameters[0]) => { - const saved = await api.saveLlmProvider(request); - await refreshLlmProviders(); - return saved; - }; - - const deleteLlmProvider = async (id: string) => { - await api.deleteLlmProvider(id); - const [nextSettings] = await Promise.all([api.getSettings(), refreshLlmProviders()]); - setSettings(nextSettings); - setDraftSettings((current) => ({ - ...current, - activeLlmProviderId: - current.activeLlmProviderId === id - ? nextSettings.activeLlmProviderId - : current.activeLlmProviderId, - })); - }; - const setVoiceprintProvider = async (id: string | null) => { const next = { ...settings, voiceprintProviderId: id }; try { await api.saveSettings(next); setSettings(next); - setDraftSettings((current) => ({ ...current, voiceprintProviderId: id })); showToast("success", id ? "声纹提取服务已更新" : "已清除声纹提取服务"); } catch (error) { showError(error); @@ -1000,6 +857,26 @@ export default function App() { const recordingDeleteTarget = recordingDeleteRequest ? recordings.find((item) => item.id === recordingDeleteRequest.id) ?? null : null; + const settingsModel = useMemo(() => ({ + settings, + asrProviders: providers, + llmProviders, + microphoneCount: micDevices.length, + appVersion, + recordingActive: isActive(snapshot.state), + }), [appVersion, llmProviders, micDevices.length, providers, settings, snapshot.state]); + const settingsActions = useMemo(() => ({ + onSettingsChange: setSettings, + onAsrProvidersChange: setProviders, + onLlmProvidersChange: setLlmProviders, + onFirstRunComplete: () => { + setSettingsRoute("recording"); + setPage("recorder"); + }, + onEditorDirtyChange: setSettingsEditorDirty, + onToast: showToast, + onError: showError, + }), [showError, showToast]); return (

@@ -1046,8 +923,8 @@ export default function App() { -
-
+
+ {page !== "settings" &&
{page === "recorder" @@ -1073,7 +950,7 @@ export default function App() {
本地优先 -
+
}
{page === "recorder" && ( @@ -1281,7 +1158,6 @@ export default function App() { autoTranscribeHotwordListId: event.target.value || null, }; setSettings(next); - setDraftSettings(next); void api.saveSettings(next).catch(showError); }} > @@ -1515,42 +1391,10 @@ export default function App() { {page === "settings" && ( void chooseDraftOutput()} - onChooseAiDocuments={() => void chooseDraftAiDocuments()} - onOpenMicrophoneSettings={() => - void api.openMicrophoneSettings().catch(showError) - } - onOpenLogDirectory={() => - void api.openLogDirectory().catch(showError) - } - onSaveProvider={saveProvider} - onDeleteProvider={deleteProvider} - onSaveLlmProvider={saveLlmProvider} - onDeleteLlmProvider={deleteLlmProvider} - onTestLlmProvider={(request: LlmProviderProbeRequest) => - api.testLlmProvider(request) - } - onListLlmModels={(request: LlmProviderProbeRequest) => - api.listLlmModels(request) - } - onTestProvider={(request: AsrProviderProbeRequest) => - api.testAsrProvider(request) - } - onListModels={(request: AsrProviderProbeRequest) => - api.listAsrModels(request) - } - onDiscardChanges={() => setDraftSettings(settings)} - onSave={() => void saveDraftSettings()} - onSkipFirstRun={() => void skipFirstRun()} + route={settingsRoute} + onRouteChange={setSettingsRoute} + model={settingsModel} + actions={settingsActions} /> )}
@@ -1617,10 +1461,10 @@ export default function App() {
)} - }
diff --git a/src/components/AiSettingsSection.tsx b/src/components/AiSettingsSection.tsx deleted file mode 100644 index 0670395..0000000 --- a/src/components/AiSettingsSection.tsx +++ /dev/null @@ -1,362 +0,0 @@ -import { - AlertTriangle, - Bot, - CheckCircle2, - Copy, - FileText, - Folder, - LoaderCircle, - Plus, - RefreshCw, - Trash2, - Wifi, - X, -} from "lucide-react"; -import { useEffect, useState } from "react"; -import { api } from "../api"; -import { isOfficialOpenAiUrl, llmProviderReady, OPENAI_API_ROOT } from "../llm"; -import { AppTooltip } from "./AppTooltip"; -import type { - AiTemplate, - AppSettings, - LlmConnectionTest, - LlmModel, - LlmProvider, - LlmProviderKind, - LlmProviderProbeRequest, - SaveAiTemplateRequest, - SaveLlmProviderRequest, -} from "../types"; - -interface AiSettingsSectionProps { - settings: AppSettings; - providers: LlmProvider[]; - onChange: (settings: AppSettings) => void; - onChooseDirectory: () => void; - onSaveProvider: (request: SaveLlmProviderRequest) => Promise; - onDeleteProvider: (id: string) => Promise; - onTestProvider: (request: LlmProviderProbeRequest) => Promise; - onListModels: (request: LlmProviderProbeRequest) => Promise; -} - -interface LlmDraft { - id: string | null; - name: string; - kind: LlmProviderKind; - baseUrl: string; - apiKey: string; - hasApiKey: boolean; - modelId: string; - inputTokenBudget: number; - maxOutputTokens: number; -} - -const KEY_MASK = "••••••••"; - -const emptyLlmDraft = (): LlmDraft => ({ - id: null, - name: "OpenAI", - kind: "openAi", - baseUrl: OPENAI_API_ROOT, - apiKey: "", - hasApiKey: false, - modelId: "gpt-5-mini", - inputTokenBudget: 32_768, - maxOutputTokens: 4_096, -}); - -const toDraft = (provider: LlmProvider): LlmDraft => ({ - id: provider.id, - name: provider.name, - kind: provider.kind, - baseUrl: provider.baseUrl, - apiKey: provider.hasApiKey ? KEY_MASK : "", - hasApiKey: provider.hasApiKey, - modelId: provider.modelId, - inputTokenBudget: provider.inputTokenBudget, - maxOutputTokens: provider.maxOutputTokens, -}); - -const keyUpdate = (draft: LlmDraft) => - draft.hasApiKey && draft.apiKey === KEY_MASK - ? ({ kind: "keep" } as const) - : draft.apiKey - ? ({ kind: "replace", value: draft.apiKey } as const) - : ({ kind: "clear" } as const); - -const probe = (draft: LlmDraft): LlmProviderProbeRequest => ({ - id: draft.id, - kind: draft.kind, - baseUrl: draft.baseUrl, - modelId: draft.modelId, - inputTokenBudget: draft.inputTokenBudget, - maxOutputTokens: draft.maxOutputTokens, - apiKey: keyUpdate(draft), -}); - -const emptyTemplate = (): SaveAiTemplateRequest => ({ - id: null, - name: "自定义会议模板", - description: "", - taskInstructions: "", - outputRequirements: "使用 Markdown 输出。", - requiresSpeakerLabels: false, -}); - -export function AiSettingsSection(props: AiSettingsSectionProps) { - const [editing, setEditing] = useState(null); - const [models, setModels] = useState([]); - const [busy, setBusy] = useState(false); - const [feedback, setFeedback] = useState(null); - const [feedbackError, setFeedbackError] = useState(false); - const [templates, setTemplates] = useState([]); - const [templateDraft, setTemplateDraft] = useState(null); - const [templateBusy, setTemplateBusy] = useState(false); - - const refreshTemplates = () => - api.listAiTemplates().then(setTemplates).catch((error) => { - setFeedbackError(true); - setFeedback(String(error)); - }); - - useEffect(() => { - void refreshTemplates(); - }, []); - - useEffect(() => { - if (editing?.id && !props.providers.some((provider) => provider.id === editing.id)) { - setEditing(null); - } - }, [editing?.id, props.providers]); - - const saveProvider = async () => { - if (!editing) return; - setBusy(true); - setFeedback("正在保存 AI 模型服务…"); - setFeedbackError(false); - try { - const saved = await props.onSaveProvider({ - id: editing.id, - name: editing.name, - kind: editing.kind, - baseUrl: editing.baseUrl, - modelId: editing.modelId, - inputTokenBudget: editing.inputTokenBudget, - maxOutputTokens: editing.maxOutputTokens, - apiKey: keyUpdate(editing), - }); - setEditing(toDraft(saved)); - if (!props.settings.activeLlmProviderId && llmProviderReady(saved)) { - props.onChange({ ...props.settings, activeLlmProviderId: saved.id }); - } - setFeedback( - llmProviderReady(saved) - ? "AI 模型服务已保存" - : "AI 模型服务已保存;OpenAI 官方服务需配置 API Key 后才能用于生成", - ); - } catch (error) { - setFeedbackError(true); - setFeedback(String(error)); - } finally { - setBusy(false); - } - }; - - const testProvider = async () => { - if (!editing) return; - setBusy(true); - setFeedback("正在发送不含会议内容的测试请求…"); - setFeedbackError(false); - try { - const result = await props.onTestProvider(probe(editing)); - setFeedback(result.message); - } catch (error) { - setFeedbackError(true); - setFeedback(String(error)); - } finally { - setBusy(false); - } - }; - - const listModels = async () => { - if (!editing) return; - setBusy(true); - setFeedback("正在读取模型列表…"); - setFeedbackError(false); - try { - const next = await props.onListModels(probe(editing)); - setModels(next); - setFeedback(next.length ? `读取到 ${next.length} 个模型` : "服务未返回模型,可继续手动填写模型 ID"); - } catch (error) { - setModels([]); - setFeedbackError(true); - setFeedback(`${String(error)};仍可手动填写模型 ID`); - } finally { - setBusy(false); - } - }; - - const saveTemplate = async () => { - if (!templateDraft) return; - setTemplateBusy(true); - try { - await api.saveAiTemplate(templateDraft); - setTemplateDraft(null); - await refreshTemplates(); - } catch (error) { - setFeedbackError(true); - setFeedback(String(error)); - } finally { - setTemplateBusy(false); - } - }; - - const cloneTemplate = async (template: AiTemplate) => { - const name = prompt("输入复制后的模板名称", `${template.name}(自定义)`); - if (!name?.trim()) return; - setTemplateBusy(true); - try { - const cloned = await api.cloneAiTemplate(template.id, name.trim()); - await refreshTemplates(); - setTemplateDraft({ - id: cloned.id, - name: cloned.name, - description: cloned.description, - taskInstructions: cloned.taskInstructions, - outputRequirements: cloned.outputRequirements, - requiresSpeakerLabels: cloned.requiresSpeakerLabels, - }); - } catch (error) { - setFeedbackError(true); - setFeedback(String(error)); - } finally { - setTemplateBusy(false); - } - }; - - return ( -
-
- -
- AI 文档 - 从已完成的转写生成可分享的 Markdown;仅在手动点击生成时联网 -
- -
- - - -
- Markdown 保存目录{props.settings.aiDocumentsDirectory} - -
- - {props.providers.length > 0 && ( -
- {props.providers.map((provider) => ( - - ))} -
- )} - - {editing && ( -
-
{editing.id ? "编辑 LLM Provider" : "添加 LLM Provider"}
-
- - - - - - {models.length > 0 && } - - -
-
- - - 测试请求不包含会议或转写内容。 -
- {feedback &&
{feedbackError ? : }{feedback}
} -
- {editing.id && } - -
- {editing.kind === "openAi" &&

Responses API 请求会发送 store: false;这不代表零数据保留,请同时查看所选服务的数据控制说明。

} -
- )} - -
-
AI 模板固定安全策略不可编辑;内置模板可复制后自定义。
- -
-
- {templates.filter((template) => !template.archived).map((template) => ( -
-
{template.name}{template.description || "暂无描述"}
- {template.builtinKey ? "内置" : `自定义 r${template.revision}`}{template.requiresSpeakerLabels ? " · 需说话人" : ""} - - {!template.builtinKey && } - {!template.builtinKey && } -
- ))} -
- - {templateDraft && ( -
-
{templateDraft.id ? "编辑自定义模板" : "新建自定义模板"}
- - -