From 744b3b2d400ad9c744e213014f0ea626c425a60d Mon Sep 17 00:00:00 2001 From: GCWing Date: Wed, 8 Jul 2026 23:21:09 +0800 Subject: [PATCH] feat: add local voice input and workflow refinements --- AGENTS.md | 32 +- Cargo.lock | 77 +- Cargo.toml | 5 + src/apps/desktop/src/api/agentic_os_api.rs | 144 +++- src/apps/desktop/src/api/app_state.rs | 5 +- src/apps/desktop/src/api/mod.rs | 1 + src/apps/desktop/src/api/session_api.rs | 93 ++- src/apps/desktop/src/api/speech_api.rs | 196 +++++ src/apps/desktop/src/lib.rs | 10 + src/crates/core/Cargo.toml | 4 + src/crates/core/src/agentic_os/work/hooks.rs | 673 ++++++++++++++++++ src/crates/core/src/agentic_os/work/mod.rs | 7 + .../src/agentic_os/work/runtime_bridge.rs | 36 + .../core/src/agentic_os/work/service.rs | 197 ++++- .../core/src/command/agentic_os/work.rs | 9 +- .../infrastructure/app_paths/path_manager.rs | 29 + src/crates/core/src/service/config/manager.rs | 17 +- src/crates/core/src/service/config/types.rs | 25 + src/crates/core/src/service/mod.rs | 9 + src/crates/core/src/service/speech/audio.rs | 23 + .../core/src/service/speech/downloader.rs | 217 ++++++ src/crates/core/src/service/speech/mod.rs | 279 ++++++++ .../core/src/service/speech/model_catalog.rs | 43 ++ .../core/src/service/speech/model_store.rs | 213 ++++++ .../core/src/service/speech/recognizer.rs | 16 + .../src/service/speech/sensevoice_int8.rs | 167 +++++ src/crates/core/src/service/speech/types.rs | 175 +++++ .../GlobalSearchDialog.scss | 337 ++++++++- .../GlobalSearchDialog/GlobalSearchDialog.tsx | 162 +++-- .../UnifiedTopBar/UnifiedTopBar.scss | 6 - .../UnifiedTopBar/UnifiedTopBar.tsx | 20 - .../src/app/components/WorkDock/WorkDock.tsx | 4 +- .../src/app/components/WorkList/WorkList.tsx | 142 +--- .../components/WorkList/workListSelection.ts | 149 ++++ src/web-ui/src/app/layout/AppLayout.tsx | 9 +- .../scenes/daily-letter/DailyLetterScene.scss | 136 +--- .../scenes/daily-letter/DailyLetterScene.tsx | 128 ++-- .../src/app/scenes/settings/SettingsScene.tsx | 2 + .../src/app/scenes/settings/settingsConfig.ts | 18 + .../settings/settingsTabSearchContent.ts | 12 + .../work-center/WorkBoard/BoardHeader.scss | 17 + .../work-center/WorkBoard/BoardHeader.tsx | 39 +- .../work-center/WorkBoard/WorkBoard.scss | 82 ++- .../work-center/WorkBoard/WorkBoard.tsx | 74 +- .../preview/registries/primitives.tsx | 59 +- .../DateRangeDialog/DateRangeDialog.scss | 189 +++++ .../DateRangeDialog/DateRangeDialog.tsx | 385 ++++++++++ .../primitives/DateRangeDialog/index.ts | 2 + .../SqueezeSegmentedControl.scss | 142 ++++ .../SqueezeSegmentedControl.tsx | 105 +++ .../SqueezeSegmentedControl/index.ts | 1 + .../src/design-system/primitives/index.ts | 2 + .../src/flow_chat/components/ChatInput.tsx | 28 + .../flow_chat/components/RichTextInput.tsx | 7 +- .../src/flow_chat/components/WelcomePanel.css | 55 +- .../src/flow_chat/components/WelcomePanel.tsx | 105 ++- .../composer/hooks/useComposerTokenUsage.ts | 51 +- .../composer/styles/_composer-actions.scss | 107 ++- .../voice/ComposerVoiceInputButton.tsx | 139 ++++ .../composer/voice/useComposerVoiceInput.ts | 561 +++++++++++++++ .../composer/voice/voiceInputAudio.ts | 138 ++++ .../modern/AgenticOSFlowChatContainer.tsx | 35 + .../modern/AgenticOsTimelineSidebar.scss | 71 +- .../modern/AgenticOsTimelineSidebar.tsx | 480 ++++++++++--- .../modern/TurnListCustomRangeDialog.scss | 196 ----- .../modern/TurnListCustomRangeDialog.tsx | 364 ++-------- .../services/AgenticEventListener.ts | 11 +- .../services/ContextBudgetService.test.ts | 73 ++ .../services/ContextBudgetService.ts | 114 +++ .../src/flow_chat/services/FlowChatManager.ts | 140 ++-- .../store/FlowChatStore.metadata.test.ts | 79 ++ .../src/flow_chat/store/FlowChatStore.ts | 265 ++++--- .../flow_chat/tool-cards/ToolManifestSync.ts | 12 + src/web-ui/src/infrastructure/api/index.ts | 5 +- .../api/service-api/SpeechAPI.ts | 171 +++++ .../config/components/AIFeaturesConfig.scss | 6 +- .../config/components/AIFeaturesConfig.tsx | 14 +- .../config/components/AIModelConfig.tsx | 10 + .../components/ModelSelectionRadio.scss | 13 + .../config/components/ModelSelectionRadio.tsx | 132 +++- .../components/PersonalizationConfig.tsx | 8 + .../config/components/VoiceInputConfig.scss | 150 ++++ .../config/components/VoiceInputConfig.tsx | 460 ++++++++++++ .../infrastructure/config/components/index.ts | 1 + .../components/useSessionSettingsConfig.ts | 11 +- .../services/AIExperienceConfigService.ts | 19 + .../src/infrastructure/config/types/index.ts | 46 +- .../src/infrastructure/i18n/constants.ts | 1 + src/web-ui/src/locales/en-US/flow-chat.json | 30 +- .../locales/en-US/scenes/daily-letter.json | 11 +- .../locales/en-US/settings/ai-features.json | 4 +- .../src/locales/en-US/settings/ai-model.json | 12 + .../locales/en-US/settings/config-center.json | 2 + .../locales/en-US/settings/default-model.json | 4 + .../en-US/settings/personalization.json | 4 +- .../locales/en-US/settings/voice-input.json | 66 ++ src/web-ui/src/locales/zh-CN/flow-chat.json | 30 +- .../locales/zh-CN/scenes/daily-letter.json | 11 +- .../locales/zh-CN/settings/ai-features.json | 4 +- .../src/locales/zh-CN/settings/ai-model.json | 12 + .../locales/zh-CN/settings/config-center.json | 2 + .../locales/zh-CN/settings/default-model.json | 4 + .../zh-CN/settings/personalization.json | 4 +- .../locales/zh-CN/settings/voice-input.json | 66 ++ .../specs/session-startup-performance.spec.ts | 128 ++++ 105 files changed, 8092 insertions(+), 1304 deletions(-) create mode 100644 src/apps/desktop/src/api/speech_api.rs create mode 100644 src/crates/core/src/agentic_os/work/hooks.rs create mode 100644 src/crates/core/src/service/speech/audio.rs create mode 100644 src/crates/core/src/service/speech/downloader.rs create mode 100644 src/crates/core/src/service/speech/mod.rs create mode 100644 src/crates/core/src/service/speech/model_catalog.rs create mode 100644 src/crates/core/src/service/speech/model_store.rs create mode 100644 src/crates/core/src/service/speech/recognizer.rs create mode 100644 src/crates/core/src/service/speech/sensevoice_int8.rs create mode 100644 src/crates/core/src/service/speech/types.rs create mode 100644 src/web-ui/src/app/components/WorkList/workListSelection.ts create mode 100644 src/web-ui/src/design-system/primitives/DateRangeDialog/DateRangeDialog.scss create mode 100644 src/web-ui/src/design-system/primitives/DateRangeDialog/DateRangeDialog.tsx create mode 100644 src/web-ui/src/design-system/primitives/DateRangeDialog/index.ts create mode 100644 src/web-ui/src/design-system/primitives/SqueezeSegmentedControl/SqueezeSegmentedControl.scss create mode 100644 src/web-ui/src/design-system/primitives/SqueezeSegmentedControl/SqueezeSegmentedControl.tsx create mode 100644 src/web-ui/src/design-system/primitives/SqueezeSegmentedControl/index.ts create mode 100644 src/web-ui/src/flow_chat/components/composer/voice/ComposerVoiceInputButton.tsx create mode 100644 src/web-ui/src/flow_chat/components/composer/voice/useComposerVoiceInput.ts create mode 100644 src/web-ui/src/flow_chat/components/composer/voice/voiceInputAudio.ts delete mode 100644 src/web-ui/src/flow_chat/components/modern/TurnListCustomRangeDialog.scss create mode 100644 src/web-ui/src/flow_chat/services/ContextBudgetService.test.ts create mode 100644 src/web-ui/src/flow_chat/services/ContextBudgetService.ts create mode 100644 src/web-ui/src/flow_chat/store/FlowChatStore.metadata.test.ts create mode 100644 src/web-ui/src/infrastructure/api/service-api/SpeechAPI.ts create mode 100644 src/web-ui/src/infrastructure/config/components/VoiceInputConfig.scss create mode 100644 src/web-ui/src/infrastructure/config/components/VoiceInputConfig.tsx create mode 100644 src/web-ui/src/locales/en-US/settings/voice-input.json create mode 100644 src/web-ui/src/locales/zh-CN/settings/voice-input.json create mode 100644 tests/e2e/specs/session-startup-performance.spec.ts diff --git a/AGENTS.md b/AGENTS.md index b26e1580..52a96d56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,20 +54,23 @@ pnpm run cli:check # Rust check for the CLI crate pnpm run e2e:test # WebDriverIO E2E suite in debug app mode ``` -For Rust-only changes, run the narrowest useful `cargo check` or `cargo test` command for the crate touched. +Use the verification strategy below to choose checks for Rust, Web UI, design-system, locale, and E2E work. -## Fast Verification Loop +## Verification Strategy -Use E2E as a focused end-to-end feedback loop for large or complex work: new product flows, multi-surface UI features, desktop/Web UI integration, or bugs that have already resisted one or two simpler fixes. In those cases, write or update one small spec that exercises the exact workflow being implemented or repaired, then iterate against that spec until the behavior is correct: +Verification is risk-based. Prefer the cheapest check that gives real confidence for the changed boundary; do not run heavy checks by default. -```bash -pnpm run e2e:test:spec -- tests/e2e/specs/.spec.ts -pnpm run e2e:test:spec:dev -- tests/e2e/specs/.spec.ts # when Tauri dev/watch should rebuild Rust -``` - -Keep the E2E proportional: small copy/style/type-only edits usually need only the cheapest relevant check, such as `pnpm run check:web:fast`, `pnpm run type-check:web`, or the narrowest `cargo check`/`cargo test`. For E2E-critical UI controls, add stable `data-testid` hooks. If the focused spec exposes stale helper infrastructure, repair the helper/spec so the e2e validation remains trustworthy. - -Use broader suites such as `pnpm run e2e:test:l0`, `pnpm run e2e:test:l0:all`, `pnpm run e2e:test:l1`, or `pnpm run e2e:test` when the change spans their surface area or before a release-style handoff. +- Skip automated checks for low-risk docs, comments, prompts, copy, logs, and obvious mechanical edits. Briefly explain when checks are skipped. +- Run formatting only when formatting may be affected, and only after edits have settled. +- Run narrow static checks only when compiler/type feedback materially reduces risk: + - Rust: use the narrowest useful `cargo check` for the affected crate or product surface. + - Web UI: use `pnpm run type-check:web` for meaningful TS/React logic changes. + - Locales: use `pnpm run check:i18n` when locale files or keys change. + - Design system: use `pnpm run check:design-system` when reusable design-system contracts change. +- If a higher-level check already compiles the touched lower-level crate, do not also run the lower-level check. +- Run tests only for changed behavior, using exact test names or the narrowest useful filter. +- Use focused E2E only for high-risk product flows, cross-surface integration, repeated regressions, or when requested. +- Avoid full builds, broad test suites, desktop builds, web builds, and full E2E unless requested, release-critical, or cheaper checks are insufficient. ## Critical Rules @@ -166,11 +169,11 @@ When adding or changing reusable UI: - Follow `src/web-ui/src/design-system/AGENTS.md`. - Start from the closest recipe in `src/web-ui/src/design-system/recipes/`. - Register deterministic preview coverage in `src/web-ui/src/design-system/preview/registries`. -- Run `pnpm run check:design-system`; use `pnpm run preview:design-system` or `pnpm run build:design-system` when visual coverage changes. +- Use `pnpm run check:design-system` when reusable design-system contracts change; use `pnpm run preview:design-system` or `pnpm run build:design-system` only when visual coverage changes and cheaper checks are insufficient. Keep UI text translated when the surrounding feature is localized. Add or update both `en-US` and `zh-CN` locale entries when introducing user-visible strings. For locale file organization and maintenance rules, follow `src/web-ui/src/locales/AGENTS.md`. -Run `pnpm run check:i18n` after locale changes. `pnpm run type-check:web` and `pnpm run build:web` now include this check through the root script chain. +Use `pnpm run check:i18n` when locale files or keys change. `pnpm run type-check:web` and `pnpm run build:web` also include this check through the root script chain. Locale files are organized by product surface. Use `scenes/*` for scene-level UI, `panels/*` for docked or embedded panels, `settings/*` for durable settings subpages, `shell/*` for global chrome and navigation, and `flow-chat/*` for larger chat subdomains. Keep `common.json` for text reused across multiple product areas. @@ -197,8 +200,7 @@ Agents: - Keep edits scoped to the requested task. - Prefer existing project patterns over new abstractions. - Avoid generated files unless the task requires regeneration. -- For frontend or UI work, run at least `pnpm run type-check:web` or explain why it was not run. -- For backend work, run the narrowest useful Rust check/test or explain why it was not run. +- Choose verification using the risk-based strategy above, and briefly explain skipped checks when relevant. ## Quick Debugging Reference diff --git a/Cargo.lock b/Cargo.lock index 830a6f01..36df5b55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2895,7 +2895,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -5377,7 +5377,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -5634,6 +5634,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -6157,6 +6158,28 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +[[package]] +name = "sherpa-onnx" +version = "1.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b142d3f255cb4e4b7808ea25869db6f5714e0a3550da355234483b4db552055" +dependencies = [ + "serde", + "serde_json", + "sherpa-onnx-sys", +] + +[[package]] +name = "sherpa-onnx-sys" +version = "1.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc951af03dc0653c0622158ca8a585a6f2bc43b7b06048cf0e5b5020005c227" +dependencies = [ + "bzip2", + "tar", + "ureq", +] + [[package]] name = "shlex" version = "1.3.0" @@ -6356,6 +6379,7 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "bzip2", "chrono", "chrono-tz", "cli-credential", @@ -6367,6 +6391,7 @@ dependencies = [ "flate2", "fluent-bundle", "futures", + "futures-util", "glob", "globset", "grep-regex", @@ -6395,12 +6420,14 @@ dependencies = [ "serde_json", "serde_yaml", "sha2", + "sherpa-onnx", "similar", "sparo-ai-adapters", "sparo-events", "sparo-relay-server", "sparo-transport", "sse-stream", + "tar", "tauri", "thiserror 1.0.69", "tokio", @@ -6773,6 +6800,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -7911,6 +7949,22 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -8371,6 +8425,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -9288,6 +9351,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xcb" version = "1.7.0" diff --git a/Cargo.toml b/Cargo.toml index 4eb1e876..04179af9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,8 @@ dunce = "1" filetime = "0.2" zip = "0.6" # plugin load flate2 = "1.0" +bzip2 = "0.4" +tar = "0.4" toml = "0.8" # Terminal @@ -141,6 +143,9 @@ qrcode = "0.14" # Remote Connect relay WebSocket client tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } +# Local speech recognition +sherpa-onnx = "1.13.4" + [profile.dev] incremental = true diff --git a/src/apps/desktop/src/api/agentic_os_api.rs b/src/apps/desktop/src/api/agentic_os_api.rs index fb19ac11..6fcc1483 100644 --- a/src/apps/desktop/src/api/agentic_os_api.rs +++ b/src/apps/desktop/src/api/agentic_os_api.rs @@ -1,23 +1,157 @@ +use std::collections::BTreeMap; use std::sync::Arc; +use crate::api::app_state::AppState; +use async_trait::async_trait; use serde::{Deserialize, Serialize}; use sparo_core::agentic::coordination::{ConversationCoordinator, DialogScheduler}; use sparo_core::agentic_os::work::{ - default_work_store, AgenticWorkRuntimeBridge, WorkExecutionGraph, WorkId, WorkService, + default_work_store, AgenticWorkRuntimeBridge, WorkCleanupAction, WorkCleanupItem, + WorkCleanupItemReport, WorkCleanupItemStatus, WorkExecutionGraph, WorkId, WorkLifecycleHookBus, + WorkLifecycleHookContext, WorkLifecycleHookHandler, WorkLifecycleHookKind, + WorkLifecycleHookOutcome, WorkLifecycleHookPhase, WorkResourceOwnership, WorkResourceRef, + WorkService, }; use sparo_core::command::agentic_os as agentic_os_command; +use sparo_core::error::CoreResult; +use sparo_core::product_app_runtime_host::ProductAppRuntimeHostWorkerPool; use tauri::State; fn work_service( coordinator: &Arc, scheduler: &Arc, +) -> Result { + work_service_with_hook_bus( + coordinator, + scheduler, + WorkLifecycleHookBus::default_handlers(), + ) +} + +fn work_service_with_desktop_hooks( + coordinator: &Arc, + scheduler: &Arc, + state: &AppState, +) -> Result { + work_service_with_hook_bus( + coordinator, + scheduler, + WorkLifecycleHookBus::default_handlers_with(vec![Arc::new( + ProductRuntimeWorkerLifecycleHook::new(state.js_worker_pool.clone()), + )]), + ) +} + +fn work_service_with_hook_bus( + coordinator: &Arc, + scheduler: &Arc, + hook_bus: WorkLifecycleHookBus, ) -> Result { let store = default_work_store().map_err(|error| error.to_string())?; let runtime = Arc::new(AgenticWorkRuntimeBridge::new( coordinator.clone(), scheduler.clone(), )); - Ok(WorkService::with_runtime_bridge(store, runtime)) + Ok(WorkService::with_lifecycle_hooks(store, runtime, hook_bus)) +} + +struct ProductRuntimeWorkerLifecycleHook { + worker_pool: Option>, +} + +impl ProductRuntimeWorkerLifecycleHook { + fn new(worker_pool: Option>) -> Self { + Self { worker_pool } + } + + fn worker_id(work_id: &str, runtime_instance_id: &str) -> String { + format!("product-app-runtime:{}:{}", work_id, runtime_instance_id) + } +} + +const PRODUCT_RUNTIME_WORKER_HOOK_PHASES: &[WorkLifecycleHookPhase] = &[ + WorkLifecycleHookPhase::Plan, + WorkLifecycleHookPhase::Prepare, +]; + +#[async_trait] +impl WorkLifecycleHookHandler for ProductRuntimeWorkerLifecycleHook { + fn id(&self) -> &'static str { + "product_runtime_worker" + } + + fn phases(&self) -> &'static [WorkLifecycleHookPhase] { + PRODUCT_RUNTIME_WORKER_HOOK_PHASES + } + + async fn handle( + &self, + context: &WorkLifecycleHookContext, + hook: &WorkLifecycleHookKind, + ) -> CoreResult { + match hook { + WorkLifecycleHookKind::DeleteRequested { .. } => { + let items = context + .work + .runtime_instances + .iter() + .map(|instance| { + let worker_id = + Self::worker_id(context.work.id.as_str(), instance.id.as_str()); + let mut metadata = BTreeMap::new(); + metadata.insert("runtime_instance_id".to_string(), instance.id.clone()); + metadata.insert( + "product_app_id".to_string(), + instance.product_app_id.clone(), + ); + metadata.insert( + "product_app_surface_id".to_string(), + instance.product_app_surface_id.clone(), + ); + WorkCleanupItem { + id: format!("product-runtime-worker:{}", worker_id), + handler_id: self.id().to_string(), + resource: WorkResourceRef { + kind: "product_runtime_worker".to_string(), + id: worker_id, + ownership: WorkResourceOwnership::Owned, + metadata, + }, + action: WorkCleanupAction::Stop, + required: false, + } + }) + .collect(); + Ok(WorkLifecycleHookOutcome::CleanupPlan(items)) + } + WorkLifecycleHookKind::Deleting { plan } => { + let mut reports = Vec::new(); + let Some(worker_pool) = &self.worker_pool else { + for item in &plan.items { + reports.push(WorkCleanupItemReport { + item: item.clone(), + status: WorkCleanupItemStatus::Skipped, + message: Some( + "Product runtime worker pool is not initialized".to_string(), + ), + }); + } + return Ok(WorkLifecycleHookOutcome::CleanupReport(reports)); + }; + + for item in &plan.items { + worker_pool.stop(&item.resource.id).await; + reports.push(WorkCleanupItemReport { + item: item.clone(), + status: WorkCleanupItemStatus::Succeeded, + message: None, + }); + } + Ok(WorkLifecycleHookOutcome::CleanupReport(reports)) + } + WorkLifecycleHookKind::Deleted { .. } => Ok(WorkLifecycleHookOutcome::Continue), + } + } } #[tauri::command] @@ -40,9 +174,13 @@ pub async fn agentic_os_get_work( #[tauri::command] pub async fn agentic_os_delete_work( + state: State<'_, AppState>, + coordinator: State<'_, Arc>, + scheduler: State<'_, Arc>, request: agentic_os_command::AgenticOsDeleteWorkRequest, ) -> Result { - agentic_os_command::delete_work(request) + let service = work_service_with_desktop_hooks(&coordinator, &scheduler, &state)?; + agentic_os_command::delete_work_with_service(&service, request) .await .map_err(|error| error.to_string()) } diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index f99e5f07..bcf95e7d 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -9,7 +9,7 @@ use sparo_core::product_app_runtime_host::{ initialize_global_product_app_runtime_host_manager, ProductAppRuntimeHostManager, ProductAppRuntimeHostWorkerPool, }; -use sparo_core::service::{announcement, config, filesystem, mcp, token_usage, workspace}; +use sparo_core::service::{announcement, config, filesystem, mcp, speech, token_usage, workspace}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -45,6 +45,7 @@ pub struct AppState { pub filesystem_service: Arc, pub agent_registry: Arc, pub mcp_service: Option>, + pub speech_service: Arc, pub token_usage_service: Arc, pub product_app_runtime_host_manager: Arc, pub js_worker_pool: Option>, @@ -96,6 +97,7 @@ impl AppState { } }; let path_manager = workspace_service.path_manager().clone(); + let speech_service = Arc::new(speech::SpeechService::new(path_manager.as_ref().clone())); let announcement_scheduler = Arc::new( announcement::AnnouncementScheduler::new(&path_manager) @@ -178,6 +180,7 @@ impl AppState { filesystem_service, agent_registry, mcp_service, + speech_service, token_usage_service, product_app_runtime_host_manager, js_worker_pool, diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index dc601a4d..62c5da25 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -41,6 +41,7 @@ pub mod session_api; pub mod session_storage_path; pub mod skill_api; pub mod snapshot_service; +pub mod speech_api; pub mod storage_commands; pub mod subagent_api; pub mod system_api; diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index 24563199..942d554b 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -20,7 +20,8 @@ use sparo_core::service::session::{ use sparo_core::util::types::ToolDefinition; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; use tauri::State; use tokio::fs; @@ -111,6 +112,78 @@ pub struct GetContextBudgetRequest { pub storage_scope: Option, } +#[derive(Clone)] +struct CachedContextBudget { + snapshot: ContextBudgetSnapshot, + stored_at: Instant, +} + +const CONTEXT_BUDGET_CACHE_TTL: Duration = Duration::from_secs(30); +static CONTEXT_BUDGET_CACHE: OnceLock>> = + OnceLock::new(); + +fn context_budget_cache() -> &'static Mutex> { + CONTEXT_BUDGET_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn normalized_context_budget_workspace_key(workspace_path: Option<&Path>) -> String { + let Some(path) = workspace_path else { + return "none".to_string(); + }; + let normalized = path.to_string_lossy().replace('\\', "/"); + if cfg!(windows) { + normalized.to_ascii_lowercase() + } else { + normalized + } +} + +fn context_budget_cache_key( + session_id: &str, + agent_type: &str, + model_name: &str, + provider: &str, + context_window: usize, + storage_scope: Option, + workspace_path: Option<&Path>, +) -> String { + [ + session_id.to_string(), + agent_type.to_string(), + model_name.to_string(), + provider.to_string(), + context_window.to_string(), + format!("{:?}", storage_scope), + normalized_context_budget_workspace_key(workspace_path), + ] + .join("\u{1f}") +} + +fn cached_context_budget_snapshot(cache_key: &str) -> Option { + let mut cache = context_budget_cache().lock().ok()?; + let entry = cache.get(cache_key)?; + if entry.stored_at.elapsed() > CONTEXT_BUDGET_CACHE_TTL { + cache.remove(cache_key); + return None; + } + let mut snapshot = entry.snapshot.clone(); + snapshot.id = uuid::Uuid::new_v4().to_string(); + snapshot.created_at = chrono::Utc::now().timestamp_millis() as u64; + Some(snapshot) +} + +fn store_context_budget_snapshot(cache_key: String, snapshot: &ContextBudgetSnapshot) { + if let Ok(mut cache) = context_budget_cache().lock() { + cache.insert( + cache_key, + CachedContextBudget { + snapshot: snapshot.clone(), + stored_at: Instant::now(), + }, + ); + } +} + fn legacy_os_agent_workspace_roots(path_manager: &PathManager) -> Vec { let _ = path_manager; Vec::new() @@ -475,6 +548,18 @@ pub async fn get_context_budget( } }); let workspace = workspace_path.map(|path| WorkspaceBinding::new(None, path)); + let cache_key = context_budget_cache_key( + &request.session_id, + &agent_type, + &model_name, + &provider, + context_window, + request.storage_scope, + workspace.as_ref().map(|binding| binding.root_path()), + ); + if let Some(snapshot) = cached_context_budget_snapshot(&cache_key) { + return Ok(snapshot); + } let current_agent = app_state .agent_registry @@ -525,7 +610,7 @@ pub async fn get_context_budget( ) .await; - Ok(ContextStatsEstimator::static_snapshot( + let snapshot = ContextStatsEstimator::static_snapshot( request.session_id, agent_type, model_name, @@ -534,7 +619,9 @@ pub async fn get_context_budget( &system_prompt, request_context_reminder.as_deref(), Some(&tool_definitions), - )) + ); + store_context_budget_snapshot(cache_key, &snapshot); + Ok(snapshot) } #[tauri::command] diff --git a/src/apps/desktop/src/api/speech_api.rs b/src/apps/desktop/src/api/speech_api.rs new file mode 100644 index 00000000..b60b82d8 --- /dev/null +++ b/src/apps/desktop/src/api/speech_api.rs @@ -0,0 +1,196 @@ +//! Local speech input API. + +use crate::api::AppState; +use log::warn; +use sparo_core::service::config::DefaultModelsConfig; +use sparo_core::service::{ + SpeechAppendAudioChunkRequest, SpeechAppendAudioChunkResponse, SpeechCancelInputSessionRequest, + SpeechCancelModelDownloadRequest, SpeechDeleteModelRequest, SpeechDownloadModelRequest, + SpeechFinishInputSessionRequest, SpeechInputSession, SpeechListModelsResponse, + SpeechModelProgressEvent, SpeechModelStatus, SpeechStartInputSessionRequest, + SpeechTranscriptionResult, SpeechVerifyModelRequest, LOCAL_SENSEVOICE_SMALL_INT8_MODEL_REF, +}; +use tauri::{AppHandle, Emitter, State}; + +pub const EVENT_SPEECH_MODEL_PROGRESS: &str = "speech://model-download-progress"; +pub const EVENT_SPEECH_MODEL_STATUS_CHANGED: &str = "speech://model-status-changed"; + +#[tauri::command] +pub async fn speech_list_models( + state: State<'_, AppState>, +) -> Result { + state + .speech_service + .list_models() + .await + .map_err(|e| format!("Failed to list speech models: {}", e)) +} + +#[tauri::command] +pub async fn speech_download_model( + state: State<'_, AppState>, + app: AppHandle, + request: SpeechDownloadModelRequest, +) -> Result { + let app_for_progress = app.clone(); + let status = state + .speech_service + .download_model(request, move |event: SpeechModelProgressEvent| { + if let Err(e) = app_for_progress.emit(EVENT_SPEECH_MODEL_PROGRESS, &event) { + warn!("Failed to emit speech model progress event: {}", e); + } + }) + .await + .map_err(|e| format!("Failed to download speech model: {}", e))?; + + sync_default_speech_model_after_install(&state).await; + emit_status(&app, &status); + Ok(status) +} + +#[tauri::command] +pub async fn speech_cancel_model_download( + state: State<'_, AppState>, + app: AppHandle, + request: SpeechCancelModelDownloadRequest, +) -> Result { + let status = state + .speech_service + .cancel_model_download(request) + .await + .map_err(|e| format!("Failed to cancel speech model download: {}", e))?; + emit_status(&app, &status); + Ok(status) +} + +#[tauri::command] +pub async fn speech_delete_model( + state: State<'_, AppState>, + app: AppHandle, + request: SpeechDeleteModelRequest, +) -> Result { + let status = state + .speech_service + .delete_model(request) + .await + .map_err(|e| format!("Failed to delete speech model: {}", e))?; + + clear_default_speech_model_if_local(&state).await; + emit_status(&app, &status); + Ok(status) +} + +#[tauri::command] +pub async fn speech_verify_model( + state: State<'_, AppState>, + app: AppHandle, + request: SpeechVerifyModelRequest, +) -> Result { + let status = state + .speech_service + .verify_model(request) + .await + .map_err(|e| format!("Failed to verify speech model: {}", e))?; + emit_status(&app, &status); + Ok(status) +} + +#[tauri::command] +pub async fn speech_start_input_session( + state: State<'_, AppState>, + request: SpeechStartInputSessionRequest, +) -> Result { + state + .speech_service + .start_input_session(request) + .await + .map_err(|e| format!("Failed to start speech input session: {}", e)) +} + +#[tauri::command] +pub async fn speech_append_audio_chunk( + state: State<'_, AppState>, + request: SpeechAppendAudioChunkRequest, +) -> Result { + state + .speech_service + .append_audio_chunk(request) + .await + .map_err(|e| format!("Failed to append speech audio chunk: {}", e)) +} + +#[tauri::command] +pub async fn speech_finish_input_session( + state: State<'_, AppState>, + request: SpeechFinishInputSessionRequest, +) -> Result { + state + .speech_service + .finish_input_session(request) + .await + .map_err(|e| format!("Failed to transcribe speech input: {}", e)) +} + +#[tauri::command] +pub async fn speech_cancel_input_session( + state: State<'_, AppState>, + request: SpeechCancelInputSessionRequest, +) -> Result<(), String> { + state + .speech_service + .cancel_input_session(request) + .await + .map_err(|e| format!("Failed to cancel speech input session: {}", e)) +} + +fn emit_status(app: &AppHandle, status: &SpeechModelStatus) { + if let Err(e) = app.emit(EVENT_SPEECH_MODEL_STATUS_CHANGED, status) { + warn!("Failed to emit speech model status event: {}", e); + } +} + +async fn sync_default_speech_model_after_install(state: &State<'_, AppState>) { + let Ok(mut defaults) = state + .config_service + .get_config::(Some("ai.default_models")) + .await + else { + return; + }; + + if defaults.speech_recognition.is_some() { + return; + } + + defaults.speech_recognition = Some(LOCAL_SENSEVOICE_SMALL_INT8_MODEL_REF.to_string()); + if let Err(e) = state + .config_service + .set_config("ai.default_models", &defaults) + .await + { + warn!("Failed to set default speech recognition model: {}", e); + } +} + +async fn clear_default_speech_model_if_local(state: &State<'_, AppState>) { + let Ok(mut defaults) = state + .config_service + .get_config::(Some("ai.default_models")) + .await + else { + return; + }; + + if defaults.speech_recognition.as_deref() != Some(LOCAL_SENSEVOICE_SMALL_INT8_MODEL_REF) { + return; + } + + defaults.speech_recognition = None; + if let Err(e) = state + .config_service + .set_config("ai.default_models", &defaults) + .await + { + warn!("Failed to clear default speech recognition model: {}", e); + } +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 3de68e30..dfa332af 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -54,6 +54,7 @@ use api::runtime_api::*; use api::session_api::*; use api::skill_api::*; use api::snapshot_service::*; +use api::speech_api::*; use api::storage_commands::*; use api::subagent_api::*; use api::system_api::*; @@ -434,6 +435,15 @@ pub fn run() { sync_config_to_global, get_global_config_health, get_runtime_logging_info, + speech_list_models, + speech_download_model, + speech_cancel_model_download, + speech_delete_model, + speech_verify_model, + speech_start_input_session, + speech_append_audio_chunk, + speech_finish_input_session, + speech_cancel_input_session, get_agent_capability_profile, update_agent_capability_profile, get_runtime_capabilities, diff --git a/src/crates/core/Cargo.toml b/src/crates/core/Cargo.toml index 67c5d7e9..b1ff51fb 100644 --- a/src/crates/core/Cargo.toml +++ b/src/crates/core/Cargo.toml @@ -16,6 +16,7 @@ tokio-stream = { workspace = true } tokio-util = { workspace = true } async-trait = { workspace = true } futures = { workspace = true } +futures-util = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -55,6 +56,8 @@ dunce = { workspace = true } filetime = { workspace = true } zip = { workspace = true } flate2 = { workspace = true } +bzip2 = { workspace = true } +tar = { workspace = true } include_dir = { workspace = true } # Command detection (cross-platform) @@ -108,6 +111,7 @@ qrcode = { workspace = true } # Remote Connect relay WebSocket client tokio-tungstenite = { workspace = true } +sherpa-onnx = { workspace = true } # Relay server shared library (embedded relay reuses standalone relay logic) sparo-relay-server = { path = "../../apps/relay-server" } diff --git a/src/crates/core/src/agentic_os/work/hooks.rs b/src/crates/core/src/agentic_os/work/hooks.rs new file mode 100644 index 00000000..a44b2370 --- /dev/null +++ b/src/crates/core/src/agentic_os/work/hooks.rs @@ -0,0 +1,673 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::error::{CoreError, CoreResult}; +use crate::infrastructure::try_get_path_manager_arc; + +use super::execution_binding::WorkExecutionSource; +use super::record::WorkRecord; +use super::runtime_bridge::WorkRuntimeBridge; +use super::surface::WorkSurfaceRef; +use super::types::WorkScope; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WorkDeleteOptions { + #[serde(default)] + pub cascade_child_works: bool, + #[serde(default)] + pub delete_linked_sessions: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkLifecycleHookPhase { + Validate, + Plan, + Prepare, + Commit, + AfterCommit, + Compensate, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WorkLifecycleHookKind { + DeleteRequested { options: WorkDeleteOptions }, + Deleting { plan: WorkCleanupPlan }, + Deleted { report: WorkCleanupReport }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkResourceOwnership { + Owned, + Linked, + Derived, + External, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkCleanupAction { + Delete, + Detach, + Retain, + Archive, + Stop, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkResourceRef { + pub kind: String, + pub id: String, + pub ownership: WorkResourceOwnership, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub metadata: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkCleanupItem { + pub id: String, + pub handler_id: String, + pub resource: WorkResourceRef, + pub action: WorkCleanupAction, + #[serde(default)] + pub required: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WorkCleanupPlan { + pub work_id: String, + pub items: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkCleanupItemStatus { + Planned, + Succeeded, + Failed, + Retained, + Skipped, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkCleanupItemReport { + pub item: WorkCleanupItem, + pub status: WorkCleanupItemStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WorkCleanupReport { + pub work_id: String, + pub items: Vec, +} + +impl WorkCleanupReport { + pub fn has_required_failures(&self) -> bool { + self.items.iter().any(|item| { + item.item.required + && matches!( + item.status, + WorkCleanupItemStatus::Failed | WorkCleanupItemStatus::Skipped + ) + }) + } +} + +#[derive(Clone)] +pub struct WorkLifecycleHookContext { + pub work: WorkRecord, + pub runtime_bridge: Arc, +} + +impl WorkLifecycleHookContext { + pub fn new(work: WorkRecord, runtime_bridge: Arc) -> Self { + Self { + work, + runtime_bridge, + } + } +} + +pub enum WorkLifecycleHookOutcome { + Continue, + CleanupPlan(Vec), + CleanupReport(Vec), +} + +#[async_trait] +pub trait WorkLifecycleHookHandler: Send + Sync { + fn id(&self) -> &'static str; + fn phases(&self) -> &'static [WorkLifecycleHookPhase]; + + async fn handle( + &self, + context: &WorkLifecycleHookContext, + hook: &WorkLifecycleHookKind, + ) -> CoreResult; +} + +#[derive(Clone)] +pub struct WorkLifecycleHookBus { + handlers: Arc>>, +} + +impl WorkLifecycleHookBus { + pub fn new(handlers: Vec>) -> Self { + Self { + handlers: Arc::new(handlers), + } + } + + pub fn default_handlers() -> Self { + Self::default_handlers_with(Vec::new()) + } + + pub fn default_handlers_with( + mut extension_handlers: Vec>, + ) -> Self { + let mut handlers: Vec> = + vec![Arc::new(WorkSessionLifecycleHook)]; + handlers.append(&mut extension_handlers); + handlers.push(Arc::new(ProductRuntimeStorageLifecycleHook)); + handlers.push(Arc::new(RetainedReferenceLifecycleHook)); + Self::new(handlers) + } + + pub async fn plan_delete( + &self, + context: &WorkLifecycleHookContext, + options: WorkDeleteOptions, + ) -> CoreResult { + let hook = WorkLifecycleHookKind::DeleteRequested { options }; + let mut items = Vec::new(); + for handler in self.handlers.iter() { + if !handler.phases().contains(&WorkLifecycleHookPhase::Plan) { + continue; + } + match handler.handle(context, &hook).await? { + WorkLifecycleHookOutcome::CleanupPlan(mut planned) => items.append(&mut planned), + WorkLifecycleHookOutcome::Continue | WorkLifecycleHookOutcome::CleanupReport(_) => { + } + } + } + Ok(WorkCleanupPlan { + work_id: context.work.id.as_str().to_string(), + items, + }) + } + + pub async fn execute_delete( + &self, + context: &WorkLifecycleHookContext, + plan: WorkCleanupPlan, + ) -> WorkCleanupReport { + let mut reports = Vec::new(); + let items_by_handler = plan.items.into_iter().fold( + BTreeMap::>::new(), + |mut map, item| { + map.entry(item.handler_id.clone()).or_default().push(item); + map + }, + ); + let mut handled_handler_ids = BTreeSet::new(); + + for handler in self.handlers.iter() { + let Some(items) = items_by_handler.get(handler.id()) else { + continue; + }; + handled_handler_ids.insert(handler.id().to_string()); + if !handler.phases().contains(&WorkLifecycleHookPhase::Prepare) { + for item in items { + reports.push(WorkCleanupItemReport { + item: item.clone(), + status: WorkCleanupItemStatus::Skipped, + message: Some(format!( + "No prepare-phase handler registered for {}", + item.handler_id + )), + }); + } + continue; + } + + let cleanup_plan = WorkCleanupPlan { + work_id: context.work.id.as_str().to_string(), + items: items.clone(), + }; + let hook = WorkLifecycleHookKind::Deleting { plan: cleanup_plan }; + match handler.handle(context, &hook).await { + Ok(WorkLifecycleHookOutcome::CleanupReport(mut handler_reports)) => { + reports.append(&mut handler_reports); + } + Ok(WorkLifecycleHookOutcome::Continue) + | Ok(WorkLifecycleHookOutcome::CleanupPlan(_)) => { + for item in items { + reports.push(WorkCleanupItemReport { + item: item.clone(), + status: WorkCleanupItemStatus::Succeeded, + message: None, + }); + } + } + Err(error) => { + for item in items { + reports.push(WorkCleanupItemReport { + item: item.clone(), + status: WorkCleanupItemStatus::Failed, + message: Some(error.to_string()), + }); + } + } + } + } + + for (handler_id, items) in &items_by_handler { + if handled_handler_ids.contains(handler_id) { + continue; + } + for item in items { + reports.push(WorkCleanupItemReport { + item: item.clone(), + status: WorkCleanupItemStatus::Skipped, + message: Some(format!( + "No lifecycle hook handler registered for {}", + handler_id + )), + }); + } + } + + WorkCleanupReport { + work_id: context.work.id.as_str().to_string(), + items: reports, + } + } +} + +struct WorkSessionLifecycleHook; + +const PLAN_AND_PREPARE_PHASES: &[WorkLifecycleHookPhase] = &[ + WorkLifecycleHookPhase::Plan, + WorkLifecycleHookPhase::Prepare, +]; + +#[async_trait] +impl WorkLifecycleHookHandler for WorkSessionLifecycleHook { + fn id(&self) -> &'static str { + "work_session" + } + + fn phases(&self) -> &'static [WorkLifecycleHookPhase] { + PLAN_AND_PREPARE_PHASES + } + + async fn handle( + &self, + context: &WorkLifecycleHookContext, + hook: &WorkLifecycleHookKind, + ) -> CoreResult { + match hook { + WorkLifecycleHookKind::DeleteRequested { options } => { + let mut planned = Vec::new(); + for session_id in owned_work_session_ids(&context.work) { + planned.push(session_delete_item( + &context.work, + self.id(), + "work-session", + session_id, + WorkResourceOwnership::Owned, + true, + )?); + } + if options.delete_linked_sessions { + for session_id in linked_session_ids(&context.work) { + planned.push(session_delete_item( + &context.work, + self.id(), + "linked-session", + session_id, + WorkResourceOwnership::Linked, + false, + )?); + } + } + Ok(WorkLifecycleHookOutcome::CleanupPlan(planned)) + } + WorkLifecycleHookKind::Deleting { plan } => { + let mut reports = Vec::new(); + for item in &plan.items { + if item.action != WorkCleanupAction::Delete { + reports.push(WorkCleanupItemReport { + item: item.clone(), + status: WorkCleanupItemStatus::Skipped, + message: Some( + "Work session hook only handles delete actions".to_string(), + ), + }); + continue; + } + + let workspace_path = item + .resource + .metadata + .get("workspace_path") + .cloned() + .ok_or_else(|| { + CoreError::validation("workspace_path is required for session cleanup") + })?; + let result = context + .runtime_bridge + .delete_work_session(&workspace_path, &item.resource.id) + .await; + reports.push(report_for_result(item, result)); + } + Ok(WorkLifecycleHookOutcome::CleanupReport(reports)) + } + WorkLifecycleHookKind::Deleted { .. } => Ok(WorkLifecycleHookOutcome::Continue), + } + } +} + +struct ProductRuntimeStorageLifecycleHook; + +#[async_trait] +impl WorkLifecycleHookHandler for ProductRuntimeStorageLifecycleHook { + fn id(&self) -> &'static str { + "product_runtime_storage" + } + + fn phases(&self) -> &'static [WorkLifecycleHookPhase] { + PLAN_AND_PREPARE_PHASES + } + + async fn handle( + &self, + context: &WorkLifecycleHookContext, + hook: &WorkLifecycleHookKind, + ) -> CoreResult { + match hook { + WorkLifecycleHookKind::DeleteRequested { .. } => { + if context.work.runtime_instances.is_empty() { + return Ok(WorkLifecycleHookOutcome::CleanupPlan(Vec::new())); + } + let runtime_instance_ids = context + .work + .runtime_instances + .iter() + .map(|instance| instance.id.as_str()) + .collect::>() + .join(","); + let mut metadata = BTreeMap::new(); + metadata.insert("runtime_instance_ids".to_string(), runtime_instance_ids); + Ok(WorkLifecycleHookOutcome::CleanupPlan(vec![ + WorkCleanupItem { + id: format!("product-runtime-storage:{}", context.work.id.as_str()), + handler_id: self.id().to_string(), + resource: WorkResourceRef { + kind: "product_runtime_storage".to_string(), + id: context.work.id.as_str().to_string(), + ownership: WorkResourceOwnership::Owned, + metadata, + }, + action: WorkCleanupAction::Delete, + required: true, + }, + ])) + } + WorkLifecycleHookKind::Deleting { plan } => { + let mut reports = Vec::new(); + for item in &plan.items { + let result = delete_work_runtime_storage(&context.work); + reports.push(report_for_result(item, result)); + } + Ok(WorkLifecycleHookOutcome::CleanupReport(reports)) + } + WorkLifecycleHookKind::Deleted { .. } => Ok(WorkLifecycleHookOutcome::Continue), + } + } +} + +struct RetainedReferenceLifecycleHook; + +#[async_trait] +impl WorkLifecycleHookHandler for RetainedReferenceLifecycleHook { + fn id(&self) -> &'static str { + "retained_reference" + } + + fn phases(&self) -> &'static [WorkLifecycleHookPhase] { + PLAN_AND_PREPARE_PHASES + } + + async fn handle( + &self, + context: &WorkLifecycleHookContext, + hook: &WorkLifecycleHookKind, + ) -> CoreResult { + match hook { + WorkLifecycleHookKind::DeleteRequested { options } => { + let mut planned = Vec::new(); + if !options.delete_linked_sessions { + for session_id in linked_session_ids(&context.work) { + planned.push(retain_item( + self.id(), + "linked-agent-session", + "agent_session", + session_id, + WorkResourceOwnership::Linked, + )); + } + } + for artifact in &context.work.artifact_refs { + planned.push(retain_item( + self.id(), + "artifact", + "artifact", + artifact.id.clone(), + WorkResourceOwnership::Linked, + )); + } + for memory in &context.work.memory_refs { + planned.push(retain_item( + self.id(), + "memory", + "memory", + memory.id.clone(), + WorkResourceOwnership::Linked, + )); + } + if !options.cascade_child_works { + for child_work_id in delegated_child_work_ids(&context.work) { + planned.push(retain_item( + self.id(), + "child-work", + "work", + child_work_id, + WorkResourceOwnership::Linked, + )); + } + } + Ok(WorkLifecycleHookOutcome::CleanupPlan(planned)) + } + WorkLifecycleHookKind::Deleting { plan } => { + let reports = plan + .items + .iter() + .map(|item| WorkCleanupItemReport { + item: item.clone(), + status: WorkCleanupItemStatus::Retained, + message: Some("Reference retained by default cleanup policy".to_string()), + }) + .collect(); + Ok(WorkLifecycleHookOutcome::CleanupReport(reports)) + } + WorkLifecycleHookKind::Deleted { .. } => Ok(WorkLifecycleHookOutcome::Continue), + } + } +} + +fn owned_work_session_ids(work: &WorkRecord) -> Vec { + let mut ids = BTreeSet::new(); + for surface in &work.surfaces { + if let WorkSurfaceRef::WorkSession { session_id } = surface { + ids.insert(session_id.clone()); + } + } + if let WorkSurfaceRef::WorkSession { session_id } = &work.primary_surface { + ids.insert(session_id.clone()); + } + ids.into_iter().collect() +} + +fn linked_session_ids(work: &WorkRecord) -> Vec { + let owned = owned_work_session_ids(work) + .into_iter() + .collect::>(); + let mut ids = BTreeSet::new(); + for session_ref in &work.session_refs { + if !owned.contains(&session_ref.session_id) { + ids.insert(session_ref.session_id.clone()); + } + } + for surface in &work.surfaces { + if let WorkSurfaceRef::AgentSession { session_id } = surface { + if !owned.contains(session_id) { + ids.insert(session_id.clone()); + } + } + } + if let WorkSurfaceRef::AgentSession { session_id } = &work.primary_surface { + if !owned.contains(session_id) { + ids.insert(session_id.clone()); + } + } + ids.into_iter().collect() +} + +fn delegated_child_work_ids(work: &WorkRecord) -> Vec { + let mut ids = BTreeSet::new(); + for binding in &work.execution_bindings { + if let WorkExecutionSource::DelegatedWorkRun { child_work_id, .. } = &binding.source { + ids.insert(child_work_id.as_str().to_string()); + } + } + ids.into_iter().collect() +} + +fn session_delete_item( + work: &WorkRecord, + handler_id: &str, + item_prefix: &str, + session_id: String, + ownership: WorkResourceOwnership, + required: bool, +) -> CoreResult { + let workspace_path = session_workspace_path(work, &session_id)?; + let mut metadata = BTreeMap::new(); + metadata.insert("workspace_path".to_string(), workspace_path); + Ok(WorkCleanupItem { + id: format!("{}:{}", item_prefix, session_id), + handler_id: handler_id.to_string(), + resource: WorkResourceRef { + kind: "agent_session".to_string(), + id: session_id, + ownership, + metadata, + }, + action: WorkCleanupAction::Delete, + required, + }) +} + +fn session_workspace_path(work: &WorkRecord, session_id: &str) -> CoreResult { + if let Some(path) = work + .session_refs + .iter() + .find(|reference| reference.session_id == session_id) + .and_then(|reference| reference.workspace_path.clone()) + .filter(|path| !path.trim().is_empty()) + { + return Ok(path); + } + + match &work.scope { + WorkScope::Workspace { workspace_path } => { + if workspace_path.trim().is_empty() { + return Err(CoreError::validation("workspace_path cannot be empty")); + } + Ok(workspace_path.clone()) + } + WorkScope::System => { + let path_manager = try_get_path_manager_arc()?; + Ok(path_manager + .agentic_os_runtime_root() + .to_string_lossy() + .into_owned()) + } + } +} + +fn delete_work_runtime_storage(work: &WorkRecord) -> CoreResult<()> { + let path_manager = try_get_path_manager_arc()?; + let root = path_manager.agentic_os_work_runtimes_dir(); + let target = root.join(work.id.as_str()); + if !target.exists() { + return Ok(()); + } + if !target.starts_with(&root) { + return Err(CoreError::validation(format!( + "Refusing to delete Work runtime storage outside root: {}", + target.display() + ))); + } + std::fs::remove_dir_all(&target).map_err(|error| { + CoreError::io(format!( + "Failed to delete Work runtime storage {}: {}", + target.display(), + error + )) + }) +} + +fn retain_item( + handler_id: &str, + item_prefix: &str, + resource_kind: &str, + resource_id: String, + ownership: WorkResourceOwnership, +) -> WorkCleanupItem { + WorkCleanupItem { + id: format!("{}:{}", item_prefix, resource_id), + handler_id: handler_id.to_string(), + resource: WorkResourceRef { + kind: resource_kind.to_string(), + id: resource_id, + ownership, + metadata: BTreeMap::new(), + }, + action: WorkCleanupAction::Retain, + required: false, + } +} + +fn report_for_result(item: &WorkCleanupItem, result: CoreResult<()>) -> WorkCleanupItemReport { + match result { + Ok(()) => WorkCleanupItemReport { + item: item.clone(), + status: WorkCleanupItemStatus::Succeeded, + message: None, + }, + Err(error) => WorkCleanupItemReport { + item: item.clone(), + status: WorkCleanupItemStatus::Failed, + message: Some(error.to_string()), + }, + } +} diff --git a/src/crates/core/src/agentic_os/work/mod.rs b/src/crates/core/src/agentic_os/work/mod.rs index d5442110..3697e2c6 100644 --- a/src/crates/core/src/agentic_os/work/mod.rs +++ b/src/crates/core/src/agentic_os/work/mod.rs @@ -1,6 +1,7 @@ pub mod assignment; pub mod execution_binding; pub mod execution_graph; +pub mod hooks; pub mod ids; pub mod lifecycle; pub mod projection; @@ -28,6 +29,12 @@ pub use execution_graph::{ WorkRuntimeIssueSeverity, WorkRuntimeLog, WorkRuntimeLogLevel, WorkRuntimeRun, WorkRuntimeRunStatus, }; +pub use hooks::{ + WorkCleanupAction, WorkCleanupItem, WorkCleanupItemReport, WorkCleanupItemStatus, + WorkCleanupPlan, WorkCleanupReport, WorkDeleteOptions, WorkLifecycleHookBus, + WorkLifecycleHookContext, WorkLifecycleHookHandler, WorkLifecycleHookKind, + WorkLifecycleHookOutcome, WorkLifecycleHookPhase, WorkResourceOwnership, WorkResourceRef, +}; pub use ids::WorkId; pub use lifecycle::{WorkLifecycle, WorkLifecycleEvent, WorkSummary}; pub use projection::WorkProjection; diff --git a/src/crates/core/src/agentic_os/work/runtime_bridge.rs b/src/crates/core/src/agentic_os/work/runtime_bridge.rs index 92c525dc..fab9f1ba 100644 --- a/src/crates/core/src/agentic_os/work/runtime_bridge.rs +++ b/src/crates/core/src/agentic_os/work/runtime_bridge.rs @@ -1,3 +1,4 @@ +use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -58,6 +59,20 @@ pub trait WorkRuntimeBridge: Send + Sync { async fn cancel_work_session_run(&self, _session_id: &str) -> CoreResult<()> { Ok(()) } + + async fn clear_work_session_queue(&self, _session_id: &str) -> CoreResult<()> { + Ok(()) + } + + async fn delete_work_session( + &self, + _workspace_path: &str, + _session_id: &str, + ) -> CoreResult<()> { + Err(CoreError::service( + "Work runtime bridge is required to delete Work-owned sessions", + )) + } } #[derive(Debug, Default)] @@ -181,4 +196,25 @@ impl WorkRuntimeBridge for AgenticWorkRuntimeBridge { .await?; Ok(()) } + + async fn clear_work_session_queue(&self, session_id: &str) -> CoreResult<()> { + self.scheduler.clear_session_queue(session_id); + Ok(()) + } + + async fn delete_work_session(&self, workspace_path: &str, session_id: &str) -> CoreResult<()> { + self.clear_work_session_queue(session_id).await?; + self.scheduler + .cancel_active_turn_for_session( + session_id, + TurnCancellationReason::SessionDeleted, + SessionControlActor::Tool, + Duration::from_millis(500), + ) + .await?; + self.coordinator + .delete_session(Path::new(workspace_path), session_id) + .await?; + Ok(()) + } } diff --git a/src/crates/core/src/agentic_os/work/service.rs b/src/crates/core/src/agentic_os/work/service.rs index 6ac2a3f9..80380272 100644 --- a/src/crates/core/src/agentic_os/work/service.rs +++ b/src/crates/core/src/agentic_os/work/service.rs @@ -21,6 +21,9 @@ use super::execution_graph::{ WorkRuntimeIssueSeverity, WorkRuntimeLog, WorkRuntimeLogLevel, WorkRuntimeRun, WorkRuntimeRunStatus, }; +use super::hooks::{ + WorkCleanupReport, WorkDeleteOptions, WorkLifecycleHookBus, WorkLifecycleHookContext, +}; use super::ids::WorkId; use super::lifecycle::WorkSummary; use super::record::{ @@ -250,6 +253,8 @@ pub struct ControlWorkResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeleteWorkResponse { pub deleted: bool, + #[serde(default)] + pub cleanup_report: WorkCleanupReport, } const MAX_WORK_RUNTIME_ISSUES: usize = 200; @@ -259,6 +264,7 @@ const MAX_WORK_RUNTIME_LOGS: usize = 500; pub struct WorkService { store: Arc, runtime_bridge: Arc, + hook_bus: WorkLifecycleHookBus, } impl WorkService { @@ -266,6 +272,7 @@ impl WorkService { Self { store, runtime_bridge: Arc::new(NoopWorkRuntimeBridge), + hook_bus: WorkLifecycleHookBus::default_handlers(), } } @@ -276,6 +283,19 @@ impl WorkService { Self { store, runtime_bridge, + hook_bus: WorkLifecycleHookBus::default_handlers(), + } + } + + pub fn with_lifecycle_hooks( + store: Arc, + runtime_bridge: Arc, + hook_bus: WorkLifecycleHookBus, + ) -> Self { + Self { + store, + runtime_bridge, + hook_bus, } } @@ -321,8 +341,39 @@ impl WorkService { } pub async fn delete(&self, id: &WorkId) -> CoreResult { + self.delete_with_options(id, WorkDeleteOptions::default()) + .await + } + + pub async fn delete_with_options( + &self, + id: &WorkId, + options: WorkDeleteOptions, + ) -> CoreResult { + let Some(record) = self.store.get(id).await? else { + return Ok(DeleteWorkResponse { + deleted: false, + cleanup_report: WorkCleanupReport { + work_id: id.as_str().to_string(), + items: Vec::new(), + }, + }); + }; + + let context = + WorkLifecycleHookContext::new(record.clone(), Arc::clone(&self.runtime_bridge)); + let plan = self.hook_bus.plan_delete(&context, options).await?; + let cleanup_report = self.hook_bus.execute_delete(&context, plan).await; + if cleanup_report.has_required_failures() { + return Err(CoreError::service(format!( + "Failed to cleanup required Work resources before deleting work_id={}", + id + ))); + } + Ok(DeleteWorkResponse { deleted: self.store.delete(id).await?, + cleanup_report, }) } @@ -3685,7 +3736,7 @@ fn runtime_log_level_str(level: WorkRuntimeLogLevel) -> &'static str { #[cfg(test)] mod tests { - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use async_trait::async_trait; @@ -3693,6 +3744,7 @@ mod tests { WorkBuilderFactStatus, WorkBuilderIssueStatus, WorkRuntimeInstanceStatus, WorkRuntimeIssueSeverity, WorkRuntimeLogLevel, }; + use super::super::hooks::{WorkCleanupAction, WorkCleanupItemStatus, WorkResourceOwnership}; use super::super::record::ArtifactRuntimeProvenance; use super::*; use crate::agentic_os::work::store::MemoryWorkStore; @@ -3727,6 +3779,48 @@ mod tests { } } + #[derive(Debug, Default)] + struct RecordingRuntimeBridge { + deleted_sessions: Mutex>, + } + + #[async_trait] + impl WorkRuntimeBridge for RecordingRuntimeBridge { + async fn create_work_session( + &self, + request: CreateWorkSessionRequest, + ) -> CoreResult { + Ok(super::super::runtime_bridge::CreateWorkSessionOutcome { + session_id: format!("session_{}", request.work_id.as_str()), + session_name: request.title, + agent_type: request.agent_type, + }) + } + + async fn advance_work_session( + &self, + request: WorkSessionAdvanceRequest, + ) -> CoreResult { + Ok(super::super::runtime_bridge::WorkSessionAdvanceOutcome { + session_id: request.session_id, + turn_id: format!("turn_{}", request.work_id.as_str()), + started: true, + }) + } + + async fn delete_work_session( + &self, + workspace_path: &str, + session_id: &str, + ) -> CoreResult<()> { + self.deleted_sessions + .lock() + .expect("deleted sessions lock") + .push((workspace_path.to_string(), session_id.to_string())); + Ok(()) + } + } + fn service() -> WorkService { WorkService::with_runtime_bridge( Arc::new(MemoryWorkStore::new()), @@ -6642,6 +6736,107 @@ mod tests { )); } + #[tokio::test] + async fn delete_work_cleans_owned_work_session_before_record_delete() { + let bridge = Arc::new(RecordingRuntimeBridge::default()); + let runtime_bridge: Arc = bridge.clone(); + let service = + WorkService::with_runtime_bridge(Arc::new(MemoryWorkStore::new()), runtime_bridge); + let record = service + .create(CreateWorkRequest { + kind: WorkKind::MultiStep, + title: "Implement feature".to_string(), + objective: "Ship the feature".to_string(), + subject: WorkSubject::Goal, + app_refs: Vec::new(), + scope: WorkScope::Workspace { + workspace_path: "D:/workspace/project".to_string(), + }, + visibility: WorkVisibility::Primary, + primary_surface_policy: PrimarySurfacePolicy::WorkSession, + primary_surface: None, + assignment: Some(WorkAssignmentRef::agent("Runno")), + title_state: None, + delegation: None, + }) + .await + .expect("create work"); + let session_id = record + .work_session_id() + .expect("work session id") + .to_string(); + + let response = service.delete(&record.id).await.expect("delete work"); + + assert!(response.deleted); + assert!(service + .store + .get(&record.id) + .await + .expect("get deleted work") + .is_none()); + assert_eq!( + bridge + .deleted_sessions + .lock() + .expect("deleted sessions lock") + .as_slice(), + &[("D:/workspace/project".to_string(), session_id.clone())] + ); + assert!(response.cleanup_report.items.iter().any(|report| { + report.item.resource.id == session_id + && report.item.resource.ownership == WorkResourceOwnership::Owned + && report.item.action == WorkCleanupAction::Delete + && report.status == WorkCleanupItemStatus::Succeeded + })); + } + + #[tokio::test] + async fn delete_work_retains_linked_sessions_by_default() { + let service = service(); + let record = service + .create(CreateWorkRequest { + kind: WorkKind::MultiStep, + title: "Investigate".to_string(), + objective: "Keep session linked to work".to_string(), + subject: WorkSubject::Goal, + app_refs: Vec::new(), + scope: WorkScope::Workspace { + workspace_path: "D:/workspace/project".to_string(), + }, + visibility: WorkVisibility::Primary, + primary_surface_policy: PrimarySurfacePolicy::WorkCenter, + primary_surface: None, + assignment: None, + title_state: None, + delegation: None, + }) + .await + .expect("create work"); + let linked = service + .link_session_to_work(LinkSessionToWorkRequest { + work_id: record.id.clone(), + session_id: "session-linked".to_string(), + workspace_path: Some("D:/workspace/project".to_string()), + surface: Some(WorkSurfaceRef::AgentSession { + session_id: "session-linked".to_string(), + }), + set_primary: true, + }) + .await + .expect("link session"); + + let response = service.delete(&linked.id).await.expect("delete work"); + + assert!(response.deleted); + assert!(response.cleanup_report.items.iter().any(|report| { + report.item.resource.id == "session-linked" + && report.item.resource.ownership == WorkResourceOwnership::Linked + && report.item.action == WorkCleanupAction::Retain + && report.status == WorkCleanupItemStatus::Retained + })); + } + #[tokio::test] async fn dispatch_new_creates_delegated_work() { let service = service(); diff --git a/src/crates/core/src/command/agentic_os/work.rs b/src/crates/core/src/command/agentic_os/work.rs index a13f0d69..e5149618 100644 --- a/src/crates/core/src/command/agentic_os/work.rs +++ b/src/crates/core/src/command/agentic_os/work.rs @@ -4,7 +4,8 @@ use crate::agentic_os::work::{ default_work_store, AdvanceWorkRequest, ControlWorkRequest, CreateWorkRequest, DispatchWorkRequest, LinkSessionToWorkRequest, ResolveAppWorkRequest, ResolveComponentWorkRequest, StartWorkRequest, UpdateWorkRequest, WorkAppRef, - WorkBuilderPreviewResult, WorkBuilderValidationResult, WorkId, WorkRecord, WorkService, + WorkBuilderPreviewResult, WorkBuilderValidationResult, WorkCleanupReport, WorkDeleteOptions, + WorkId, WorkRecord, WorkService, }; use super::super::{CommandError, CommandResult}; @@ -34,11 +35,14 @@ pub struct AgenticOsGetWorkResponse { #[derive(Debug, Clone, Deserialize)] pub struct AgenticOsDeleteWorkRequest { pub work_id: WorkId, + #[serde(default)] + pub options: WorkDeleteOptions, } #[derive(Debug, Clone, Serialize)] pub struct AgenticOsDeleteWorkResponse { pub deleted: bool, + pub cleanup_report: WorkCleanupReport, } #[derive(Debug, Clone, Deserialize)] @@ -221,11 +225,12 @@ pub async fn delete_work_with_service( request: AgenticOsDeleteWorkRequest, ) -> CommandResult { let response = service - .delete(&request.work_id) + .delete_with_options(&request.work_id, request.options) .await .map_err(CommandError::session)?; Ok(AgenticOsDeleteWorkResponse { deleted: response.deleted, + cleanup_report: response.cleanup_report, }) } diff --git a/src/crates/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/core/src/infrastructure/app_paths/path_manager.rs index d3521ef1..88521190 100644 --- a/src/crates/core/src/infrastructure/app_paths/path_manager.rs +++ b/src/crates/core/src/infrastructure/app_paths/path_manager.rs @@ -150,6 +150,31 @@ impl PathManager { self.user_root.join("data") } + /// User-level managed model resources shared across workspaces. + pub fn user_models_dir(&self) -> PathBuf { + self.user_data_dir().join("models") + } + + /// User-level speech recognition model resources shared across workspaces. + pub fn speech_models_dir(&self) -> PathBuf { + self.user_models_dir().join("speech") + } + + /// Versioned speech model resource directory. + pub fn speech_model_dir(&self, model_id: &str, version: &str) -> PathBuf { + self.speech_models_dir().join(model_id).join(version) + } + + /// Temporary download workspace for managed speech model resources. + pub fn speech_model_downloads_dir(&self) -> PathBuf { + self.cache_root().join("model-downloads").join("speech") + } + + /// Temporary audio chunks for local voice input sessions. + pub fn speech_input_temp_dir(&self) -> PathBuf { + self.temp_dir().join("speech-input") + } + /// Get user apps directory: /apps/ pub fn apps_dir(&self) -> PathBuf { self.user_root.join("apps") @@ -534,6 +559,9 @@ impl PathManager { self.user_agents_dir(), self.cache_root(), self.user_data_dir(), + self.user_models_dir(), + self.speech_models_dir(), + self.speech_model_downloads_dir(), self.user_state_dir(), self.user_cron_dir(), self.apps_dir(), @@ -548,6 +576,7 @@ impl PathManager { self.reset_backups_dir(), self.logs_dir(), self.temp_dir(), + self.speech_input_temp_dir(), ]; for dir in dirs { diff --git a/src/crates/core/src/service/config/manager.rs b/src/crates/core/src/service/config/manager.rs index f08cbd6a..438a63db 100644 --- a/src/crates/core/src/service/config/manager.rs +++ b/src/crates/core/src/service/config/manager.rs @@ -679,9 +679,24 @@ pub(crate) fn migrate_0_0_0_to_1_0_0(mut config: Value) -> CoreResult { "spritesheetMimeType": "image/webp" }, "show_thinking_process": true, - "show_completed_thinking_item": true + "show_completed_thinking_item": true, + "voice_input": { + "enabled": true, + "default_language": "auto", + "max_recording_seconds": 60 + } }), ); + } else if let Some(ai_experience) = + app.get_mut("ai_experience").and_then(|v| v.as_object_mut()) + { + ai_experience.entry("voice_input").or_insert_with(|| { + serde_json::json!({ + "enabled": true, + "default_language": "auto", + "max_recording_seconds": 60 + }) + }); } } diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index a374639c..b57ad7e0 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -203,6 +203,20 @@ pub struct AIExperienceConfig { pub show_thinking_process: bool, /// Whether completed thinking blocks remain as expandable collapsed items. pub show_completed_thinking_item: bool, + /// Local voice input settings for the composer. + pub voice_input: VoiceInputConfig, +} + +/// Local voice input configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct VoiceInputConfig { + /// Whether the composer should show the microphone action. + pub enabled: bool, + /// Default language passed to local speech recognition. + pub default_language: String, + /// Maximum recording length for one voice input. + pub max_recording_seconds: u32, } /// User-selected Agent companion package. @@ -1456,6 +1470,17 @@ impl Default for AIExperienceConfig { }), show_thinking_process: true, show_completed_thinking_item: true, + voice_input: VoiceInputConfig::default(), + } + } +} + +impl Default for VoiceInputConfig { + fn default() -> Self { + Self { + enabled: true, + default_language: "auto".to_string(), + max_recording_seconds: 60, } } } diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index 5ab54126..3f360c6b 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -22,6 +22,7 @@ pub mod remote_connect; // Remote Connect (phone → desktop) pub mod runtime; // Managed runtime and capability management pub mod session; // Session persistence pub mod snapshot; // Snapshot-based change tracking +pub mod speech; // Local speech input and model resource management pub mod system; // System command detection and execution pub mod system_fs; pub mod token_usage; // Token usage tracking @@ -84,6 +85,14 @@ pub use mcp::MCPService; pub use project_detection::{ProjectDetector, ProjectInfo}; pub use runtime::{ResolvedCommand, RuntimeCommandCapability, RuntimeManager, RuntimeSource}; pub use snapshot::SnapshotService; +pub use speech::{ + SpeechAppendAudioChunkRequest, SpeechAppendAudioChunkResponse, SpeechCancelInputSessionRequest, + SpeechCancelModelDownloadRequest, SpeechDeleteModelRequest, SpeechDownloadModelRequest, + SpeechFinishInputSessionRequest, SpeechInputSession, SpeechListModelsResponse, + SpeechModelProgressEvent, SpeechModelStatus, SpeechService, SpeechStartInputSessionRequest, + SpeechTranscriptionResult, SpeechVerifyModelRequest, LOCAL_SENSEVOICE_SMALL_INT8_MODEL_ID, + LOCAL_SENSEVOICE_SMALL_INT8_MODEL_REF, +}; pub use system::{ check_command, check_commands, run_command, run_command_simple, CheckCommandResult, CommandOutput, SystemError, diff --git a/src/crates/core/src/service/speech/audio.rs b/src/crates/core/src/service/speech/audio.rs new file mode 100644 index 00000000..9adc16ed --- /dev/null +++ b/src/crates/core/src/service/speech/audio.rs @@ -0,0 +1,23 @@ +use crate::error::{CoreError, CoreResult}; + +pub fn pcm16_le_to_f32_samples(bytes: &[u8]) -> CoreResult> { + if bytes.len() % 2 != 0 { + return Err(CoreError::validation( + "PCM16 audio payload must have an even number of bytes", + )); + } + + let mut samples = Vec::with_capacity(bytes.len() / 2); + for chunk in bytes.chunks_exact(2) { + let sample = i16::from_le_bytes([chunk[0], chunk[1]]); + samples.push(sample as f32 / i16::MAX as f32); + } + Ok(samples) +} + +pub fn pcm16_duration_seconds(byte_len: u64, sample_rate: u32) -> f64 { + if sample_rate == 0 { + return 0.0; + } + byte_len as f64 / 2.0 / sample_rate as f64 +} diff --git a/src/crates/core/src/service/speech/downloader.rs b/src/crates/core/src/service/speech/downloader.rs new file mode 100644 index 00000000..7b031d9c --- /dev/null +++ b/src/crates/core/src/service/speech/downloader.rs @@ -0,0 +1,217 @@ +use super::model_store::{validate_relative_archive_path, SpeechModelStore}; +use super::types::{SpeechModelManifest, SpeechModelProgress, SpeechModelStatus}; +use crate::error::{CoreError, CoreResult}; +use bzip2::read::BzDecoder; +use futures_util::StreamExt; +use sha2::{Digest, Sha256}; +use std::fs::File; +use std::path::{Path, PathBuf}; +use tar::Archive; +use tokio::fs; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +pub async fn download_and_install_model( + store: &SpeechModelStore, + manifest: &SpeechModelManifest, + cancel: CancellationToken, + on_progress: F, +) -> CoreResult +where + F: Fn(SpeechModelProgress) + Send + Sync, +{ + let archive_path = store.archive_download_path(manifest); + let partial_path = store.archive_partial_path(manifest); + if let Some(parent) = partial_path.parent() { + fs::create_dir_all(parent).await?; + } + + if archive_path.exists() { + let actual_hash = sha256_file(&archive_path).await?; + if actual_hash == manifest.archive_sha256 { + on_progress(SpeechModelProgress { + model_id: manifest.id.clone(), + downloaded_bytes: manifest.archive_size_bytes, + total_bytes: manifest.archive_size_bytes, + percent: 100.0, + }); + install_archive(store, manifest, &archive_path).await?; + return store.status_for_manifest(manifest).await; + } + fs::remove_file(&archive_path).await?; + } + + let client = reqwest::Client::new(); + let response = client + .get(&manifest.source_url) + .header(reqwest::header::USER_AGENT, "SparoOS") + .send() + .await? + .error_for_status()?; + + let total_bytes = response + .content_length() + .unwrap_or(manifest.archive_size_bytes); + let mut stream = response.bytes_stream(); + let mut file = fs::File::create(&partial_path).await?; + let mut hasher = Sha256::new(); + let mut downloaded = 0u64; + + while let Some(chunk) = stream.next().await { + if cancel.is_cancelled() { + let _ = fs::remove_file(&partial_path).await; + return Err(CoreError::Cancelled(format!( + "Speech model download cancelled: {}", + manifest.id + ))); + } + + let chunk = chunk?; + file.write_all(&chunk).await?; + hasher.update(&chunk); + downloaded += chunk.len() as u64; + let percent = if total_bytes > 0 { + downloaded as f64 / total_bytes as f64 * 100.0 + } else { + 0.0 + }; + on_progress(SpeechModelProgress { + model_id: manifest.id.clone(), + downloaded_bytes: downloaded, + total_bytes, + percent, + }); + } + file.flush().await?; + drop(file); + + let actual_hash = format!("{:x}", hasher.finalize()); + if actual_hash != manifest.archive_sha256 { + let _ = fs::remove_file(&partial_path).await; + return Err(CoreError::validation(format!( + "Speech model checksum mismatch: expected={}, actual={}", + manifest.archive_sha256, actual_hash + ))); + } + + if archive_path.exists() { + fs::remove_file(&archive_path).await?; + } + fs::rename(&partial_path, &archive_path).await?; + install_archive(store, manifest, &archive_path).await?; + store.status_for_manifest(manifest).await +} + +async fn install_archive( + store: &SpeechModelStore, + manifest: &SpeechModelManifest, + archive_path: &Path, +) -> CoreResult<()> { + let final_dir = store.model_dir(manifest); + let parent = final_dir.parent().ok_or_else(|| { + CoreError::service(format!( + "Speech model path has no parent: {}", + final_dir.display() + )) + })?; + fs::create_dir_all(parent).await?; + + let staging = parent.join(format!(".installing-{}", Uuid::new_v4().simple())); + if staging.exists() { + fs::remove_dir_all(&staging).await?; + } + fs::create_dir_all(&staging).await?; + + let install_result = + install_archive_into_staging(store, manifest, archive_path, &staging, &final_dir).await; + if install_result.is_err() && staging.exists() { + let _ = fs::remove_dir_all(&staging).await; + } + install_result +} + +async fn install_archive_into_staging( + store: &SpeechModelStore, + manifest: &SpeechModelManifest, + archive_path: &Path, + staging: &Path, + final_dir: &Path, +) -> CoreResult<()> { + let archive_path = archive_path.to_path_buf(); + let staging_for_extract = staging.to_path_buf(); + tokio::task::spawn_blocking(move || extract_tar_bz2(&archive_path, &staging_for_extract)) + .await + .map_err(|e| CoreError::service(format!("Speech model extraction task failed: {e}")))??; + + let payload_dir = find_payload_dir(&staging, &manifest.required_files).await?; + if final_dir.exists() { + fs::remove_dir_all(&final_dir).await?; + } + + if payload_dir == staging { + fs::rename(&staging, &final_dir).await?; + } else { + fs::rename(&payload_dir, &final_dir).await?; + if staging.exists() { + fs::remove_dir_all(&staging).await?; + } + } + + store.write_install_record(manifest, &final_dir).await?; + Ok(()) +} + +async fn sha256_file(path: &Path) -> CoreResult { + let mut file = fs::File::open(path).await?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0u8; 1024 * 1024]; + loop { + let read = file.read(&mut buffer).await?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn extract_tar_bz2(archive_path: &Path, destination: &Path) -> CoreResult<()> { + let file = File::open(archive_path)?; + let decoder = BzDecoder::new(file); + let mut archive = Archive::new(decoder); + for entry in archive.entries()? { + let mut entry = entry?; + let relative = validate_relative_archive_path(&entry.path()?)?; + let target = destination.join(relative); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + entry.unpack(&target)?; + } + Ok(()) +} + +async fn find_payload_dir(staging: &Path, required_files: &[String]) -> CoreResult { + if has_required_files_at(staging, required_files) { + return Ok(staging.to_path_buf()); + } + + let mut entries = fs::read_dir(staging).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.is_dir() && has_required_files_at(&path, required_files) { + return Ok(path); + } + } + + Err(CoreError::validation( + "Downloaded speech model archive does not contain the required model files", + )) +} + +fn has_required_files_at(path: &Path, required_files: &[String]) -> bool { + required_files + .iter() + .all(|relative| path.join(relative).is_file()) +} diff --git a/src/crates/core/src/service/speech/mod.rs b/src/crates/core/src/service/speech/mod.rs new file mode 100644 index 00000000..2631287c --- /dev/null +++ b/src/crates/core/src/service/speech/mod.rs @@ -0,0 +1,279 @@ +//! Local speech input services. + +pub mod audio; +mod downloader; +pub mod model_catalog; +pub mod model_store; +pub mod recognizer; +mod sensevoice_int8; +pub mod types; + +use self::downloader::download_and_install_model; +use self::model_catalog::get_builtin_speech_model_manifest; +use self::model_store::SpeechModelStore; +use self::recognizer::SpeechRecognizer; +use self::sensevoice_int8::SenseVoiceInt8Recognizer; +pub use self::types::*; +use crate::error::{CoreError, CoreResult}; +use crate::infrastructure::PathManager; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::fs; +use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +#[derive(Clone)] +pub struct SpeechService { + store: SpeechModelStore, + recognizer: Arc, + downloads: Arc>>, + sessions: Arc>>, +} + +#[derive(Debug)] +struct SpeechInputSessionState { + session: SpeechInputSession, + audio_path: PathBuf, + received_bytes: u64, +} + +impl SpeechService { + pub fn new(path_manager: PathManager) -> Self { + Self { + store: SpeechModelStore::new(path_manager), + recognizer: Arc::new(SenseVoiceInt8Recognizer::new()), + downloads: Arc::new(Mutex::new(HashMap::new())), + sessions: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub async fn list_models(&self) -> CoreResult { + Ok(SpeechListModelsResponse { + models: self.store.list_statuses().await?, + }) + } + + pub async fn model_status(&self, model_id: &str) -> CoreResult { + let manifest = get_builtin_speech_model_manifest(model_id)?; + self.store.status_for_manifest(&manifest).await + } + + pub async fn download_model( + &self, + request: SpeechDownloadModelRequest, + on_progress: F, + ) -> CoreResult + where + F: Fn(SpeechModelProgressEvent) + Send + Sync, + { + let manifest = get_builtin_speech_model_manifest(&request.model_id)?; + let cancel = CancellationToken::new(); + { + let mut downloads = self.downloads.lock().await; + if downloads.contains_key(&manifest.id) { + return Err(CoreError::validation(format!( + "Speech model is already downloading: {}", + manifest.id + ))); + } + downloads.insert(manifest.id.clone(), cancel.clone()); + } + + let result = download_and_install_model(&self.store, &manifest, cancel, |progress| { + let status = SpeechModelStatus { + model_id: manifest.id.clone(), + display_name: manifest.display_name.clone(), + version: manifest.version.clone(), + state: SpeechModelInstallState::Downloading, + installed_path: None, + installed_bytes: progress.downloaded_bytes, + expected_bytes: manifest.archive_size_bytes, + progress: Some(progress), + error: None, + }; + on_progress(SpeechModelProgressEvent { status }); + }) + .await; + + self.downloads.lock().await.remove(&manifest.id); + result + } + + pub async fn cancel_model_download( + &self, + request: SpeechCancelModelDownloadRequest, + ) -> CoreResult { + let manifest = get_builtin_speech_model_manifest(&request.model_id)?; + if let Some(token) = self.downloads.lock().await.remove(&manifest.id) { + token.cancel(); + } + self.store.cleanup_download(&manifest).await?; + self.store.status_for_manifest(&manifest).await + } + + pub async fn delete_model( + &self, + request: SpeechDeleteModelRequest, + ) -> CoreResult { + let manifest = get_builtin_speech_model_manifest(&request.model_id)?; + if let Some(token) = self.downloads.lock().await.remove(&manifest.id) { + token.cancel(); + } + self.recognizer.unload().await?; + self.store.delete_model(&manifest).await + } + + pub async fn verify_model( + &self, + request: SpeechVerifyModelRequest, + ) -> CoreResult { + let manifest = get_builtin_speech_model_manifest(&request.model_id)?; + self.store.verify_model(&manifest).await + } + + pub async fn start_input_session( + &self, + request: SpeechStartInputSessionRequest, + ) -> CoreResult { + let model_id = request + .model_id + .unwrap_or_else(|| LOCAL_SENSEVOICE_SMALL_INT8_MODEL_ID.to_string()); + let manifest = get_builtin_speech_model_manifest(&model_id)?; + if !self.store.has_required_files(&manifest).await { + return Err(CoreError::NotFound( + "Speech model is not installed; download it before starting voice input" + .to_string(), + )); + } + + let sample_rate = request.sample_rate.unwrap_or(DEFAULT_SPEECH_SAMPLE_RATE); + if sample_rate == 0 { + return Err(CoreError::validation( + "Sample rate must be greater than zero", + )); + } + let max_recording_seconds = request + .max_recording_seconds + .unwrap_or(DEFAULT_MAX_RECORDING_SECONDS); + let language = request.language.unwrap_or_else(|| "auto".to_string()); + let model_dir = self.store.model_dir(&manifest); + let recognizer = Arc::clone(&self.recognizer); + let warmup_language = language.clone(); + let warmup_model_id = model_id.clone(); + tokio::spawn(async move { + if let Err(error) = recognizer.warmup(model_dir, warmup_language).await { + log::warn!( + "Failed to warm up speech recognizer: model_id={}, error={}", + warmup_model_id, + error + ); + } + }); + + let session_id = Uuid::new_v4().to_string(); + let temp_dir = self.store.path_manager().speech_input_temp_dir(); + fs::create_dir_all(&temp_dir).await?; + let audio_path = temp_dir.join(format!("{session_id}.pcm")); + fs::File::create(&audio_path).await?; + + let session = SpeechInputSession { + session_id: session_id.clone(), + model_id, + language, + sample_rate, + max_recording_seconds, + }; + self.sessions.lock().await.insert( + session_id, + SpeechInputSessionState { + session: session.clone(), + audio_path, + received_bytes: 0, + }, + ); + Ok(session) + } + + pub async fn append_audio_chunk( + &self, + request: SpeechAppendAudioChunkRequest, + ) -> CoreResult { + let bytes = BASE64_STANDARD + .decode(request.pcm16_base64.as_bytes()) + .map_err(|e| CoreError::validation(format!("Invalid base64 audio chunk: {e}")))?; + if bytes.len() % 2 != 0 { + return Err(CoreError::validation( + "PCM16 audio chunks must contain complete samples", + )); + } + + let mut sessions = self.sessions.lock().await; + let state = sessions + .get_mut(&request.session_id) + .ok_or_else(|| CoreError::NotFound("Speech input session not found".to_string()))?; + let max_bytes = + state.session.sample_rate as u64 * state.session.max_recording_seconds as u64 * 2; + if state.received_bytes + bytes.len() as u64 > max_bytes { + return Err(CoreError::validation( + "Speech input audio exceeds the recording limit", + )); + } + + let mut file = fs::OpenOptions::new() + .append(true) + .open(&state.audio_path) + .await?; + file.write_all(&bytes).await?; + state.received_bytes += bytes.len() as u64; + Ok(SpeechAppendAudioChunkResponse { + received_bytes: state.received_bytes, + received_seconds: audio::pcm16_duration_seconds( + state.received_bytes, + state.session.sample_rate, + ), + }) + } + + pub async fn finish_input_session( + &self, + request: SpeechFinishInputSessionRequest, + ) -> CoreResult { + let state = self + .sessions + .lock() + .await + .remove(&request.session_id) + .ok_or_else(|| CoreError::NotFound("Speech input session not found".to_string()))?; + let manifest = get_builtin_speech_model_manifest(&state.session.model_id)?; + let pcm16_le = fs::read(&state.audio_path).await?; + let _ = fs::remove_file(&state.audio_path).await; + if pcm16_le.is_empty() { + return Err(CoreError::validation("No speech audio was captured")); + } + + self.recognizer + .transcribe(SpeechTranscribeRequest { + model_id: state.session.model_id, + model_dir: self.store.model_dir(&manifest), + pcm16_le, + sample_rate: state.session.sample_rate, + language: state.session.language, + }) + .await + } + + pub async fn cancel_input_session( + &self, + request: SpeechCancelInputSessionRequest, + ) -> CoreResult<()> { + if let Some(state) = self.sessions.lock().await.remove(&request.session_id) { + let _ = fs::remove_file(state.audio_path).await; + } + Ok(()) + } +} diff --git a/src/crates/core/src/service/speech/model_catalog.rs b/src/crates/core/src/service/speech/model_catalog.rs new file mode 100644 index 00000000..5a350d98 --- /dev/null +++ b/src/crates/core/src/service/speech/model_catalog.rs @@ -0,0 +1,43 @@ +use super::types::{SpeechModelManifest, LOCAL_SENSEVOICE_SMALL_INT8_MODEL_ID}; +use crate::error::{CoreError, CoreResult}; + +pub fn builtin_speech_model_manifests() -> Vec { + vec![sensevoice_small_int8_manifest()] +} + +pub fn get_builtin_speech_model_manifest(model_id: &str) -> CoreResult { + builtin_speech_model_manifests() + .into_iter() + .find(|manifest| manifest.id == model_id) + .ok_or_else(|| CoreError::NotFound(format!("Unknown speech model: {model_id}"))) +} + +pub fn sensevoice_small_int8_manifest() -> SpeechModelManifest { + SpeechModelManifest { + id: LOCAL_SENSEVOICE_SMALL_INT8_MODEL_ID.to_string(), + display_name: "SenseVoice Small INT8".to_string(), + provider: "k2-fsa/sherpa-onnx".to_string(), + version: "2025-09-09".to_string(), + variant: "int8".to_string(), + description: "Local multilingual speech recognition for Mandarin, Cantonese, English, Japanese, and Korean.".to_string(), + source_url: "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09.tar.bz2".to_string(), + source_page_url: "https://k2-fsa.github.io/sherpa/onnx/sense-voice/index.html".to_string(), + archive_name: "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09.tar.bz2".to_string(), + archive_size_bytes: 165_783_878, + archive_sha256: "7305f7905bfcf77fa0b39388a313f3da35c68d971661a65475b56fb2162c8e63".to_string(), + license_name: Some("Apache-2.0".to_string()), + languages: vec![ + "auto".to_string(), + "zh".to_string(), + "yue".to_string(), + "en".to_string(), + "ja".to_string(), + "ko".to_string(), + ], + required_files: vec![ + "model.int8.onnx".to_string(), + "tokens.txt".to_string(), + "README.md".to_string(), + ], + } +} diff --git a/src/crates/core/src/service/speech/model_store.rs b/src/crates/core/src/service/speech/model_store.rs new file mode 100644 index 00000000..4db57438 --- /dev/null +++ b/src/crates/core/src/service/speech/model_store.rs @@ -0,0 +1,213 @@ +use super::model_catalog::builtin_speech_model_manifests; +use super::types::{ + InstalledSpeechModelRecord, SpeechModelInstallState, SpeechModelManifest, SpeechModelStatus, +}; +use crate::error::{CoreError, CoreResult}; +use crate::infrastructure::PathManager; +use chrono::Utc; +use serde_json::json; +use std::path::{Component, Path, PathBuf}; +use tokio::fs; + +const INSTALL_RECORD_FILE: &str = "sparo-model-install.json"; + +#[derive(Clone)] +pub struct SpeechModelStore { + path_manager: PathManager, +} + +impl SpeechModelStore { + pub fn new(path_manager: PathManager) -> Self { + Self { path_manager } + } + + pub fn path_manager(&self) -> &PathManager { + &self.path_manager + } + + pub fn model_dir(&self, manifest: &SpeechModelManifest) -> PathBuf { + self.path_manager + .speech_model_dir(&manifest.id, &manifest.version) + } + + pub fn archive_download_path(&self, manifest: &SpeechModelManifest) -> PathBuf { + self.path_manager + .speech_model_downloads_dir() + .join(&manifest.id) + .join(&manifest.version) + .join(&manifest.archive_name) + } + + pub fn archive_partial_path(&self, manifest: &SpeechModelManifest) -> PathBuf { + self.archive_download_path(manifest) + .with_extension("partial") + } + + pub async fn list_statuses(&self) -> CoreResult> { + let mut statuses = Vec::new(); + for manifest in builtin_speech_model_manifests() { + statuses.push(self.status_for_manifest(&manifest).await?); + } + Ok(statuses) + } + + pub async fn status_for_manifest( + &self, + manifest: &SpeechModelManifest, + ) -> CoreResult { + let model_dir = self.model_dir(manifest); + let installed_bytes = dir_size(&model_dir).await?; + let installed = self.has_required_files(manifest).await; + let state = if installed { + SpeechModelInstallState::Installed + } else if model_dir.exists() { + SpeechModelInstallState::Corrupt + } else { + SpeechModelInstallState::NotInstalled + }; + + Ok(SpeechModelStatus { + model_id: manifest.id.clone(), + display_name: manifest.display_name.clone(), + version: manifest.version.clone(), + state, + installed_path: installed.then_some(model_dir), + installed_bytes, + expected_bytes: manifest.archive_size_bytes, + progress: None, + error: None, + }) + } + + pub async fn has_required_files(&self, manifest: &SpeechModelManifest) -> bool { + let model_dir = self.model_dir(manifest); + if !model_dir.is_dir() { + return false; + } + + manifest + .required_files + .iter() + .all(|relative| model_dir.join(relative).is_file()) + } + + pub async fn verify_model( + &self, + manifest: &SpeechModelManifest, + ) -> CoreResult { + let mut status = self.status_for_manifest(manifest).await?; + if !self.has_required_files(manifest).await { + status.state = SpeechModelInstallState::Corrupt; + status.error = Some("Required model files are missing".to_string()); + } + Ok(status) + } + + pub async fn write_install_record( + &self, + manifest: &SpeechModelManifest, + model_dir: &Path, + ) -> CoreResult<()> { + let record = InstalledSpeechModelRecord { + id: manifest.id.clone(), + version: manifest.version.clone(), + installed_at_ms: Utc::now().timestamp_millis(), + source_url: manifest.source_url.clone(), + archive_sha256: manifest.archive_sha256.clone(), + }; + let payload = serde_json::to_vec_pretty(&json!({ + "model": manifest, + "install": record, + }))?; + fs::write(model_dir.join(INSTALL_RECORD_FILE), payload).await?; + Ok(()) + } + + pub async fn delete_model( + &self, + manifest: &SpeechModelManifest, + ) -> CoreResult { + let root = self.path_manager.speech_models_dir(); + let target = self.model_dir(manifest); + if !target.exists() { + return self.status_for_manifest(manifest).await; + } + + let root = canonical_or_create(&root).await?; + let resolved = target + .canonicalize() + .map_err(|e| CoreError::service(format!("Failed to resolve speech model path: {e}")))?; + if !resolved.starts_with(&root) { + return Err(CoreError::validation( + "Refusing to delete path outside managed speech models directory", + )); + } + + fs::remove_dir_all(&resolved).await?; + self.cleanup_download(manifest).await?; + self.status_for_manifest(manifest).await + } + + pub async fn cleanup_download(&self, manifest: &SpeechModelManifest) -> CoreResult<()> { + let dir = self + .path_manager + .speech_model_downloads_dir() + .join(&manifest.id) + .join(&manifest.version); + if dir.exists() { + fs::remove_dir_all(dir).await?; + } + Ok(()) + } +} + +pub fn validate_relative_archive_path(path: &Path) -> CoreResult { + let mut sanitized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(part) => sanitized.push(part), + Component::CurDir => {} + _ => { + return Err(CoreError::validation(format!( + "Archive entry contains unsafe path: {}", + path.display() + ))); + } + } + } + if sanitized.as_os_str().is_empty() { + return Err(CoreError::validation("Archive entry path is empty")); + } + Ok(sanitized) +} + +pub async fn dir_size(path: &Path) -> CoreResult { + fn inner( + path: &Path, + ) -> std::pin::Pin> + Send + '_>> { + Box::pin(async move { + if !path.exists() { + return Ok(0); + } + let metadata = fs::metadata(path).await?; + if metadata.is_file() { + return Ok(metadata.len()); + } + + let mut total = 0u64; + let mut entries = fs::read_dir(path).await?; + while let Some(entry) = entries.next_entry().await? { + total += inner(&entry.path()).await?; + } + Ok(total) + }) + } + + inner(path).await +} + +async fn canonical_or_create(path: &Path) -> CoreResult { + fs::create_dir_all(path).await?; + path.canonicalize() + .map_err(|e| CoreError::service(format!("Failed to resolve directory: {e}"))) +} diff --git a/src/crates/core/src/service/speech/recognizer.rs b/src/crates/core/src/service/speech/recognizer.rs new file mode 100644 index 00000000..2050aca7 --- /dev/null +++ b/src/crates/core/src/service/speech/recognizer.rs @@ -0,0 +1,16 @@ +use super::types::{SpeechTranscribeRequest, SpeechTranscriptionResult}; +use crate::error::CoreResult; +use async_trait::async_trait; +use std::path::PathBuf; + +#[async_trait] +pub trait SpeechRecognizer: Send + Sync { + async fn warmup(&self, model_dir: PathBuf, language: String) -> CoreResult<()>; + + async fn unload(&self) -> CoreResult<()>; + + async fn transcribe( + &self, + request: SpeechTranscribeRequest, + ) -> CoreResult; +} diff --git a/src/crates/core/src/service/speech/sensevoice_int8.rs b/src/crates/core/src/service/speech/sensevoice_int8.rs new file mode 100644 index 00000000..8ffc3ba1 --- /dev/null +++ b/src/crates/core/src/service/speech/sensevoice_int8.rs @@ -0,0 +1,167 @@ +use super::audio::pcm16_le_to_f32_samples; +use super::recognizer::SpeechRecognizer; +use super::types::{SpeechTranscribeRequest, SpeechTranscriptionResult}; +use crate::error::{CoreError, CoreResult}; +use async_trait::async_trait; +use sherpa_onnx::{OfflineRecognizer, OfflineRecognizerConfig, OfflineSenseVoiceModelConfig}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +#[derive(Clone, Default)] +pub struct SenseVoiceInt8Recognizer { + cache: Arc>>, +} + +struct CachedSenseVoiceRecognizer { + model_path: PathBuf, + tokens_path: PathBuf, + language: String, + recognizer: OfflineRecognizer, +} + +impl SenseVoiceInt8Recognizer { + pub fn new() -> Self { + Self::default() + } +} + +#[async_trait] +impl SpeechRecognizer for SenseVoiceInt8Recognizer { + async fn warmup(&self, model_dir: PathBuf, language: String) -> CoreResult<()> { + let cache = Arc::clone(&self.cache); + tokio::task::spawn_blocking(move || { + let model_path = model_dir.join("model.int8.onnx"); + let tokens_path = model_dir.join("tokens.txt"); + ensure_model_files(&model_path, &tokens_path)?; + let mut cache = cache + .lock() + .map_err(|_| CoreError::service("Speech recognizer cache lock is poisoned"))?; + ensure_cached_recognizer(&mut cache, model_path, tokens_path, language)?; + Ok(()) + }) + .await + .map_err(|e| CoreError::service(format!("Speech recognizer warmup task failed: {e}")))? + } + + async fn unload(&self) -> CoreResult<()> { + let cache = Arc::clone(&self.cache); + tokio::task::spawn_blocking(move || { + let mut cache = cache + .lock() + .map_err(|_| CoreError::service("Speech recognizer cache lock is poisoned"))?; + *cache = None; + Ok(()) + }) + .await + .map_err(|e| CoreError::service(format!("Speech recognizer unload task failed: {e}")))? + } + + async fn transcribe( + &self, + request: SpeechTranscribeRequest, + ) -> CoreResult { + let cache = Arc::clone(&self.cache); + tokio::task::spawn_blocking(move || transcribe_blocking(request, cache)) + .await + .map_err(|e| CoreError::service(format!("Speech transcription task failed: {e}")))? + } +} + +fn transcribe_blocking( + request: SpeechTranscribeRequest, + cache: Arc>>, +) -> CoreResult { + let started = Instant::now(); + let model_path = request.model_dir.join("model.int8.onnx"); + let tokens_path = request.model_dir.join("tokens.txt"); + ensure_model_files(&model_path, &tokens_path)?; + + let samples = pcm16_le_to_f32_samples(&request.pcm16_le)?; + if samples.is_empty() { + return Err(CoreError::validation("No audio samples were provided")); + } + + let text = { + let mut cache = cache + .lock() + .map_err(|_| CoreError::service("Speech recognizer cache lock is poisoned"))?; + let cached = ensure_cached_recognizer( + &mut cache, + model_path, + tokens_path, + request.language.clone(), + )?; + let stream = cached.recognizer.create_stream(); + stream.accept_waveform(request.sample_rate as i32, &samples); + cached.recognizer.decode(&stream); + + stream + .get_result() + .ok_or_else(|| CoreError::service("Failed to read speech result"))? + .text + .trim() + .to_string() + }; + + let audio_duration_seconds = samples.len() as f64 / request.sample_rate as f64; + Ok(SpeechTranscriptionResult { + text, + language: request.language, + duration_ms: started.elapsed().as_millis() as u64, + audio_duration_seconds, + }) +} + +fn ensure_model_files(model_path: &Path, tokens_path: &Path) -> CoreResult<()> { + if !model_path.is_file() || !tokens_path.is_file() { + return Err(CoreError::NotFound( + "SenseVoice model files are missing; download or repair the model first".to_string(), + )); + } + Ok(()) +} + +fn ensure_cached_recognizer( + cache: &mut Option, + model_path: PathBuf, + tokens_path: PathBuf, + language: String, +) -> CoreResult<&mut CachedSenseVoiceRecognizer> { + let should_reload = cache.as_ref().is_none_or(|cached| { + cached.model_path != model_path + || cached.tokens_path != tokens_path + || cached.language != language + }); + + if should_reload { + let recognizer = create_recognizer(&model_path, &tokens_path, &language)?; + *cache = Some(CachedSenseVoiceRecognizer { + model_path, + tokens_path, + language, + recognizer, + }); + } + + cache + .as_mut() + .ok_or_else(|| CoreError::service("Speech recognizer cache is empty")) +} + +fn create_recognizer( + model_path: &Path, + tokens_path: &Path, + language: &str, +) -> CoreResult { + let mut config = OfflineRecognizerConfig::default(); + config.model_config.sense_voice = OfflineSenseVoiceModelConfig { + model: Some(model_path.to_string_lossy().to_string()), + language: Some(language.to_string()), + use_itn: true, + }; + config.model_config.tokens = Some(tokens_path.to_string_lossy().to_string()); + + OfflineRecognizer::create(&config) + .ok_or_else(|| CoreError::service("Failed to create speech recognizer")) +} diff --git a/src/crates/core/src/service/speech/types.rs b/src/crates/core/src/service/speech/types.rs new file mode 100644 index 00000000..314e413b --- /dev/null +++ b/src/crates/core/src/service/speech/types.rs @@ -0,0 +1,175 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +pub const LOCAL_SENSEVOICE_SMALL_INT8_MODEL_ID: &str = "sensevoice-small-int8"; +pub const LOCAL_SENSEVOICE_SMALL_INT8_MODEL_REF: &str = "local:sensevoice-small-int8"; +pub const DEFAULT_SPEECH_SAMPLE_RATE: u32 = 16_000; +pub const DEFAULT_MAX_RECORDING_SECONDS: u32 = 60; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechModelManifest { + pub id: String, + pub display_name: String, + pub provider: String, + pub version: String, + pub variant: String, + pub description: String, + pub source_url: String, + pub source_page_url: String, + pub archive_name: String, + pub archive_size_bytes: u64, + pub archive_sha256: String, + pub license_name: Option, + pub languages: Vec, + pub required_files: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SpeechModelInstallState { + NotInstalled, + Downloading, + Installed, + Verifying, + Corrupt, + Deleting, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechModelProgress { + pub model_id: String, + pub downloaded_bytes: u64, + pub total_bytes: u64, + pub percent: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechModelStatus { + pub model_id: String, + pub display_name: String, + pub version: String, + pub state: SpeechModelInstallState, + pub installed_path: Option, + pub installed_bytes: u64, + pub expected_bytes: u64, + pub progress: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechListModelsResponse { + pub models: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechDownloadModelRequest { + pub model_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechCancelModelDownloadRequest { + pub model_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechDeleteModelRequest { + pub model_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechVerifyModelRequest { + pub model_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechModelProgressEvent { + pub status: SpeechModelStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechStartInputSessionRequest { + #[serde(default)] + pub model_id: Option, + #[serde(default)] + pub language: Option, + #[serde(default)] + pub sample_rate: Option, + #[serde(default)] + pub max_recording_seconds: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechInputSession { + pub session_id: String, + pub model_id: String, + pub language: String, + pub sample_rate: u32, + pub max_recording_seconds: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechAppendAudioChunkRequest { + pub session_id: String, + /// Base64-encoded PCM16 little-endian mono audio. + pub pcm16_base64: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechAppendAudioChunkResponse { + pub received_bytes: u64, + pub received_seconds: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechFinishInputSessionRequest { + pub session_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechCancelInputSessionRequest { + pub session_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeechTranscriptionResult { + pub text: String, + pub language: String, + pub duration_ms: u64, + pub audio_duration_seconds: f64, +} + +#[derive(Debug, Clone)] +pub struct SpeechTranscribeRequest { + pub model_id: String, + pub model_dir: PathBuf, + pub pcm16_le: Vec, + pub sample_rate: u32, + pub language: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledSpeechModelRecord { + pub id: String, + pub version: String, + pub installed_at_ms: i64, + pub source_url: String, + pub archive_sha256: String, +} diff --git a/src/web-ui/src/app/components/GlobalSearchDialog/GlobalSearchDialog.scss b/src/web-ui/src/app/components/GlobalSearchDialog/GlobalSearchDialog.scss index eb19ea29..6ab77136 100644 --- a/src/web-ui/src/app/components/GlobalSearchDialog/GlobalSearchDialog.scss +++ b/src/web-ui/src/app/components/GlobalSearchDialog/GlobalSearchDialog.scss @@ -150,8 +150,20 @@ } } -.sparo-search-dialog__group-label { +.sparo-search-dialog__group-header { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + gap: var(--ds-space-2); padding: var(--ds-space-1) var(--ds-space-1) var(--ds-space-1) 2px; +} + +.sparo-search-dialog__group-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; @include eyebrow-label.ds-eyebrow-label( $font-size: var(--ds-compact-font-size-xxs), $letter-spacing: 0.05em, @@ -162,6 +174,24 @@ opacity: 0.88; } +.sparo-search-dialog__group-actions { + display: inline-flex; + align-items: center; + flex-shrink: 0; + gap: 2px; +} + +.sparo-search-dialog__group-action { + width: 24px; + height: 24px; + color: var(--ds-color-text-muted); + + &:hover { + color: var(--ds-color-text-primary); + background: var(--ds-color-element-soft); + } +} + .sparo-search-dialog__session-hint { padding: var(--ds-space-2) var(--ds-space-2) var(--ds-space-1) 2px; font-size: var(--ds-font-size-xs); @@ -201,7 +231,10 @@ color: var(--ds-color-text-primary); display: flex; align-items: center; + justify-content: center; flex-shrink: 0; + width: 22px; + height: 22px; opacity: 0.82; transition: opacity 0.2s ease; @@ -211,6 +244,294 @@ } } +.sparo-search-dialog__work-mode-icon { + --sparo-search-dialog-work-tone-local: var(--sparo-search-dialog-work-tone, var(--ds-color-text-muted)); + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: var(--ds-radius-sm); + color: var(--ds-color-text-secondary); + background: transparent; + transform: scale(1); + transform-origin: center; + transition: + color var(--ds-motion-fast) var(--ds-easing-standard), + background var(--ds-motion-fast) var(--ds-easing-standard), + opacity var(--ds-motion-fast) var(--ds-easing-standard), + transform var(--ds-motion-fast) var(--ds-easing-standard); + + &::before, + &::after { + content: ''; + position: absolute; + pointer-events: none; + opacity: 0; + } + + .sparo-search-dialog__item:hover &, + .sparo-search-dialog__item--active & { + opacity: 1; + transform: scale(1.04); + } + + &.has-state-instrument { + color: var(--sparo-search-dialog-work-tone-local); + background: transparent; + } +} + +.sparo-search-dialog__work-mode-glyph { + position: relative; + z-index: var(--ds-z-local); + display: inline-flex; + align-items: center; + justify-content: center; + transition: + color var(--ds-motion-fast) var(--ds-easing-standard), + opacity var(--ds-motion-fast) var(--ds-easing-standard), + transform var(--ds-motion-fast) var(--ds-easing-standard); +} + +.sparo-search-dialog__work-state-mark { + position: absolute; + pointer-events: none; +} + +.sparo-search-dialog__work-mode-icon--running { + --sparo-search-dialog-pixel-dim: color-mix(in srgb, var(--sparo-search-dialog-work-tone-local) 20%, transparent); + --sparo-search-dialog-pixel-mid: color-mix(in srgb, var(--sparo-search-dialog-work-tone-local) 54%, transparent); + --sparo-search-dialog-pixel-bright: var(--sparo-search-dialog-work-tone-local); + color: var(--sparo-search-dialog-work-tone-local); + + &::before { + left: 4px; + top: 4px; + width: 3px; + height: 3px; + border-radius: 1px; + background: var(--sparo-search-dialog-pixel-bright); + box-shadow: + 5px -3px 0 var(--sparo-search-dialog-pixel-dim), + 5px 1px 0 var(--sparo-search-dialog-pixel-mid), + 10px 1px 0 var(--sparo-search-dialog-pixel-dim), + -3px 5px 0 var(--sparo-search-dialog-pixel-dim), + 1px 5px 0 var(--sparo-search-dialog-pixel-mid), + 5px 5px 0 var(--sparo-search-dialog-pixel-bright), + 10px 5px 0 var(--sparo-search-dialog-pixel-mid), + 14px 5px 0 var(--sparo-search-dialog-pixel-dim), + 1px 10px 0 var(--sparo-search-dialog-pixel-dim), + 5px 10px 0 var(--sparo-search-dialog-pixel-mid), + 10px 10px 0 var(--sparo-search-dialog-pixel-dim), + 5px 14px 0 var(--sparo-search-dialog-pixel-dim); + opacity: 1; + animation: sparo-search-dialog-status-pixel-bloom 2.4s steps(1, end) infinite; + } +} + +.sparo-search-dialog__work-mode-icon--running .sparo-search-dialog__work-mode-glyph { + opacity: 0; + transform: scale(0.88); +} + +.sparo-search-dialog__work-mode-icon--running .sparo-search-dialog__work-state-mark, +.sparo-search-dialog__work-mode-icon--waiting-user .sparo-search-dialog__work-state-mark { + display: none; +} + +.sparo-search-dialog__work-mode-icon--waiting-user { + color: var(--sparo-search-dialog-work-tone-local); + + &::before { + top: 2px; + right: 1px; + width: 5px; + height: 5px; + border-radius: var(--ds-radius-full); + background: var(--sparo-search-dialog-work-tone-local); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--sparo-search-dialog-work-tone-local) 12%, transparent); + opacity: 1; + animation: sparo-search-dialog-status-attention 1.9s ease-in-out infinite; + } +} + +.sparo-search-dialog__work-mode-icon--blocked { + color: var(--sparo-search-dialog-work-tone-local); + + &::before { + left: 4px; + right: 4px; + bottom: 3px; + height: 2px; + border-radius: var(--ds-radius-full); + background: var(--sparo-search-dialog-work-tone-local); + opacity: 1; + box-shadow: 5px 0 0 color-mix(in srgb, var(--ds-color-bg-elevated) 88%, transparent); + } +} + +.sparo-search-dialog__work-mode-icon--failed { + color: var(--sparo-search-dialog-work-tone-local); +} + +.sparo-search-dialog__work-mode-icon--failed .sparo-search-dialog__work-state-mark { + width: 8px; + height: 8px; + right: -2px; + top: -2px; + + &::before, + &::after { + content: ''; + position: absolute; + left: 3px; + top: 0; + width: 2px; + height: 8px; + border-radius: var(--ds-radius-full); + background: var(--sparo-search-dialog-work-tone-local); + } + + &::before { + transform: rotate(45deg); + } + + &::after { + transform: rotate(-45deg); + } +} + +.sparo-search-dialog__work-mode-icon--paused { + color: color-mix(in srgb, var(--ds-color-text-muted) 82%, transparent); + background: color-mix(in srgb, var(--ds-color-element-medium) 56%, transparent); +} + +.sparo-search-dialog__work-mode-icon--paused .sparo-search-dialog__work-state-mark { + width: 7px; + height: 8px; + right: 2px; + bottom: 2px; + border-left: 2px solid currentColor; + border-right: 2px solid currentColor; + opacity: 0.75; +} + +.sparo-search-dialog__work-mode-icon--completed { + color: var(--sparo-search-dialog-work-tone-local); +} + +.sparo-search-dialog__work-mode-icon--completed .sparo-search-dialog__work-state-mark { + width: 8px; + height: 5px; + right: -2px; + top: 0; + border-left: 2px solid var(--sparo-search-dialog-work-tone-local); + border-bottom: 2px solid var(--sparo-search-dialog-work-tone-local); + transform: rotate(-45deg); +} + +@keyframes sparo-search-dialog-status-pixel-bloom { + 0% { + background: var(--sparo-search-dialog-pixel-bright); + box-shadow: + 5px -3px 0 var(--sparo-search-dialog-pixel-dim), + 5px 1px 0 var(--sparo-search-dialog-pixel-mid), + 10px 1px 0 var(--sparo-search-dialog-pixel-dim), + -3px 5px 0 var(--sparo-search-dialog-pixel-dim), + 1px 5px 0 var(--sparo-search-dialog-pixel-mid), + 5px 5px 0 var(--sparo-search-dialog-pixel-bright), + 10px 5px 0 var(--sparo-search-dialog-pixel-mid), + 14px 5px 0 var(--sparo-search-dialog-pixel-dim), + 1px 10px 0 var(--sparo-search-dialog-pixel-dim), + 5px 10px 0 var(--sparo-search-dialog-pixel-mid), + 10px 10px 0 var(--sparo-search-dialog-pixel-dim), + 5px 14px 0 var(--sparo-search-dialog-pixel-dim); + } + + 24% { + background: var(--sparo-search-dialog-pixel-bright); + box-shadow: + 5px -3px 0 var(--sparo-search-dialog-pixel-bright), + 5px 1px 0 var(--sparo-search-dialog-pixel-bright), + 10px 1px 0 var(--sparo-search-dialog-pixel-mid), + -3px 5px 0 var(--sparo-search-dialog-pixel-mid), + 1px 5px 0 var(--sparo-search-dialog-pixel-bright), + 5px 5px 0 var(--sparo-search-dialog-pixel-bright), + 10px 5px 0 var(--sparo-search-dialog-pixel-mid), + 14px 5px 0 var(--sparo-search-dialog-pixel-dim), + 1px 10px 0 var(--sparo-search-dialog-pixel-dim), + 5px 10px 0 var(--sparo-search-dialog-pixel-mid), + 10px 10px 0 var(--sparo-search-dialog-pixel-dim), + 5px 14px 0 var(--sparo-search-dialog-pixel-dim); + } + + 48% { + background: var(--sparo-search-dialog-pixel-mid); + box-shadow: + 5px -3px 0 var(--sparo-search-dialog-pixel-mid), + 5px 1px 0 var(--sparo-search-dialog-pixel-mid), + 10px 1px 0 var(--sparo-search-dialog-pixel-bright), + -3px 5px 0 var(--sparo-search-dialog-pixel-dim), + 1px 5px 0 var(--sparo-search-dialog-pixel-mid), + 5px 5px 0 var(--sparo-search-dialog-pixel-bright), + 10px 5px 0 var(--sparo-search-dialog-pixel-bright), + 14px 5px 0 var(--sparo-search-dialog-pixel-bright), + 1px 10px 0 var(--sparo-search-dialog-pixel-dim), + 5px 10px 0 var(--sparo-search-dialog-pixel-mid), + 10px 10px 0 var(--sparo-search-dialog-pixel-mid), + 5px 14px 0 var(--sparo-search-dialog-pixel-dim); + } + + 72% { + background: var(--sparo-search-dialog-pixel-bright); + box-shadow: + 5px -3px 0 var(--sparo-search-dialog-pixel-dim), + 5px 1px 0 var(--sparo-search-dialog-pixel-mid), + 10px 1px 0 var(--sparo-search-dialog-pixel-dim), + -3px 5px 0 var(--sparo-search-dialog-pixel-bright), + 1px 5px 0 var(--sparo-search-dialog-pixel-bright), + 5px 5px 0 var(--sparo-search-dialog-pixel-bright), + 10px 5px 0 var(--sparo-search-dialog-pixel-bright), + 14px 5px 0 var(--sparo-search-dialog-pixel-bright), + 1px 10px 0 var(--sparo-search-dialog-pixel-dim), + 5px 10px 0 var(--sparo-search-dialog-pixel-mid), + 10px 10px 0 var(--sparo-search-dialog-pixel-dim), + 5px 14px 0 var(--sparo-search-dialog-pixel-dim); + } + + 100% { + background: var(--sparo-search-dialog-pixel-bright); + box-shadow: + 5px -3px 0 var(--sparo-search-dialog-pixel-dim), + 5px 1px 0 var(--sparo-search-dialog-pixel-mid), + 10px 1px 0 var(--sparo-search-dialog-pixel-dim), + -3px 5px 0 var(--sparo-search-dialog-pixel-dim), + 1px 5px 0 var(--sparo-search-dialog-pixel-mid), + 5px 5px 0 var(--sparo-search-dialog-pixel-bright), + 10px 5px 0 var(--sparo-search-dialog-pixel-mid), + 14px 5px 0 var(--sparo-search-dialog-pixel-dim), + 1px 10px 0 var(--sparo-search-dialog-pixel-dim), + 5px 10px 0 var(--sparo-search-dialog-pixel-mid), + 10px 10px 0 var(--sparo-search-dialog-pixel-dim), + 5px 14px 0 var(--sparo-search-dialog-pixel-dim); + } +} + +@keyframes sparo-search-dialog-status-attention { + 0%, + 100% { + transform: scale(0.92); + opacity: 0.82; + } + + 50% { + transform: scale(1.05); + opacity: 1; + } +} + .sparo-search-dialog__item-content { flex: 1; min-width: 0; @@ -253,6 +574,20 @@ transform: none; } + .sparo-search-dialog__work-mode-icon, + .sparo-search-dialog__work-mode-icon::before, + .sparo-search-dialog__work-mode-icon::after, + .sparo-search-dialog__work-mode-glyph, + .sparo-search-dialog__work-state-mark { + transition: none; + animation: none; + } + + .sparo-search-dialog__item:hover .sparo-search-dialog__work-mode-icon, + .sparo-search-dialog__item--active .sparo-search-dialog__work-mode-icon { + transform: scale(1); + } + .sparo-search-dialog__item { transition: background 0.12s ease; } diff --git a/src/web-ui/src/app/components/GlobalSearchDialog/GlobalSearchDialog.tsx b/src/web-ui/src/app/components/GlobalSearchDialog/GlobalSearchDialog.tsx index 708a57b5..b66a5d85 100644 --- a/src/web-ui/src/app/components/GlobalSearchDialog/GlobalSearchDialog.tsx +++ b/src/web-ui/src/app/components/GlobalSearchDialog/GlobalSearchDialog.tsx @@ -1,6 +1,16 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { AppWindow, Boxes, FileText, FolderOpen, Layers3, ListChecks, MessageSquare } from 'lucide-react'; -import { Dialog, Search, SelectableRow } from '@/design-system'; +import { + AppWindow, + Boxes, + FileText, + FolderOpen, + Layers3, + LayoutDashboard, + ListChecks, + MessageSquare, + Plus, +} from 'lucide-react'; +import { Dialog, IconButton, Search, SelectableRow } from '@/design-system'; import { useI18n } from '@/infrastructure/i18n'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; @@ -25,7 +35,7 @@ import { import { productAppRequiresWorkspace } from '@/app/agentic-os/work/domain/productAppLaunchPolicy'; import { useWorks } from '@/app/agentic-os/work/hooks/useWorks'; import { filterWorkProjections } from '@/app/agentic-os/work/data/workSelectors'; -import { openArtifactInCenter, openWorkInCenter } from '@/app/agentic-os/work/navigation/openWork'; +import { openArtifactInCenter, openWorkCenterHome, openWorkInCenter } from '@/app/agentic-os/work/navigation/openWork'; import type { WorkProjection } from '@/app/agentic-os/work/projections/workProjection'; import { isSystemAgenticOsSession } from '@/flow_chat/domain/sessionDescriptor'; import { notificationService } from '@/shared/notification-system'; @@ -33,6 +43,14 @@ import { openWorkspaceScene } from '@/app/navigation/workspaceNavigation'; import { useAppsStore } from '@/app/scenes/apps/appsStore'; import { openAppBuilderSession } from '@/app/scenes/apps/app-builder/openAppBuilderSession'; import type { ArtifactRef, WorkRecord } from '@/app/agentic-os/work/domain/workTypes'; +import { + getWorkModeIcon, + getWorkToneValue, + isInstrumentedStatus, + selectWorksForDockList, + statusKey, + WORK_DOCK_LIST_LIMIT, +} from '@/app/components/WorkList/workListSelection'; import './GlobalSearchDialog.scss'; interface GlobalSearchDialogProps { @@ -49,6 +67,7 @@ interface SearchResultItem { sublabel?: string; workspaceId?: string; workId?: string; + work?: WorkProjection; appId?: string; productApp?: ProductAppCatalogEntry; workObject?: WorkObjectKind; @@ -57,7 +76,6 @@ interface SearchResultItem { } const MAX_PER_GROUP = 20; -const RECENT_TASKS_DEFAULT = 5; const getSessionTitle = (session: Session): string => session.title?.trim() || `Task ${session.sessionId.slice(0, 6)}`; @@ -141,6 +159,7 @@ function buildWorkResult( return { kind: 'work', id: work.id, + work, label: work.title, sublabel: workspaceLabel ? t('nav.search.workWorkspaceHint', { status, workspace: workspaceLabel }) @@ -239,6 +258,7 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } const [activeIndex, setActiveIndex] = useState(0); const [productApps, setProductApps] = useState([]); const [workspaceLaunchApp, setWorkspaceLaunchApp] = useState(null); + const [newWorkDialogOpen, setNewWorkDialogOpen] = useState(false); const [components, setComponents] = useState([]); const [flowChatState, setFlowChatState] = useState(() => flowChatStore.getState()); const [persistedOpenWorkspaceSessions, setPersistedOpenWorkspaceSessions] = useState< @@ -362,7 +382,10 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } const visibleWorks = projections.filter(work => work.status !== 'archived'); const matchedWorks = trimmedQuery ? filterWorkProjections(visibleWorks, trimmedQuery).slice(0, MAX_PER_GROUP) - : visibleWorks.slice(0, RECENT_TASKS_DEFAULT); + : selectWorksForDockList(projections, { + maxWorks: WORK_DOCK_LIST_LIMIT, + includeCompleted: false, + }); const matchedWorkSessionIds = new Set( matchedWorks .map(work => work.sessionId) @@ -374,40 +397,6 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } } if (!trimmedQuery) { - const mergedEntries = buildMergedSessionEntries( - topLevelSessions, - persistedOpenWorkspaceSessions, - openedWorkspaceIdSet, - '', - { excludeAgenticOs: true } - ); - for (const entry of mergedEntries - .filter(entry => { - const sessionId = 'session' in entry ? entry.session.sessionId : entry.disk.sessionId; - return !matchedWorkSessionIds.has(sessionId); - }) - .slice(0, RECENT_TASKS_DEFAULT) - ) { - if ('session' in entry) { - const { session, workspace } = entry; - items.push({ - kind: 'session', - id: session.sessionId, - label: getSessionTitle(session), - sublabel: t('nav.search.sessionWorkspaceHint', { workspace: workspace.name }), - workspaceId: workspace.id, - }); - } else { - const { disk, workspace } = entry; - items.push({ - kind: 'session', - id: disk.sessionId, - label: disk.sessionName?.trim() || `Task ${disk.sessionId.slice(0, 6)}`, - sublabel: t('nav.search.sessionWorkspaceHint', { workspace: workspace.name }), - workspaceId: workspace.id, - }); - } - } return items; } @@ -606,6 +595,16 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } tApps, ]); + const handleOpenWorkCenter = useCallback(() => { + onClose(); + openWorkCenterHome(); + }, [onClose]); + + const handleCreateWork = useCallback(() => { + setNewWorkDialogOpen(true); + onClose(); + }, [onClose]); + const handleInputKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); @@ -649,8 +648,21 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } initialScopeRequirement={workspaceLaunchApp?.launch?.scopeRequirement} /> ); + const newWorkDialog = ( + setNewWorkDialogOpen(false)} + /> + ); - if (!open) return workspaceLaunchApp ? workspaceLaunchDialog : null; + if (!open) { + return ( + <> + {workspaceLaunchApp ? workspaceLaunchDialog : null} + {newWorkDialogOpen ? newWorkDialog : null} + + ); + } const workspaceItems = results.filter(result => result.kind === 'workspace'); const workItems = results.filter(result => result.kind === 'work'); @@ -661,18 +673,47 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } const sessionItems = results.filter(result => result.kind === 'session'); const queryTrimmed = query.trim(); + const renderWorkIcon = (item: SearchResultItem) => { + if (!item.work) return ; + const ModeIcon = getWorkModeIcon(item.work); + const instrumented = isInstrumentedStatus(item.work.status); + return ( + + + + + {instrumented ? : null} + + ); + }; + let globalIndex = 0; const renderGroup = ( groupLabel: string, items: SearchResultItem[], - renderIcon: (item: SearchResultItem) => React.ReactNode + renderIcon: (item: SearchResultItem) => React.ReactNode, + headerActions?: React.ReactNode ) => { if (items.length === 0) return null; const startIndex = globalIndex; globalIndex += items.length; return (
-
{groupLabel}
+
+
{groupLabel}
+ {headerActions ? ( +
+ {headerActions} +
+ ) : null} +
{items.map((item, itemIndex) => { const itemGlobalIndex = startIndex + itemIndex; return ( @@ -737,23 +778,52 @@ const GlobalSearchDialog: React.FC = ({ open, onClose } {renderGroup( queryTrimmed ? t('nav.search.groupWorks') : t('nav.search.groupRecentWork'), workItems, - () => + renderWorkIcon, + !queryTrimmed ? ( + <> + + + + + + + + ) : undefined )} {renderGroup(t('nav.search.groupWorkspaces'), workspaceItems, () => )} {renderGroup(t('nav.search.groupApps'), appItems, () => )} {renderGroup(t('nav.search.groupWorkObjects'), workObjectItems, () => )} {renderGroup(t('nav.search.groupComponents'), componentItems, () => )} {renderGroup(t('nav.search.groupArtifacts'), artifactItems, () => )} - {renderGroup( - queryTrimmed ? t('nav.search.groupSessions') : t('nav.search.groupRecentTasks'), + {queryTrimmed ? renderGroup( + t('nav.search.groupSessions'), sessionItems, () => - )} + ) : null} )}
{workspaceLaunchDialog} + {newWorkDialog} ); }; diff --git a/src/web-ui/src/app/components/UnifiedTopBar/UnifiedTopBar.scss b/src/web-ui/src/app/components/UnifiedTopBar/UnifiedTopBar.scss index 8f4da54d..135f446b 100644 --- a/src/web-ui/src/app/components/UnifiedTopBar/UnifiedTopBar.scss +++ b/src/web-ui/src/app/components/UnifiedTopBar/UnifiedTopBar.scss @@ -294,11 +294,6 @@ height: 24px; } - &__work-list-control { - width: 28px; - height: 28px; - } - &__home-control { width: 28px; height: 28px; @@ -513,7 +508,6 @@ &__home-control, &__context-history-item, &__context-title-button, - &__work-list-control, &__search-trigger { transition: none; } diff --git a/src/web-ui/src/app/components/UnifiedTopBar/UnifiedTopBar.tsx b/src/web-ui/src/app/components/UnifiedTopBar/UnifiedTopBar.tsx index aebad268..b91fb95e 100644 --- a/src/web-ui/src/app/components/UnifiedTopBar/UnifiedTopBar.tsx +++ b/src/web-ui/src/app/components/UnifiedTopBar/UnifiedTopBar.tsx @@ -20,7 +20,6 @@ import { ArrowLeft, FolderOpen, House, - ListChecks, Search, } from 'lucide-react'; import { Button, Dialog, IconButton, Tooltip, WindowControls, DropdownMenu } from '@/design-system'; @@ -38,7 +37,6 @@ import { setRemoteConnectDisclaimerAgreed, } from '../RemoteConnectDialog/remoteConnectDisclaimerStorage'; import { useHeaderStore } from '../../stores/headerStore'; -import { useWorkDockStore } from '../../stores/workDockStore'; import { useWorkStore } from '@/app/agentic-os/work/data/workStore'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { useSessionProfile } from '../../session-profiles'; @@ -109,7 +107,6 @@ const UnifiedTopBar: React.FC = ({ const { warning } = useNotification(); const sessionContext = useSessionHeaderContext(); const contextNavOverrides = useHeaderStore((s) => s.contextNavOverrides); - const requestOpenWorkDock = useWorkDockStore((s) => s.requestOpenWorkDock); const works = useWorkStore((s) => s.works); const surfaceContext = useWorkspaceSurfaceStore((s) => s.surfaceContext); const sceneHistory = useWorkspaceSurfaceStore((s) => s.sceneHistory); @@ -122,7 +119,6 @@ const UnifiedTopBar: React.FC = ({ [surfaceContext, works] ); const hasSurfaceContext = activeSurface.kind !== 'agentic-os-home' || !!workContext; - const showWorkListControl = activeSurface.kind === 'scene'; const [searchOpen, setSearchOpen] = useState(false); const [logoMenuOpen, setLogoMenuOpen] = useState(false); @@ -591,22 +587,6 @@ const UnifiedTopBar: React.FC = ({ )} - {showWorkListControl && ( - - - )} - {showContextCapsule && (
{
void; } -function isFocusStatus(status: WorkStatus): boolean { - return isWorkRunningStatus(status) || isWorkAttentionStatus(status); -} - function isCancellableStatus(status: WorkStatus): boolean { return status === 'running' || status === 'waiting_user' || status === 'blocked'; } @@ -56,106 +58,6 @@ function groupKey(work: WorkProjection): 'running' | 'active' | 'done' { return 'active'; } -function statusKey(status: WorkStatus): string { - return status.replace(/_/g, '-'); -} - -function hasSessionLikeSurface(work: WorkProjection): boolean { - return work.primarySurface.kind === 'work_session' - || work.primarySurface.kind === 'agent_session' - || work.surfaces?.some((surface) => ( - surface.kind === 'work_session' || surface.kind === 'agent_session' - )) === true; -} - -function getWorkModeIcon(work: WorkProjection) { - if (work.kind === 'app_workflow') { - if (hasSessionLikeSurface(work)) return MessageSquare; - if (work.primarySurface.kind === 'application_surface') return AppWindow; - return Sparkles; - } - const { kind } = work; - if (kind === 'tracking' || kind === 'recurring') return ListTodo; - if (kind === 'topic') return Brush; - if (kind === 'long_running_session') return Clock3; - if (kind === 'one_shot' || kind === 'multi_step' || kind === 'delegated_work') return ListChecks; - return Code2; -} - -function getWorkToneValue(status: WorkStatus): string { - if (status === 'waiting_user' || status === 'blocked') return 'var(--ds-color-warning)'; - if (status === 'failed') return 'var(--ds-color-danger)'; - if (status === 'completed') return 'var(--ds-color-success)'; - if (status === 'running') return 'var(--ds-color-accent-500)'; - return 'var(--ds-color-text-muted)'; -} - -function isInstrumentedStatus(status: WorkStatus): boolean { - return status === 'running' - || status === 'waiting_user' - || status === 'blocked' - || status === 'failed' - || status === 'paused' - || status === 'completed' - || status === 'cancelled' - || status === 'interrupted'; -} - -function statusPriority(status: WorkStatus): number { - switch (status) { - case 'waiting_user': - return 0; - case 'blocked': - return 1; - case 'failed': - return 2; - case 'running': - return 3; - case 'active': - return 4; - case 'paused': - return 5; - case 'draft': - return 6; - case 'completed': - return 7; - case 'cancelled': - return 8; - case 'interrupted': - return 9; - case 'archived': - return 10; - } -} - -function kindContinuityPriority(kind: WorkKind): number { - switch (kind) { - case 'recurring': - return 0; - case 'long_running_session': - case 'tracking': - case 'topic': - return 1; - case 'app_workflow': - return 2; - case 'multi_step': - case 'delegated_work': - return 3; - case 'one_shot': - return 4; - } -} - -function compareWorksForDock(left: WorkProjection, right: WorkProjection): number { - const byStatus = statusPriority(left.status) - statusPriority(right.status); - if (byStatus !== 0) return byStatus; - const byKind = kindContinuityPriority(left.kind) - kindContinuityPriority(right.kind); - if (byKind !== 0) return byKind; - const byTime = right.updatedAt - left.updatedAt; - if (byTime !== 0) return byTime; - return left.id.localeCompare(right.id); -} - const WorkList: React.FC = ({ className, query = '', @@ -175,23 +77,13 @@ const WorkList: React.FC = ({ const workById = useMemo(() => new Map(works.map((work) => [work.id, work])), [works]); const visibleWorks = useMemo(() => { - const filtered = filterWorkProjections(projections, query) - .filter((work) => { - const running = isFocusStatus(work.status); - if (runningFilter === 'running') return running; - if (runningFilter === 'not-running') return !running; - return true; - }) - .filter((work) => (includeArchived ? true : work.status !== 'archived')) - .filter((work) => ( - includeCompleted - ? true - : work.status !== 'completed' - && work.status !== 'cancelled' - && work.status !== 'interrupted' - )) - .sort(compareWorksForDock); - return typeof maxWorks === 'number' ? filtered.slice(0, maxWorks) : filtered; + return selectWorksForDockList(projections, { + query, + maxWorks, + runningFilter, + includeArchived, + includeCompleted, + }); }, [includeArchived, includeCompleted, maxWorks, projections, query, runningFilter]); const indexedVisibleWorks = useMemo( diff --git a/src/web-ui/src/app/components/WorkList/workListSelection.ts b/src/web-ui/src/app/components/WorkList/workListSelection.ts new file mode 100644 index 00000000..095bcefa --- /dev/null +++ b/src/web-ui/src/app/components/WorkList/workListSelection.ts @@ -0,0 +1,149 @@ +import { AppWindow, Brush, Clock3, Code2, ListChecks, ListTodo, MessageSquare, Sparkles } from 'lucide-react'; +import { filterWorkProjections } from '@/app/agentic-os/work/data/workSelectors'; +import { isWorkAttentionStatus, isWorkRunningStatus } from '@/app/agentic-os/work/domain/workClassification'; +import type { WorkKind, WorkStatus } from '@/app/agentic-os/work/domain/workTypes'; +import type { WorkProjection } from '@/app/agentic-os/work/projections/workProjection'; + +export const WORK_DOCK_LIST_LIMIT = 9; + +export interface WorkListSelectionOptions { + query?: string; + maxWorks?: number; + runningFilter?: 'all' | 'running' | 'not-running'; + includeArchived?: boolean; + includeCompleted?: boolean; +} + +export function isFocusStatus(status: WorkStatus): boolean { + return isWorkRunningStatus(status) || isWorkAttentionStatus(status); +} + +export function statusKey(status: WorkStatus): string { + return status.replace(/_/g, '-'); +} + +function hasSessionLikeSurface(work: WorkProjection): boolean { + return work.primarySurface.kind === 'work_session' + || work.primarySurface.kind === 'agent_session' + || work.surfaces?.some((surface) => ( + surface.kind === 'work_session' || surface.kind === 'agent_session' + )) === true; +} + +export function getWorkModeIcon(work: WorkProjection) { + if (work.kind === 'app_workflow') { + if (hasSessionLikeSurface(work)) return MessageSquare; + if (work.primarySurface.kind === 'application_surface') return AppWindow; + return Sparkles; + } + const { kind } = work; + if (kind === 'tracking' || kind === 'recurring') return ListTodo; + if (kind === 'topic') return Brush; + if (kind === 'long_running_session') return Clock3; + if (kind === 'one_shot' || kind === 'multi_step' || kind === 'delegated_work') return ListChecks; + return Code2; +} + +export function getWorkToneValue(status: WorkStatus): string { + if (status === 'waiting_user' || status === 'blocked') return 'var(--ds-color-warning)'; + if (status === 'failed') return 'var(--ds-color-danger)'; + if (status === 'completed') return 'var(--ds-color-success)'; + if (status === 'running') return 'var(--ds-color-accent-500)'; + return 'var(--ds-color-text-muted)'; +} + +export function isInstrumentedStatus(status: WorkStatus): boolean { + return status === 'running' + || status === 'waiting_user' + || status === 'blocked' + || status === 'failed' + || status === 'paused' + || status === 'completed' + || status === 'cancelled' + || status === 'interrupted'; +} + +function statusPriority(status: WorkStatus): number { + switch (status) { + case 'waiting_user': + return 0; + case 'blocked': + return 1; + case 'failed': + return 2; + case 'running': + return 3; + case 'active': + return 4; + case 'paused': + return 5; + case 'draft': + return 6; + case 'completed': + return 7; + case 'cancelled': + return 8; + case 'interrupted': + return 9; + case 'archived': + return 10; + } +} + +function kindContinuityPriority(kind: WorkKind): number { + switch (kind) { + case 'recurring': + return 0; + case 'long_running_session': + case 'tracking': + case 'topic': + return 1; + case 'app_workflow': + return 2; + case 'multi_step': + case 'delegated_work': + return 3; + case 'one_shot': + return 4; + } +} + +export function compareWorksForDock(left: WorkProjection, right: WorkProjection): number { + const byStatus = statusPriority(left.status) - statusPriority(right.status); + if (byStatus !== 0) return byStatus; + const byKind = kindContinuityPriority(left.kind) - kindContinuityPriority(right.kind); + if (byKind !== 0) return byKind; + const byTime = right.updatedAt - left.updatedAt; + if (byTime !== 0) return byTime; + return left.id.localeCompare(right.id); +} + +export function selectWorksForDockList( + projections: WorkProjection[], + { + query = '', + maxWorks, + runningFilter = 'all', + includeArchived = false, + includeCompleted = true, + }: WorkListSelectionOptions = {} +): WorkProjection[] { + const filtered = filterWorkProjections(projections, query) + .filter((work) => { + const running = isFocusStatus(work.status); + if (runningFilter === 'running') return running; + if (runningFilter === 'not-running') return !running; + return true; + }) + .filter((work) => (includeArchived ? true : work.status !== 'archived')) + .filter((work) => ( + includeCompleted + ? true + : work.status !== 'completed' + && work.status !== 'cancelled' + && work.status !== 'interrupted' + )) + .sort(compareWorksForDock); + + return typeof maxWorks === 'number' ? filtered.slice(0, maxWorks) : filtered; +} diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index c87de4c0..9d7d8324 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -37,8 +37,8 @@ import './AppLayout.scss'; const log = createLogger('AppLayout'); const RECENT_WORKSPACE_PRELOAD_LIMIT = 7; -const RECENT_SESSION_WARMUP_LIMIT = 5; -const RECENT_DISPATCHER_WARMUP_LIMIT = 3; +const STARTUP_SESSION_HISTORY_WARMUP_LIMIT = 0; +const STARTUP_AGENTIC_OS_HISTORY_WARMUP_LIMIT = 0; interface AppLayoutProps { className?: string; @@ -299,7 +299,8 @@ const AppLayout: React.FC = ({ className = '' }) => { preloadTargets, { metadataLimit: RECENT_WORKSPACE_PRELOAD_LIMIT, - warmHistoryCount: RECENT_SESSION_WARMUP_LIMIT, + warmHistoryCount: STARTUP_SESSION_HISTORY_WARMUP_LIMIT, + warmAgenticOsCount: STARTUP_AGENTIC_OS_HISTORY_WARMUP_LIMIT, } ); if (!cancelled) { @@ -326,7 +327,7 @@ const AppLayout: React.FC = ({ className = '' }) => { const handle = appRuntime.scheduleTask('session-preload:agentic-os', async () => { try { const result = await FlowChatManager.getInstance().preloadAgenticOsSessions({ - warmAgenticOsCount: RECENT_DISPATCHER_WARMUP_LIMIT, + warmAgenticOsCount: STARTUP_AGENTIC_OS_HISTORY_WARMUP_LIMIT, }); if (!cancelled) { log.info('Agentic OS session preload completed', result); diff --git a/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.scss b/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.scss index 2d3e6b9f..2431a2fe 100644 --- a/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.scss +++ b/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.scss @@ -271,105 +271,6 @@ $dl-serif: georgia, 'Iowan Old Style', 'Times New Roman', 'Songti SC', simsun, ' } } -// Inline range editor: a single hairline row that appears under the -// section title only while the filter is being edited. -.dl-rail__date-range { - display: flex; - align-items: center; - gap: $size-gap-1; - margin: 0 $size-gap-1 $size-gap-2 $size-gap-3; - padding: 2px $size-gap-1 2px $size-gap-2; - border: 1px solid var(--ds-color-border-subtle); - border-radius: $size-radius-base; - background: var(--ds-color-element-subtle); - animation: dl-filter-drop 160ms $easing-ease-out; - transition: - border-color $motion-fast $easing-standard, - box-shadow $motion-fast $easing-standard; - - &:focus-within { - border-color: color-mix(in srgb, var(--ds-color-accent) 36%, var(--ds-color-border-subtle)); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--ds-color-accent) 12%, transparent); - } - - input[type='date'] { - flex: 1 1 0%; - min-width: 0; - min-height: 22px; - padding: 0; - border: 0; - color: var(--ds-color-text-muted); - background: transparent; - font: inherit; - font-size: $font-size-xs; - font-variant-numeric: tabular-nums; - cursor: pointer; - appearance: none; - - &.is-set { - color: var(--ds-color-text-primary); - font-weight: $font-weight-medium; - } - - &::-webkit-calendar-picker-indicator { - margin-left: 0; - opacity: 0.4; - cursor: pointer; - transition: opacity $motion-fast $easing-standard; - } - - &:hover::-webkit-calendar-picker-indicator { - opacity: 0.75; - } - - &:focus, - &:focus-visible { - outline: none; - } - } -} - -.dl-rail__date-range-sep { - flex: 0 0 auto; - color: var(--ds-color-text-muted); - font-size: $font-size-xs; - line-height: 1; - user-select: none; -} - -.dl-rail__date-range-clear { - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - flex: 0 0 auto; - padding: 0; - border: 0; - border-radius: $size-radius-base; - color: var(--ds-color-text-tertiary); - background: transparent; - cursor: pointer; - transition: - background $motion-fast $easing-standard, - color $motion-fast $easing-standard; - - &:hover:not(:disabled) { - color: var(--ds-color-text-primary); - background: var(--ds-color-element-medium); - } - - &:disabled { - cursor: default; - opacity: 0.4; - } - - &:focus-visible { - outline: 2px solid var(--ds-focus-outline, var(--ds-focus-ring)); - outline-offset: 1px; - } -} - // Applied-range chip: the collapsed reminder that a date filter is active. .dl-rail__date-chip { display: inline-flex; @@ -379,38 +280,24 @@ $dl-serif: georgia, 'Iowan Old Style', 'Times New Roman', 'Songti SC', simsun, ' border-radius: 999px; background: color-mix(in srgb, var(--ds-color-accent) 6%, transparent); animation: dl-filter-drop 160ms $easing-ease-out; - - button { - display: inline-flex; - align-items: center; - padding: 0; - border: 0; - color: color-mix(in srgb, var(--ds-color-accent) 85%, var(--ds-color-text-secondary)); - background: transparent; - font: inherit; - cursor: pointer; - transition: color $motion-fast $easing-standard; - - &:hover { - color: var(--ds-color-accent); - } - - &:focus-visible { - outline: 2px solid var(--ds-focus-outline, var(--ds-focus-ring)); - outline-offset: 1px; - border-radius: 999px; - } - } } .dl-rail__date-chip-range { gap: $size-gap-1; + min-width: 0; height: 20px; padding: 0 $size-gap-1 0 $size-gap-2; + border-radius: 999px 0 0 999px; + color: color-mix(in srgb, var(--ds-color-accent) 85%, var(--ds-color-text-secondary)); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; + &:hover:not(:disabled) { + color: var(--ds-color-accent); + background: transparent; + } + svg { flex: 0 0 auto; } @@ -421,6 +308,12 @@ $dl-serif: georgia, 'Iowan Old Style', 'Times New Roman', 'Songti SC', simsun, ' width: 20px; height: 20px; border-radius: 50%; + color: color-mix(in srgb, var(--ds-color-accent) 85%, var(--ds-color-text-secondary)); + + &:hover:not(:disabled) { + color: var(--ds-color-accent); + background: transparent; + } } .dl-rail__list { @@ -1434,7 +1327,6 @@ $dl-serif: georgia, 'Iowan Old Style', 'Times New Roman', 'Songti SC', simsun, ' } @media (prefers-reduced-motion: reduce) { - .dl-rail__date-range, .dl-rail__date-chip, .dl-rail__today-writing, .dl-chip__pulse, diff --git a/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.tsx b/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.tsx index a65718f5..3c508405 100644 --- a/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.tsx +++ b/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.tsx @@ -14,10 +14,12 @@ import { import { Badge, Button, + DateRangeDialog, Dialog, DialogBody, DialogFooter, IconButton, + type DateRangeValue, } from '@/design-system'; import { Markdown } from '@/shared/markdown'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; @@ -57,6 +59,25 @@ function parseDateKey(date: string): Date { return new Date(`${date}T00:00:00`); } +function formatDateKey(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function startOfLocalToday(): Date { + const today = new Date(); + return new Date(today.getFullYear(), today.getMonth(), today.getDate()); +} + +function defaultDateFilterRange(): DateRangeValue { + const endDate = startOfLocalToday(); + const startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - 30); + return { startDate, endDate }; +} + function pendingReceiptCount(letter: DailyLetterRecord): number { return letter.receiptCandidates.filter((candidate) => candidate.status === 'pending').length; } @@ -85,7 +106,7 @@ function letterBodyForPaper(markdown: string): string { } const DailyLetterScene: React.FC = ({ workspacePath }) => { - const { t, formatDate } = useI18n('scenes/daily-letter'); + const { t, formatDate, currentLanguage } = useI18n('scenes/daily-letter'); const [letters, setLetters] = useState([]); const [selectedId, setSelectedId] = useState(null); const [dateFrom, setDateFrom] = useState(''); @@ -271,11 +292,30 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => }, []); const dateRangeLabel = useMemo(() => { - if (dateFrom && dateTo) return `${dateFrom} – ${dateTo}`; - if (dateFrom) return `${dateFrom} –`; - return `– ${dateTo}`; + if (dateFrom && dateTo) return `${dateFrom} - ${dateTo}`; + if (dateFrom) return `${dateFrom} -`; + return `- ${dateTo}`; }, [dateFrom, dateTo]); + const dateFilterInitialRange = useMemo(() => { + if (!dateFrom && !dateTo) return null; + const fallbackEnd = startOfLocalToday(); + const startDate = dateFrom ? parseDateKey(dateFrom) : dateTo ? parseDateKey(dateTo) : fallbackEnd; + const endDate = dateTo ? parseDateKey(dateTo) : fallbackEnd; + return startDate.getTime() <= endDate.getTime() + ? { startDate, endDate } + : { startDate: endDate, endDate: startDate }; + }, [dateFrom, dateTo]); + + const dateFilterDefaultRange = useMemo(() => defaultDateFilterRange(), []); + const dateFilterMaxDate = useMemo(() => startOfLocalToday(), []); + + const applyDateRange = useCallback((range: DateRangeValue) => { + setDateFrom(formatDateKey(range.startDate)); + setDateTo(formatDateKey(range.endDate)); + setDateFilterOpen(false); + }, []); + const scopeLabel = useCallback((letter: DailyLetterRecord) => ( letter.workspace?.name ?? t(`scope.${letter.scope === 'agentic_os' ? 'agenticOs' : 'workspace'}`) ), [t]); @@ -440,67 +480,41 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => {t('rail.total', { count: filteredLetters.length })} - +
- {dateFilterOpen ? ( -
- setDateFrom(event.target.value)} - /> - - setDateTo(event.target.value)} - /> - -
- ) : hasDateFilter && ( + {hasDateFilter && (
- - +
)}
@@ -602,6 +616,28 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => t={t} /> + t('date.summary', { start, end }), + pickEndHint: (start) => t('date.pickEndHint', { start }), + pickEndError: t('date.pickEndError'), + previousMonth: t('date.previousMonth'), + nextMonth: t('date.nextMonth'), + cancel: t('date.cancel'), + apply: t('date.apply'), + }} + closeLabel={t('actions.close')} + /> + { diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx index 07e8340d..26284abe 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx @@ -15,6 +15,7 @@ import './SettingsScene.scss'; import AIModelConfig from '../../../infrastructure/config/components/AIModelConfig'; import AIUsageConfig from '../../../infrastructure/config/components/AIUsageConfig'; import PersonalizationConfig from '../../../infrastructure/config/components/PersonalizationConfig'; +import VoiceInputConfig from '../../../infrastructure/config/components/VoiceInputConfig'; import BitFunCoderConfig from '../../../infrastructure/config/components/BitFunCoderConfig'; import PermissionsConfig from '../../../infrastructure/config/components/PermissionsConfig'; import MemoryConfig from '../../../infrastructure/config/components/MemoryConfig'; @@ -44,6 +45,7 @@ const SettingsScene: React.FC = () => { case 'aiUsage': Content = AIUsageConfig; break; case 'dataStorage': Content = DataStorageConfig; break; case 'personalization': Content = PersonalizationConfig; break; + case 'voiceInput': Content = VoiceInputConfig; break; case 'bitfunCoder': Content = BitFunCoderConfig; break; case 'permissions': Content = PermissionsConfig; break; case 'memory': Content = MemoryConfig; break; diff --git a/src/web-ui/src/app/scenes/settings/settingsConfig.ts b/src/web-ui/src/app/scenes/settings/settingsConfig.ts index 24e9b1a1..b7280278 100644 --- a/src/web-ui/src/app/scenes/settings/settingsConfig.ts +++ b/src/web-ui/src/app/scenes/settings/settingsConfig.ts @@ -12,6 +12,7 @@ export type ConfigTab = | 'aiUsage' | 'dataStorage' | 'personalization' + | 'voiceInput' | 'bitfunCoder' | 'permissions' | 'memory' @@ -128,6 +129,22 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [ '伙伴', ], }, + { + id: 'voiceInput', + labelKey: 'tabs.voiceInput', + descriptionKey: 'tabDescriptions.voiceInput', + keywords: [ + 'voice', + 'speech', + 'speech input', + 'voice input', + 'microphone', + 'dictation', + 'sensevoice', + 'local speech', + 'asr', + ], + }, { id: 'permissions', labelKey: 'tabs.permissions', @@ -265,6 +282,7 @@ export function normalizeSettingsTab(section: string): ConfigTab { if (section === 'ai-usage' || section === 'usage' || section === 'token-usage') return 'aiUsage'; if (section === 'data-storage' || section === 'storage' || section === 'reset' || section === 'cleanup') return 'dataStorage'; if (section === 'session-config' || section === 'personal' || section === 'companion') return 'personalization'; + if (section === 'voice-input' || section === 'voice' || section === 'speech' || section === 'speech-input' || section === 'microphone') return 'voiceInput'; if (section === 'bitfun-coder' || section === 'debug-mode') return 'bitfunCoder'; if (section === 'permission' || section === 'permissions' || section === 'computer-use' || section === 'tool-execution') return 'permissions'; if (section === 'memory' || section === 'auto-memory' || section === 'auto_memory' || section === 'extract-memory') return 'memory'; diff --git a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts index 75b2b7ee..f07334c6 100644 --- a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts +++ b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts @@ -81,6 +81,18 @@ export const SETTINGS_TAB_SEARCH_CONTENT: Record void; onClearFilters?: () => void; onGroupingChange: (value: WorkCenterGrouping) => void; + rightControls?: React.ReactNode; } const BoardHeader: React.FC = ({ @@ -52,6 +53,7 @@ const BoardHeader: React.FC = ({ onAppFilterChange, onClearFilters, onGroupingChange, + rightControls, }) => { const { t } = useI18n('scenes/work-center'); const scopeLabel = (() => { @@ -188,20 +190,29 @@ const BoardHeader: React.FC = ({ ) : null}