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 @@
-
+
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 @@
-
+
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() {
-
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 && (
-
- )}
-
-
-
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 ? "编辑自定义模板" : "新建自定义模板"}
-
-
-
-
-
-
-
- )}
-
- );
-}
diff --git a/src/components/SettingsWorkspace.test.tsx b/src/components/SettingsWorkspace.test.tsx
deleted file mode 100644
index f1682e9..0000000
--- a/src/components/SettingsWorkspace.test.tsx
+++ /dev/null
@@ -1,347 +0,0 @@
-import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import type {
- AppSettings,
- AsrConnectionTest,
- AsrModel,
- AsrProvider,
- LlmConnectionTest,
- LlmModel,
- LlmProvider,
-} from "../types";
-import { SettingsWorkspace } from "./SettingsWorkspace";
-
-const settings: AppSettings = {
- outputDirectory: "C:\\Recordings",
- aiDocumentsDirectory: "C:\\AI Documents",
- aecMode: "auto",
- microphoneEnabled: true,
- firstRunComplete: true,
- shortcutsEnabled: true,
- toggleShortcut: "Ctrl+Alt+F9",
- stopShortcut: "Ctrl+Alt+F10",
- activeAsrProviderId: "lan",
- voiceprintProviderId: null,
- autoTranscribe: false,
- activeLlmProviderId: null,
-};
-
-const llmProvider: LlmProvider = {
- id: "llm",
- name: "OpenAI",
- kind: "openAi",
- baseUrl: "https://api.openai.com/v1",
- modelId: "gpt-5-mini",
- inputTokenBudget: 32768,
- maxOutputTokens: 4096,
- hasApiKey: true,
- createdAt: "2026-07-28T00:00:00Z",
- updatedAt: "2026-07-28T00:00:00Z",
-};
-
-const provider: AsrProvider = {
- id: "lan",
- name: "LAN FunASR",
- kind: "funAsr",
- baseUrl: "http://192.168.1.20:8000/v1",
- modelId: "sensevoice",
- hasApiKey: true,
- capabilities: {
- wholeMeeting: true,
- diarization: true,
- speakerCountMin: 1,
- speakerCountMax: 64,
- voiceprintAnalysis: true,
- modelDiscovery: true,
- cloudUpload: false,
- maxReliableAudioSeconds: null,
- },
- createdAt: "2026-07-28T00:00:00Z",
- updatedAt: "2026-07-28T00:00:00Z",
-};
-
-const renderSettings = (
- overrides: Partial[0]> = {},
-) => {
- const actions = {
- onChange: vi.fn(),
- onChooseOutput: vi.fn(),
- onChooseAiDocuments: vi.fn(),
- onOpenMicrophoneSettings: vi.fn(),
- onOpenLogDirectory: vi.fn(),
- onSaveProvider: vi.fn(async () => provider),
- onDeleteProvider: vi.fn(async () => undefined),
- onTestProvider: vi.fn(async (): Promise => ({
- reachable: true,
- level: "success",
- message: "服务可用",
- models: [],
- device: null,
- })),
- onListModels: vi.fn(async (): Promise => []),
- onSaveLlmProvider: vi.fn(async () => llmProvider),
- onDeleteLlmProvider: vi.fn(async () => undefined),
- onTestLlmProvider: vi.fn(async (): Promise => ({
- reachable: true,
- message: "连接成功",
- })),
- onListLlmModels: vi.fn(async (): Promise => []),
- onDiscardChanges: vi.fn(),
- onSave: vi.fn(),
- onSkipFirstRun: vi.fn(),
- };
- render(
- ,
- );
- return actions;
-};
-
-const openProvider = async () => {
- fireEvent.click(await screen.findByRole("button", { name: /LAN FunASR/ }));
-};
-
-afterEach(cleanup);
-
-describe("SettingsWorkspace ASR provider feedback", () => {
- it("uses fixed DashScope fields and explains the cloud boundary", async () => {
- const actions = renderSettings();
- fireEvent.click(screen.getByRole("button", { name: "添加" }));
- fireEvent.change(screen.getByDisplayValue("FunASR"), {
- target: { value: "dashScope" },
- });
-
- expect(screen.getByDisplayValue("千问云转写")).toBeInTheDocument();
- expect(screen.getByDisplayValue("https://dashscope.aliyuncs.com/api/v1"))
- .toHaveAttribute("readonly");
- expect(screen.getByDisplayValue("qwen-audio-3.0-asr-flash-filetrans"))
- .toHaveAttribute("readonly");
- expect(screen.queryByRole("button", { name: "获取模型列表" })).not.toBeInTheDocument();
- expect(screen.getByText(/约在 48 小时后清理/)).toBeInTheDocument();
- expect(screen.getByText(/不支持 Nota 声纹分析/)).toBeInTheDocument();
-
- fireEvent.change(screen.getByLabelText("API Key"), {
- target: { value: "test-key" },
- });
- fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
- await waitFor(() => expect(actions.onTestProvider).toHaveBeenCalledWith({
- id: null,
- kind: "dashScope",
- baseUrl: "https://dashscope.aliyuncs.com/api/v1",
- modelId: "qwen-audio-3.0-asr-flash-filetrans",
- apiKey: { kind: "replace", value: "test-key" },
- }));
- });
-
- it("tests the current unsaved draft and shows loading then success inline", async () => {
- let resolveProbe: ((result: AsrConnectionTest) => void) | undefined;
- const actions = renderSettings();
- actions.onTestProvider.mockImplementation(
- () =>
- new Promise((resolve) => {
- resolveProbe = resolve;
- }),
- );
- await openProvider();
- fireEvent.change(screen.getByDisplayValue(provider.baseUrl), {
- target: { value: "http://draft-host:9000/v1" },
- });
- fireEvent.change(screen.getByLabelText("API Key"), {
- target: { value: "draft-key" },
- });
- fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
-
- expect(screen.getByRole("button", { name: "测试中…" })).toBeDisabled();
- expect(screen.getByText("正在连接服务并检查接口…")).toBeInTheDocument();
- expect(actions.onTestProvider).toHaveBeenCalledWith({
- id: "lan",
- kind: "funAsr",
- baseUrl: "http://draft-host:9000/v1",
- modelId: "sensevoice",
- apiKey: { kind: "replace", value: "draft-key" },
- });
-
- await act(async () => {
- resolveProbe?.({
- reachable: true,
- level: "success",
- message: "服务可用",
- models: [
- {
- id: "sensevoice",
- ownedBy: "FunASR",
- ready: true,
- },
- ],
- device: "cuda",
- });
- });
- expect(
- await screen.findByText("服务可用 · 设备:cuda · 发现 1 个模型"),
- ).toBeInTheDocument();
- expect(screen.getByRole("combobox", { name: "服务返回的模型" })).toBeInTheDocument();
- });
-
- it("shows warning and failure results without relying on a global toast", async () => {
- const actions = renderSettings();
- actions.onTestProvider
- .mockResolvedValueOnce({
- reachable: true,
- level: "warning",
- message: "服务健康检查通过,但模型接口不可用",
- models: [],
- device: "cpu",
- })
- .mockRejectedValueOnce(new Error("401 Unauthorized"));
- await openProvider();
-
- fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
- expect(
- await screen.findByText(/服务健康检查通过,但模型接口不可用/),
- ).toBeInTheDocument();
-
- fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
- expect(await screen.findByText(/连接失败:Error: 401 Unauthorized/)).toBeInTheDocument();
- });
-
- it("loads a visible model list from the draft and fills a single model", async () => {
- const actions = renderSettings();
- actions.onListModels.mockResolvedValue([
- {
- id: "paraformer-zh",
- ownedBy: "FunASR",
- ready: true,
- },
- ]);
- await openProvider();
- fireEvent.change(screen.getByDisplayValue(provider.baseUrl), {
- target: { value: "http://new-host:8000/v1" },
- });
- fireEvent.change(screen.getByDisplayValue(provider.modelId), {
- target: { value: "" },
- });
- fireEvent.click(screen.getByRole("button", { name: "获取模型列表" }));
-
- expect(actions.onListModels).toHaveBeenCalledWith(
- expect.objectContaining({
- id: "lan",
- baseUrl: "http://new-host:8000/v1",
- modelId: "",
- apiKey: { kind: "keep" },
- }),
- );
- expect(await screen.findByText("已读取 1 个模型,请从下方列表选择。")).toBeInTheDocument();
- expect(screen.getByDisplayValue("paraformer-zh")).toBeInTheDocument();
- });
-
- it("keeps manual model entry available when model discovery fails", async () => {
- const actions = renderSettings();
- actions.onListModels.mockRejectedValue(new Error("404 Not Found"));
- await openProvider();
- fireEvent.click(screen.getByRole("button", { name: "获取模型列表" }));
- expect(
- await screen.findByText(/无法获取模型列表:Error: 404 Not Found/),
- ).toBeInTheDocument();
- expect(screen.getByDisplayValue("sensevoice")).toBeEnabled();
- });
-
- it("can probe a new provider before it is saved", async () => {
- const actions = renderSettings();
- fireEvent.click(screen.getByRole("button", { name: "添加" }));
- fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
- await waitFor(() =>
- expect(actions.onTestProvider).toHaveBeenCalledWith(
- expect.objectContaining({
- id: null,
- baseUrl: "http://127.0.0.1:8000/v1",
- apiKey: { kind: "clear" },
- }),
- ),
- );
- });
-
- it("uses a normal password field and clears a stored key when saved empty", async () => {
- const actions = renderSettings();
- await openProvider();
-
- const apiKey = screen.getByLabelText("API Key");
- expect(apiKey).toHaveAttribute("type", "password");
- expect(apiKey).toHaveValue("••••••••");
- expect(screen.queryByText("清除已保存的 API Key")).not.toBeInTheDocument();
- expect(
- screen.queryByText(/API Key 将以明文保存在本机 Nota SQLite 数据库中/),
- ).not.toBeInTheDocument();
-
- fireEvent.change(apiKey, { target: { value: "" } });
- fireEvent.click(screen.getByRole("button", { name: "保存服务" }));
-
- await waitFor(() =>
- expect(actions.onSaveProvider).toHaveBeenCalledWith(
- expect.objectContaining({
- id: "lan",
- apiKey: { kind: "clear" },
- }),
- ),
- );
- });
-});
-
-describe("SettingsWorkspace Responses API provider", () => {
- it("allows a third-party base URL and does not require a key for an unauthenticated service", async () => {
- const actions = renderSettings();
- actions.onSaveLlmProvider.mockResolvedValue({
- ...llmProvider,
- name: "Workflow Responses",
- baseUrl: "https://workflow.example.test/openai/v1",
- hasApiKey: false,
- });
-
- fireEvent.click(screen.getByRole("button", { name: /添加 Provider/ }));
- const baseUrl = screen.getByLabelText("API Base URL");
- expect(baseUrl).toBeEnabled();
- fireEvent.change(baseUrl, {
- target: { value: "https://workflow.example.test/openai/v1" },
- });
- expect(screen.getByLabelText("API Key")).toHaveAttribute(
- "placeholder",
- "按服务要求,可留空",
- );
- fireEvent.click(screen.getByRole("button", { name: "保存 Provider" }));
-
- await waitFor(() =>
- expect(actions.onSaveLlmProvider).toHaveBeenCalledWith(
- expect.objectContaining({
- kind: "openAi",
- baseUrl: "https://workflow.example.test/openai/v1",
- apiKey: { kind: "clear" },
- }),
- ),
- );
- expect(actions.onChange).toHaveBeenCalledWith(
- expect.objectContaining({ activeLlmProviderId: "llm" }),
- );
- });
-});
-
-describe("SettingsWorkspace diagnostics", () => {
- it("opens the local log directory without changing settings", () => {
- const actions = renderSettings();
-
- expect(screen.getByText("诊断与日志")).toBeInTheDocument();
- expect(screen.getByText(/不包含录音、转写正文或 AI 请求与响应内容/)).toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: "打开日志目录" }));
-
- expect(actions.onOpenLogDirectory).toHaveBeenCalledOnce();
- expect(actions.onChange).not.toHaveBeenCalled();
- });
-});
diff --git a/src/components/SettingsWorkspace.tsx b/src/components/SettingsWorkspace.tsx
deleted file mode 100644
index b22f64c..0000000
--- a/src/components/SettingsWorkspace.tsx
+++ /dev/null
@@ -1,780 +0,0 @@
-import { useEffect, useMemo, useState } from "react";
-import {
- AlertTriangle,
- Bot,
- CheckCircle2,
- FileText,
- Folder,
- Info,
- Keyboard,
- LoaderCircle,
- Mic,
- Plus,
- RefreshCw,
- ShieldCheck,
- Trash2,
- Wifi,
- X,
-} from "lucide-react";
-import type {
- AppSettings,
- AsrConnectionTest,
- AsrModel,
- AsrProvider,
- AsrProviderKind,
- AsrProviderProbeRequest,
- LlmConnectionTest,
- LlmModel,
- LlmProvider,
- LlmProviderProbeRequest,
- SaveLlmProviderRequest,
- SaveAsrProviderRequest,
-} from "../types";
-import { AiSettingsSection } from "./AiSettingsSection";
-import { AppTooltip } from "./AppTooltip";
-
-interface SettingsWorkspaceProps {
- firstRun: boolean;
- dirty: boolean;
- recordingActive: boolean;
- settings: AppSettings;
- providers: AsrProvider[];
- llmProviders: LlmProvider[];
- microphoneCount: number;
- appVersion: string;
- onChange: (settings: AppSettings) => void;
- onChooseOutput: () => void;
- onChooseAiDocuments: () => void;
- onOpenMicrophoneSettings: () => void;
- onOpenLogDirectory: () => void;
- onSaveProvider: (request: SaveAsrProviderRequest) => Promise;
- onDeleteProvider: (id: string) => Promise;
- onTestProvider: (request: AsrProviderProbeRequest) => Promise;
- onListModels: (request: AsrProviderProbeRequest) => Promise;
- onSaveLlmProvider: (request: SaveLlmProviderRequest) => Promise;
- onDeleteLlmProvider: (id: string) => Promise;
- onTestLlmProvider: (request: LlmProviderProbeRequest) => Promise;
- onListLlmModels: (request: LlmProviderProbeRequest) => Promise;
- onDiscardChanges: () => void;
- onSave: () => void;
- onSkipFirstRun: () => void;
-}
-
-interface ProviderDraft {
- id: string | null;
- name: string;
- kind: AsrProviderKind;
- baseUrl: string;
- apiKey: string;
- modelId: string;
- hasApiKey: boolean;
-}
-
-type FeedbackTone = "loading" | "success" | "warning" | "error";
-
-interface Feedback {
- tone: FeedbackTone;
- message: string;
-}
-
-const STORED_API_KEY_MASK = "••••••••";
-const DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/api/v1";
-const DASHSCOPE_MODEL = "qwen-audio-3.0-asr-flash-filetrans";
-
-const providerKindLabel = (kind: AsrProviderKind) => {
- if (kind === "funAsr") return "FunASR";
- if (kind === "dashScope") return "阿里云千问";
- return "Compatible";
-};
-
-const emptyProvider = (): ProviderDraft => ({
- id: null,
- name: "本地 FunASR",
- kind: "funAsr",
- baseUrl: "http://127.0.0.1:8000/v1",
- apiKey: "",
- modelId: "sensevoice",
- hasApiKey: false,
-});
-
-const providerDraft = (provider: AsrProvider): ProviderDraft => ({
- id: provider.id,
- name: provider.name,
- kind: provider.kind,
- baseUrl: provider.baseUrl,
- apiKey: provider.hasApiKey ? STORED_API_KEY_MASK : "",
- modelId: provider.modelId,
- hasApiKey: provider.hasApiKey,
-});
-
-const changeProviderKind = (draft: ProviderDraft, kind: AsrProviderKind): ProviderDraft => {
- if (kind === "dashScope") {
- return {
- ...draft,
- kind,
- name: draft.id ? draft.name : "千问云转写",
- baseUrl: DASHSCOPE_BASE_URL,
- modelId: DASHSCOPE_MODEL,
- };
- }
- if (kind === "funAsr") {
- return {
- ...draft,
- kind,
- name: draft.id ? draft.name : "本地 FunASR",
- baseUrl: "http://127.0.0.1:8000/v1",
- modelId: "sensevoice",
- };
- }
- return {
- ...draft,
- kind,
- name: draft.id ? draft.name : "兼容转写服务",
- baseUrl: "http://127.0.0.1:8000/v1",
- modelId: "whisper-1",
- };
-};
-
-const apiKeyUpdate = (draft: ProviderDraft) =>
- draft.hasApiKey && draft.apiKey === STORED_API_KEY_MASK
- ? ({ kind: "keep" } as const)
- : draft.apiKey
- ? ({ kind: "replace", value: draft.apiKey } as const)
- : ({ kind: "clear" } as const);
-
-const probeRequest = (draft: ProviderDraft): AsrProviderProbeRequest => ({
- id: draft.id,
- kind: draft.kind,
- baseUrl: draft.baseUrl,
- modelId: draft.modelId,
- apiKey: apiKeyUpdate(draft),
-});
-
-function InlineFeedback({ feedback }: { feedback: Feedback | null }) {
- if (!feedback) return null;
- return (
-
- {feedback.tone === "loading" ? (
-
- ) : feedback.tone === "success" ? (
-
- ) : (
-
- )}
-
{feedback.message}
-
- );
-}
-
-export function SettingsWorkspace(props: SettingsWorkspaceProps) {
- const [editing, setEditing] = useState(null);
- const [models, setModels] = useState([]);
- const [testingConnection, setTestingConnection] = useState(false);
- const [loadingModels, setLoadingModels] = useState(false);
- const [savingProvider, setSavingProvider] = useState(false);
- const [connectionFeedback, setConnectionFeedback] = useState(null);
- const [modelFeedback, setModelFeedback] = useState(null);
- const [saveFeedback, setSaveFeedback] = useState(null);
-
- const activeProvider = useMemo(
- () => props.providers.find((provider) => provider.id === props.settings.activeAsrProviderId),
- [props.providers, props.settings.activeAsrProviderId],
- );
-
- useEffect(() => {
- if (
- editing?.id &&
- !props.providers.some((provider) => provider.id === editing.id)
- ) {
- setEditing(null);
- setModels([]);
- setConnectionFeedback(null);
- setModelFeedback(null);
- setSaveFeedback(null);
- }
- }, [editing?.id, props.providers]);
-
- const selectProvider = (draft: ProviderDraft) => {
- setEditing(draft);
- setModels([]);
- setConnectionFeedback(null);
- setModelFeedback(null);
- setSaveFeedback(null);
- };
-
- const changeConnectionField = (next: ProviderDraft) => {
- setEditing(next);
- setModels([]);
- setConnectionFeedback(null);
- setModelFeedback(null);
- setSaveFeedback(null);
- };
-
- const changeModelId = (modelId: string) => {
- if (!editing) return;
- setEditing({ ...editing, modelId });
- setConnectionFeedback(null);
- setSaveFeedback(null);
- };
-
- const applyReturnedModels = (next: AsrModel[]) => {
- setModels(next);
- if (editing && !editing.modelId.trim() && next.length === 1) {
- setEditing({ ...editing, modelId: next[0].id });
- }
- };
-
- const saveProvider = async () => {
- if (!editing) return;
- setSavingProvider(true);
- setSaveFeedback({ tone: "loading", message: "正在保存服务配置…" });
- try {
- const saved = await props.onSaveProvider({
- id: editing.id,
- name: editing.name,
- kind: editing.kind,
- baseUrl: editing.baseUrl,
- modelId: editing.modelId,
- apiKey: apiKeyUpdate(editing),
- });
- setEditing(providerDraft(saved));
- if (!props.settings.activeAsrProviderId) {
- props.onChange({ ...props.settings, activeAsrProviderId: saved.id });
- }
- setSaveFeedback({ tone: "success", message: "服务配置已保存。" });
- } catch (error) {
- setSaveFeedback({ tone: "error", message: String(error) });
- } finally {
- setSavingProvider(false);
- }
- };
-
- const testConnection = async () => {
- if (!editing) return;
- setTestingConnection(true);
- setConnectionFeedback({ tone: "loading", message: "正在连接服务并检查接口…" });
- try {
- const result = await props.onTestProvider(probeRequest(editing));
- applyReturnedModels(result.models);
- const details = [
- result.message,
- result.device ? `设备:${result.device}` : null,
- result.models.length ? `发现 ${result.models.length} 个模型` : null,
- ]
- .filter(Boolean)
- .join(" · ");
- setConnectionFeedback({
- tone: result.level,
- message: details,
- });
- } catch (error) {
- setConnectionFeedback({
- tone: "error",
- message: `连接失败:${String(error)}`,
- });
- } finally {
- setTestingConnection(false);
- }
- };
-
- const refreshModels = async () => {
- if (!editing) return;
- setLoadingModels(true);
- setModelFeedback({ tone: "loading", message: "正在从服务读取模型列表…" });
- try {
- const next = await props.onListModels(probeRequest(editing));
- applyReturnedModels(next);
- setModelFeedback(
- next.length
- ? { tone: "success", message: `已读取 ${next.length} 个模型,请从下方列表选择。` }
- : { tone: "warning", message: "服务没有返回模型,仍可手工填写模型 ID。" },
- );
- } catch (error) {
- setModels([]);
- setModelFeedback({
- tone: "error",
- message: `无法获取模型列表:${String(error)}。仍可手工填写模型 ID。`,
- });
- } finally {
- setLoadingModels(false);
- }
- };
-
- const deleteProvider = async () => {
- if (!editing?.id) return;
- if (!confirm(`删除语音转写服务“${editing.name}”?`)) return;
- setSavingProvider(true);
- try {
- await props.onDeleteProvider(editing.id);
- setEditing(null);
- setModels([]);
- } catch (error) {
- setSaveFeedback({ tone: "error", message: String(error) });
- } finally {
- setSavingProvider(false);
- }
- };
-
- return (
-
-
-
-
PREFERENCES
-
设置
-
录音设置和语音服务配置只保存在这台电脑上。
-
-
-
- {props.firstRun && (
-
-
-
- 欢迎使用 Nota
- 建议确认麦克风、保存目录和托盘行为;也可以稍后再设置。
-
-
- )}
-
- {props.recordingActive && (
-
-
- 当前录音不会被设置页操作中断;这里保存的录音设置将在下一次录音时生效。
-
- )}
-
-
-
-
-
- 麦克风与回声消除
-
- {props.microphoneCount > 0
- ? `已发现 ${props.microphoneCount} 个可用输入设备`
- : "未发现输入设备;请检查权限或连接"}
-
-
-
-
-
-
-
-
-
-
-
-
-
- 默认保存目录
- {props.settings.outputDirectory}
-
-
-
-
-
-
-
-
-
- 语音转写
- 支持 Nota ASR Server、OpenAI-compatible 与阿里云千问文件转写
-
-
-
-
-
- {activeProvider?.kind === "dashScope" && (
-
-
- 千问云转写会把完整录音上传至阿里云临时存储,约 48 小时后清理;支持匿名说话人分离,但不支持 Nota 声纹分析。
-
- )}
-
-
- {props.providers.length > 0 && (
-
- {props.providers.map((provider) => (
-
- ))}
-
- )}
-
- {editing && (
-
-
-
{editing.id ? "编辑服务" : "添加服务"}
-
-
-
-
-
-
-
-
-
-
-
- {editing.kind === "dashScope"
- ? "只获取临时上传凭证,不上传音频,也不创建计费任务。"
- : "检测健康状态和模型接口,不上传录音文件。"}
-
-
-
-
-
-
- {editing.id && (
-
- )}
-
-
-
-
- )}
-
-
-
- 只有手动开始转写,或明确开启自动转写后,录音音频才会发送到所选服务。
-
-
-
-
-
-
-
- 全局快捷键
- 发生冲突时不会覆盖其他应用。
-
-
-
- {props.settings.shortcutsEnabled && (
-
-
-
-
- )}
-
-
-
-
-
-
- 诊断与日志
- 技术日志仅保存在本机并自动轮转,不包含录音、转写正文或 AI 请求与响应内容。
-
-
-
-
-
-
-
-
-
- 关于此应用
- Nota · 本地优先的 Windows 会议录音工具
-
-
v{props.appVersion}
-
-
- Windows 11 x64
- 本地录音 · 无账号 · 无遥测
- © 2026 Nota Contributors
-
-
-
- {props.firstRun && (
-
- 关闭主窗口后应用会留在系统托盘。录音中退出时必须先选择“停止并保存”或取消退出。
-
- )}
-
-
-
- {props.dirty ? (
- <>
-
-
有未保存的更改
- >
- ) : (
- <>
-
-
所有普通设置均已保存
- >
- )}
-
-
- {props.firstRun && (
-
- )}
-
-
-
-
-
- );
-}
diff --git a/src/components/settings/SettingsPrimitives.tsx b/src/components/settings/SettingsPrimitives.tsx
new file mode 100644
index 0000000..e0038dc
--- /dev/null
+++ b/src/components/settings/SettingsPrimitives.tsx
@@ -0,0 +1,100 @@
+import type { ReactNode, SelectHTMLAttributes } from "react";
+import { ChevronDown, ChevronRight } from "lucide-react";
+
+export function SelectControl(props: SelectHTMLAttributes) {
+ const { children, ...selectProps } = props;
+ return (
+
+
+
+
+ );
+}
+
+export function SettingsPageHeader(props: {
+ title: string;
+ description: string;
+ action?: ReactNode;
+ notice?: ReactNode;
+}) {
+ return (
+
+
+
{props.title}
+
{props.description}
+
+ {props.action}
+ {props.notice}
+
+ );
+}
+
+export function SettingsSection(props: {
+ title: string;
+ children: ReactNode;
+}) {
+ return (
+
+ {props.title}
+ {props.children}
+
+ );
+}
+
+export function SettingsCard(props: {
+ icon?: ReactNode;
+ title: string;
+ description?: ReactNode;
+ children?: ReactNode;
+ onClick?: () => void;
+ ariaLabel?: string;
+ className?: string;
+}) {
+ const content = (
+ <>
+ {props.icon && {props.icon}}
+
+ {props.title}
+ {props.description && {props.description}}
+
+
+ {props.children ?? (props.onClick ? : null)}
+
+ >
+ );
+
+ return props.onClick ? (
+
+ ) : (
+ {content}
+ );
+}
+
+export type ProviderFeedbackTone = "loading" | "success" | "warning" | "error";
+
+export interface ProviderFeedback {
+ tone: ProviderFeedbackTone;
+ message: string;
+}
+
+export function InlineStatus(props: {
+ tone?: "neutral" | "success" | "warning" | "error";
+ children: ReactNode;
+ role?: "status" | "alert";
+}) {
+ return (
+
+ {props.children}
+
+ );
+}
diff --git a/src/components/settings/SettingsWorkspace.test.tsx b/src/components/settings/SettingsWorkspace.test.tsx
new file mode 100644
index 0000000..208d02d
--- /dev/null
+++ b/src/components/settings/SettingsWorkspace.test.tsx
@@ -0,0 +1,443 @@
+import { useMemo, useState } from "react";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type {
+ AiTemplate,
+ AppSettings,
+ AsrProvider,
+ LlmProvider,
+} from "../../types";
+import { SettingsWorkspace } from "./SettingsWorkspace";
+import type { SettingsRoute } from "./types";
+
+const mocks = vi.hoisted(() => ({
+ open: vi.fn(),
+ saveSettings: vi.fn(),
+ setActiveAsrProvider: vi.fn(),
+ setActiveLlmProvider: vi.fn(),
+ openMicrophoneSettings: vi.fn(),
+ openLogDirectory: vi.fn(),
+ listAsrProviders: vi.fn(),
+ saveAsrProvider: vi.fn(),
+ deleteAsrProvider: vi.fn(),
+ testAsrProvider: vi.fn(),
+ listAsrModels: vi.fn(),
+ listLlmProviders: vi.fn(),
+ saveLlmProvider: vi.fn(),
+ deleteLlmProvider: vi.fn(),
+ testLlmProvider: vi.fn(),
+ listLlmModels: vi.fn(),
+ getSettings: vi.fn(),
+ listAiTemplates: vi.fn(),
+ saveAiTemplate: vi.fn(),
+ cloneAiTemplate: vi.fn(),
+ archiveAiTemplate: vi.fn(),
+}));
+
+vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open }));
+vi.mock("../../api", () => ({
+ api: {
+ saveSettings: mocks.saveSettings,
+ setActiveAsrProvider: mocks.setActiveAsrProvider,
+ setActiveLlmProvider: mocks.setActiveLlmProvider,
+ openMicrophoneSettings: mocks.openMicrophoneSettings,
+ openLogDirectory: mocks.openLogDirectory,
+ listAsrProviders: mocks.listAsrProviders,
+ saveAsrProvider: mocks.saveAsrProvider,
+ deleteAsrProvider: mocks.deleteAsrProvider,
+ testAsrProvider: mocks.testAsrProvider,
+ listAsrModels: mocks.listAsrModels,
+ listLlmProviders: mocks.listLlmProviders,
+ saveLlmProvider: mocks.saveLlmProvider,
+ deleteLlmProvider: mocks.deleteLlmProvider,
+ testLlmProvider: mocks.testLlmProvider,
+ listLlmModels: mocks.listLlmModels,
+ getSettings: mocks.getSettings,
+ listAiTemplates: mocks.listAiTemplates,
+ saveAiTemplate: mocks.saveAiTemplate,
+ cloneAiTemplate: mocks.cloneAiTemplate,
+ archiveAiTemplate: mocks.archiveAiTemplate,
+ },
+}));
+
+const settings: AppSettings = {
+ outputDirectory: "C:\\Recordings",
+ aiDocumentsDirectory: "C:\\AI Documents",
+ aecMode: "auto",
+ microphoneEnabled: true,
+ firstRunComplete: true,
+ shortcutsEnabled: true,
+ toggleShortcut: "Ctrl+Alt+F9",
+ stopShortcut: "Ctrl+Alt+F10",
+ activeAsrProviderId: "lan",
+ voiceprintProviderId: null,
+ autoTranscribe: false,
+ activeLlmProviderId: "llm",
+};
+
+const asrProvider: AsrProvider = {
+ id: "lan",
+ name: "LAN FunASR",
+ kind: "funAsr",
+ baseUrl: "http://192.168.1.20:8000/v1",
+ modelId: "sensevoice",
+ hasApiKey: true,
+ capabilities: {
+ wholeMeeting: true,
+ diarization: true,
+ speakerCountMin: 1,
+ speakerCountMax: 64,
+ voiceprintAnalysis: true,
+ modelDiscovery: true,
+ cloudUpload: false,
+ maxReliableAudioSeconds: null,
+ },
+ createdAt: "2026-08-01T00:00:00Z",
+ updatedAt: "2026-08-01T00:00:00Z",
+};
+
+const llmProvider: LlmProvider = {
+ id: "llm",
+ name: "OpenAI",
+ kind: "openAi",
+ baseUrl: "https://api.openai.com/v1",
+ modelId: "gpt-5-mini",
+ inputTokenBudget: 32768,
+ maxOutputTokens: 4096,
+ hasApiKey: true,
+ createdAt: "2026-08-01T00:00:00Z",
+ updatedAt: "2026-08-01T00:00:00Z",
+};
+
+const builtinTemplate: AiTemplate = {
+ id: "builtin-summary",
+ name: "会议纪要",
+ description: "生成结构化会议纪要",
+ builtinKey: "summary",
+ taskInstructions: "总结会议内容。",
+ outputRequirements: "使用 Markdown 输出。",
+ requiresSpeakerLabels: false,
+ revision: 1,
+ archived: false,
+ createdAt: "2026-08-01T00:00:00Z",
+ updatedAt: "2026-08-01T00:00:00Z",
+};
+
+const customTemplate: AiTemplate = {
+ ...builtinTemplate,
+ id: "custom-summary",
+ name: "产品复盘",
+ description: "提炼产品决策与行动项",
+ builtinKey: null,
+ taskInstructions: "整理产品讨论。",
+ revision: 2,
+};
+
+interface HarnessProps {
+ initialRoute?: SettingsRoute;
+ initialSettings?: AppSettings;
+ asrProviders?: AsrProvider[];
+ llmProviders?: LlmProvider[];
+ onFirstRunComplete?: () => void;
+ onError?: (error: unknown) => void;
+}
+
+function Harness(props: HarnessProps) {
+ const [route, setRoute] = useState(props.initialRoute ?? "recording");
+ const [currentSettings, setCurrentSettings] = useState(props.initialSettings ?? settings);
+ const [asrProviders, setAsrProviders] = useState(props.asrProviders ?? [asrProvider]);
+ const [llmProviders, setLlmProviders] = useState(props.llmProviders ?? [llmProvider]);
+ const actions = useMemo(() => ({
+ onSettingsChange: setCurrentSettings,
+ onAsrProvidersChange: setAsrProviders,
+ onLlmProvidersChange: setLlmProviders,
+ onFirstRunComplete: props.onFirstRunComplete ?? vi.fn(),
+ onEditorDirtyChange: vi.fn(),
+ onToast: vi.fn(),
+ onError: props.onError ?? vi.fn(),
+ }), [props.onError, props.onFirstRunComplete]);
+
+ return (
+
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.saveSettings.mockResolvedValue(undefined);
+ mocks.openMicrophoneSettings.mockResolvedValue(undefined);
+ mocks.openLogDirectory.mockResolvedValue(undefined);
+ mocks.setActiveAsrProvider.mockImplementation(async (id: string | null) => ({
+ ...settings,
+ activeAsrProviderId: id,
+ autoTranscribe: id ? settings.autoTranscribe : false,
+ }));
+ mocks.setActiveLlmProvider.mockImplementation(async (id: string | null) => ({
+ ...settings,
+ activeLlmProviderId: id,
+ }));
+ mocks.getSettings.mockResolvedValue(settings);
+ mocks.listAsrProviders.mockResolvedValue([asrProvider]);
+ mocks.listLlmProviders.mockResolvedValue([llmProvider]);
+ mocks.saveAsrProvider.mockResolvedValue(asrProvider);
+ mocks.saveLlmProvider.mockResolvedValue(llmProvider);
+ mocks.testAsrProvider.mockResolvedValue({
+ reachable: true,
+ level: "success",
+ message: "服务可用",
+ models: [],
+ device: "cpu",
+ });
+ mocks.listAsrModels.mockResolvedValue([]);
+ mocks.testLlmProvider.mockResolvedValue({ reachable: true, message: "连接成功" });
+ mocks.listLlmModels.mockResolvedValue([]);
+ mocks.listAiTemplates.mockResolvedValue([builtinTemplate]);
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe("SettingsWorkspace navigation and preferences", () => {
+ it("shows one category at a time and keeps the parent category selected on manager pages", async () => {
+ render();
+ expect(screen.getByRole("heading", { name: "录音与保存" })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "语音转写" }));
+ expect(screen.getByRole("heading", { name: "语音转写" })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: /管理转写服务/ }));
+ expect(screen.getByRole("heading", { name: "语音转写 / 服务管理" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "语音转写" })).toHaveAttribute("aria-current", "page");
+ });
+
+ it("auto-saves ordinary preferences without completing first run", async () => {
+ render();
+ fireEvent.change(screen.getByRole("combobox", { name: "回声消除" }), {
+ target: { value: "off" },
+ });
+ await waitFor(() => expect(mocks.saveSettings).toHaveBeenCalledWith(
+ expect.objectContaining({ aecMode: "off", firstRunComplete: false }),
+ ));
+ expect(screen.queryByRole("button", { name: "保存设置" })).not.toBeInTheDocument();
+ });
+
+ it("rolls back an optimistic preference when persistence fails", async () => {
+ const onError = vi.fn();
+ mocks.saveSettings.mockRejectedValueOnce(new Error("disk full"));
+ render();
+ fireEvent.change(screen.getByRole("combobox", { name: "回声消除" }), {
+ target: { value: "off" },
+ });
+ await waitFor(() => expect(screen.getByRole("combobox", { name: "回声消除" })).toHaveValue("auto"));
+ expect(onError).toHaveBeenCalled();
+ expect(screen.getByRole("alert")).toHaveTextContent("disk full");
+ });
+
+ it("serializes writes and coalesces pending ordinary-setting snapshots", async () => {
+ let finishFirst!: () => void;
+ mocks.saveSettings.mockImplementationOnce(() => new Promise((resolve) => {
+ finishFirst = resolve;
+ }));
+ render();
+ const aec = screen.getByRole("combobox", { name: "回声消除" });
+ fireEvent.change(aec, { target: { value: "on" } });
+ fireEvent.change(aec, { target: { value: "off" } });
+ fireEvent.change(aec, { target: { value: "auto" } });
+ expect(mocks.saveSettings).toHaveBeenCalledTimes(1);
+ finishFirst();
+ await waitFor(() => expect(mocks.saveSettings).toHaveBeenCalledTimes(2));
+ expect(mocks.saveSettings).toHaveBeenLastCalledWith(
+ expect.objectContaining({ aecMode: "auto" }),
+ );
+ });
+
+ it("does not save when directory selection is cancelled", async () => {
+ mocks.open.mockResolvedValueOnce(null);
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "更改" }));
+ await waitFor(() => expect(mocks.open).toHaveBeenCalled());
+ expect(mocks.saveSettings).not.toHaveBeenCalled();
+ });
+
+ it("uses the dedicated command when changing the default ASR provider", async () => {
+ render();
+ const selector = screen.getByRole("combobox", { name: "默认语音转写服务" });
+ expect(selector.parentElement).toHaveClass("select-wrap", "settings-select");
+ fireEvent.change(selector, {
+ target: { value: "" },
+ });
+ await waitFor(() => expect(mocks.setActiveAsrProvider).toHaveBeenCalledWith(null));
+ });
+});
+
+describe("Settings resource managers", () => {
+ it("keeps stored ASR keys masked and tests the unsaved draft", async () => {
+ render();
+ const key = screen.getByLabelText("API Key");
+ expect(key).toHaveAttribute("type", "password");
+ expect(key).toHaveValue("••••••••");
+ fireEvent.change(screen.getByDisplayValue(asrProvider.baseUrl), {
+ target: { value: "http://draft-host:9000/v1" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
+ await waitFor(() => expect(mocks.testAsrProvider).toHaveBeenCalledWith(
+ expect.objectContaining({ baseUrl: "http://draft-host:9000/v1", apiKey: { kind: "keep" } }),
+ ));
+ expect(await screen.findByText(/服务可用 · 设备:cpu/)).toBeInTheDocument();
+ });
+
+ it("applies fixed DashScope fields and preserves its cloud disclosure", () => {
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /添加/ }));
+ const draftCard = screen.getByRole("button", { name: "正在添加 本地 FunASR FunASR" });
+ expect(draftCard).toHaveClass("resource-draft", "selected");
+ expect(screen.getByText("填写中")).toBeInTheDocument();
+ fireEvent.change(screen.getByLabelText("服务类型"), { target: { value: "dashScope" } });
+ expect(screen.getByRole("button", { name: "正在添加 千问云转写 阿里云千问" })).toHaveClass("selected");
+ expect(screen.getByDisplayValue("https://dashscope.aliyuncs.com/api/v1")).toHaveAttribute("readonly");
+ expect(screen.getByDisplayValue("qwen-audio-3.0-asr-flash-filetrans")).toHaveAttribute("readonly");
+ expect(screen.getByText(/约在 48 小时后清理/)).toBeInTheDocument();
+ });
+
+ it("renders LLM provider readiness and explicit save controls", () => {
+ render();
+ expect(screen.getByRole("heading", { name: "OpenAI" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /保存服务/ })).toBeDisabled();
+ fireEvent.change(screen.getByLabelText("模型 ID"), { target: { value: "gpt-5.1" } });
+ expect(screen.getByRole("button", { name: /保存服务/ })).toBeEnabled();
+ });
+
+ it("loads built-in templates as read-only resources", async () => {
+ render();
+ expect(await screen.findByRole("heading", { name: "会议纪要" })).toBeInTheDocument();
+ expect(screen.getByText("内置模板不能直接修改")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /复制为自定义模板/ })).toBeEnabled();
+ });
+
+ it("shows template loading failures and offers a retry", async () => {
+ mocks.listAiTemplates.mockRejectedValueOnce(new Error("数据库暂时不可用"));
+ render();
+ expect(await screen.findByRole("alert")).toHaveTextContent("数据库暂时不可用");
+ expect(screen.getByRole("button", { name: "重试" })).toBeEnabled();
+ });
+
+ it("copies a built-in template into an editable custom resource", async () => {
+ vi.spyOn(window, "prompt").mockReturnValue("产品复盘");
+ mocks.cloneAiTemplate.mockResolvedValue(customTemplate);
+ mocks.listAiTemplates
+ .mockResolvedValueOnce([builtinTemplate])
+ .mockResolvedValueOnce([builtinTemplate, customTemplate]);
+ render();
+ fireEvent.click(await screen.findByRole("button", { name: /复制为自定义模板/ }));
+ await waitFor(() => expect(mocks.cloneAiTemplate).toHaveBeenCalledWith(
+ builtinTemplate.id,
+ "产品复盘",
+ ));
+ expect(await screen.findByRole("heading", { name: "产品复盘" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /保存模板/ })).toBeDisabled();
+ });
+
+ it("saves a custom template and refreshes its revision", async () => {
+ const revised = { ...customTemplate, taskInstructions: "整理产品决策。", revision: 3 };
+ mocks.listAiTemplates
+ .mockResolvedValueOnce([customTemplate])
+ .mockResolvedValueOnce([revised]);
+ mocks.saveAiTemplate.mockResolvedValue(revised);
+ render();
+ const instructions = await screen.findByLabelText("任务指令");
+ fireEvent.change(instructions, { target: { value: "整理产品决策。" } });
+ fireEvent.click(screen.getByRole("button", { name: /保存模板/ }));
+ await waitFor(() => expect(mocks.saveAiTemplate).toHaveBeenCalledWith(
+ expect.objectContaining({ id: customTemplate.id, taskInstructions: "整理产品决策。" }),
+ ));
+ expect(await screen.findByText("r3")).toBeInTheDocument();
+ });
+
+ it("archives custom templates and guards dirty category navigation", async () => {
+ mocks.listAiTemplates.mockResolvedValue([customTemplate]);
+ const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
+ render();
+ const name = await screen.findByLabelText("模板名称");
+ fireEvent.change(name, { target: { value: "尚未保存的模板" } });
+ fireEvent.click(screen.getByRole("button", { name: "关于" }));
+ expect(confirm).toHaveBeenCalled();
+ expect(screen.getByRole("heading", { name: "AI 文档 / 模板管理" })).toBeInTheDocument();
+ expect(name).toHaveValue("尚未保存的模板");
+
+ confirm.mockReturnValue(true);
+ mocks.archiveAiTemplate.mockResolvedValue(undefined);
+ mocks.listAiTemplates.mockResolvedValueOnce([]);
+ fireEvent.click(screen.getByRole("button", { name: /归档/ }));
+ await waitFor(() => expect(mocks.archiveAiTemplate).toHaveBeenCalledWith(customTemplate.id));
+ });
+});
+
+describe("Settings setup and utilities", () => {
+ it("keeps first run non-blocking and only completes it from the setup actions", async () => {
+ const complete = vi.fn();
+ render();
+ expect(screen.getByRole("heading", { name: "开始使用 Nota" })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "稍后设置" }));
+ await waitFor(() => expect(mocks.saveSettings).toHaveBeenCalledWith(
+ expect.objectContaining({ firstRunComplete: true }),
+ ));
+ expect(complete).toHaveBeenCalled();
+ });
+
+ it("returns from Provider setup to the first-run checklist", async () => {
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "管理语音转写服务" }));
+ expect(screen.getByRole("heading", { name: "语音转写 / 服务管理" })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "返回" }));
+ expect(screen.getByRole("heading", { name: "开始使用 Nota" })).toBeInTheDocument();
+ });
+
+ it("commits shortcut drafts on blur and keeps a registration error inline", async () => {
+ mocks.saveSettings.mockRejectedValueOnce(new Error("快捷键冲突"));
+ render();
+ const input = screen.getByRole("textbox", { name: "开始暂停快捷键" });
+ fireEvent.change(input, { target: { value: "Ctrl+Alt+X" } });
+ fireEvent.blur(input);
+ expect((await screen.findAllByRole("alert")).some((alert) => alert.textContent?.includes("快捷键冲突"))).toBe(true);
+ expect(input).toHaveValue("Ctrl+Alt+X");
+ });
+
+ it("trims and commits the shortcut pair when Enter moves focus", async () => {
+ render();
+ const input = screen.getByRole("textbox", { name: "开始暂停快捷键" });
+ fireEvent.change(input, { target: { value: " Ctrl+Alt+X " } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ await waitFor(() => expect(mocks.saveSettings).toHaveBeenCalledWith(
+ expect.objectContaining({
+ toggleShortcut: "Ctrl+Alt+X",
+ stopShortcut: "Ctrl+Alt+F10",
+ }),
+ ));
+ });
+
+ it("opens the local diagnostic directory without saving settings", async () => {
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "打开日志目录" }));
+ await waitFor(() => expect(mocks.openLogDirectory).toHaveBeenCalled());
+ expect(mocks.saveSettings).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/settings/SettingsWorkspace.tsx b/src/components/settings/SettingsWorkspace.tsx
new file mode 100644
index 0000000..dc5170e
--- /dev/null
+++ b/src/components/settings/SettingsWorkspace.tsx
@@ -0,0 +1,197 @@
+import { useCallback, useEffect, useState } from "react";
+import {
+ Bot,
+ CheckCircle2,
+ FileText,
+ Info,
+ Keyboard,
+ LoaderCircle,
+ Mic,
+ Settings2,
+ ShieldCheck,
+ TriangleAlert,
+} from "lucide-react";
+import { useSettingsController } from "./hooks/useSettingsController";
+import { AiDocumentsSettingsPage } from "./pages/AiDocumentsSettingsPage";
+import { RecordingSettingsPage } from "./pages/RecordingSettingsPage";
+import { SetupPage } from "./pages/SetupPage";
+import { TranscriptionSettingsPage } from "./pages/TranscriptionSettingsPage";
+import {
+ AboutSettingsPage,
+ DiagnosticsSettingsPage,
+ ShortcutsSettingsPage,
+} from "./pages/UtilitySettingsPages";
+import { AsrProviderManager } from "./providers/AsrProviderManager";
+import { LlmProviderManager } from "./providers/LlmProviderManager";
+import { AiTemplateManager } from "./templates/AiTemplateManager";
+import {
+ parentSettingsRoute,
+ type SettingsRoute,
+ type SettingsWorkspaceProps,
+} from "./types";
+
+const navigation: Array<{
+ route: SettingsRoute;
+ label: string;
+ icon: typeof Mic;
+}> = [
+ { route: "recording", label: "录音与保存", icon: Mic },
+ { route: "transcription", label: "语音转写", icon: Settings2 },
+ { route: "aiDocuments", label: "AI 文档", icon: Bot },
+ { route: "shortcuts", label: "快捷键", icon: Keyboard },
+ { route: "diagnostics", label: "诊断", icon: FileText },
+ { route: "about", label: "关于", icon: Info },
+];
+
+export function SettingsWorkspace(props: SettingsWorkspaceProps) {
+ const controller = useSettingsController(props.model, props.actions);
+ const [editorDirty, setEditorDirty] = useState(false);
+ const [asrReturnRoute, setAsrReturnRoute] = useState<"setup" | "transcription">("transcription");
+ const activeRoute = parentSettingsRoute(props.route);
+
+ useEffect(() => {
+ props.actions.onEditorDirtyChange(editorDirty);
+ return () => props.actions.onEditorDirtyChange(false);
+ }, [editorDirty, props.actions]);
+
+ const requestRoute = useCallback((route: SettingsRoute) => {
+ if (route === props.route) return;
+ if (editorDirty && !confirm("当前编辑内容尚未保存。放弃这些更改吗?")) return;
+ setEditorDirty(false);
+ props.onRouteChange(route);
+ }, [editorDirty, props]);
+
+ const page = (() => {
+ switch (props.route) {
+ case "setup":
+ return (
+ {
+ setAsrReturnRoute("setup");
+ requestRoute("asrProviders");
+ }}
+ />
+ );
+ case "recording":
+ return ;
+ case "transcription":
+ return (
+ {
+ setAsrReturnRoute("transcription");
+ requestRoute("asrProviders");
+ }}
+ />
+ );
+ case "asrProviders":
+ return (
+ props.onRouteChange(asrReturnRoute)}
+ onDirtyChange={setEditorDirty}
+ />
+ );
+ case "aiDocuments":
+ return (
+ requestRoute("llmProviders")}
+ onManageTemplates={() => requestRoute("aiTemplates")}
+ />
+ );
+ case "llmProviders":
+ return (
+ props.onRouteChange("aiDocuments")}
+ onDirtyChange={setEditorDirty}
+ />
+ );
+ case "aiTemplates":
+ return (
+ props.onRouteChange("aiDocuments")}
+ onDirtyChange={setEditorDirty}
+ />
+ );
+ case "shortcuts":
+ return ;
+ case "diagnostics":
+ return ;
+ case "about":
+ return ;
+ }
+ })();
+
+ return (
+
+
+
+ {props.model.recordingActive && (
+
+
+ 当前录音不会被设置操作中断;录音相关更改将在下一次录音时生效。
+
+ )}
+ {controller.preferenceError && (
+
+
+ 设置未能保存,已恢复为上次成功保存的值:{controller.preferenceError}
+
+ )}
+ {page}
+
+
+ );
+}
diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts
new file mode 100644
index 0000000..3ea35d5
--- /dev/null
+++ b/src/components/settings/hooks/useSettingsController.ts
@@ -0,0 +1,243 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { open } from "@tauri-apps/plugin-dialog";
+import { api } from "../../../api";
+import { llmProviderReady } from "../../../llm";
+import type {
+ AiTemplate,
+ AppSettings,
+ AsrProviderProbeRequest,
+ LlmProviderProbeRequest,
+ SaveAiTemplateRequest,
+ SaveAsrProviderRequest,
+ SaveLlmProviderRequest,
+} from "../../../types";
+import type { SettingsActions, SettingsModel } from "../types";
+
+interface SettingsMutation {
+ apply: (current: AppSettings) => AppSettings;
+ persist: (next: AppSettings) => Promise;
+ coalesceKey?: "settings";
+ waiters: Array<{
+ resolve: () => void;
+ reject: (error: unknown) => void;
+ }>;
+}
+
+export function useSettingsController(model: SettingsModel, actions: SettingsActions) {
+ const confirmedRef = useRef(model.settings);
+ const queueRef = useRef([]);
+ const drainingRef = useRef(false);
+ const modelRef = useRef(model);
+ const actionsRef = useRef(actions);
+ const [savingPreferences, setSavingPreferences] = useState(false);
+ const [preferenceError, setPreferenceError] = useState(null);
+
+ modelRef.current = model;
+ actionsRef.current = actions;
+
+ useEffect(() => {
+ if (!drainingRef.current && queueRef.current.length === 0) {
+ confirmedRef.current = model.settings;
+ }
+ }, [model.settings]);
+
+ const projectedSettings = useCallback(() =>
+ queueRef.current.reduce(
+ (current, mutation) => mutation.apply(current),
+ confirmedRef.current,
+ ), []);
+
+ const drain = useCallback(async () => {
+ if (drainingRef.current) return;
+ drainingRef.current = true;
+ setSavingPreferences(true);
+ setPreferenceError(null);
+
+ while (queueRef.current.length > 0) {
+ const mutation = queueRef.current[0];
+ const next = mutation.apply(confirmedRef.current);
+ try {
+ confirmedRef.current = await mutation.persist(next);
+ queueRef.current.shift();
+ setPreferenceError(null);
+ mutation.waiters.forEach(({ resolve }) => resolve());
+ } catch (error) {
+ queueRef.current.shift();
+ mutation.waiters.forEach(({ reject }) => reject(error));
+ const message = String(error);
+ setPreferenceError(message);
+ actionsRef.current.onError(error);
+ }
+ actionsRef.current.onSettingsChange(projectedSettings());
+ }
+
+ drainingRef.current = false;
+ setSavingPreferences(false);
+ }, [projectedSettings]);
+
+ const enqueue = useCallback((
+ apply: SettingsMutation["apply"],
+ persist: SettingsMutation["persist"],
+ coalesceKey?: SettingsMutation["coalesceKey"],
+ ) => {
+ const result = new Promise((resolve, reject) => {
+ const pendingStart = drainingRef.current ? 1 : 0;
+ const pending = queueRef.current.at(-1);
+ if (
+ coalesceKey
+ && pending
+ && queueRef.current.length > pendingStart
+ && pending.coalesceKey === coalesceKey
+ ) {
+ const previousApply = pending.apply;
+ pending.apply = (current) => apply(previousApply(current));
+ pending.persist = persist;
+ pending.waiters.push({ resolve, reject });
+ } else {
+ queueRef.current.push({ apply, persist, coalesceKey, waiters: [{ resolve, reject }] });
+ }
+ });
+ actionsRef.current.onSettingsChange(projectedSettings());
+ void drain();
+ return result;
+ }, [drain, projectedSettings]);
+
+ const savePatch = useCallback((patch: Partial) =>
+ enqueue(
+ (current) => ({ ...current, ...patch }),
+ async (next) => {
+ await api.saveSettings(next);
+ return next;
+ },
+ "settings",
+ ), [enqueue]);
+
+ const setActiveAsrProvider = useCallback((id: string | null) =>
+ enqueue(
+ (current) => ({
+ ...current,
+ activeAsrProviderId: id,
+ autoTranscribe: id ? current.autoTranscribe : false,
+ }),
+ () => api.setActiveAsrProvider(id),
+ ), [enqueue]);
+
+ const setActiveLlmProvider = useCallback((id: string | null) =>
+ enqueue(
+ (current) => ({ ...current, activeLlmProviderId: id }),
+ () => api.setActiveLlmProvider(id),
+ ), [enqueue]);
+
+ const chooseDirectory = useCallback(async (
+ setting: "outputDirectory" | "aiDocumentsDirectory",
+ ) => {
+ const selected = await open({ directory: true, multiple: false });
+ if (typeof selected !== "string") return false;
+ await savePatch({ [setting]: selected });
+ return true;
+ }, [savePatch]);
+
+ const refreshAsrState = useCallback(async () => {
+ const [settings, providers] = await Promise.all([
+ api.getSettings(),
+ api.listAsrProviders(),
+ ]);
+ confirmedRef.current = settings;
+ actionsRef.current.onSettingsChange(settings);
+ actionsRef.current.onAsrProvidersChange(providers);
+ return { settings, providers };
+ }, []);
+
+ const refreshLlmState = useCallback(async () => {
+ const [settings, providers] = await Promise.all([
+ api.getSettings(),
+ api.listLlmProviders(),
+ ]);
+ confirmedRef.current = settings;
+ actionsRef.current.onSettingsChange(settings);
+ actionsRef.current.onLlmProvidersChange(providers);
+ return { settings, providers };
+ }, []);
+
+ const saveAsrProvider = useCallback(async (request: SaveAsrProviderRequest) => {
+ const saved = await api.saveAsrProvider(request);
+ let state = await refreshAsrState();
+ if (!state.settings.activeAsrProviderId) {
+ const settings = await api.setActiveAsrProvider(saved.id);
+ confirmedRef.current = settings;
+ actionsRef.current.onSettingsChange(settings);
+ state = { ...state, settings };
+ }
+ return saved;
+ }, [refreshAsrState]);
+
+ const deleteAsrProvider = useCallback(async (id: string) => {
+ await api.deleteAsrProvider(id);
+ await refreshAsrState();
+ }, [refreshAsrState]);
+
+ const saveLlmProvider = useCallback(async (request: SaveLlmProviderRequest) => {
+ const saved = await api.saveLlmProvider(request);
+ let state = await refreshLlmState();
+ if (!state.settings.activeLlmProviderId && llmProviderReady(saved)) {
+ const settings = await api.setActiveLlmProvider(saved.id);
+ confirmedRef.current = settings;
+ actionsRef.current.onSettingsChange(settings);
+ state = { ...state, settings };
+ }
+ return saved;
+ }, [refreshLlmState]);
+
+ const deleteLlmProvider = useCallback(async (id: string) => {
+ await api.deleteLlmProvider(id);
+ await refreshLlmState();
+ }, [refreshLlmState]);
+
+ const completeFirstRun = useCallback(async (skipped: boolean) => {
+ await savePatch({ firstRunComplete: true });
+ actionsRef.current.onToast(
+ skipped ? "info" : "success",
+ skipped ? "已跳过首次设置,可以随时从侧边栏返回。" : "首次设置已完成",
+ );
+ actionsRef.current.onFirstRunComplete();
+ }, [savePatch]);
+
+ return useMemo(() => ({
+ savingPreferences,
+ preferenceError,
+ savePatch,
+ setActiveAsrProvider,
+ setActiveLlmProvider,
+ chooseOutputDirectory: () => chooseDirectory("outputDirectory"),
+ chooseAiDocumentsDirectory: () => chooseDirectory("aiDocumentsDirectory"),
+ openMicrophoneSettings: () => api.openMicrophoneSettings(),
+ openLogDirectory: () => api.openLogDirectory(),
+ saveAsrProvider,
+ deleteAsrProvider,
+ testAsrProvider: (request: AsrProviderProbeRequest) => api.testAsrProvider(request),
+ listAsrModels: (request: AsrProviderProbeRequest) => api.listAsrModels(request),
+ saveLlmProvider,
+ deleteLlmProvider,
+ testLlmProvider: (request: LlmProviderProbeRequest) => api.testLlmProvider(request),
+ listLlmModels: (request: LlmProviderProbeRequest) => api.listLlmModels(request),
+ listAiTemplates: (): Promise => api.listAiTemplates(),
+ saveAiTemplate: (request: SaveAiTemplateRequest) => api.saveAiTemplate(request),
+ cloneAiTemplate: (id: string, name: string) => api.cloneAiTemplate(id, name),
+ archiveAiTemplate: (id: string) => api.archiveAiTemplate(id),
+ completeFirstRun,
+ }), [
+ chooseDirectory,
+ completeFirstRun,
+ deleteAsrProvider,
+ deleteLlmProvider,
+ preferenceError,
+ saveAsrProvider,
+ saveLlmProvider,
+ savePatch,
+ savingPreferences,
+ setActiveAsrProvider,
+ setActiveLlmProvider,
+ ]);
+}
+
+export type SettingsController = ReturnType;
diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts
new file mode 100644
index 0000000..5b80a2a
--- /dev/null
+++ b/src/components/settings/index.ts
@@ -0,0 +1,7 @@
+export { SettingsWorkspace } from "./SettingsWorkspace";
+export type {
+ SettingsActions,
+ SettingsModel,
+ SettingsRoute,
+ SettingsWorkspaceProps,
+} from "./types";
diff --git a/src/components/settings/pages/AiDocumentsSettingsPage.tsx b/src/components/settings/pages/AiDocumentsSettingsPage.tsx
new file mode 100644
index 0000000..f903e86
--- /dev/null
+++ b/src/components/settings/pages/AiDocumentsSettingsPage.tsx
@@ -0,0 +1,83 @@
+import { Bot, FileText, Folder } from "lucide-react";
+import { llmProviderReady } from "../../../llm";
+import type { SettingsController } from "../hooks/useSettingsController";
+import { SelectControl, SettingsCard, SettingsPageHeader, SettingsSection } from "../SettingsPrimitives";
+import type { SettingsModel } from "../types";
+
+export function AiDocumentsSettingsPage(props: {
+ model: SettingsModel;
+ controller: SettingsController;
+ onManageProviders: () => void;
+ onManageTemplates: () => void;
+}) {
+ const activeProvider = props.model.llmProviders.find(
+ (provider) => provider.id === props.model.settings.activeLlmProviderId,
+ );
+
+ return (
+
+
+
+ }
+ title="默认 LLM Provider"
+ description={activeProvider ? `${activeProvider.modelId} · ${activeProvider.baseUrl}` : "未选择"}
+ >
+ void props.controller
+ .setActiveLlmProvider(event.target.value || null)
+ .catch(() => undefined)}
+ >
+
+ {props.model.llmProviders.map((provider) => (
+
+ ))}
+
+
+ }
+ title="Markdown 保存目录"
+ description={props.model.settings.aiDocumentsDirectory}
+ className="settings-path-card"
+ >
+
+
+
+
+ }
+ title="管理模型服务"
+ description={`${props.model.llmProviders.length} 个 Provider`}
+ onClick={props.onManageProviders}
+ />
+ }
+ title="管理 AI 模板"
+ description="查看内置模板,创建和维护自定义模板"
+ onClick={props.onManageTemplates}
+ />
+
+
+
+ 只有手动生成 AI 文档时,转写内容才会发送到所选模型服务。
+
+
+ );
+}
diff --git a/src/components/settings/pages/RecordingSettingsPage.tsx b/src/components/settings/pages/RecordingSettingsPage.tsx
new file mode 100644
index 0000000..5428139
--- /dev/null
+++ b/src/components/settings/pages/RecordingSettingsPage.tsx
@@ -0,0 +1,71 @@
+import { Folder, Mic, Volume2 } from "lucide-react";
+import type { AecMode } from "../../../types";
+import type { SettingsController } from "../hooks/useSettingsController";
+import { SelectControl, SettingsCard, SettingsPageHeader, SettingsSection } from "../SettingsPrimitives";
+import type { SettingsModel } from "../types";
+
+export function RecordingSettingsPage(props: {
+ model: SettingsModel;
+ controller: SettingsController;
+}) {
+ const saveAecMode = (aecMode: AecMode) => {
+ void props.controller.savePatch({ aecMode }).catch(() => undefined);
+ };
+
+ return (
+
+
+
+ }
+ title="麦克风"
+ description={props.model.microphoneCount > 0
+ ? `已发现 ${props.model.microphoneCount} 个可用输入设备`
+ : "未发现输入设备,请检查权限或连接"}
+ >
+
+
+ }
+ title="回声消除"
+ description="自动模式会在使用扬声器时开启,使用耳机时关闭。"
+ >
+ saveAecMode(event.target.value as AecMode)}
+ >
+
+
+
+
+
+
+
+ }
+ title="默认保存目录"
+ description={props.model.settings.outputDirectory}
+ className="settings-path-card"
+ >
+
+
+
+
+ );
+}
diff --git a/src/components/settings/pages/SetupPage.tsx b/src/components/settings/pages/SetupPage.tsx
new file mode 100644
index 0000000..e64b209
--- /dev/null
+++ b/src/components/settings/pages/SetupPage.tsx
@@ -0,0 +1,91 @@
+import { CheckCircle2, Folder, Mic, Server, ShieldCheck } from "lucide-react";
+import type { SettingsController } from "../hooks/useSettingsController";
+import { SettingsCard, SettingsPageHeader, SettingsSection } from "../SettingsPrimitives";
+import type { SettingsModel } from "../types";
+
+export function SetupPage(props: {
+ model: SettingsModel;
+ controller: SettingsController;
+ onManageProviders: () => void;
+}) {
+ const hasMicrophone = props.model.microphoneCount > 0;
+ const hasProvider = props.model.asrProviders.length > 0;
+
+ return (
+
+
+
+
+
+ 录音始终可以离线使用
+ Nota 不要求账号,也不会在录音过程中自动上传音频。
+
+
+
+
+ }
+ title="麦克风"
+ description={hasMicrophone
+ ? `已发现 ${props.model.microphoneCount} 个可用输入设备`
+ : "未发现输入设备,请检查 Windows 权限或设备连接"}
+ >
+
+
+ }
+ title="录音保存目录"
+ description={props.model.settings.outputDirectory}
+ >
+
+
+ }
+ title="语音转写服务(可选)"
+ description={hasProvider
+ ? `已配置 ${props.model.asrProviders.length} 个服务`
+ : "不配置也可以正常录音,之后可随时添加"}
+ onClick={props.onManageProviders}
+ ariaLabel="管理语音转写服务"
+ />
+
+
+
+
以上项目不会阻止你稍后继续调整设置
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/settings/pages/TranscriptionSettingsPage.tsx b/src/components/settings/pages/TranscriptionSettingsPage.tsx
new file mode 100644
index 0000000..7b6d120
--- /dev/null
+++ b/src/components/settings/pages/TranscriptionSettingsPage.tsx
@@ -0,0 +1,74 @@
+import { Bot, ShieldCheck, Wifi } from "lucide-react";
+import type { SettingsController } from "../hooks/useSettingsController";
+import { SelectControl, SettingsCard, SettingsPageHeader, SettingsSection } from "../SettingsPrimitives";
+import type { SettingsModel } from "../types";
+
+export function TranscriptionSettingsPage(props: {
+ model: SettingsModel;
+ controller: SettingsController;
+ onManageProviders: () => void;
+}) {
+ const activeProvider = props.model.asrProviders.find(
+ (provider) => provider.id === props.model.settings.activeAsrProviderId,
+ );
+
+ return (
+
+
+
+ }
+ title="默认转写服务"
+ description={activeProvider
+ ? `${activeProvider.baseUrl} · ${activeProvider.modelId}`
+ : "未选择服务时仍可正常录音"}
+ >
+ void props.controller
+ .setActiveAsrProvider(event.target.value || null)
+ .catch(() => undefined)}
+ >
+
+ {props.model.asrProviders.map((provider) => (
+
+ ))}
+
+
+ }
+ title="录音结束后自动转写"
+ description={activeProvider?.kind === "dashScope"
+ ? "停止并保存后会上传完整录音至阿里云;录音进行中不会上传。"
+ : "仅在停止并保存后开始;录音进行中不会上传。"}
+ >
+ void props.controller
+ .savePatch({ autoTranscribe: event.target.checked })
+ .catch(() => undefined)}
+ />
+
+
+
+ }
+ title="管理转写服务"
+ description={`${props.model.asrProviders.length} 个服务${activeProvider ? ` · ${activeProvider.name} 当前启用` : ""}`}
+ onClick={props.onManageProviders}
+ />
+
+
+
+ 只有手动开始转写或开启自动转写后,录音才会发送到所选服务。
+
+
+ );
+}
diff --git a/src/components/settings/pages/UtilitySettingsPages.tsx b/src/components/settings/pages/UtilitySettingsPages.tsx
new file mode 100644
index 0000000..c71c996
--- /dev/null
+++ b/src/components/settings/pages/UtilitySettingsPages.tsx
@@ -0,0 +1,164 @@
+import { useEffect, useState } from "react";
+import { FileText, Info, Keyboard } from "lucide-react";
+import type { SettingsController } from "../hooks/useSettingsController";
+import { InlineStatus, SettingsCard, SettingsPageHeader, SettingsSection } from "../SettingsPrimitives";
+import type { SettingsModel } from "../types";
+
+export function ShortcutsSettingsPage(props: {
+ model: SettingsModel;
+ controller: SettingsController;
+}) {
+ const [toggleShortcut, setToggleShortcut] = useState(props.model.settings.toggleShortcut);
+ const [stopShortcut, setStopShortcut] = useState(props.model.settings.stopShortcut);
+ const [draftDirty, setDraftDirty] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (draftDirty) return;
+ setToggleShortcut(props.model.settings.toggleShortcut);
+ setStopShortcut(props.model.settings.stopShortcut);
+ }, [draftDirty, props.model.settings.stopShortcut, props.model.settings.toggleShortcut]);
+
+ const commit = async (shortcutsEnabled = props.model.settings.shortcutsEnabled) => {
+ const nextToggle = toggleShortcut.trim();
+ const nextStop = stopShortcut.trim();
+ if (
+ shortcutsEnabled === props.model.settings.shortcutsEnabled
+ && nextToggle === props.model.settings.toggleShortcut
+ && nextStop === props.model.settings.stopShortcut
+ ) {
+ setDraftDirty(false);
+ return;
+ }
+ setError(null);
+ try {
+ await props.controller.savePatch({
+ shortcutsEnabled,
+ toggleShortcut: nextToggle,
+ stopShortcut: nextStop,
+ });
+ setToggleShortcut(nextToggle);
+ setStopShortcut(nextStop);
+ setDraftDirty(false);
+ } catch (nextError) {
+ setError(String(nextError));
+ setDraftDirty(true);
+ }
+ };
+
+ return (
+
+ );
+}
+
+export function DiagnosticsSettingsPage(props: {
+ controller: SettingsController;
+}) {
+ return (
+
+
+
+ }
+ title="诊断与日志"
+ description="日志自动轮转,不包含录音、转写正文或 AI 请求与响应内容。"
+ >
+
+
+
+
+ );
+}
+
+export function AboutSettingsPage(props: { appVersion: string }) {
+ return (
+
+
+
+ }
+ title="Nota"
+ description="本地录音 · 无账号 · 无遥测"
+ >
+ v{props.appVersion}
+
+
+ Windows 11 x64
+ © 2026 Nota Contributors
+
+
+
+ );
+}
diff --git a/src/components/settings/providers/AsrProviderManager.tsx b/src/components/settings/providers/AsrProviderManager.tsx
new file mode 100644
index 0000000..4ea339a
--- /dev/null
+++ b/src/components/settings/providers/AsrProviderManager.tsx
@@ -0,0 +1,422 @@
+import { useEffect, useMemo, useState } from "react";
+import {
+ AlertTriangle,
+ CheckCircle2,
+ LoaderCircle,
+ RefreshCw,
+ Trash2,
+ Wifi,
+} from "lucide-react";
+import type {
+ AsrModel,
+ AsrProvider,
+ AsrProviderKind,
+ AsrProviderProbeRequest,
+} from "../../../types";
+import type { SettingsController } from "../hooks/useSettingsController";
+import { InlineStatus, SelectControl, type ProviderFeedback } from "../SettingsPrimitives";
+import type { SettingsModel } from "../types";
+import { ProviderManagerShell, ResourceEmptyState } from "./ProviderManagerShell";
+
+interface AsrProviderDraft {
+ id: string | null;
+ name: string;
+ kind: AsrProviderKind;
+ baseUrl: string;
+ apiKey: string;
+ modelId: string;
+ hasApiKey: boolean;
+}
+
+const KEY_MASK = "••••••••";
+const DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/api/v1";
+const DASHSCOPE_MODEL = "qwen-audio-3.0-asr-flash-filetrans";
+
+const emptyDraft = (): AsrProviderDraft => ({
+ id: null,
+ name: "本地 FunASR",
+ kind: "funAsr",
+ baseUrl: "http://127.0.0.1:8000/v1",
+ apiKey: "",
+ modelId: "sensevoice",
+ hasApiKey: false,
+});
+
+const toDraft = (provider: AsrProvider): AsrProviderDraft => ({
+ id: provider.id,
+ name: provider.name,
+ kind: provider.kind,
+ baseUrl: provider.baseUrl,
+ apiKey: provider.hasApiKey ? KEY_MASK : "",
+ modelId: provider.modelId,
+ hasApiKey: provider.hasApiKey,
+});
+
+const keyUpdate = (draft: AsrProviderDraft) =>
+ draft.hasApiKey && draft.apiKey === KEY_MASK
+ ? ({ kind: "keep" } as const)
+ : draft.apiKey
+ ? ({ kind: "replace", value: draft.apiKey } as const)
+ : ({ kind: "clear" } as const);
+
+const probeRequest = (draft: AsrProviderDraft): AsrProviderProbeRequest => ({
+ id: draft.id,
+ kind: draft.kind,
+ baseUrl: draft.baseUrl,
+ modelId: draft.modelId,
+ apiKey: keyUpdate(draft),
+});
+
+const kindLabel = (kind: AsrProviderKind) => {
+ if (kind === "funAsr") return "FunASR";
+ if (kind === "dashScope") return "阿里云千问";
+ return "Compatible";
+};
+
+const applyKind = (draft: AsrProviderDraft, kind: AsrProviderKind): AsrProviderDraft => {
+ if (kind === "dashScope") {
+ return {
+ ...draft,
+ kind,
+ name: draft.id ? draft.name : "千问云转写",
+ baseUrl: DASHSCOPE_BASE_URL,
+ modelId: DASHSCOPE_MODEL,
+ };
+ }
+ if (kind === "funAsr") {
+ return {
+ ...draft,
+ kind,
+ name: draft.id ? draft.name : "本地 FunASR",
+ baseUrl: "http://127.0.0.1:8000/v1",
+ modelId: "sensevoice",
+ };
+ }
+ return {
+ ...draft,
+ kind,
+ name: draft.id ? draft.name : "兼容转写服务",
+ baseUrl: "http://127.0.0.1:8000/v1",
+ modelId: "whisper-1",
+ };
+};
+
+export function AsrProviderManager(props: {
+ model: SettingsModel;
+ controller: SettingsController;
+ onBack: () => void;
+ onDirtyChange: (dirty: boolean) => void;
+}) {
+ const initialProvider = props.model.asrProviders.find(
+ (provider) => provider.id === props.model.settings.activeAsrProviderId,
+ ) ?? props.model.asrProviders[0];
+ const [draft, setDraft] = useState(
+ initialProvider ? toDraft(initialProvider) : null,
+ );
+ const [baseline, setBaseline] = useState(
+ initialProvider ? toDraft(initialProvider) : null,
+ );
+ const [models, setModels] = useState([]);
+ const [busy, setBusy] = useState(false);
+ const [feedback, setFeedback] = useState(null);
+
+ const dirty = useMemo(
+ () => JSON.stringify(draft) !== JSON.stringify(baseline),
+ [baseline, draft],
+ );
+
+ useEffect(() => props.onDirtyChange(dirty), [dirty, props.onDirtyChange]);
+ useEffect(() => () => props.onDirtyChange(false), [props.onDirtyChange]);
+
+ useEffect(() => {
+ if (draft?.id && !props.model.asrProviders.some((provider) => provider.id === draft.id)) {
+ setDraft(null);
+ setBaseline(null);
+ setModels([]);
+ }
+ }, [draft?.id, props.model.asrProviders]);
+
+ const confirmDiscard = () => !dirty || confirm("服务配置尚未保存。放弃这些更改吗?");
+
+ const selectDraft = (next: AsrProviderDraft) => {
+ if (!confirmDiscard()) return;
+ setDraft(next);
+ setBaseline(next.id ? next : null);
+ setModels([]);
+ setFeedback(null);
+ };
+
+ const changeDraft = (next: AsrProviderDraft, clearModels = true) => {
+ setDraft(next);
+ if (clearModels) setModels([]);
+ setFeedback(null);
+ };
+
+ const saveProvider = async () => {
+ if (!draft) return;
+ setBusy(true);
+ setFeedback({ tone: "loading", message: "正在保存服务配置…" });
+ try {
+ const saved = await props.controller.saveAsrProvider({
+ id: draft.id,
+ name: draft.name,
+ kind: draft.kind,
+ baseUrl: draft.baseUrl,
+ modelId: draft.modelId,
+ apiKey: keyUpdate(draft),
+ });
+ const next = toDraft(saved);
+ setDraft(next);
+ setBaseline(next);
+ setFeedback({ tone: "success", message: "服务配置已保存。" });
+ } catch (error) {
+ setFeedback({ tone: "error", message: String(error) });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const testConnection = async () => {
+ if (!draft) return;
+ setBusy(true);
+ setFeedback({ tone: "loading", message: "正在连接服务并检查接口…" });
+ try {
+ const result = await props.controller.testAsrProvider(probeRequest(draft));
+ setModels(result.models);
+ if (!draft.modelId.trim() && result.models.length === 1) {
+ setDraft({ ...draft, modelId: result.models[0].id });
+ }
+ setFeedback({
+ tone: result.level,
+ message: [
+ result.message,
+ result.device ? `设备:${result.device}` : null,
+ result.models.length ? `发现 ${result.models.length} 个模型` : null,
+ ].filter(Boolean).join(" · "),
+ });
+ } catch (error) {
+ setFeedback({ tone: "error", message: `连接失败:${String(error)}` });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const loadModels = async () => {
+ if (!draft) return;
+ setBusy(true);
+ setFeedback({ tone: "loading", message: "正在从服务读取模型列表…" });
+ try {
+ const next = await props.controller.listAsrModels(probeRequest(draft));
+ setModels(next);
+ if (!draft.modelId.trim() && next.length === 1) setDraft({ ...draft, modelId: next[0].id });
+ setFeedback(next.length
+ ? { tone: "success", message: `已读取 ${next.length} 个模型,请从列表选择。` }
+ : { tone: "warning", message: "服务没有返回模型,仍可手工填写模型 ID。" });
+ } catch (error) {
+ setModels([]);
+ setFeedback({
+ tone: "error",
+ message: `无法获取模型列表:${String(error)}。仍可手工填写模型 ID。`,
+ });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const deleteProvider = async () => {
+ if (!draft?.id || !confirm(`删除语音转写服务“${draft.name}”?`)) return;
+ setBusy(true);
+ try {
+ await props.controller.deleteAsrProvider(draft.id);
+ const nextProvider = props.model.asrProviders.find((provider) => provider.id !== draft.id);
+ const next = nextProvider ? toDraft(nextProvider) : null;
+ setDraft(next);
+ setBaseline(next);
+ setFeedback(null);
+ } catch (error) {
+ setFeedback({ tone: "error", message: String(error) });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const requestBack = () => {
+ if (confirmDiscard()) props.onBack();
+ };
+
+ const persistedList = props.model.asrProviders.map((provider) => {
+ const active = provider.id === props.model.settings.activeAsrProviderId;
+ const selected = provider.id === draft?.id;
+ return (
+
+ );
+ });
+ const list = (
+ <>
+ {draft?.id === null && (
+
+ )}
+ {persistedList}
+ >
+ );
+
+ return (
+ selectDraft(emptyDraft())}
+ list={list}
+ detail={draft ? (
+
+ ) : (
+ selectDraft(emptyDraft())}
+ />
+ )}
+ />
+ );
+}
diff --git a/src/components/settings/providers/LlmProviderManager.tsx b/src/components/settings/providers/LlmProviderManager.tsx
new file mode 100644
index 0000000..47a744a
--- /dev/null
+++ b/src/components/settings/providers/LlmProviderManager.tsx
@@ -0,0 +1,382 @@
+import { useEffect, useMemo, useState } from "react";
+import {
+ AlertTriangle,
+ CheckCircle2,
+ LoaderCircle,
+ RefreshCw,
+ Trash2,
+ Wifi,
+} from "lucide-react";
+import { isOfficialOpenAiUrl, llmProviderReady, OPENAI_API_ROOT } from "../../../llm";
+import type {
+ LlmModel,
+ LlmProvider,
+ LlmProviderKind,
+ LlmProviderProbeRequest,
+} from "../../../types";
+import type { SettingsController } from "../hooks/useSettingsController";
+import { InlineStatus, SelectControl, type ProviderFeedback } from "../SettingsPrimitives";
+import type { SettingsModel } from "../types";
+import { ProviderManagerShell, ResourceEmptyState } from "./ProviderManagerShell";
+
+interface LlmProviderDraft {
+ id: string | null;
+ name: string;
+ kind: LlmProviderKind;
+ baseUrl: string;
+ apiKey: string;
+ hasApiKey: boolean;
+ modelId: string;
+ inputTokenBudget: number;
+ maxOutputTokens: number;
+}
+
+const KEY_MASK = "••••••••";
+
+const emptyDraft = (): LlmProviderDraft => ({
+ 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): LlmProviderDraft => ({
+ 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: LlmProviderDraft) =>
+ draft.hasApiKey && draft.apiKey === KEY_MASK
+ ? ({ kind: "keep" } as const)
+ : draft.apiKey
+ ? ({ kind: "replace", value: draft.apiKey } as const)
+ : ({ kind: "clear" } as const);
+
+const probeRequest = (draft: LlmProviderDraft): LlmProviderProbeRequest => ({
+ id: draft.id,
+ kind: draft.kind,
+ baseUrl: draft.baseUrl,
+ modelId: draft.modelId,
+ inputTokenBudget: draft.inputTokenBudget,
+ maxOutputTokens: draft.maxOutputTokens,
+ apiKey: keyUpdate(draft),
+});
+
+const kindLabel = (kind: LlmProviderKind) => kind === "openAi" ? "Responses" : "Compatible";
+
+export function LlmProviderManager(props: {
+ model: SettingsModel;
+ controller: SettingsController;
+ onBack: () => void;
+ onDirtyChange: (dirty: boolean) => void;
+}) {
+ const initialProvider = props.model.llmProviders.find(
+ (provider) => provider.id === props.model.settings.activeLlmProviderId,
+ ) ?? props.model.llmProviders[0];
+ const [draft, setDraft] = useState(
+ initialProvider ? toDraft(initialProvider) : null,
+ );
+ const [baseline, setBaseline] = useState(
+ initialProvider ? toDraft(initialProvider) : null,
+ );
+ const [models, setModels] = useState([]);
+ const [busy, setBusy] = useState(false);
+ const [feedback, setFeedback] = useState(null);
+
+ const dirty = useMemo(
+ () => JSON.stringify(draft) !== JSON.stringify(baseline),
+ [baseline, draft],
+ );
+ useEffect(() => props.onDirtyChange(dirty), [dirty, props.onDirtyChange]);
+ useEffect(() => () => props.onDirtyChange(false), [props.onDirtyChange]);
+
+ useEffect(() => {
+ if (draft?.id && !props.model.llmProviders.some((provider) => provider.id === draft.id)) {
+ setDraft(null);
+ setBaseline(null);
+ setModels([]);
+ }
+ }, [draft?.id, props.model.llmProviders]);
+
+ const confirmDiscard = () => !dirty || confirm("模型服务配置尚未保存。放弃这些更改吗?");
+ const selectDraft = (next: LlmProviderDraft) => {
+ if (!confirmDiscard()) return;
+ setDraft(next);
+ setBaseline(next.id ? next : null);
+ setModels([]);
+ setFeedback(null);
+ };
+ const changeDraft = (next: LlmProviderDraft, clearModels = true) => {
+ setDraft(next);
+ if (clearModels) setModels([]);
+ setFeedback(null);
+ };
+
+ const saveProvider = async () => {
+ if (!draft) return;
+ setBusy(true);
+ setFeedback({ tone: "loading", message: "正在保存 AI 模型服务…" });
+ try {
+ const saved = await props.controller.saveLlmProvider({
+ id: draft.id,
+ name: draft.name,
+ kind: draft.kind,
+ baseUrl: draft.baseUrl,
+ modelId: draft.modelId,
+ inputTokenBudget: draft.inputTokenBudget,
+ maxOutputTokens: draft.maxOutputTokens,
+ apiKey: keyUpdate(draft),
+ });
+ const next = toDraft(saved);
+ setDraft(next);
+ setBaseline(next);
+ setFeedback({
+ tone: llmProviderReady(saved) ? "success" : "warning",
+ message: llmProviderReady(saved)
+ ? "AI 模型服务已保存。"
+ : "服务已保存;OpenAI 官方服务需配置 API Key 后才能用于生成。",
+ });
+ } catch (error) {
+ setFeedback({ tone: "error", message: String(error) });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const testConnection = async () => {
+ if (!draft) return;
+ setBusy(true);
+ setFeedback({ tone: "loading", message: "正在发送不含会议内容的测试请求…" });
+ try {
+ const result = await props.controller.testLlmProvider(probeRequest(draft));
+ setFeedback({ tone: "success", message: result.message });
+ } catch (error) {
+ setFeedback({ tone: "error", message: String(error) });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const loadModels = async () => {
+ if (!draft) return;
+ setBusy(true);
+ setFeedback({ tone: "loading", message: "正在读取模型列表…" });
+ try {
+ const next = await props.controller.listLlmModels(probeRequest(draft));
+ setModels(next);
+ setFeedback(next.length
+ ? { tone: "success", message: `读取到 ${next.length} 个模型。` }
+ : { tone: "warning", message: "服务未返回模型,可继续手动填写模型 ID。" });
+ } catch (error) {
+ setModels([]);
+ setFeedback({ tone: "error", message: `${String(error)};仍可手动填写模型 ID。` });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const deleteProvider = async () => {
+ if (!draft?.id || !confirm(`删除 AI 模型服务“${draft.name}”?`)) return;
+ setBusy(true);
+ try {
+ await props.controller.deleteLlmProvider(draft.id);
+ const nextProvider = props.model.llmProviders.find((provider) => provider.id !== draft.id);
+ const next = nextProvider ? toDraft(nextProvider) : null;
+ setDraft(next);
+ setBaseline(next);
+ setFeedback(null);
+ } catch (error) {
+ setFeedback({ tone: "error", message: String(error) });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const persistedList = props.model.llmProviders.map((provider) => {
+ const active = provider.id === props.model.settings.activeLlmProviderId;
+ const selected = provider.id === draft?.id;
+ return (
+
+ );
+ });
+ const list = (
+ <>
+ {draft?.id === null && (
+
+ )}
+ {persistedList}
+ >
+ );
+
+ return (
+ confirmDiscard() && props.onBack()}
+ onAdd={() => selectDraft(emptyDraft())}
+ list={list}
+ detail={draft ? (
+
+
+
+
+
+
+ 测试请求不包含会议或转写内容。
+
+ {feedback && (
+
+ {feedback.tone === "loading" ?
+ : feedback.tone === "success" ?
+ : }
+ {feedback.message}
+
+ )}
+ {draft.kind === "openAi" && (
+
+
+ Responses API 会发送 store: false;这不代表零数据保留,请查看所选服务的数据控制说明。
+
+ )}
+
+
+ ) : (
+ selectDraft(emptyDraft())}
+ />
+ )}
+ />
+ );
+}
diff --git a/src/components/settings/providers/ProviderManagerShell.tsx b/src/components/settings/providers/ProviderManagerShell.tsx
new file mode 100644
index 0000000..ea9e179
--- /dev/null
+++ b/src/components/settings/providers/ProviderManagerShell.tsx
@@ -0,0 +1,57 @@
+import type { ReactNode } from "react";
+import { ArrowLeft, Plus } from "lucide-react";
+
+export function ProviderManagerShell(props: {
+ title: string;
+ description: string;
+ listTitle: string;
+ count: number;
+ onBack: () => void;
+ onAdd: () => void;
+ list: ReactNode;
+ detail: ReactNode;
+}) {
+ return (
+
+
+
+
+
{props.title}
+
{props.description}
+
+
+
+
+
+ );
+}
+
+export function ResourceEmptyState(props: {
+ title: string;
+ description: string;
+ actionLabel: string;
+ onAction: () => void;
+}) {
+ return (
+
+
{props.title}
+
{props.description}
+
+
+ );
+}
diff --git a/src/components/settings/templates/AiTemplateManager.tsx b/src/components/settings/templates/AiTemplateManager.tsx
new file mode 100644
index 0000000..f8bdc81
--- /dev/null
+++ b/src/components/settings/templates/AiTemplateManager.tsx
@@ -0,0 +1,236 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { AlertTriangle, Copy, FileText, LoaderCircle, Trash2 } from "lucide-react";
+import type { AiTemplate, SaveAiTemplateRequest } from "../../../types";
+import type { SettingsController } from "../hooks/useSettingsController";
+import { InlineStatus } from "../SettingsPrimitives";
+import { ProviderManagerShell, ResourceEmptyState } from "../providers/ProviderManagerShell";
+
+const emptyDraft = (): SaveAiTemplateRequest => ({
+ id: null,
+ name: "自定义会议模板",
+ description: "",
+ taskInstructions: "",
+ outputRequirements: "使用 Markdown 输出。",
+ requiresSpeakerLabels: false,
+});
+
+const toDraft = (template: AiTemplate): SaveAiTemplateRequest => ({
+ id: template.id,
+ name: template.name,
+ description: template.description,
+ taskInstructions: template.taskInstructions,
+ outputRequirements: template.outputRequirements,
+ requiresSpeakerLabels: template.requiresSpeakerLabels,
+});
+
+export function AiTemplateManager(props: {
+ controller: SettingsController;
+ onBack: () => void;
+ onDirtyChange: (dirty: boolean) => void;
+}) {
+ const [templates, setTemplates] = useState([]);
+ const [selected, setSelected] = useState(null);
+ const [draft, setDraft] = useState(null);
+ const [baseline, setBaseline] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ const visibleTemplates = useMemo(
+ () => templates.filter((template) => !template.archived),
+ [templates],
+ );
+ const dirty = useMemo(
+ () => JSON.stringify(draft) !== JSON.stringify(baseline),
+ [baseline, draft],
+ );
+
+ useEffect(() => props.onDirtyChange(dirty), [dirty, props.onDirtyChange]);
+ useEffect(() => () => props.onDirtyChange(false), [props.onDirtyChange]);
+
+ const refresh = useCallback(async (selectId?: string) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const next = await props.controller.listAiTemplates();
+ setTemplates(next);
+ const nextSelected = next.find((template) => template.id === selectId)
+ ?? next.find((template) => !template.archived)
+ ?? null;
+ setSelected(nextSelected);
+ const nextDraft = nextSelected && !nextSelected.builtinKey ? toDraft(nextSelected) : null;
+ setDraft(nextDraft);
+ setBaseline(nextDraft);
+ } catch (nextError) {
+ setError(String(nextError));
+ } finally {
+ setLoading(false);
+ }
+ }, [props.controller]);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ const confirmDiscard = () => !dirty || confirm("AI 模板尚未保存。放弃这些更改吗?");
+ const selectTemplate = (template: AiTemplate) => {
+ if (!confirmDiscard()) return;
+ setSelected(template);
+ const nextDraft = template.builtinKey ? null : toDraft(template);
+ setDraft(nextDraft);
+ setBaseline(nextDraft);
+ setError(null);
+ };
+ const addTemplate = () => {
+ if (!confirmDiscard()) return;
+ setSelected(null);
+ setDraft(emptyDraft());
+ setBaseline(null);
+ setError(null);
+ };
+
+ const saveTemplate = async () => {
+ if (!draft) return;
+ setBusy(true);
+ setError(null);
+ try {
+ const saved = await props.controller.saveAiTemplate(draft);
+ await refresh(saved.id);
+ } catch (nextError) {
+ setError(String(nextError));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const cloneTemplate = async (template: AiTemplate) => {
+ if (!confirmDiscard()) return;
+ const name = prompt("输入复制后的模板名称", `${template.name}(自定义)`);
+ if (!name?.trim()) return;
+ setBusy(true);
+ setError(null);
+ try {
+ const cloned = await props.controller.cloneAiTemplate(template.id, name.trim());
+ await refresh(cloned.id);
+ } catch (nextError) {
+ setError(String(nextError));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const archiveTemplate = async () => {
+ if (!selected || selected.builtinKey || !confirm(`归档模板“${selected.name}”?已有 AI 文档仍可继续使用它。`)) return;
+ setBusy(true);
+ setError(null);
+ try {
+ await props.controller.archiveAiTemplate(selected.id);
+ await refresh();
+ } catch (nextError) {
+ setError(String(nextError));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const list = visibleTemplates.map((template) => (
+
+ ));
+
+ const detail = loading ? (
+
+ ) : error && !selected && !draft ? (
+
+
+
模板读取失败:{error}
+
+
+ ) : draft ? (
+
+ ) : selected ? (
+
+
+
+
{selected.description || "暂无描述"}
+
任务指令{selected.taskInstructions}
+
输出结构与要求{selected.outputRequirements}
+
+ {error &&
{error}}
+
+
+ ) : (
+
+ );
+
+ return (
+ confirmDiscard() && props.onBack()}
+ onAdd={addTemplate}
+ list={list}
+ detail={detail}
+ />
+ );
+}
diff --git a/src/components/settings/types.ts b/src/components/settings/types.ts
new file mode 100644
index 0000000..03fe498
--- /dev/null
+++ b/src/components/settings/types.ts
@@ -0,0 +1,50 @@
+import type {
+ AppSettings,
+ AsrProvider,
+ LlmProvider,
+} from "../../types";
+import type { ToastTone } from "../ToastRegion";
+
+export type SettingsRoute =
+ | "setup"
+ | "recording"
+ | "transcription"
+ | "asrProviders"
+ | "aiDocuments"
+ | "llmProviders"
+ | "aiTemplates"
+ | "shortcuts"
+ | "diagnostics"
+ | "about";
+
+export interface SettingsModel {
+ settings: AppSettings;
+ asrProviders: AsrProvider[];
+ llmProviders: LlmProvider[];
+ microphoneCount: number;
+ appVersion: string;
+ recordingActive: boolean;
+}
+
+export interface SettingsActions {
+ onSettingsChange: (settings: AppSettings) => void;
+ onAsrProvidersChange: (providers: AsrProvider[]) => void;
+ onLlmProvidersChange: (providers: LlmProvider[]) => void;
+ onFirstRunComplete: () => void;
+ onEditorDirtyChange: (dirty: boolean) => void;
+ onToast: (tone: ToastTone, message: string) => void;
+ onError: (error: unknown) => void;
+}
+
+export interface SettingsWorkspaceProps {
+ route: SettingsRoute;
+ onRouteChange: (route: SettingsRoute) => void;
+ model: SettingsModel;
+ actions: SettingsActions;
+}
+
+export const parentSettingsRoute = (route: SettingsRoute): SettingsRoute => {
+ if (route === "asrProviders") return "transcription";
+ if (route === "llmProviders" || route === "aiTemplates") return "aiDocuments";
+ return route;
+};
diff --git a/src/styles.css b/src/styles.css
index 9e95f68..31203d5 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -301,33 +301,12 @@ h2 { margin: 0; font-size: var(--font-size-subtitle); line-height: var(--line-he
.delete-ai-documents-option span { display: grid; gap: var(--space-1); }
.delete-ai-documents-option strong { font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
.delete-ai-documents-option small { color: var(--color-text-muted); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
-.settings-group { margin-top: var(--space-4); padding: var(--space-4); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-large); background: var(--color-surface-raised); }
-.settings-heading { display: flex; align-items: center; gap: var(--space-2-5); }
-.settings-heading > svg { flex: 0 0 auto; color: var(--color-text-info); }
-.settings-heading > div { flex: 1; min-width: 0; display: grid; gap: var(--space-1); }
-.settings-heading strong { font-size: var(--font-size-body); line-height: var(--line-height-body); }
-.settings-heading span { color: var(--color-text-muted); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
-.path-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.text-button { flex: 0 0 auto; border: 0; background: transparent; color: var(--color-text-success); cursor: pointer; font-size: var(--font-size-body); line-height: var(--line-height-body); font-weight: var(--font-weight-semibold); }
-.settings-row { display: flex; align-items: center; justify-content: space-between; margin-top: var(--space-3); padding-top: var(--space-3); border-top: 1px solid var(--color-border-subtle); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
-.settings-row select { max-width: 270px; border: 1px solid var(--color-border-default); border-radius: var(--radius-medium); background: var(--color-surface-raised); padding: var(--space-2) var(--space-2); }
-.switch-label { display: flex; align-items: center; gap: var(--space-1); color: var(--color-text-secondary); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
-.switch-label input { accent-color: var(--color-text-brand); }
-.shortcut-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2-5); margin-top: var(--space-3); padding-top: var(--space-3); border-top: 1px solid var(--color-border-subtle); }
-.shortcut-grid label { display: grid; gap: var(--space-1-5); color: var(--color-text-secondary); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
-.shortcut-grid input { width: 100%; border: 1px solid var(--color-border-default); border-radius: var(--radius-medium); padding: var(--space-2) var(--space-2); outline: none; }
-.shortcut-grid input:focus { border-color: var(--color-focus-ring); box-shadow: var(--shadow-focus); }
-.tray-note { margin-top: var(--space-4); padding: var(--space-3) var(--space-3); border-radius: var(--radius-large); background: var(--color-surface-subtle); color: var(--color-text-secondary); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
-.about-group { background: var(--color-surface-primary); }
.version-badge {
flex: 0 0 auto; padding: var(--space-1) var(--space-2); border-radius: var(--radius-round);
color: var(--color-text-success); background: var(--color-surface-brand); font-size: var(--font-size-caption); line-height: var(--line-height-caption); font-weight: var(--font-weight-bold);
font-variant-numeric: tabular-nums;
}
-.about-details {
- display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); margin-top: var(--space-3); padding-top: var(--space-2-5);
- border-top: 1px solid var(--color-border-subtle); color: var(--color-text-muted); font-size: var(--font-size-caption); line-height: var(--line-height-caption);
-}
@media (max-width: 760px) {
body { min-width: 560px; }
@@ -662,93 +641,218 @@ body.capture-prompt-body {
.ai-provider-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2-5); }.ai-token-estimate { margin-top: var(--space-2-5); color: var(--color-text-muted); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }.ai-token-estimate.over { color: var(--color-text-danger); }.ai-generation-dialog footer { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-4); padding-top: var(--space-3); border-top: 1px solid var(--color-border-subtle); }
.ai-generation-tabs { margin-bottom: var(--space-3); }.ai-generation-tab-panel { min-height: 0; }.ai-request-preview { display: grid; gap: var(--space-2-5); }.ai-request-preview-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }.ai-request-preview-header > div { display: grid; gap: var(--space-0-5); }.ai-request-preview-header strong { font-size: var(--font-size-body); line-height: var(--line-height-body); }.ai-request-preview-header small { color: var(--color-text-muted); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }.ai-request-json { min-height: 280px; max-height: min(440px, 52vh); margin: 0; overflow-x: hidden; overflow-y: auto; padding: var(--space-3); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-medium); color: var(--color-text-primary); background: var(--color-surface-canvas); font-family: var(--font-family-mono); font-size: var(--font-size-caption); line-height: var(--line-height-caption); tab-size: 2; white-space: pre-wrap; overflow-wrap: anywhere; scrollbar-gutter: stable; }.ai-request-preview-state { min-height: 280px; display: flex; align-items: center; justify-content: center; gap: var(--space-2); padding: var(--space-4); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-medium); color: var(--color-text-muted); background: var(--color-surface-canvas); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }.ai-request-preview-state.error { color: var(--color-text-danger); background: var(--color-surface-warning); }.ai-request-token-estimate { display: grid; gap: var(--space-0-5); margin-top: 0; }.ai-request-token-estimate small { color: var(--color-text-subtle); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
-/* AI settings */
-.ai-settings-group { background: var(--color-surface-primary); }.ai-directory-row .button { padding: var(--space-2) var(--space-2); font-size: var(--font-size-body); line-height: var(--line-height-body); }.ai-directory-row small { max-width: 580px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.ai-template-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin-top: var(--space-4); padding-top: var(--space-4); border-top: 1px solid var(--color-border-subtle); }.ai-template-heading > div { display: flex; align-items: center; gap: var(--space-2); }.ai-template-heading span { display: grid; gap: var(--space-0-5); }.ai-template-heading strong { font-size: var(--font-size-caption); line-height: var(--line-height-caption); }.ai-template-heading small { color: var(--color-text-subtle); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
-.ai-template-list { display: grid; gap: var(--space-1-5); margin-top: var(--space-2-5); }.ai-template-list article { display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto auto; align-items: center; gap: var(--space-1-5); padding: var(--space-2) var(--space-2-5); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-medium); background: var(--color-surface-raised); }.ai-template-list article > div { min-width: 0; display: grid; gap: var(--space-0-5); }.ai-template-list strong { font-size: var(--font-size-caption); line-height: var(--line-height-caption); }.ai-template-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-subtle); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }.ai-template-list article > span { color: var(--color-text-muted); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }.ai-template-list article > button { width: 27px; height: 27px; display: grid; place-items: center; border: 0; border-radius: var(--radius-medium); color: var(--color-text-secondary); background: var(--color-surface-canvas); cursor: pointer; }
-.ai-template-editor { display: grid; gap: var(--space-2); }.ai-template-editor > label { display: grid; gap: var(--space-1); color: var(--color-text-secondary); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }.ai-template-editor input, .ai-template-editor textarea { width: 100%; padding: var(--space-2) var(--space-2); border: 1px solid var(--color-border-default); border-radius: var(--radius-medium); outline: none; background: var(--color-surface-raised); font-size: var(--font-size-body); line-height: var(--line-height-body); }.ai-template-editor textarea { resize: vertical; }
-
-/* ASR settings */
-.page-settings { padding: 0; overflow: auto; }
-.settings-workspace {
- width: min(900px, calc(100% - 48px)); min-height: 100%; margin: 0 auto;
- padding: var(--space-8) 0 0;
-}
-.settings-page-header { display: flex; align-items: end; justify-content: space-between; }
-.settings-page-header h1 { font-size: var(--font-size-subtitle); line-height: var(--line-height-subtitle); }
-.settings-page-header .muted { margin: var(--space-1) 0 0; }
-.first-run-banner, .settings-recording-note {
- display: flex; align-items: center; gap: var(--space-3); margin-top: var(--space-5); padding: var(--space-3) var(--space-4);
- border: 1px solid var(--color-border-default); border-radius: var(--radius-large); color: var(--color-text-brand); background: var(--color-surface-canvas);
-}
-.first-run-banner > div { display: grid; gap: var(--space-1); }
-.first-run-banner strong { font-size: var(--font-size-body); line-height: var(--line-height-body); }
-.first-run-banner span { color: var(--color-text-muted); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
-.settings-recording-note {
- border-color: var(--color-border-danger); color: var(--color-text-warning); background: var(--color-surface-warning); font-size: var(--font-size-caption); line-height: var(--line-height-caption);
-}
-.settings-row > span:has(small) { display: grid; gap: var(--space-1); }
-.settings-row small { color: var(--color-text-subtle); font-size: var(--font-size-caption); line-height: var(--line-height-caption); font-weight: var(--font-weight-regular); }
-.transcription-settings { background: var(--color-surface-primary); }
-.transcription-settings .settings-heading .text-button { display: flex; align-items: center; gap: var(--space-1); }
-.provider-list { display: grid; gap: var(--space-1-5); margin-top: var(--space-3); padding-top: var(--space-3); border-top: 1px solid var(--color-border-subtle); }
-.provider-list > button {
- display: flex; align-items: center; justify-content: space-between; gap: var(--space-2-5); padding: var(--space-2) var(--space-2-5);
- border: 1px solid var(--color-border-subtle); border-radius: var(--radius-medium); text-align: left; background: var(--color-surface-raised); cursor: pointer;
-}
-.provider-list > button.active { border-color: var(--color-border-strong); background: var(--color-surface-canvas); }
-.provider-list button > span:first-child { min-width: 0; display: grid; gap: var(--space-1); }
-.provider-list strong { font-size: var(--font-size-caption); line-height: var(--line-height-caption); }.provider-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-subtle); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
-.provider-list button > span:last-child { color: var(--color-text-muted); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
-.provider-editor { margin-top: var(--space-3); padding: var(--space-3); border: 1px solid var(--color-border-default); border-radius: var(--radius-large); background: var(--color-surface-primary); }
-.provider-editor-title { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-2-5); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
-.provider-form { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2-5); }
-.provider-form label { display: grid; gap: var(--space-1); color: var(--color-text-secondary); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
-.provider-form .wide { grid-column: 1 / -1; }
-.provider-form input, .provider-form select {
- width: 100%; min-width: 0; padding: var(--space-2) var(--space-2); border: 1px solid var(--color-border-default); border-radius: var(--radius-medium); outline: none; background: var(--color-surface-raised); font-size: var(--font-size-caption); line-height: var(--line-height-caption);
-}
-.provider-form input:focus, .provider-form select:focus { border-color: var(--color-focus-ring); box-shadow: var(--shadow-focus); }
-.model-fetch-row, .provider-test-row {
- display: flex; align-items: center; gap: var(--space-2-5);
-}
-.model-fetch-row .button, .provider-test-row .button { flex: 0 0 auto; padding: var(--space-2) var(--space-2-5); font-size: var(--font-size-body); line-height: var(--line-height-body); }
-.model-fetch-row > span, .provider-test-row > span { color: var(--color-text-subtle); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
-.returned-models { margin-top: var(--space-0-5); }
-.upload-disclosure { display: flex; align-items: flex-start; gap: var(--space-1-5); margin: var(--space-2) 0 0; color: var(--color-text-secondary); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
-.provider-test-row { margin-top: var(--space-3); padding-top: var(--space-3); border-top: 1px solid var(--color-border-subtle); }
-.provider-feedback {
- display: flex; align-items: flex-start; gap: var(--space-2); margin-top: var(--space-2); padding: var(--space-2) var(--space-2-5);
- border: 1px solid transparent; border-radius: var(--radius-medium); font-size: var(--font-size-caption); line-height: var(--line-height-caption);
-}
-.provider-feedback svg { flex: 0 0 auto; margin-top: var(--space-0-5); }
-.feedback-loading { color: var(--color-text-info); border-color: var(--color-border-subtle); background: var(--color-surface-subtle); }
-.feedback-success { color: var(--color-text-success); border-color: var(--color-border-default); background: var(--color-surface-subtle); }
-.feedback-warning { color: var(--color-text-warning); border-color: var(--color-border-danger); background: var(--color-surface-warning); }
-.feedback-error { color: var(--color-text-danger); border-color: var(--color-border-danger); background: var(--color-surface-warning); }
-.provider-actions { display: flex; gap: var(--space-2); margin-top: var(--space-3); }
-.provider-actions .button { padding: var(--space-2) var(--space-2-5); font-size: var(--font-size-body); line-height: var(--line-height-body); }
-.provider-save { margin-left: auto; }
.danger-button { color: var(--color-text-danger); border-color: var(--color-border-danger); background: var(--color-surface-primary); }
-.upload-disclosure { margin-top: var(--space-3); padding-top: var(--space-3); border-top: 1px solid var(--color-border-subtle); color: var(--color-text-secondary); }
-.settings-savebar {
- position: sticky; z-index: 4; bottom: 0; display: flex; align-items: center;
- justify-content: space-between; gap: var(--space-4); margin-top: var(--space-6); padding: var(--space-3) var(--space-0-5) var(--space-4);
- border-top: 1px solid var(--color-border-default);
- background: linear-gradient(to bottom, var(--color-surface-app-chrome), var(--color-surface-primary) 28%);
- backdrop-filter: blur(8px);
-}
-.settings-savebar > div { display: flex; align-items: center; gap: var(--space-2); }
-.settings-savebar > div:first-child { color: var(--color-text-muted); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
-.settings-savebar > div:last-child { gap: var(--space-2); }
-.settings-savebar .button { padding: var(--space-2) var(--space-3); font-size: var(--font-size-body); line-height: var(--line-height-body); }
@media (max-width: 1080px) {
.library-workspace { grid-template-columns: 310px minmax(0, 1fr); }
.transcript-pane { --transcript-gutter: 22px; }
- .settings-workspace { width: calc(100% - 36px); }
.app-footer > span:last-child { display: none; }
}
+
+/* Settings center */
+.app-content.settings-mode { grid-template-rows: minmax(0, 1fr); }
+.settings-mode .page-settings { min-height: 0; padding: 0; overflow: hidden; }
+.settings-workspace {
+ width: 100%; height: 100%; min-height: 0; display: grid;
+ grid-template-columns: 200px minmax(0, 1fr); margin: 0; padding: 0;
+ background: var(--color-surface-primary);
+}
+.settings-navigation {
+ min-width: 0; display: flex; flex-direction: column; padding: var(--space-5) var(--space-3);
+ border-right: 1px solid var(--color-border-default); background: var(--color-surface-canvas);
+}
+.settings-navigation > header { display: flex; align-items: center; gap: var(--space-2-5); padding: 0 var(--space-2) var(--space-5); }
+.settings-navigation > header > span {
+ width: 34px; height: 34px; display: grid; place-items: center; flex: 0 0 auto;
+ border-radius: var(--radius-medium); color: var(--color-text-brand); background: var(--color-surface-brand);
+}
+.settings-navigation > header > div { min-width: 0; display: grid; gap: var(--space-0-5); }
+.settings-navigation > header strong { font-size: var(--font-size-body-large); line-height: var(--line-height-body-large); }
+.settings-navigation > header small { color: var(--color-text-muted); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
+.settings-navigation nav { display: grid; gap: var(--space-1); }
+.settings-navigation nav button {
+ min-width: 0; min-height: 38px; display: flex; align-items: center; gap: var(--space-2-5);
+ padding: var(--space-2) var(--space-3); border: 0; border-radius: var(--radius-medium);
+ color: var(--color-text-secondary); background: transparent; cursor: pointer;
+ font-size: var(--font-size-body); line-height: var(--line-height-body); text-align: left;
+}
+.settings-navigation nav button:hover { color: var(--color-text-brand); background: var(--color-surface-hover); }
+.settings-navigation nav button.active { color: var(--color-text-brand); background: var(--color-surface-brand); font-weight: var(--font-weight-semibold); }
+.settings-navigation nav button:focus-visible { outline: 0; box-shadow: var(--shadow-focus); }
+.settings-navigation nav svg { flex: 0 0 auto; }
+.settings-navigation nav span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.settings-navigation > footer {
+ display: flex; align-items: center; gap: var(--space-2); margin-top: auto; padding: var(--space-3) var(--space-2) 0;
+ border-top: 1px solid var(--color-border-subtle); color: var(--color-text-muted);
+ font-size: var(--font-size-caption); line-height: var(--line-height-caption);
+}
+.settings-detail-pane { min-width: 0; min-height: 0; position: relative; overflow: hidden; background: var(--color-surface-primary); }
+.settings-preference-page {
+ width: min(840px, calc(100% - 64px)); height: 100%; min-height: 0; margin: 0 auto; padding: var(--space-8) 0;
+ overflow: auto;
+}
+.settings-detail-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: var(--space-4); }
+.settings-detail-header h1, .settings-manager-header h1 { margin: 0; font-size: var(--font-size-subtitle); line-height: var(--line-height-subtitle); }
+.settings-detail-header p, .settings-manager-header p {
+ margin: var(--space-1) 0 0; color: var(--color-text-muted);
+ font-size: var(--font-size-body); line-height: var(--line-height-body);
+}
+.settings-section { margin-top: var(--space-7); }
+.settings-section h2 { margin: 0 0 var(--space-2-5); font-size: var(--font-size-body); line-height: var(--line-height-body); }
+.settings-card-stack { overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-large); background: var(--color-surface-raised); }
+.settings-card {
+ width: 100%; min-height: 68px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4);
+ border: 0; border-bottom: 1px solid var(--color-border-subtle); color: var(--color-text-primary);
+ background: var(--color-surface-raised); text-align: left;
+}
+.settings-card:last-child { border-bottom: 0; }
+.settings-card-link { cursor: pointer; }
+.settings-card-link:hover { background: var(--color-surface-hover); }
+.settings-card-link:focus-visible { position: relative; z-index: 1; outline: 0; box-shadow: var(--shadow-focus); }
+.settings-card-icon { width: 32px; height: 32px; display: grid; place-items: center; border-radius: var(--radius-medium); color: var(--color-text-info); background: var(--color-surface-subtle); }
+.settings-card-copy { min-width: 0; display: grid; gap: var(--space-1); }
+.settings-card-copy strong { font-size: var(--font-size-body); line-height: var(--line-height-body); }
+.settings-card-copy small {
+ min-width: 0; color: var(--color-text-muted); font-size: var(--font-size-caption); line-height: var(--line-height-caption);
+ overflow-wrap: anywhere;
+}
+.settings-card-control { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: var(--space-2); color: var(--color-text-muted); }
+.settings-card-control .settings-select { width: 260px; max-width: 100%; }
+.settings-card-control input[type="checkbox"], .template-checkbox input { accent-color: var(--color-control-primary); }
+.settings-card .button, .settings-manager-header .button, .settings-resource-empty .button {
+ min-height: 34px; padding: var(--space-1-5) var(--space-3); font-size: var(--font-size-body); line-height: var(--line-height-body);
+}
+.settings-path-card .settings-card-copy small { max-width: 560px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.settings-privacy-note, .settings-setup-intro, .settings-recording-note, .settings-save-error {
+ display: flex; align-items: flex-start; gap: var(--space-2-5); margin-top: var(--space-5); padding: var(--space-3) var(--space-4);
+ border: 1px solid var(--color-border-default); border-radius: var(--radius-large); color: var(--color-text-secondary);
+ background: var(--color-surface-brand); font-size: var(--font-size-caption); line-height: var(--line-height-caption);
+}
+.settings-privacy-note svg, .settings-setup-intro svg, .settings-recording-note svg, .settings-save-error svg { flex: 0 0 auto; }
+.settings-recording-note, .settings-save-error {
+ position: absolute; z-index: 4; left: 50%; top: var(--space-3); width: min(760px, calc(100% - 48px));
+ margin: 0; transform: translateX(-50%); color: var(--color-text-warning); background: var(--color-surface-warning);
+}
+.settings-save-error { top: var(--space-14); color: var(--color-text-danger); border-color: var(--color-border-danger); }
+.settings-detail-pane:has(.settings-recording-note) .settings-preference-page,
+.settings-detail-pane:has(.settings-recording-note) .settings-manager-page { padding-top: var(--space-16); }
+.settings-setup-intro > div { display: grid; gap: var(--space-1); }
+.settings-setup-intro span { color: var(--color-text-muted); }
+.settings-setup-actions {
+ display: flex; align-items: center; justify-content: space-between; gap: var(--space-4);
+ margin-top: var(--space-7); padding-top: var(--space-4); border-top: 1px solid var(--color-border-default);
+}
+.settings-setup-actions > span { display: flex; align-items: center; gap: var(--space-2); color: var(--color-text-muted); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
+.settings-setup-actions > div { display: flex; gap: var(--space-2); }
+.settings-shortcut-fields { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); padding: var(--space-4); }
+.settings-shortcut-fields label, .provider-field-grid label, .template-field-grid label { display: grid; gap: var(--space-1); color: var(--color-text-secondary); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
+.settings-shortcut-fields input, .provider-field-grid input, .provider-field-grid select,
+.template-field-grid input, .template-field-grid textarea {
+ width: 100%; min-width: 0; border: 1px solid var(--color-border-default); border-radius: var(--radius-medium);
+ outline: 0; background: var(--color-surface-raised); font-family: var(--font-family-ui);
+ font-size: var(--font-size-body); line-height: var(--line-height-body);
+}
+.settings-shortcut-fields input, .provider-field-grid input, .provider-field-grid select, .template-field-grid input { min-height: 36px; padding: var(--space-2); }
+.template-field-grid textarea { padding: var(--space-2); resize: vertical; }
+.settings-shortcut-fields input:focus, .provider-field-grid input:focus, .provider-field-grid select:focus,
+.template-field-grid input:focus, .template-field-grid textarea:focus { border-color: var(--color-focus-ring); box-shadow: var(--shadow-focus); }
+.settings-select { min-width: 0; }
+.settings-select select {
+ appearance: none; width: 100%; padding: var(--space-3) var(--space-9) var(--space-3) var(--space-3);
+ border: 1px solid var(--color-border-default); border-radius: var(--radius-large); outline: 0;
+ color: var(--color-text-primary); background: var(--color-surface-raised); cursor: pointer;
+ font-family: var(--font-family-ui); font-size: var(--font-size-body); line-height: var(--line-height-body);
+}
+.settings-select select:focus { border-color: var(--color-focus-ring); box-shadow: var(--shadow-focus); }
+.settings-select select:disabled { color: var(--color-text-disabled); background: var(--color-surface-subtle); cursor: default; }
+.settings-inline-status {
+ display: flex; align-items: flex-start; gap: var(--space-2); margin-top: var(--space-3); padding: var(--space-2-5) var(--space-3);
+ border: 1px solid var(--color-border-default); border-radius: var(--radius-medium); color: var(--color-text-secondary);
+ background: var(--color-surface-subtle); font-size: var(--font-size-caption); line-height: var(--line-height-caption);
+}
+.settings-inline-status svg { flex: 0 0 auto; }
+.settings-inline-status.status-success { color: var(--color-text-success); background: var(--color-surface-success); }
+.settings-inline-status.status-warning { color: var(--color-text-warning); border-color: var(--color-border-danger); background: var(--color-surface-warning); }
+.settings-inline-status.status-error { color: var(--color-text-danger); border-color: var(--color-border-danger); background: var(--color-surface-danger); }
+.settings-about-details { display: flex; justify-content: space-between; gap: var(--space-4); padding: var(--space-3) var(--space-4); color: var(--color-text-muted); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
+
+.settings-manager-page { height: 100%; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); padding: var(--space-5) var(--space-5) 0; }
+.settings-manager-header {
+ display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-4);
+ padding-bottom: var(--space-4); border-bottom: 1px solid var(--color-border-default);
+}
+.settings-back-button {
+ display: flex; align-items: center; gap: var(--space-1); min-height: 34px; padding: var(--space-1-5) var(--space-2);
+ border: 0; border-radius: var(--radius-medium); color: var(--color-text-secondary); background: transparent;
+ cursor: pointer; font-size: var(--font-size-body); line-height: var(--line-height-body);
+}
+.settings-back-button:hover { color: var(--color-text-brand); background: var(--color-surface-hover); }
+.settings-back-button:focus-visible { outline: 0; box-shadow: var(--shadow-focus); }
+.settings-manager-grid { min-width: 0; min-height: 0; display: grid; grid-template-columns: 260px minmax(0, 1fr); }
+.settings-resource-pane { min-width: 0; min-height: 0; padding: var(--space-4) var(--space-3) var(--space-4) 0; border-right: 1px solid var(--color-border-default); overflow: auto; }
+.settings-resource-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); padding: 0 var(--space-2) var(--space-3); }
+.settings-resource-heading strong { font-size: var(--font-size-body); line-height: var(--line-height-body); }
+.settings-resource-heading span { min-width: 24px; padding: var(--space-0-5) var(--space-1); border-radius: var(--radius-round); color: var(--color-text-muted); background: var(--color-surface-subtle); font-size: var(--font-size-caption); line-height: var(--line-height-caption); text-align: center; }
+.settings-resource-list { display: grid; gap: var(--space-2); }
+.settings-resource-list > button {
+ min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: var(--space-2);
+ padding: var(--space-3); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-medium);
+ color: var(--color-text-primary); background: var(--color-surface-raised); cursor: pointer; text-align: left;
+}
+.settings-resource-list > button:hover { border-color: var(--color-border-default); background: var(--color-surface-hover); }
+.settings-resource-list > button.selected { padding-left: var(--space-2); border-color: var(--color-border-strong); border-left-width: var(--space-1); border-left-color: var(--color-control-primary); background: var(--color-surface-brand); }
+.settings-resource-list > button.resource-draft { border-style: dashed; }
+.settings-resource-list > button.resource-draft.selected { border-left-style: solid; }
+.settings-resource-list > button:focus-visible { outline: 0; box-shadow: var(--shadow-focus); }
+.settings-resource-list button > span { min-width: 0; display: grid; gap: var(--space-1); }
+.settings-resource-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--font-size-body); line-height: var(--line-height-body); }
+.settings-resource-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-muted); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
+.settings-resource-list em { flex: 0 0 auto; padding: var(--space-0-5) var(--space-1-5); border-radius: var(--radius-round); color: var(--color-text-success); background: var(--color-surface-success); font-size: var(--font-size-caption); line-height: var(--line-height-caption); font-style: normal; }
+.settings-resource-list em.warning { color: var(--color-text-warning); background: var(--color-surface-warning); }
+.settings-resource-list em.draft { color: var(--color-text-info); background: var(--color-surface-subtle); }
+.settings-resource-detail { min-width: 0; min-height: 0; overflow: auto; }
+.settings-resource-empty { min-height: 100%; display: grid; place-content: center; justify-items: center; gap: var(--space-2); padding: var(--space-7); text-align: center; }
+.settings-resource-empty p { max-width: 420px; margin: 0 0 var(--space-2); color: var(--color-text-muted); font-size: var(--font-size-body); line-height: var(--line-height-body); }
+.provider-detail-form { min-height: 100%; display: flex; flex-direction: column; padding: var(--space-5); }
+.provider-detail-form > header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding-bottom: var(--space-4); border-bottom: 1px solid var(--color-border-subtle); }
+.provider-detail-form > header > div { display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
+.provider-detail-form h2 { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--font-size-body-large); line-height: var(--line-height-body-large); }
+.provider-detail-form > header span { padding: var(--space-0-5) var(--space-2); border-radius: var(--radius-round); color: var(--color-text-success); background: var(--color-surface-success); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
+.provider-detail-form fieldset { min-width: 0; margin: var(--space-5) 0 0; padding: 0; border: 0; }
+.provider-detail-form legend { margin-bottom: var(--space-3); font-size: var(--font-size-body); line-height: var(--line-height-body); font-weight: var(--font-weight-semibold); }
+.provider-field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
+.provider-field-grid .wide { grid-column: 1 / -1; }
+.provider-probe-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-4); padding-top: var(--space-4); border-top: 1px solid var(--color-border-subtle); }
+.provider-probe-actions .button, .provider-detail-actions .button { min-height: 34px; padding: var(--space-1-5) var(--space-3); font-size: var(--font-size-body); line-height: var(--line-height-body); }
+.provider-probe-actions > span { color: var(--color-text-muted); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
+.provider-detail-actions {
+ display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin: auto calc(-1 * var(--space-5)) calc(-1 * var(--space-5));
+ padding: var(--space-3) var(--space-5); border-top: 1px solid var(--color-border-default); background: var(--color-surface-primary);
+}
+.provider-detail-actions > span { color: var(--color-text-muted); font-size: var(--font-size-caption); line-height: var(--line-height-caption); }
+.provider-detail-actions > div { display: flex; gap: var(--space-2); }
+.template-field-grid { display: grid; gap: var(--space-3); }
+.template-checkbox { display: flex !important; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-3); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-medium); background: var(--color-surface-subtle); }
+.template-checkbox > span { display: grid; gap: var(--space-1); }
+.template-checkbox small { color: var(--color-text-muted); font-weight: var(--font-weight-regular); }
+.template-readonly-body { display: grid; gap: var(--space-4); padding: var(--space-5) 0; }
+.template-readonly-body > p { margin: 0; color: var(--color-text-muted); }
+.template-readonly-body section { display: grid; gap: var(--space-2); }
+.template-readonly-body pre { margin: 0; padding: var(--space-3); overflow: auto; border: 1px solid var(--color-border-subtle); border-radius: var(--radius-medium); color: var(--color-text-secondary); background: var(--color-surface-subtle); font-family: var(--font-family-ui); font-size: var(--font-size-caption); line-height: var(--line-height-body); white-space: pre-wrap; }
+
+@media (max-width: 1100px) {
+ .settings-workspace { width: 100%; grid-template-columns: 168px minmax(0, 1fr); }
+ .settings-navigation { padding-right: var(--space-2); padding-left: var(--space-2); }
+ .settings-manager-page { padding-right: var(--space-3); padding-left: var(--space-3); }
+ .settings-manager-grid { grid-template-columns: 220px minmax(0, 1fr); }
+ .provider-field-grid { grid-template-columns: minmax(0, 1fr); }
+ .provider-field-grid .wide { grid-column: auto; }
+ .provider-detail-form { padding: var(--space-4); }
+ .provider-detail-actions { margin: auto calc(-1 * var(--space-4)) calc(-1 * var(--space-4)); padding-right: var(--space-4); padding-left: var(--space-4); }
+ .settings-preference-page { width: calc(100% - 40px); }
+ .settings-card-control .settings-select { width: 210px; }
+}
.modal-backdrop {
position: fixed;
inset: 0;