diff --git a/docs/aiprompts/README.md b/docs/aiprompts/README.md index 8f0eed56d..574d9e948 100644 --- a/docs/aiprompts/README.md +++ b/docs/aiprompts/README.md @@ -34,6 +34,7 @@ AI Agent 专用文档目录,提供模块级别的详细说明。 ### Aster 集成 - `aster-integration.md` - **Aster 框架集成方案** +- `workspace.md` - **Workspace 设计文档**(工作目录管理) ## 使用方式 @@ -48,6 +49,9 @@ AI Agent 在处理特定模块时,应先阅读对应的 aiprompts 文档: # 处理 Aster Agent 集成 → 先读 docs/aiprompts/aster-integration.md + +# 处理 Workspace 相关任务 +→ 先读 docs/aiprompts/workspace.md ``` ## 更新提醒 diff --git a/docs/aiprompts/workspace.md b/docs/aiprompts/workspace.md new file mode 100644 index 000000000..7a3ea0a63 --- /dev/null +++ b/docs/aiprompts/workspace.md @@ -0,0 +1,317 @@ +# Workspace 设计文档 + +## 概述 + +Workspace 是 ProxyCast 应用层的概念,用于组织和管理 AI Agent 的工作上下文。它是对 Aster 框架 `Session.working_dir` 的命名和配置包装,不修改 Aster 框架本身。 + +## 设计背景 + +### 行业调研 + +基于对 Cursor、Claude Code、Manus、AI21 等产品的调研,总结出以下关键洞察: + +| 来源 | 核心观点 | +|------|---------| +| Manus (philschmid.de) | "Share memory by communicating, don't communicate by sharing memory" | +| AI21 | "Agents that only read can share an environment. Agents that write need isolation" | +| Cursor | Shadow Workspace 实现后台 AI 迭代,不影响用户体验 | +| Claude Code | 通过 `--add-dir` 支持多目录,`CLAUDE.md` 实现层级配置 | + +### 核心原则 + +1. **读共享,写隔离** - 只读操作可以共享环境,写操作需要隔离 +2. **最小有效 context** - 只传递必要的 context,避免 context pollution +3. **Workspace = 边界** - 文件系统边界 + context 边界 + 配置边界 + +## 架构设计 + +### 层级关系 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ProxyCast (应用层) │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Workspace 管理 ││ +│ │ - WorkspaceManager: CRUD 操作 ││ +│ │ - WorkspaceSettings: workspace 级配置 ││ +│ │ - 通过 working_dir 关联 Aster Session ││ +│ └─────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Aster (框架层) - 不修改 │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Session 管理 ││ +│ │ - SessionManager: Session CRUD ││ +│ │ - Session.working_dir: 工作目录 ││ +│ │ - Conversation: 对话历史 ││ +│ │ - EnhancedContextManager: context 压缩 ││ +│ └─────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 与 Aster Session 的关系 + +- Workspace 通过 `root_path` 与 Aster `Session.working_dir` 关联 +- 一个 Workspace 可以包含多个 Session(同一 working_dir) +- ProxyCast 按 Workspace 分组显示 Session 列表 + + + +## 数据模型 + +### Workspace 类型定义 + +```rust +// proxycast/src-tauri/src/workspace/types.rs + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use chrono::{DateTime, Utc}; + +/// Workspace 唯一标识 +pub type WorkspaceId = String; + +/// Workspace 类型 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceType { + #[default] + Persistent, // 持久化 workspace + Temporary, // 临时 workspace(自动清理) +} + +/// Workspace 元数据 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workspace { + pub id: WorkspaceId, + pub name: String, + pub workspace_type: WorkspaceType, + pub root_path: PathBuf, // 对应 Aster Session.working_dir + pub is_default: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub settings: WorkspaceSettings, +} + +/// Workspace 级别设置 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct WorkspaceSettings { + pub mcp_config: Option, // workspace 级 MCP 配置 + pub default_provider: Option, // 默认 provider + pub auto_compact: bool, // 自动压缩 context +} +``` + +### 数据库 Schema + +```sql +-- proxycast 应用数据库 +CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + workspace_type TEXT NOT NULL DEFAULT 'persistent', + root_path TEXT NOT NULL UNIQUE, -- 对应 Aster Session.working_dir + is_default BOOLEAN DEFAULT FALSE, + settings_json TEXT DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_workspaces_root_path ON workspaces(root_path); +``` + + + +## 核心接口 + +### WorkspaceManager + +```rust +// proxycast/src-tauri/src/workspace/manager.rs + +impl WorkspaceManager { + /// 创建新 workspace + pub async fn create(&self, name: String, root_path: PathBuf) -> Result; + + /// 获取 workspace + pub async fn get(&self, id: &WorkspaceId) -> Result; + + /// 列出所有 workspace + pub async fn list(&self) -> Result>; + + /// 更新 workspace + pub async fn update(&self, id: &WorkspaceId, updates: WorkspaceUpdate) -> Result; + + /// 删除 workspace + pub async fn delete(&self, id: &WorkspaceId) -> Result<()>; + + /// 设置默认 workspace + pub async fn set_default(&self, id: &WorkspaceId) -> Result<()>; + + /// 获取默认 workspace + pub async fn get_default(&self) -> Result>; + + /// 获取 workspace 下的所有 sessions(通过 working_dir 关联) + pub async fn list_sessions(&self, workspace_id: &WorkspaceId) -> Result> { + let workspace = self.get(workspace_id).await?; + + // 使用 Aster 的 SessionManager,按 working_dir 过滤 + let all_sessions = aster::session::SessionManager::list_sessions().await?; + + Ok(all_sessions + .into_iter() + .filter(|s| s.working_dir == workspace.root_path) + .collect()) + } + + /// 在 workspace 中创建新 session + pub async fn create_session( + &self, + workspace_id: &WorkspaceId, + name: String + ) -> Result { + let workspace = self.get(workspace_id).await?; + + // 使用 Aster 的 SessionManager + aster::session::SessionManager::create_session( + workspace.root_path.clone(), + name, + aster::session::SessionType::User, + ).await + } +} +``` + +### Tauri 命令 + +```rust +// proxycast/src-tauri/src/commands/workspace_cmd.rs + +#[tauri::command] +pub async fn workspace_create(name: String, root_path: String) -> Result; + +#[tauri::command] +pub async fn workspace_list() -> Result, String>; + +#[tauri::command] +pub async fn workspace_get(id: String) -> Result; + +#[tauri::command] +pub async fn workspace_update(id: String, updates: WorkspaceUpdate) -> Result; + +#[tauri::command] +pub async fn workspace_delete(id: String) -> Result<(), String>; + +#[tauri::command] +pub async fn workspace_set_default(id: String) -> Result<(), String>; + +#[tauri::command] +pub async fn workspace_list_sessions(workspace_id: String) -> Result, String>; + +#[tauri::command] +pub async fn workspace_create_session(workspace_id: String, name: String) -> Result; +``` + + + +## 目录结构 + +``` +~/.aster/ # Aster 框架目录(不修改) +├── config/ +├── data/ +│ └── sessions/ +│ └── sessions.db # Aster 管理的 session 数据库 +└── state/ + +~/Library/Application Support/proxycast/ # ProxyCast 应用目录 +├── proxycast.db # 应用数据库(包含 workspaces 表) +├── credentials/ # 凭证文件 +└── workspaces/ # workspace 级配置 + ├── default/ + │ └── mcp.json # workspace 级 MCP 配置 + └── my-project/ + └── mcp.json +``` + +## 前端组件 + +### WorkspaceSelector + +```typescript +// proxycast/src/components/workspace/WorkspaceSelector.tsx + +interface Workspace { + id: string; + name: string; + rootPath: string; + isDefault: boolean; + workspaceType: 'persistent' | 'temporary'; +} + +export function WorkspaceSelector() { + const [workspaces, setWorkspaces] = useState([]); + const [current, setCurrent] = useState(null); + + useEffect(() => { + invoke('workspace_list').then(setWorkspaces); + invoke('workspace_get_default').then(setCurrent); + }, []); + + const switchWorkspace = async (id: string) => { + await invoke('workspace_set_default', { id }); + const ws = workspaces.find(w => w.id === id); + setCurrent(ws || null); + // 触发 session 列表刷新 + }; + + return ( + + ); +} +``` + +## 实现优先级 + +### Phase 1: 基础功能 +1. 数据库 schema 迁移 +2. WorkspaceManager 核心 CRUD +3. Tauri 命令实现 +4. WorkspaceSelector 组件 + +### Phase 2: 集成功能 +1. Session 列表按 Workspace 分组 +2. 创建 Session 时自动关联当前 Workspace +3. Workspace 级别的 MCP 配置 + +### Phase 3: 高级功能 +1. 临时 Workspace 支持 +2. Workspace 导入/导出 +3. Workspace 级别的 Provider 配置 + +## 设计决策 + +| 决策点 | 选择 | 理由 | +|--------|------|------| +| Workspace 存储位置 | ProxyCast 应用层 | 不修改 Aster 框架,保持框架通用性 | +| 与 Session 关系 | 通过 working_dir 关联 | 利用 Aster 现有字段,无需修改框架 | +| 配置存储 | 独立目录 + 数据库 | 配置文件便于编辑,元数据存数据库 | +| 默认 Workspace | 支持 | 新 Session 自动关联默认 Workspace | + +## 参考资料 + +- [Context Engineering for AI Agents](https://www.philschmid.de/context-engineering-part-2) - Manus 团队经验 +- [Scaling State-Modifying AI Agents with MCP Workspaces](https://www.ai21.com/blog/stateful-agent-workspaces-mcp) - AI21 的 MCP Workspace 扩展 +- [Iterating with shadow workspaces](https://www.cursor.com/blog/shadow-workspace) - Cursor 的 Shadow Workspace 实现 +- [Managing Claude Code's Context](https://www.cometapi.com/managing-claude-codes-context/) - Claude Code 的 Context 管理 diff --git a/package.json b/package.json index cb9e35c4c..b62e0d4ff 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.48.4", + "version": "0.49.0", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4f16a0c33..77af492cf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -72,6 +72,28 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.10.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -146,11 +168,11 @@ dependencies = [ "clipboard-win", "image", "log", - "objc2", - "objc2-app-kit", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", + "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", "windows-sys 0.60.2", @@ -260,7 +282,7 @@ dependencies = [ "utoipa", "uuid", "webbrowser", - "which", + "which 8.0.0", "winapi", "winreg 0.55.0", "zip", @@ -329,7 +351,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 1.1.3", "slab", "windows-sys 0.61.2", ] @@ -360,7 +382,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 1.1.3", ] [[package]] @@ -386,7 +408,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.1.3", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -1013,6 +1035,47 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.114", + "which 4.4.2", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.114", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -1087,13 +1150,41 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae85a0696e7ea3b835a453750bf002770776609115e6d25c6d2ff28a8200f7e7" +dependencies = [ + "objc-sys", +] + +[[package]] +name = "block2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58aa60e59d8dbfcc36138f5f18be5f24394d33b38b24f7fd0b1caa33095f22f" +dependencies = [ + "block-sys", + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2 0.6.3", ] [[package]] @@ -1153,7 +1244,7 @@ dependencies = [ "icu_normalizer", "indexmap 2.13.0", "intrusive-collections", - "itertools", + "itertools 0.14.0", "num-bigint", "num-integer", "num-traits", @@ -1456,6 +1547,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfb" version = "0.7.3" @@ -1513,6 +1613,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + [[package]] name = "clap" version = "4.5.54" @@ -1769,6 +1880,19 @@ dependencies = [ "libc", ] +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "libc", +] + [[package]] name = "core-graphics" version = "0.24.0" @@ -1804,12 +1928,55 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen 0.72.1", +] + [[package]] name = "cow-utils" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -2092,6 +2259,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "data-encoding" version = "2.10.0" @@ -2272,9 +2445,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.3", ] [[package]] @@ -2462,6 +2635,43 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enigo" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0087a01fc8591217447d28005379fb5a183683cc83f0a4707af28cc6603f70fb" +dependencies = [ + "core-graphics 0.23.2", + "foreign-types-shared 0.3.1", + "icrate", + "libc", + "log", + "objc2 0.5.2", + "serde", + "windows 0.56.0", + "xkbcommon 0.7.0", + "xkeysym", +] + +[[package]] +name = "enigo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cf6f550bbbdd5fe66f39d429cb2604bcdacbf00dca0f5bbe2e9306a0009b7c6" +dependencies = [ + "core-foundation 0.10.1", + "core-graphics 0.24.0", + "foreign-types-shared 0.3.1", + "libc", + "log", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "windows 0.58.0", + "xkbcommon 0.8.0", + "xkeysym", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -3120,7 +3330,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix", + "rustix 1.1.3", "windows-link 0.2.1", ] @@ -3264,8 +3474,8 @@ checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" dependencies = [ "crossbeam-channel", "keyboard-types", - "objc2", - "objc2-app-kit", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "once_cell", "serde", "thiserror 2.0.17", @@ -3508,6 +3718,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "html5ever" version = "0.27.0" @@ -3748,7 +3964,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.56.0", + "windows-core 0.61.2", ] [[package]] @@ -3770,6 +3986,16 @@ dependencies = [ "png 0.17.16", ] +[[package]] +name = "icrate" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb69199826926eb864697bddd27f73d9fddcffc004f5733131e15b465e30642" +dependencies = [ + "block2 0.4.0", + "objc2 0.5.2", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -4097,6 +4323,24 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -4309,6 +4553,12 @@ dependencies = [ "spin", ] +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "libappindicator" version = "0.9.0" @@ -4329,7 +4579,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading", + "libloading 0.7.4", "once_cell", ] @@ -4359,6 +4609,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libm" version = "0.2.15" @@ -4413,6 +4673,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -4467,6 +4733,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -4549,6 +4824,24 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memmap2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a5a03cefb0d953ec0be133036f14e109412fa594edc2f77227249db66cc3ed" +dependencies = [ + "libc", +] + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + [[package]] name = "memo-map" version = "0.3.3" @@ -4579,6 +4872,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minijinja" version = "2.14.0" @@ -4661,10 +4964,10 @@ dependencies = [ "dpi", "gtk", "keyboard-types", - "objc2", - "objc2-app-kit", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "once_cell", "png 0.17.16", "serde", @@ -4698,6 +5001,20 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "ndk" version = "0.9.0" @@ -4707,7 +5024,7 @@ dependencies = [ "bitflags 2.10.0", "jni-sys", "log", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -4719,6 +5036,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -4966,7 +5292,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 2.0.2", + "proc-macro-crate 3.4.0", "proc-macro2", "quote", "syn 2.0.114", @@ -5010,6 +5336,22 @@ dependencies = [ "malloc_buf", ] +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + [[package]] name = "objc2" version = "0.6.3" @@ -5020,6 +5362,22 @@ dependencies = [ "objc2-exception-helper", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + [[package]] name = "objc2-app-kit" version = "0.3.2" @@ -5027,18 +5385,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.3", "objc2-cloud-kit", - "objc2-core-data", + "objc2-core-data 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-core-image", + "objc2-core-image 0.3.2", "objc2-core-text", "objc2-core-video", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -5048,8 +5406,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ "bitflags 2.10.0", - "objc2", - "objc2-foundation", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -5059,8 +5429,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ "bitflags 2.10.0", - "objc2", - "objc2-foundation", + "objc2 0.6.3", + "objc2-foundation 0.3.2", ] [[package]] @@ -5071,7 +5441,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.10.0", "dispatch2", - "objc2", + "objc2 0.6.3", ] [[package]] @@ -5082,19 +5452,31 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.10.0", "dispatch2", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + [[package]] name = "objc2-core-image" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.3", + "objc2-foundation 0.3.2", ] [[package]] @@ -5104,7 +5486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", "objc2-core-graphics", ] @@ -5116,7 +5498,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", @@ -5137,6 +5519,18 @@ dependencies = [ "cc", ] +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + [[package]] name = "objc2-foundation" version = "0.3.2" @@ -5144,9 +5538,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", ] @@ -5157,7 +5551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", ] @@ -5167,10 +5561,35 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" dependencies = [ - "objc2", + "objc2 0.6.3", "objc2-core-foundation", ] +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -5178,9 +5597,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -5190,7 +5609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", ] @@ -5201,9 +5620,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -5213,15 +5632,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ "bitflags 2.10.0", - "block2", - "objc2", - "objc2-app-kit", + "block2 0.6.2", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "objc2-javascript-core", "objc2-security", ] +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -5866,7 +6308,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -5942,6 +6384,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -6060,7 +6512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.114", @@ -6068,7 +6520,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.48.1" +version = "0.49.0" dependencies = [ "anyhow", "arboard", @@ -6081,8 +6533,10 @@ dependencies = [ "bytes", "chrono", "cocoa", + "cpal", "dashmap 5.5.3", "dirs 5.0.1", + "enigo 0.3.0", "flate2", "fs2", "futures", @@ -6137,6 +6591,7 @@ dependencies = [ "url", "urlencoding", "uuid", + "voice-core", "whoami", "winapi", "window-vibrancy 0.7.1", @@ -6147,7 +6602,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.48.4" +version = "0.49.0" dependencies = [ "chrono", "dirs 5.0.1", @@ -6163,7 +6618,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.48.4" +version = "0.49.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -6627,6 +7082,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -6659,17 +7115,17 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" dependencies = [ - "block2", + "block2 0.6.2", "dispatch2", "glib-sys", "gobject-sys", "gtk-sys", "js-sys", "log", - "objc2", - "objc2-app-kit", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "raw-window-handle", "wasm-bindgen", "wasm-bindgen-futures", @@ -6807,6 +7263,19 @@ dependencies = [ "semver 1.0.27", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.3" @@ -6816,7 +7285,7 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -7467,7 +7936,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs 4.0.0", + "dirs 6.0.0", ] [[package]] @@ -7605,12 +8074,12 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "bytemuck", "js-sys", - "ndk", - "objc2", + "ndk 0.9.0", + "objc2 0.6.3", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "raw-window-handle", "redox_syscall 0.5.18", "tracing", @@ -8090,7 +8559,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "core-foundation 0.10.1", "core-graphics 0.24.0", "crossbeam-channel", @@ -8104,12 +8573,12 @@ dependencies = [ "lazy_static", "libc", "log", - "ndk", + "ndk 0.9.0", "ndk-context", - "ndk-sys", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "ndk-sys 0.6.0+11769913", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "once_cell", "parking_lot", "raw-window-handle", @@ -8180,9 +8649,9 @@ dependencies = [ "log", "mime", "muda", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "objc2-ui-kit", "objc2-web-kit", "percent-encoding", @@ -8426,7 +8895,7 @@ dependencies = [ "gtk", "http 1.4.0", "jni", - "objc2", + "objc2 0.6.3", "objc2-ui-kit", "objc2-web-kit", "raw-window-handle", @@ -8450,9 +8919,9 @@ dependencies = [ "http 1.4.0", "jni", "log", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "once_cell", "percent-encoding", "raw-window-handle", @@ -8525,7 +8994,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -8795,7 +9264,9 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" dependencies = [ "futures-util", "log", + "native-tls", "tokio", + "tokio-native-tls", "tungstenite 0.24.0", ] @@ -9111,11 +9582,11 @@ dependencies = [ "dirs 6.0.0", "libappindicator", "muda", - "objc2", - "objc2-app-kit", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", + "objc2-foundation 0.3.2", "once_cell", "png 0.17.16", "serde", @@ -9141,6 +9612,7 @@ dependencies = [ "http 1.4.0", "httparse", "log", + "native-tls", "rand 0.8.5", "sha1", "thiserror 1.0.69", @@ -9248,6 +9720,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -9431,6 +9909,31 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "voice-core" +version = "0.1.0" +dependencies = [ + "arboard", + "async-trait", + "base64 0.22.1", + "chrono", + "cpal", + "enigo 0.2.1", + "futures-util", + "hmac", + "hound", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite 0.24.0", + "tracing", + "urlencoding", + "whisper-rs", +] + [[package]] name = "vsimd" version = "0.8.0" @@ -9614,8 +10117,8 @@ dependencies = [ "jni", "log", "ndk-context", - "objc2", - "objc2-foundation", + "objc2 0.6.3", + "objc2-foundation 0.3.2", "url", "web-sys", ] @@ -9721,6 +10224,18 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "which" version = "8.0.0" @@ -9728,10 +10243,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" dependencies = [ "env_home", - "rustix", + "rustix 1.1.3", "winsafe", ] +[[package]] +name = "whisper-rs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c597ac8a9d5c4719fee232abc871da184ea50a4fea38d2d00348fd95072b2b0" +dependencies = [ + "whisper-rs-sys", +] + +[[package]] +name = "whisper-rs-sys" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22f00ed0995463eecc34ef89905845f6bf6fd37ea70789fed180520050da8f8" +dependencies = [ + "bindgen 0.69.5", + "cfg-if", + "cmake", + "fs_extra", +] + [[package]] name = "whoami" version = "1.6.1" @@ -9780,10 +10316,10 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ - "objc2", - "objc2-app-kit", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "raw-window-handle", "windows-sys 0.59.0", "windows-version", @@ -9795,15 +10331,25 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "010797bd7c40396fbc59d3105089fed0885fe267a0ef4a0a4646df54e28647f6" dependencies = [ - "objc2", - "objc2-app-kit", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "raw-window-handle", "windows-sys 0.60.2", "windows-version", ] +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.56.0" @@ -9824,6 +10370,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -9846,6 +10402,16 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.56.0" @@ -9870,6 +10436,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -9880,7 +10459,7 @@ dependencies = [ "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", - "windows-strings", + "windows-strings 0.4.2", ] [[package]] @@ -9916,6 +10495,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -9949,6 +10539,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -9990,7 +10591,7 @@ checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ "windows-link 0.1.3", "windows-result 0.3.4", - "windows-strings", + "windows-strings 0.4.2", ] [[package]] @@ -10002,6 +10603,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -10011,6 +10621,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -10423,7 +11043,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" dependencies = [ "base64 0.22.1", - "block2", + "block2 0.6.2", "cookie", "crossbeam-channel", "dirs 6.0.0", @@ -10437,11 +11057,11 @@ dependencies = [ "jni", "kuchikiki", "libc", - "ndk", - "objc2", - "objc2-app-kit", + "ndk 0.9.0", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "objc2-ui-kit", "objc2-web-kit", "once_cell", @@ -10489,7 +11109,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix", + "rustix 1.1.3", "x11rb-protocol", ] @@ -10506,7 +11126,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.3", +] + +[[package]] +name = "xkbcommon" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13867d259930edc7091a6c41b4ce6eee464328c6ff9659b7e4c668ca20d4c91e" +dependencies = [ + "libc", + "memmap2 0.8.0", + "xkeysym", +] + +[[package]] +name = "xkbcommon" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" +dependencies = [ + "libc", + "memmap2 0.9.9", + "xkeysym", ] [[package]] @@ -10572,7 +11214,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix", + "rustix 1.1.3", "serde", "serde_repr", "tracing", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 38f679359..e6d89b24d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.48.4" +version = "0.49.0" edition = "2021" authors = ["you"] repository = "https://github.com/aiclientproxy/proxycast" @@ -13,6 +13,7 @@ homepage = "https://github.com/aiclientproxy/proxycast" # 项目内 crate 依赖 proxycast-core = { path = "crates/core" } proxycast-infra = { path = "crates/infra" } +voice-core = { path = "crates/voice-core" } # 序列化 serde = { version = "1", features = ["derive"] } @@ -80,6 +81,9 @@ scopeguard = "1" sysinfo = "0.32" whoami = "1" +# 音频 +cpal = "0.15" + # TLS rustls-pemfile = "2" @@ -94,6 +98,7 @@ openssl = { version = "0.10", features = ["vendored"] } mouse_position = "0.1.4" window-vibrancy = "0.7.1" if-addrs = "0.13" +enigo = "0.3" # Aster Agent Framework # 开发时使用本地 aster-rust,CI/CD 使用远程 GitHub 仓库 @@ -159,7 +164,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.48.1" +version = "0.49.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" @@ -177,6 +182,7 @@ tauri-build.workspace = true # 项目内 crate proxycast-core.workspace = true proxycast-infra.workspace = true +voice-core.workspace = true # Tauri tauri.workspace = true @@ -264,6 +270,10 @@ openssl.workspace = true mouse_position.workspace = true window-vibrancy.workspace = true if-addrs.workspace = true +enigo.workspace = true + +# 音频 +cpal.workspace = true # Aster Agent Framework aster.workspace = true diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index ef33f4c57..8743d5d2f 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -22,5 +22,9 @@ + NSMicrophoneUsageDescription + ProxyCast 需要访问麦克风以使用语音输入功能 + NSAppleEventsUsageDescription + ProxyCast 需要控制其他应用以输入识别的文本 diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 78e2e2134..b25fea619 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -35,6 +35,11 @@ "name": "binaries/aster-server", "sidecar": true, "args": true + }, + { + "name": "open", + "cmd": "open", + "args": true } ] }, diff --git a/src-tauri/crates/voice-core/Cargo.toml b/src-tauri/crates/voice-core/Cargo.toml new file mode 100644 index 000000000..450bb293f --- /dev/null +++ b/src-tauri/crates/voice-core/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "voice-core" +version = "0.1.0" +edition = "2021" +description = "语音输入核心库 - 音频录制、语音识别、文字输出" +authors = ["ProxyCast Team"] +license = "MIT" + +[dependencies] +# 音频录制 +cpal = "0.15" + +# Whisper 本地识别 +whisper-rs = "0.12" + +# WAV 处理 +hound = "3.5" + +# 键盘模拟 +enigo = { version = "0.2", features = ["serde"] } + +# 剪贴板 +arboard = "3.4" + +# HTTP 客户端(云端 ASR) +reqwest = { version = "0.12", features = ["json", "multipart"] } + +# 异步运行时 +tokio = { version = "1", features = ["sync", "time"] } + +# WebSocket 客户端(讯飞 ASR) +tokio-tungstenite = { version = "0.24", features = ["native-tls"] } +futures-util = "0.3" + +# 序列化 +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# 错误处理 +thiserror = "1" + +# 异步 trait +async-trait = "0.1" + +# 日志 +tracing = "0.1" + +# Base64 编码(云端 ASR) +base64 = "0.22" + +# HMAC 签名(讯飞 ASR) +hmac = "0.12" +sha2 = "0.10" + +# URL 编码 +urlencoding = "2" + +# 时间处理 +chrono = "0.4" + +[dev-dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/src-tauri/crates/voice-core/README.md b/src-tauri/crates/voice-core/README.md new file mode 100644 index 000000000..a73a95760 --- /dev/null +++ b/src-tauri/crates/voice-core/README.md @@ -0,0 +1,55 @@ +# voice-core + +语音输入核心库 - 音频录制、语音识别、文字输出。 + +## 功能 + +- **音频录制** - 使用 cpal 进行跨平台音频采集 +- **本地识别** - 使用 whisper-rs 进行本地 Whisper 识别 +- **云端 ASR** - 支持讯飞、百度、OpenAI Whisper API +- **文字输出** - 支持模拟键盘输入和剪贴板 + +## 模块 + +``` +src/ +├── lib.rs # 库入口 +├── types.rs # 类型定义 +├── error.rs # 错误类型 +├── recorder.rs # 音频录制 +├── transcriber.rs # Whisper 本地识别 +├── output.rs # 文字输出 +└── asr_client/ # 云端 ASR + ├── mod.rs + ├── openai.rs # OpenAI Whisper + ├── xunfei.rs # 讯飞语音 + └── baidu.rs # 百度语音 +``` + +## 使用示例 + +```rust +use voice_core::{AudioRecorder, WhisperTranscriber, OutputHandler, OutputMode}; + +// 录音 +let mut recorder = AudioRecorder::new()?; +recorder.start()?; +// ... 等待用户说话 ... +let audio = recorder.stop()?; + +// 识别 +let transcriber = WhisperTranscriber::new(model_path, WhisperModel::Base, "zh")?; +let result = transcriber.transcribe(&audio)?; + +// 输出 +let mut output = OutputHandler::new()?; +output.output(&result.text, OutputMode::Type)?; +``` + +## 依赖 + +- `cpal` - 跨平台音频采集 +- `whisper-rs` - Whisper.cpp Rust 绑定 +- `enigo` - 跨平台键盘模拟 +- `arboard` - 跨平台剪贴板 +- `reqwest` - HTTP 客户端(云端 ASR) diff --git a/src-tauri/crates/voice-core/src/asr_client/baidu.rs b/src-tauri/crates/voice-core/src/asr_client/baidu.rs new file mode 100644 index 000000000..4b1881e8f --- /dev/null +++ b/src-tauri/crates/voice-core/src/asr_client/baidu.rs @@ -0,0 +1,144 @@ +//! 百度语音识别客户端 +//! +//! 使用百度 AI 开放平台的语音识别 API。 + +use async_trait::async_trait; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use serde::{Deserialize, Serialize}; + +use super::AsrClient; +use crate::error::{Result, VoiceError}; +use crate::types::{AudioData, TranscribeResult}; + +/// 百度 Token 响应 +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + #[allow(dead_code)] + expires_in: u64, +} + +/// 百度 ASR 响应 +#[derive(Debug, Deserialize)] +struct AsrResponse { + err_no: i32, + err_msg: String, + #[serde(default)] + result: Vec, +} + +/// 百度 ASR 请求 +#[derive(Debug, Serialize)] +struct AsrRequest { + format: String, + rate: u32, + channel: u16, + cuid: String, + token: String, + speech: String, + len: usize, +} + +/// 百度客户端 +pub struct BaiduClient { + api_key: String, + secret_key: String, + cached_token: Option, +} + +impl BaiduClient { + /// 创建新的客户端 + pub fn new(api_key: String, secret_key: String) -> Self { + Self { + api_key, + secret_key, + cached_token: None, + } + } + + /// 获取 Access Token + async fn get_token(&mut self) -> Result { + if let Some(ref token) = self.cached_token { + return Ok(token.clone()); + } + + let url = format!( + "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id={}&client_secret={}", + self.api_key, self.secret_key + ); + + let client = reqwest::Client::new(); + let response = client + .post(&url) + .send() + .await + .map_err(|e| VoiceError::NetworkError(e.to_string()))?; + + if !response.status().is_success() { + return Err(VoiceError::AsrAuthError("获取百度 Token 失败".to_string())); + } + + let token_resp: TokenResponse = response + .json() + .await + .map_err(|e| VoiceError::AsrAuthError(e.to_string()))?; + + self.cached_token = Some(token_resp.access_token.clone()); + Ok(token_resp.access_token) + } +} + +#[async_trait] +impl AsrClient for BaiduClient { + async fn transcribe(&self, audio: &AudioData) -> Result { + // 需要可变引用来缓存 token + let mut client = BaiduClient::new(self.api_key.clone(), self.secret_key.clone()); + let token = client.get_token().await?; + + let wav_bytes = audio.to_wav_bytes(); + let speech = BASE64.encode(&wav_bytes); + + let request = AsrRequest { + format: "wav".to_string(), + rate: audio.sample_rate, + channel: audio.channels, + cuid: "proxycast".to_string(), + token, + speech, + len: wav_bytes.len(), + }; + + let http_client = reqwest::Client::new(); + let response = http_client + .post("https://vop.baidu.com/server_api") + .json(&request) + .send() + .await + .map_err(|e| VoiceError::NetworkError(e.to_string()))?; + + let result: AsrResponse = response + .json() + .await + .map_err(|e| VoiceError::AsrError(e.to_string()))?; + + if result.err_no != 0 { + return Err(VoiceError::AsrError(format!( + "百度 ASR 错误: {} - {}", + result.err_no, result.err_msg + ))); + } + + let text = result.result.join(""); + + Ok(TranscribeResult { + text, + language: Some("zh".to_string()), + confidence: None, + segments: vec![], + }) + } + + fn name(&self) -> &'static str { + "百度语音" + } +} diff --git a/src-tauri/crates/voice-core/src/asr_client/mod.rs b/src-tauri/crates/voice-core/src/asr_client/mod.rs new file mode 100644 index 000000000..c063d10b5 --- /dev/null +++ b/src-tauri/crates/voice-core/src/asr_client/mod.rs @@ -0,0 +1,26 @@ +//! 云端 ASR 客户端模块 +//! +//! 支持讯飞、百度、OpenAI Whisper 等云端语音识别服务。 + +pub mod baidu; +pub mod openai; +pub mod xunfei; + +use async_trait::async_trait; + +use crate::error::Result; +use crate::types::{AudioData, TranscribeResult}; + +/// ASR 客户端 trait +#[async_trait] +pub trait AsrClient: Send + Sync { + /// 识别音频 + async fn transcribe(&self, audio: &AudioData) -> Result; + + /// 获取服务名称 + fn name(&self) -> &'static str; +} + +pub use baidu::BaiduClient; +pub use openai::OpenAIWhisperClient; +pub use xunfei::XunfeiClient; diff --git a/src-tauri/crates/voice-core/src/asr_client/openai.rs b/src-tauri/crates/voice-core/src/asr_client/openai.rs new file mode 100644 index 000000000..dc108258e --- /dev/null +++ b/src-tauri/crates/voice-core/src/asr_client/openai.rs @@ -0,0 +1,108 @@ +//! OpenAI Whisper API 客户端 +//! +//! 使用 OpenAI 的 Whisper API 进行语音识别。 + +use async_trait::async_trait; +use reqwest::multipart::{Form, Part}; +use serde::Deserialize; + +use super::AsrClient; +use crate::error::{Result, VoiceError}; +use crate::types::{AudioData, TranscribeResult}; + +/// OpenAI Whisper 响应 +#[derive(Debug, Deserialize)] +struct WhisperResponse { + text: String, + #[serde(default)] + language: Option, +} + +/// OpenAI Whisper 客户端 +pub struct OpenAIWhisperClient { + api_key: String, + api_host: String, + model: String, + language: Option, +} + +impl OpenAIWhisperClient { + /// 创建新的客户端 + pub fn new(api_key: String) -> Self { + Self { + api_key, + api_host: "https://api.openai.com".to_string(), + model: "whisper-1".to_string(), + language: None, + } + } + + /// 设置 API Host(用于代理) + pub fn with_host(mut self, host: String) -> Self { + self.api_host = host; + self + } + + /// 设置语言 + pub fn with_language(mut self, language: String) -> Self { + self.language = Some(language); + self + } +} + +#[async_trait] +impl AsrClient for OpenAIWhisperClient { + async fn transcribe(&self, audio: &AudioData) -> Result { + let url = format!("{}/v1/audio/transcriptions", self.api_host); + let wav_bytes = audio.to_wav_bytes(); + + // 构建 multipart form + let file_part = Part::bytes(wav_bytes) + .file_name("audio.wav") + .mime_str("audio/wav") + .map_err(|e| VoiceError::AsrError(e.to_string()))?; + + let mut form = Form::new() + .part("file", file_part) + .text("model", self.model.clone()); + + if let Some(ref lang) = self.language { + form = form.text("language", lang.clone()); + } + + // 发送请求 + let client = reqwest::Client::new(); + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .multipart(form) + .send() + .await + .map_err(|e| VoiceError::NetworkError(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(VoiceError::AsrError(format!( + "OpenAI API 错误: {} - {}", + status, body + ))); + } + + let result: WhisperResponse = response + .json() + .await + .map_err(|e| VoiceError::AsrError(e.to_string()))?; + + Ok(TranscribeResult { + text: result.text, + language: result.language, + confidence: None, + segments: vec![], + }) + } + + fn name(&self) -> &'static str { + "OpenAI Whisper" + } +} diff --git a/src-tauri/crates/voice-core/src/asr_client/xunfei.rs b/src-tauri/crates/voice-core/src/asr_client/xunfei.rs new file mode 100644 index 000000000..8e918f5de --- /dev/null +++ b/src-tauri/crates/voice-core/src/asr_client/xunfei.rs @@ -0,0 +1,469 @@ +//! 讯飞语音识别客户端 +//! +//! 使用讯飞开放平台的语音识别 WebSocket API (v2)。 +//! +//! ## 协议说明 +//! +//! 讯飞语音识别使用 WebSocket 流式传输,协议流程: +//! 1. 建立 WebSocket 连接(带鉴权参数) +//! 2. 分帧发送音频数据(每帧约 1280 字节) +//! 3. 接收识别结果(流式返回) +//! 4. 发送结束帧,等待最终结果 +//! +//! ## 参考文档 +//! https://www.xfyun.cn/doc/asr/voicedictation/API.html + +use async_trait::async_trait; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use chrono::Utc; +use futures_util::{SinkExt, StreamExt}; +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +use super::AsrClient; +use crate::error::{Result, VoiceError}; +use crate::types::{AudioData, Segment, TranscribeResult}; + +/// 讯飞 WebSocket 帧大小(字节) +/// 讯飞建议每帧发送 1280 字节(约 40ms 的 16kHz 16bit 单声道音频) +const FRAME_SIZE: usize = 1280; + +/// 讯飞客户端 +pub struct XunfeiClient { + app_id: String, + api_key: String, + api_secret: String, + language: String, +} + +impl XunfeiClient { + /// 创建新的客户端 + pub fn new(app_id: String, api_key: String, api_secret: String) -> Self { + Self { + app_id, + api_key, + api_secret, + language: "zh_cn".to_string(), + } + } + + /// 设置语言 + pub fn with_language(mut self, language: String) -> Self { + self.language = language; + self + } + + /// 生成鉴权 URL + /// + /// 讯飞 WebSocket 鉴权使用 URL 参数传递,包含: + /// - authorization: Base64 编码的鉴权信息 + /// - date: RFC1123 格式的时间戳 + /// - host: 主机名 + fn generate_auth_url(&self) -> Result { + let host = "iat-api.xfyun.cn"; + let path = "/v2/iat"; + let date = Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); + + tracing::debug!("讯飞鉴权 - date: {}", date); + tracing::debug!("讯飞鉴权 - api_key 长度: {}", self.api_key.len()); + tracing::debug!("讯飞鉴权 - api_secret 长度: {}", self.api_secret.len()); + + // 构建签名原文 + let signature_origin = format!("host: {}\ndate: {}\nGET {} HTTP/1.1", host, date, path); + tracing::debug!("讯飞鉴权 - signature_origin:\n{}", signature_origin); + + // HMAC-SHA256 签名 + type HmacSha256 = Hmac; + let mut mac = HmacSha256::new_from_slice(self.api_secret.as_bytes()) + .map_err(|e| VoiceError::AsrAuthError(e.to_string()))?; + mac.update(signature_origin.as_bytes()); + let signature = BASE64.encode(mac.finalize().into_bytes()); + tracing::debug!("讯飞鉴权 - signature: {}", signature); + + // 构建 authorization + let authorization_origin = format!( + "api_key=\"{}\", algorithm=\"hmac-sha256\", headers=\"host date request-line\", signature=\"{}\"", + self.api_key, signature + ); + let authorization = BASE64.encode(authorization_origin.as_bytes()); + tracing::debug!("讯飞鉴权 - authorization 长度: {}", authorization.len()); + + // 构建 URL + let url = format!( + "wss://{}{}?authorization={}&date={}&host={}", + host, + path, + urlencoding::encode(&authorization), + urlencoding::encode(&date), + urlencoding::encode(host) + ); + + Ok(url) + } + + /// 构建首帧请求(包含业务参数) + fn build_first_frame(&self, audio_chunk: &[u8]) -> XunfeiRequest { + XunfeiRequest { + common: XunfeiCommon { + app_id: self.app_id.clone(), + }, + business: Some(XunfeiBusiness { + language: self.language.clone(), + domain: "iat".to_string(), + accent: "mandarin".to_string(), + vad_eos: 3000, // 静音检测时间(毫秒) + dwa: Some("wpgs".to_string()), // 动态修正 + ptt: Some(1), // 添加标点 + }), + data: XunfeiData { + status: 0, // 首帧 + format: "audio/L16;rate=16000".to_string(), + encoding: "raw".to_string(), + audio: BASE64.encode(audio_chunk), + }, + } + } + + /// 构建中间帧请求 + fn build_continue_frame(&self, audio_chunk: &[u8]) -> XunfeiRequest { + XunfeiRequest { + common: XunfeiCommon { + app_id: self.app_id.clone(), + }, + business: None, + data: XunfeiData { + status: 1, // 中间帧 + format: "audio/L16;rate=16000".to_string(), + encoding: "raw".to_string(), + audio: BASE64.encode(audio_chunk), + }, + } + } + + /// 构建尾帧请求 + fn build_last_frame(&self, audio_chunk: &[u8]) -> XunfeiRequest { + XunfeiRequest { + common: XunfeiCommon { + app_id: self.app_id.clone(), + }, + business: None, + data: XunfeiData { + status: 2, // 尾帧 + format: "audio/L16;rate=16000".to_string(), + encoding: "raw".to_string(), + audio: BASE64.encode(audio_chunk), + }, + } + } + + /// 解析识别结果 + fn parse_result(responses: &[XunfeiResponse]) -> TranscribeResult { + let mut full_text = String::new(); + let mut segments = Vec::new(); + + for resp in responses { + if let Some(ref data) = resp.data { + if let Some(ref result) = data.result { + // 拼接所有词 + for ws in &result.ws { + for cw in &ws.cw { + full_text.push_str(&cw.w); + } + } + } + } + } + + // 如果有文本,创建一个整体的 segment + if !full_text.is_empty() { + segments.push(Segment { + start: 0.0, + end: 0.0, // 讯飞不返回时间戳 + text: full_text.clone(), + }); + } + + TranscribeResult { + text: full_text, + language: Some("zh".to_string()), + confidence: None, + segments, + } + } +} + +#[async_trait] +impl AsrClient for XunfeiClient { + async fn transcribe(&self, audio: &AudioData) -> Result { + // 生成鉴权 URL + let url = self.generate_auth_url()?; + tracing::debug!("讯飞 WebSocket URL 长度: {}", url.len()); + + // 建立 WebSocket 连接 + tracing::info!("正在连接讯飞 WebSocket..."); + let (ws_stream, response) = connect_async(&url).await.map_err(|e| { + tracing::error!("讯飞 WebSocket 连接失败: {:?}", e); + VoiceError::NetworkError(format!("WebSocket 连接失败: {}", e)) + })?; + + tracing::info!( + "讯飞 WebSocket 连接成功,HTTP 状态: {:?}", + response.status() + ); + + let (mut write, mut read) = ws_stream.split(); + + // 将音频数据转换为字节(16-bit PCM) + let audio_bytes: Vec = audio.samples.iter().flat_map(|s| s.to_le_bytes()).collect(); + + // 分帧发送音频数据 + let chunks: Vec<&[u8]> = audio_bytes.chunks(FRAME_SIZE).collect(); + let total_chunks = chunks.len(); + + tracing::info!( + "开始发送音频数据,共 {} 帧,总大小 {} 字节", + total_chunks, + audio_bytes.len() + ); + + // 启动接收任务 + let receive_task = tokio::spawn(async move { + let mut responses: Vec = Vec::new(); + + while let Some(msg) = read.next().await { + match msg { + Ok(Message::Text(text)) => { + tracing::debug!("收到讯飞响应: {}", text); + + match serde_json::from_str::(&text) { + Ok(response) => { + // 检查是否是最后一帧 + let is_last = response + .data + .as_ref() + .map(|d| d.status == 2) + .unwrap_or(false); + + responses.push(response); + + if is_last { + tracing::info!("收到最终识别结果"); + break; + } + } + Err(e) => { + tracing::error!("解析响应失败: {}", e); + } + } + } + Ok(Message::Close(frame)) => { + tracing::info!("WebSocket 连接关闭: {:?}", frame); + break; + } + Ok(Message::Ping(_)) => { + tracing::debug!("收到 Ping"); + } + Err(e) => { + tracing::error!("接收数据失败: {}", e); + break; + } + _ => {} + } + } + + responses + }); + + // 发送音频数据 + let mut send_error: Option = None; + + for (i, chunk) in chunks.iter().enumerate() { + let request = if i == 0 { + // 首帧 + self.build_first_frame(chunk) + } else if i == total_chunks - 1 { + // 尾帧 + self.build_last_frame(chunk) + } else { + // 中间帧 + self.build_continue_frame(chunk) + }; + + let json = match serde_json::to_string(&request) { + Ok(j) => j, + Err(e) => { + send_error = Some(VoiceError::AsrError(format!("序列化请求失败: {}", e))); + break; + } + }; + + // 发送数据,如果失败则记录错误但继续尝试 + match write.send(Message::Text(json)).await { + Ok(_) => { + if i == 0 { + tracing::debug!("首帧发送成功"); + } else if i == total_chunks - 1 { + tracing::debug!("尾帧发送成功"); + } + } + Err(e) => { + tracing::error!("发送第 {} 帧失败: {}", i, e); + send_error = Some(VoiceError::NetworkError(format!("发送数据失败: {}", e))); + break; + } + } + + // 控制发送速率,避免发送过快 + // 讯飞建议发送间隔与音频时长一致,每帧 1280 字节 = 40ms 音频 + // 增加一点缓冲时间以提高稳定性 + if i < total_chunks - 1 { + tokio::time::sleep(tokio::time::Duration::from_millis(45)).await; + } + } + + // 等待接收任务完成(设置超时) + let responses = + match tokio::time::timeout(tokio::time::Duration::from_secs(30), receive_task).await { + Ok(Ok(responses)) => responses, + Ok(Err(e)) => { + return Err(VoiceError::AsrError(format!("接收任务失败: {}", e))); + } + Err(_) => { + return Err(VoiceError::AsrError("等待识别结果超时".to_string())); + } + }; + + // 如果发送过程中有错误,但仍然收到了响应,则检查响应 + if let Some(err) = send_error { + if responses.is_empty() { + return Err(err); + } + tracing::warn!("发送过程中出现错误,但仍收到 {} 个响应", responses.len()); + } + + // 检查响应中是否有错误 + for response in &responses { + if response.code != 0 { + return Err(VoiceError::AsrError(format!( + "讯飞 ASR 错误 [{}]: {}", + response.code, + response.message.clone().unwrap_or_default() + ))); + } + } + + // 解析最终结果 + let result = Self::parse_result(&responses); + tracing::info!("讯飞识别完成: {}", result.text); + + Ok(result) + } + + fn name(&self) -> &'static str { + "讯飞语音" + } +} + +// ============================================================================ +// 讯飞 WebSocket 协议数据结构 +// ============================================================================ + +/// 讯飞请求 +#[derive(Debug, Serialize)] +struct XunfeiRequest { + /// 公共参数 + common: XunfeiCommon, + /// 业务参数(仅首帧需要) + #[serde(skip_serializing_if = "Option::is_none")] + business: Option, + /// 数据 + data: XunfeiData, +} + +/// 公共参数 +#[derive(Debug, Serialize)] +struct XunfeiCommon { + /// 应用 ID + app_id: String, +} + +/// 业务参数 +#[derive(Debug, Serialize)] +struct XunfeiBusiness { + /// 语言(zh_cn: 中文,en_us: 英文) + language: String, + /// 领域(iat: 日常用语) + domain: String, + /// 方言(mandarin: 普通话) + accent: String, + /// 静音检测时间(毫秒) + vad_eos: u32, + /// 动态修正(wpgs: 开启) + #[serde(skip_serializing_if = "Option::is_none")] + dwa: Option, + /// 是否添加标点(1: 添加) + #[serde(skip_serializing_if = "Option::is_none")] + ptt: Option, +} + +/// 数据参数 +#[derive(Debug, Serialize)] +struct XunfeiData { + /// 状态(0: 首帧,1: 中间帧,2: 尾帧) + status: u8, + /// 音频格式 + format: String, + /// 编码方式 + encoding: String, + /// Base64 编码的音频数据 + audio: String, +} + +/// 讯飞响应 +#[derive(Debug, Deserialize)] +struct XunfeiResponse { + /// 错误码(0 表示成功) + code: i32, + /// 错误信息 + message: Option, + /// 会话 ID + #[allow(dead_code)] + sid: Option, + /// 数据 + data: Option, +} + +/// 响应数据 +#[derive(Debug, Deserialize)] +struct XunfeiResponseData { + /// 状态(0: 首帧,1: 中间帧,2: 尾帧) + status: u8, + /// 识别结果 + result: Option, +} + +/// 识别结果 +#[derive(Debug, Deserialize)] +struct XunfeiResult { + /// 词列表 + ws: Vec, + /// 是否是最终结果 + #[allow(dead_code)] + ls: Option, +} + +/// 词 +#[derive(Debug, Deserialize)] +struct XunfeiWord { + /// 候选词列表 + cw: Vec, +} + +/// 候选词 +#[derive(Debug, Deserialize)] +struct XunfeiCandidate { + /// 词内容 + w: String, +} diff --git a/src-tauri/crates/voice-core/src/error.rs b/src-tauri/crates/voice-core/src/error.rs new file mode 100644 index 000000000..161703fd7 --- /dev/null +++ b/src-tauri/crates/voice-core/src/error.rs @@ -0,0 +1,68 @@ +//! 错误类型定义 +//! +//! 定义语音输入相关的错误类型。 + +use thiserror::Error; + +/// 语音输入错误 +#[derive(Debug, Error)] +pub enum VoiceError { + /// 录音错误 + #[error("录音错误: {0}")] + RecorderError(String), + + /// 麦克风权限错误 + #[error("麦克风权限不足,请在系统设置中授权")] + MicrophonePermissionDenied, + + /// 没有可用的麦克风 + #[error("没有找到可用的麦克风设备")] + NoMicrophoneFound, + + /// 识别错误 + #[error("语音识别错误: {0}")] + TranscriberError(String), + + /// Whisper 模型加载错误 + #[error("Whisper 模型加载失败: {0}")] + WhisperModelError(String), + + /// ASR 服务错误 + #[error("ASR 服务错误: {0}")] + AsrError(String), + + /// ASR 认证错误 + #[error("ASR 认证失败: {0}")] + AsrAuthError(String), + + /// 输出错误 + #[error("文字输出错误: {0}")] + OutputError(String), + + /// 剪贴板错误 + #[error("剪贴板操作失败: {0}")] + ClipboardError(String), + + /// 键盘模拟错误 + #[error("键盘模拟失败: {0}")] + KeyboardError(String), + + /// 音频格式错误 + #[error("音频格式错误: {0}")] + AudioFormatError(String), + + /// 录音时间过短 + #[error("录音时间过短(需要至少 0.5 秒)")] + RecordingTooShort, + + /// 网络错误 + #[error("网络请求失败: {0}")] + NetworkError(String), + + /// IO 错误 + #[error("IO 错误: {0}")] + IoError(#[from] std::io::Error), +} + +/// Result 类型别名 +pub type Result = std::result::Result; diff --git a/src-tauri/crates/voice-core/src/lib.rs b/src-tauri/crates/voice-core/src/lib.rs new file mode 100644 index 000000000..9279ce989 --- /dev/null +++ b/src-tauri/crates/voice-core/src/lib.rs @@ -0,0 +1,17 @@ +//! voice-core - 语音输入核心库 +//! +//! 提供音频录制、语音识别、文字输出等功能。 +//! 不依赖 Tauri,可被任何 Rust 项目使用。 + +pub mod asr_client; +pub mod error; +pub mod output; +pub mod recorder; +pub mod transcriber; +pub mod types; + +pub use error::{Result, VoiceError}; +pub use output::OutputHandler; +pub use recorder::AudioRecorder; +pub use transcriber::WhisperTranscriber; +pub use types::*; diff --git a/src-tauri/crates/voice-core/src/output.rs b/src-tauri/crates/voice-core/src/output.rs new file mode 100644 index 000000000..e88948702 --- /dev/null +++ b/src-tauri/crates/voice-core/src/output.rs @@ -0,0 +1,66 @@ +//! 文字输出模块 +//! +//! 支持模拟键盘输入和剪贴板两种输出方式。 + +use arboard::Clipboard; +use enigo::{Enigo, Keyboard, Settings}; + +use crate::error::{Result, VoiceError}; +use crate::types::OutputMode; + +/// 文字输出处理器 +pub struct OutputHandler { + /// 键盘模拟器 + enigo: Enigo, +} + +impl OutputHandler { + /// 创建新的输出处理器 + pub fn new() -> Result { + let enigo = Enigo::new(&Settings::default()) + .map_err(|e| VoiceError::KeyboardError(e.to_string()))?; + + Ok(Self { enigo }) + } + + /// 输出文字 + pub fn output(&mut self, text: &str, mode: OutputMode) -> Result<()> { + match mode { + OutputMode::Type => self.type_text(text), + OutputMode::Clipboard => self.copy_to_clipboard(text), + OutputMode::Both => { + self.copy_to_clipboard(text)?; + self.type_text(text) + } + } + } + + /// 模拟键盘输入文字 + pub fn type_text(&mut self, text: &str) -> Result<()> { + self.enigo + .text(text) + .map_err(|e| VoiceError::KeyboardError(e.to_string()))?; + + tracing::info!("键盘输入完成: {} 字符", text.chars().count()); + Ok(()) + } + + /// 复制到剪贴板 + pub fn copy_to_clipboard(&self, text: &str) -> Result<()> { + let mut clipboard = + Clipboard::new().map_err(|e| VoiceError::ClipboardError(e.to_string()))?; + + clipboard + .set_text(text) + .map_err(|e| VoiceError::ClipboardError(e.to_string()))?; + + tracing::info!("已复制到剪贴板: {} 字符", text.chars().count()); + Ok(()) + } +} + +impl Default for OutputHandler { + fn default() -> Self { + Self::new().expect("创建输出处理器失败") + } +} diff --git a/src-tauri/crates/voice-core/src/recorder.rs b/src-tauri/crates/voice-core/src/recorder.rs new file mode 100644 index 000000000..ec6ea03cf --- /dev/null +++ b/src-tauri/crates/voice-core/src/recorder.rs @@ -0,0 +1,198 @@ +//! 音频录制模块 +//! +//! 使用 cpal 进行跨平台音频采集。 + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use crate::error::{Result, VoiceError}; +use crate::types::AudioData; + +/// 默认采样率(ASR 标准) +pub const DEFAULT_SAMPLE_RATE: u32 = 16000; +/// 默认声道数 +pub const DEFAULT_CHANNELS: u16 = 1; +/// 最大录音时长(秒) +pub const MAX_RECORDING_DURATION: f32 = 60.0; + +/// 音频录制器 +pub struct AudioRecorder { + /// 录音数据缓冲区 + samples: Arc>>, + /// 当前音量级别(0-100) + volume_level: Arc, + /// 是否正在录音 + is_recording: Arc, + /// 录音开始时间 + start_time: Option, + /// 音频流(录音时持有) + stream: Option, + /// 采样率 + sample_rate: u32, +} + +impl AudioRecorder { + /// 创建新的录音器 + pub fn new() -> Result { + Ok(Self { + samples: Arc::new(Mutex::new(Vec::new())), + volume_level: Arc::new(AtomicU32::new(0)), + is_recording: Arc::new(AtomicBool::new(false)), + start_time: None, + stream: None, + sample_rate: DEFAULT_SAMPLE_RATE, + }) + } + + /// 开始录音 + pub fn start(&mut self) -> Result<()> { + if self.is_recording.load(Ordering::SeqCst) { + return Ok(()); + } + + // 清空缓冲区 + if let Ok(mut samples) = self.samples.lock() { + samples.clear(); + } + + // 获取默认输入设备 + let host = cpal::default_host(); + let device = host + .default_input_device() + .ok_or(VoiceError::NoMicrophoneFound)?; + + tracing::info!("使用麦克风: {:?}", device.name()); + + // 配置音频格式 + let config = cpal::StreamConfig { + channels: DEFAULT_CHANNELS, + sample_rate: cpal::SampleRate(DEFAULT_SAMPLE_RATE), + buffer_size: cpal::BufferSize::Default, + }; + + self.sample_rate = DEFAULT_SAMPLE_RATE; + + // 创建共享状态 + let samples = Arc::clone(&self.samples); + let volume_level = Arc::clone(&self.volume_level); + let is_recording = Arc::clone(&self.is_recording); + + // 创建输入流 + let stream = device + .build_input_stream( + &config, + move |data: &[f32], _: &cpal::InputCallbackInfo| { + if !is_recording.load(Ordering::SeqCst) { + return; + } + + // 计算音量级别 + let sum: f32 = data.iter().map(|s| s.abs()).sum(); + let avg = sum / data.len() as f32; + let level = (avg * 100.0).min(100.0) as u32; + volume_level.store(level, Ordering::SeqCst); + + // 转换为 i16 并存储 + let i16_samples: Vec = + data.iter().map(|&s| (s * i16::MAX as f32) as i16).collect(); + + if let Ok(mut buffer) = samples.lock() { + buffer.extend(i16_samples); + } + }, + |err| { + tracing::error!("录音流错误: {}", err); + }, + None, + ) + .map_err(|e| VoiceError::RecorderError(e.to_string()))?; + + // 开始录音 + stream + .play() + .map_err(|e| VoiceError::RecorderError(e.to_string()))?; + + self.stream = Some(stream); + self.is_recording.store(true, Ordering::SeqCst); + self.start_time = Some(Instant::now()); + + tracing::info!("开始录音"); + Ok(()) + } + + /// 停止录音并返回音频数据 + pub fn stop(&mut self) -> Result { + if !self.is_recording.load(Ordering::SeqCst) { + return Err(VoiceError::RecorderError("未在录音中".to_string())); + } + + // 停止录音 + self.is_recording.store(false, Ordering::SeqCst); + + // 停止流 + if let Some(stream) = self.stream.take() { + drop(stream); + } + + // 获取录音数据 + let samples = self + .samples + .lock() + .map_err(|e| VoiceError::RecorderError(e.to_string()))? + .clone(); + + let audio = AudioData::new(samples, self.sample_rate, DEFAULT_CHANNELS); + + tracing::info!("停止录音,时长: {:.2}s", audio.duration_secs); + + // 检查录音时长 + if !audio.is_valid() { + return Err(VoiceError::RecordingTooShort); + } + + Ok(audio) + } + + /// 获取当前音量级别(0-100) + pub fn get_volume(&self) -> u32 { + self.volume_level.load(Ordering::SeqCst) + } + + /// 获取录音时长(秒) + pub fn get_duration(&self) -> f32 { + self.start_time + .map(|t| t.elapsed().as_secs_f32()) + .unwrap_or(0.0) + } + + /// 是否正在录音 + pub fn is_recording(&self) -> bool { + self.is_recording.load(Ordering::SeqCst) + } + + /// 取消录音 + pub fn cancel(&mut self) { + self.is_recording.store(false, Ordering::SeqCst); + if let Some(stream) = self.stream.take() { + drop(stream); + } + if let Ok(mut samples) = self.samples.lock() { + samples.clear(); + } + tracing::info!("取消录音"); + } +} + +impl Default for AudioRecorder { + fn default() -> Self { + Self::new().expect("创建录音器失败") + } +} + +impl Drop for AudioRecorder { + fn drop(&mut self) { + self.cancel(); + } +} diff --git a/src-tauri/crates/voice-core/src/transcriber.rs b/src-tauri/crates/voice-core/src/transcriber.rs new file mode 100644 index 000000000..c2e42d299 --- /dev/null +++ b/src-tauri/crates/voice-core/src/transcriber.rs @@ -0,0 +1,124 @@ +//! Whisper 本地语音识别模块 +//! +//! 使用 whisper-rs 进行本地语音识别。 + +use std::path::PathBuf; +use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; + +use crate::error::{Result, VoiceError}; +use crate::types::{AudioData, Segment, TranscribeResult, WhisperModel}; + +/// Whisper 识别器 +pub struct WhisperTranscriber { + /// Whisper 上下文 + ctx: WhisperContext, + /// 模型大小 + model: WhisperModel, + /// 语言(如 "zh", "en", "auto") + language: String, +} + +impl WhisperTranscriber { + /// 创建新的 Whisper 识别器 + /// + /// # 参数 + /// - `model_path`: 模型文件路径 + /// - `model`: 模型大小 + /// - `language`: 语言代码("zh", "en", "auto") + pub fn new(model_path: PathBuf, model: WhisperModel, language: &str) -> Result { + let ctx = WhisperContext::new_with_params( + model_path.to_str().unwrap_or_default(), + WhisperContextParameters::default(), + ) + .map_err(|e| VoiceError::WhisperModelError(e.to_string()))?; + + Ok(Self { + ctx, + model, + language: language.to_string(), + }) + } + + /// 识别音频 + pub fn transcribe(&self, audio: &AudioData) -> Result { + // 转换为 f32 采样 + let samples: Vec = audio + .samples + .iter() + .map(|&s| s as f32 / i16::MAX as f32) + .collect(); + + // 创建识别参数 + let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); + + // 设置语言 + if self.language != "auto" { + params.set_language(Some(&self.language)); + } + + // 其他参数 + params.set_print_special(false); + params.set_print_progress(false); + params.set_print_realtime(false); + params.set_print_timestamps(false); + params.set_translate(false); + params.set_no_context(true); + params.set_single_segment(false); + + // 创建状态并识别 + let mut state = self + .ctx + .create_state() + .map_err(|e| VoiceError::TranscriberError(e.to_string()))?; + + state + .full(params, &samples) + .map_err(|e| VoiceError::TranscriberError(e.to_string()))?; + + // 获取结果 + let num_segments = state.full_n_segments().unwrap_or(0); + let mut text = String::new(); + let mut segments = Vec::new(); + + for i in 0..num_segments { + if let Ok(segment_text) = state.full_get_segment_text(i) { + let start = state.full_get_segment_t0(i).unwrap_or(0) as f32 / 100.0; + let end = state.full_get_segment_t1(i).unwrap_or(0) as f32 / 100.0; + + text.push_str(&segment_text); + segments.push(Segment { + start, + end, + text: segment_text, + }); + } + } + + // 检测语言 + let detected_language = if self.language == "auto" { + state + .full_lang_id_from_state() + .ok() + .and_then(|id| whisper_rs::get_lang_str(id).map(|s| s.to_string())) + } else { + Some(self.language.clone()) + }; + + Ok(TranscribeResult { + text: text.trim().to_string(), + language: detected_language, + confidence: None, + segments, + }) + } + + /// 获取模型大小 + pub fn model(&self) -> WhisperModel { + self.model + } + + /// 获取语言设置 + pub fn language(&self) -> &str { + &self.language + } +} diff --git a/src-tauri/crates/voice-core/src/types.rs b/src-tauri/crates/voice-core/src/types.rs new file mode 100644 index 000000000..67fc37a17 --- /dev/null +++ b/src-tauri/crates/voice-core/src/types.rs @@ -0,0 +1,141 @@ +//! 类型定义 +//! +//! 定义语音输入相关的核心类型。 + +use serde::{Deserialize, Serialize}; + +/// 音频数据 +#[derive(Debug, Clone)] +pub struct AudioData { + /// PCM 采样数据(16-bit signed) + pub samples: Vec, + /// 采样率(默认 16000) + pub sample_rate: u32, + /// 声道数(默认 1) + pub channels: u16, + /// 录音时长(秒) + pub duration_secs: f32, +} + +impl AudioData { + /// 创建新的音频数据 + pub fn new(samples: Vec, sample_rate: u32, channels: u16) -> Self { + let duration_secs = samples.len() as f32 / sample_rate as f32 / channels as f32; + Self { + samples, + sample_rate, + channels, + duration_secs, + } + } + + /// 检查音频是否有效(时长 >= 0.5 秒) + pub fn is_valid(&self) -> bool { + self.duration_secs >= 0.5 + } + + /// 转换为 WAV 格式字节 + pub fn to_wav_bytes(&self) -> Vec { + let mut cursor = std::io::Cursor::new(Vec::new()); + let spec = hound::WavSpec { + channels: self.channels, + sample_rate: self.sample_rate, + bits_per_sample: 16, + sample_format: hound::SampleFormat::Int, + }; + + if let Ok(mut writer) = hound::WavWriter::new(&mut cursor, spec) { + for sample in &self.samples { + let _ = writer.write_sample(*sample); + } + let _ = writer.finalize(); + } + + cursor.into_inner() + } +} + +/// 识别结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TranscribeResult { + /// 识别文本 + pub text: String, + /// 语言(如 "zh", "en") + pub language: Option, + /// 置信度(0.0 - 1.0) + pub confidence: Option, + /// 分段信息 + pub segments: Vec, +} + +/// 识别分段 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Segment { + /// 开始时间(秒) + pub start: f32, + /// 结束时间(秒) + pub end: f32, + /// 文本内容 + pub text: String, +} + +/// ASR 引擎类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AsrEngine { + /// 本地 Whisper + WhisperLocal, + /// 讯飞语音 + Xunfei, + /// 百度语音 + Baidu, + /// OpenAI Whisper API + OpenAI, +} + +/// Whisper 模型大小 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WhisperModel { + /// tiny - 最小,最快 + Tiny, + /// base - 基础 + Base, + /// small - 小型 + Small, + /// medium - 中型 + Medium, + /// large - 大型,最准确 + Large, +} + +impl WhisperModel { + /// 获取模型文件名 + pub fn filename(&self) -> &'static str { + match self { + Self::Tiny => "ggml-tiny.bin", + Self::Base => "ggml-base.bin", + Self::Small => "ggml-small.bin", + Self::Medium => "ggml-medium.bin", + Self::Large => "ggml-large.bin", + } + } +} + +/// 输出模式 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OutputMode { + /// 模拟键盘输入 + Type, + /// 复制到剪贴板 + Clipboard, + /// 两者都做 + Both, +} + +impl Default for OutputMode { + fn default() -> Self { + Self::Type + } +} diff --git a/src-tauri/entitlements.plist b/src-tauri/entitlements.plist index e7b78d299..5c0b869ad 100644 --- a/src-tauri/entitlements.plist +++ b/src-tauri/entitlements.plist @@ -14,5 +14,7 @@ com.apple.security.files.user-selected.read-write + com.apple.security.device.audio-input + diff --git a/src-tauri/src/README.md b/src-tauri/src/README.md index 3c3701ec4..3fd7ee5ac 100644 --- a/src-tauri/src/README.md +++ b/src-tauri/src/README.md @@ -32,6 +32,7 @@ Tauri 后端核心代码,处理系统级功能和 API 服务。 - `terminal/` - 终端核心模块(PTY 管理、会话管理) - `tray/` - 系统托盘 - `websocket/` - WebSocket 支持 +- `workspace/` - Workspace 工作目录管理 - `lib.rs` - 库入口 - `main.rs` - 应用入口 - `logger.rs` - 日志配置 diff --git a/src-tauri/src/agent/aster_state.rs b/src-tauri/src/agent/aster_state.rs index 5f538e201..f93cc1058 100644 --- a/src-tauri/src/agent/aster_state.rs +++ b/src-tauri/src/agent/aster_state.rs @@ -11,7 +11,7 @@ use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; use crate::agent::credential_bridge::{ - create_aster_provider, AsterProviderConfig, CredentialBridge, CredentialBridgeError, + create_aster_provider, AsterProviderConfig, CredentialBridge, }; use crate::database::DbConnection; diff --git a/src-tauri/src/agent/credential_bridge.rs b/src-tauri/src/agent/credential_bridge.rs index ef25d27b5..3b816ae06 100644 --- a/src-tauri/src/agent/credential_bridge.rs +++ b/src-tauri/src/agent/credential_bridge.rs @@ -219,8 +219,8 @@ impl CredentialBridge { async fn get_kiro_token( &self, creds_path: &str, - db: &DbConnection, - uuid: &str, + _db: &DbConnection, + _uuid: &str, ) -> Result { use crate::providers::kiro::KiroProvider; diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index eddfe1a0d..66f3225ae 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -44,9 +44,10 @@ use crate::services::token_cache_service::TokenCacheService; use crate::services::tool_hooks_service::ToolHooksService; use crate::services::update_check_service::UpdateCheckServiceState; use crate::telemetry; +use crate::voice::recording_service::{create_recording_service_state, RecordingServiceState}; use super::types::{AppState, LogState, TokenCacheServiceState}; -use super::utils::{generate_api_key, is_non_local_bind, is_valid_bind_host}; +use super::utils::{generate_api_key, is_valid_bind_host}; /// 配置验证错误 #[derive(Debug)] @@ -148,6 +149,7 @@ pub struct AppStates { pub session_files: SessionFilesState, pub context_memory_service: ContextMemoryServiceState, pub tool_hooks_service: ToolHooksServiceState, + pub recording_service: RecordingServiceState, // 用于 setup hook 的共享实例 pub shared_stats: Arc>, pub shared_tokens: Arc>, @@ -265,6 +267,9 @@ pub fn init_states(config: &Config) -> Result { let tool_hooks_service = ToolHooksService::new(context_memory_service_arc.clone()); let tool_hooks_service_state = ToolHooksServiceState(Arc::new(tool_hooks_service)); + // 录音服务(使用独立线程 + channel 通信解决 cpal::Stream 不是 Send 的问题) + let recording_service_state = create_recording_service_state(); + Ok(AppStates { state, logs, @@ -300,6 +305,7 @@ pub fn init_states(config: &Config) -> Result { session_files: session_files_state, context_memory_service: context_memory_service_state, tool_hooks_service: tool_hooks_service_state, + recording_service: recording_service_state, shared_stats, shared_tokens, shared_logger, diff --git a/src-tauri/src/app/commands/config.rs b/src-tauri/src/app/commands/config.rs index a7166b3da..19b77f6db 100644 --- a/src-tauri/src/app/commands/config.rs +++ b/src-tauri/src/app/commands/config.rs @@ -3,11 +3,11 @@ //! 包含配置读取、保存、Provider 设置等命令。 use crate::app::types::{AppState, LogState}; -use crate::app::utils::{is_non_local_bind, is_valid_bind_host}; +use crate::app::utils::is_valid_bind_host; use crate::config::{ self, observer::{ConfigChangeEvent, RoutingChangeEvent}, - ConfigChangeSource, GlobalConfigManagerState, DEFAULT_API_KEY, + ConfigChangeSource, GlobalConfigManagerState, }; /// 获取配置 diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index ff7e6dd60..9b4ec1587 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -81,6 +81,7 @@ pub fn run() { session_files: session_files_state, context_memory_service, tool_hooks_service, + recording_service, shared_stats, shared_tokens, shared_logger, @@ -164,6 +165,7 @@ pub fn run() { .manage(session_files_state) .manage(context_memory_service) .manage(tool_hooks_service) + .manage(recording_service) .on_window_event(move |window, event| { // 处理窗口关闭事件 if let tauri::WindowEvent::CloseRequested { api, .. } = event { @@ -240,6 +242,20 @@ pub fn run() { } } + // 初始化语音输入模块 + { + let app_handle = app.handle(); + match crate::voice::init(app_handle) { + Ok(()) => { + tracing::info!("[启动] 语音输入模块初始化成功"); + } + Err(e) => { + tracing::error!("[启动] 语音输入模块初始化失败: {}", e); + // 语音模块初始化失败不影响应用运行 + } + } + } + // 初始化 Connect 状态 // _Requirements: 1.4, 2.1_ { @@ -1171,10 +1187,9 @@ pub fn run() { commands::screenshot_cmd::validate_shortcut, commands::screenshot_cmd::update_screenshot_shortcut, commands::screenshot_cmd::close_screenshot_chat_window, + commands::screenshot_cmd::open_input_with_text, commands::screenshot_cmd::read_image_as_base64, commands::screenshot_cmd::send_screenshot_chat, - commands::screenshot_cmd::close_screenshot_chat_window, - commands::screenshot_cmd::read_image_as_base64, // Update Check commands commands::update_cmd::check_update, commands::update_cmd::get_update_check_settings, @@ -1213,6 +1228,15 @@ pub fn run() { commands::general_chat_cmd::general_chat_add_message, commands::general_chat_cmd::general_chat_send_message, commands::general_chat_cmd::general_chat_stop_generation, + // Workspace commands + commands::workspace_cmd::workspace_create, + commands::workspace_cmd::workspace_list, + commands::workspace_cmd::workspace_get, + commands::workspace_cmd::workspace_update, + commands::workspace_cmd::workspace_delete, + commands::workspace_cmd::workspace_set_default, + commands::workspace_cmd::workspace_get_default, + commands::workspace_cmd::workspace_get_by_path, // Context Memory commands commands::context_memory::save_memory_entry, commands::context_memory::get_session_memories, @@ -1230,6 +1254,29 @@ pub fn run() { commands::tool_hooks::get_hook_rules, commands::tool_hooks::get_hook_execution_stats, commands::tool_hooks::clear_hook_execution_stats, + // ASR commands + commands::asr_cmd::get_asr_credentials, + commands::asr_cmd::add_asr_credential, + commands::asr_cmd::update_asr_credential, + commands::asr_cmd::delete_asr_credential, + commands::asr_cmd::set_default_asr_credential, + commands::asr_cmd::test_asr_credential, + // Voice Input commands + crate::voice::commands::get_voice_input_config, + crate::voice::commands::save_voice_input_config, + crate::voice::commands::get_voice_instructions, + crate::voice::commands::save_voice_instruction, + crate::voice::commands::delete_voice_instruction, + crate::voice::commands::open_voice_window, + crate::voice::commands::close_voice_window, + crate::voice::commands::transcribe_audio, + crate::voice::commands::polish_voice_text, + crate::voice::commands::output_voice_text, + // 录音命令(使用独立线程 + channel 通信) + crate::voice::commands::start_recording, + crate::voice::commands::stop_recording, + crate::voice::commands::cancel_recording, + crate::voice::commands::get_recording_status, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/commands/asr_cmd.rs b/src-tauri/src/commands/asr_cmd.rs new file mode 100644 index 000000000..1a771cf99 --- /dev/null +++ b/src-tauri/src/commands/asr_cmd.rs @@ -0,0 +1,230 @@ +//! ASR 凭证管理命令 +//! +//! 提供语音识别服务凭证的 CRUD 操作 + +use crate::config::{ + load_config, save_config, AsrCredentialEntry, AsrProviderType, BaiduConfig, OpenAIAsrConfig, + WhisperLocalConfig, XunfeiConfig, +}; +use serde::{Deserialize, Serialize}; +use tauri::command; +use uuid::Uuid; + +/// 获取所有 ASR 凭证 +#[command] +pub async fn get_asr_credentials() -> Result, String> { + let config = load_config().map_err(|e| e.to_string())?; + Ok(config.credential_pool.asr) +} + +/// 添加 ASR 凭证的请求参数 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddAsrCredentialRequest { + pub provider: AsrProviderType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default)] + pub is_default: bool, + #[serde(default)] + pub disabled: bool, + #[serde(default = "default_language")] + pub language: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub whisper_config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub xunfei_config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub baidu_config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openai_config: Option, +} + +fn default_language() -> String { + "zh".to_string() +} + +/// 添加 ASR 凭证 +#[command] +pub async fn add_asr_credential( + entry: AddAsrCredentialRequest, +) -> Result { + tracing::info!( + "[ASR] 添加凭证: provider={:?}, name={:?}", + entry.provider, + entry.name + ); + + let mut config = load_config().map_err(|e| { + tracing::error!("[ASR] 加载配置失败: {}", e); + e.to_string() + })?; + + // 创建新凭证 + let mut new_entry = AsrCredentialEntry { + id: Uuid::new_v4().to_string(), + provider: entry.provider, + name: entry.name, + is_default: entry.is_default, + disabled: entry.disabled, + language: entry.language, + whisper_config: entry.whisper_config, + xunfei_config: entry.xunfei_config, + baidu_config: entry.baidu_config, + openai_config: entry.openai_config, + }; + + tracing::info!("[ASR] 生成新 ID: {}", new_entry.id); + + // 如果是第一个凭证,设为默认 + if config.credential_pool.asr.is_empty() { + new_entry.is_default = true; + tracing::info!("[ASR] 设为默认凭证"); + } + + config.credential_pool.asr.push(new_entry.clone()); + + save_config(&config).map_err(|e| { + tracing::error!("[ASR] 保存配置失败: {}", e); + e.to_string() + })?; + + tracing::info!("[ASR] 凭证添加成功: {}", new_entry.id); + Ok(new_entry) +} + +/// 更新 ASR 凭证 +#[command] +pub async fn update_asr_credential(entry: AsrCredentialEntry) -> Result<(), String> { + let mut config = load_config().map_err(|e| e.to_string())?; + + let idx = config + .credential_pool + .asr + .iter() + .position(|c| c.id == entry.id) + .ok_or_else(|| format!("凭证不存在: {}", entry.id))?; + + config.credential_pool.asr[idx] = entry; + save_config(&config).map_err(|e| e.to_string())?; + + Ok(()) +} + +/// 删除 ASR 凭证 +#[command] +pub async fn delete_asr_credential(id: String) -> Result<(), String> { + let mut config = load_config().map_err(|e| e.to_string())?; + + let idx = config + .credential_pool + .asr + .iter() + .position(|c| c.id == id) + .ok_or_else(|| format!("凭证不存在: {}", id))?; + + let was_default = config.credential_pool.asr[idx].is_default; + config.credential_pool.asr.remove(idx); + + // 如果删除的是默认凭证,将第一个设为默认 + if was_default && !config.credential_pool.asr.is_empty() { + config.credential_pool.asr[0].is_default = true; + } + + save_config(&config).map_err(|e| e.to_string())?; + + Ok(()) +} + +/// 设置默认 ASR 凭证 +#[command] +pub async fn set_default_asr_credential(id: String) -> Result<(), String> { + let mut config = load_config().map_err(|e| e.to_string())?; + + // 检查凭证是否存在 + let exists = config.credential_pool.asr.iter().any(|c| c.id == id); + if !exists { + return Err(format!("凭证不存在: {}", id)); + } + + // 更新默认状态 + for cred in &mut config.credential_pool.asr { + cred.is_default = cred.id == id; + } + + save_config(&config).map_err(|e| e.to_string())?; + + Ok(()) +} + +/// 测试 ASR 凭证连通性 +#[command] +pub async fn test_asr_credential(id: String) -> Result { + let config = load_config().map_err(|e| e.to_string())?; + + let credential = config + .credential_pool + .asr + .iter() + .find(|c| c.id == id) + .ok_or_else(|| format!("凭证不存在: {}", id))?; + + // 根据 Provider 类型测试 + match credential.provider { + AsrProviderType::WhisperLocal => { + // 本地 Whisper 检查模型文件是否存在 + Ok(TestResult { + success: true, + message: "本地 Whisper 已就绪".to_string(), + }) + } + AsrProviderType::Xunfei => { + // TODO: 实现讯飞 API 测试 + if credential.xunfei_config.is_some() { + Ok(TestResult { + success: true, + message: "讯飞配置已设置(实际测试待实现)".to_string(), + }) + } else { + Ok(TestResult { + success: false, + message: "讯飞配置缺失".to_string(), + }) + } + } + AsrProviderType::Baidu => { + // TODO: 实现百度 API 测试 + if credential.baidu_config.is_some() { + Ok(TestResult { + success: true, + message: "百度配置已设置(实际测试待实现)".to_string(), + }) + } else { + Ok(TestResult { + success: false, + message: "百度配置缺失".to_string(), + }) + } + } + AsrProviderType::OpenAI => { + // TODO: 实现 OpenAI API 测试 + if credential.openai_config.is_some() { + Ok(TestResult { + success: true, + message: "OpenAI 配置已设置(实际测试待实现)".to_string(), + }) + } else { + Ok(TestResult { + success: false, + message: "OpenAI 配置缺失".to_string(), + }) + } + } + } +} + +/// 测试结果 +#[derive(serde::Serialize)] +pub struct TestResult { + pub success: bool, + pub message: String, +} diff --git a/src-tauri/src/commands/context_memory.rs b/src-tauri/src/commands/context_memory.rs index 17388a7c7..e985a20a6 100644 --- a/src-tauri/src/commands/context_memory.rs +++ b/src-tauri/src/commands/context_memory.rs @@ -4,7 +4,6 @@ use crate::services::context_memory_service::{ ContextMemoryService, MemoryEntry, MemoryFileType, MemoryStats, }; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::sync::Arc; use tauri::State; use tracing::{debug, info}; diff --git a/src-tauri/src/commands/general_chat_cmd.rs b/src-tauri/src/commands/general_chat_cmd.rs index 28df53b82..5fd3c40b6 100644 --- a/src-tauri/src/commands/general_chat_cmd.rs +++ b/src-tauri/src/commands/general_chat_cmd.rs @@ -15,8 +15,7 @@ use crate::database::dao::general_chat::GeneralChatDao; use crate::database::DbConnection; use crate::services::general_chat::{ - ChatMessage, ChatSession, ContentBlock, CreateMessageRequest, CreateSessionRequest, - MessageRole, SessionDetail, + ChatMessage, ChatSession, ContentBlock, MessageRole, SessionDetail, }; use serde::{Deserialize, Serialize}; use tauri::State; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7baa2b31c..2ea0abf8b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod agent_cmd; pub mod api_key_provider_cmd; +pub mod asr_cmd; pub mod aster_agent_cmd; pub mod auto_fix_cmd; pub mod browser_interceptor_cmd; @@ -40,3 +41,4 @@ pub mod usage_cmd; pub mod websocket_cmd; pub mod webview_cmd; pub mod window_cmd; +pub mod workspace_cmd; diff --git a/src-tauri/src/commands/music_cmd.rs b/src-tauri/src/commands/music_cmd.rs index 7b25b8833..6e8adc860 100644 --- a/src-tauri/src/commands/music_cmd.rs +++ b/src-tauri/src/commands/music_cmd.rs @@ -1,7 +1,6 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::process::Command; -use tauri::State; /// MIDI 分析结果 #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/commands/screenshot_cmd.rs b/src-tauri/src/commands/screenshot_cmd.rs index ed340ec5a..98bdf8fcd 100644 --- a/src-tauri/src/commands/screenshot_cmd.rs +++ b/src-tauri/src/commands/screenshot_cmd.rs @@ -224,6 +224,24 @@ pub fn close_screenshot_chat_window(app: AppHandle) -> Result<(), String> { .map_err(|e| format!("关闭窗口失败: {}", e)) } +/// 打开带预填文本的输入框 +/// +/// 用于语音识别完成后,将识别结果填入输入框 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// - `text`: 预填文本 +/// +/// # 返回 +/// 成功返回 Ok(()), 失败返回错误信息 +#[tauri::command] +pub fn open_input_with_text(app: AppHandle, text: String) -> Result<(), String> { + info!("打开带预填文本的输入框: {} 字符", text.len()); + + crate::screenshot::window::open_floating_window_with_text(&app, &text) + .map_err(|e| format!("打开窗口失败: {}", e)) +} + /// 读取图片文件并转换为 Base64 /// /// 读取指定路径的图片文件,并将其内容编码为 Base64 字符串 diff --git a/src-tauri/src/commands/tool_hooks.rs b/src-tauri/src/commands/tool_hooks.rs index 49fa6b5ea..44619043f 100644 --- a/src-tauri/src/commands/tool_hooks.rs +++ b/src-tauri/src/commands/tool_hooks.rs @@ -1,8 +1,7 @@ //! 工具钩子管理相关的 Tauri 命令 use crate::services::tool_hooks_service::{ - HookAction, HookCondition, HookContext, HookExecutionStats, HookRule, HookTrigger, - ToolHooksService, + HookContext, HookExecutionStats, HookRule, HookTrigger, ToolHooksService, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; diff --git a/src-tauri/src/commands/workspace_cmd.rs b/src-tauri/src/commands/workspace_cmd.rs new file mode 100644 index 000000000..4952667d4 --- /dev/null +++ b/src-tauri/src/commands/workspace_cmd.rs @@ -0,0 +1,168 @@ +//! Workspace Tauri 命令模块 +//! +//! 提供 Workspace 管理功能的前端调用接口。 +//! +//! ## 主要命令 +//! - `workspace_create` - 创建新 workspace +//! - `workspace_list` - 获取 workspace 列表 +//! - `workspace_get` - 获取 workspace 详情 +//! - `workspace_update` - 更新 workspace +//! - `workspace_delete` - 删除 workspace +//! - `workspace_set_default` - 设置默认 workspace +//! - `workspace_get_default` - 获取默认 workspace + +use crate::database::DbConnection; +use crate::workspace::{ + Workspace, WorkspaceManager, WorkspaceSettings, WorkspaceType, WorkspaceUpdate, +}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; +use tauri::State; +use tokio::sync::RwLock; + +/// Workspace 管理器状态 +pub struct WorkspaceManagerState(pub Arc>>); + +/// Workspace 列表项(前端展示用) +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceListItem { + pub id: String, + pub name: String, + pub workspace_type: String, + pub root_path: String, + pub is_default: bool, + pub created_at: i64, + pub updated_at: i64, +} + +impl From for WorkspaceListItem { + fn from(ws: Workspace) -> Self { + Self { + id: ws.id, + name: ws.name, + workspace_type: ws.workspace_type.as_str().to_string(), + root_path: ws.root_path.to_string_lossy().to_string(), + is_default: ws.is_default, + created_at: ws.created_at.timestamp_millis(), + updated_at: ws.updated_at.timestamp_millis(), + } + } +} + +/// 创建 workspace 请求 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateWorkspaceRequest { + pub name: String, + pub root_path: String, + #[serde(default)] + pub workspace_type: Option, +} + +/// 更新 workspace 请求 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateWorkspaceRequest { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub settings: Option, +} + +// ==================== Tauri 命令 ==================== + +/// 创建新 workspace +#[tauri::command] +pub async fn workspace_create( + db: State<'_, DbConnection>, + request: CreateWorkspaceRequest, +) -> Result { + let manager = WorkspaceManager::new(db.inner().clone()); + + let workspace_type = request + .workspace_type + .map(|t| WorkspaceType::from_str(&t)) + .unwrap_or_default(); + + let workspace = manager.create_with_type( + request.name, + PathBuf::from(&request.root_path), + workspace_type, + )?; + + Ok(workspace.into()) +} + +/// 获取 workspace 列表 +#[tauri::command] +pub async fn workspace_list(db: State<'_, DbConnection>) -> Result, String> { + let manager = WorkspaceManager::new(db.inner().clone()); + let workspaces = manager.list()?; + Ok(workspaces.into_iter().map(|ws| ws.into()).collect()) +} + +/// 获取 workspace 详情 +#[tauri::command] +pub async fn workspace_get( + db: State<'_, DbConnection>, + id: String, +) -> Result, String> { + let manager = WorkspaceManager::new(db.inner().clone()); + let workspace = manager.get(&id)?; + Ok(workspace.map(|ws| ws.into())) +} + +/// 更新 workspace +#[tauri::command] +pub async fn workspace_update( + db: State<'_, DbConnection>, + id: String, + request: UpdateWorkspaceRequest, +) -> Result { + let manager = WorkspaceManager::new(db.inner().clone()); + + let updates = WorkspaceUpdate { + name: request.name, + settings: request.settings, + }; + + let workspace = manager.update(&id, updates)?; + Ok(workspace.into()) +} + +/// 删除 workspace +#[tauri::command] +pub async fn workspace_delete(db: State<'_, DbConnection>, id: String) -> Result { + let manager = WorkspaceManager::new(db.inner().clone()); + manager.delete(&id) +} + +/// 设置默认 workspace +#[tauri::command] +pub async fn workspace_set_default(db: State<'_, DbConnection>, id: String) -> Result<(), String> { + let manager = WorkspaceManager::new(db.inner().clone()); + manager.set_default(&id) +} + +/// 获取默认 workspace +#[tauri::command] +pub async fn workspace_get_default( + db: State<'_, DbConnection>, +) -> Result, String> { + let manager = WorkspaceManager::new(db.inner().clone()); + let workspace = manager.get_default()?; + Ok(workspace.map(|ws| ws.into())) +} + +/// 通过路径获取 workspace +#[tauri::command] +pub async fn workspace_get_by_path( + db: State<'_, DbConnection>, + root_path: String, +) -> Result, String> { + let manager = WorkspaceManager::new(db.inner().clone()); + let workspace = manager.get_by_path(&PathBuf::from(&root_path))?; + Ok(workspace.map(|ws| ws.into())) +} diff --git a/src-tauri/src/config/export.rs b/src-tauri/src/config/export.rs index 691ae90d7..6048e7868 100644 --- a/src-tauri/src/config/export.rs +++ b/src-tauri/src/config/export.rs @@ -335,6 +335,7 @@ impl ExportService { gemini_api_keys: pool.gemini_api_keys.clone(), vertex_api_keys: pool.vertex_api_keys.clone(), codex: pool.codex.clone(), + asr: pool.asr.clone(), } } diff --git a/src-tauri/src/config/hot_reload.rs b/src-tauri/src/config/hot_reload.rs index feb21eec4..42edeadac 100644 --- a/src-tauri/src/config/hot_reload.rs +++ b/src-tauri/src/config/hot_reload.rs @@ -374,9 +374,9 @@ impl HotReloadManager { /// 验证配置 fn validate_config(&self, config: &Config) -> Result<(), HotReloadError> { - let is_localhost = is_localhost_host(&config.server.host); + let _is_localhost = is_localhost_host(&config.server.host); let is_valid_host = is_valid_bind_host(&config.server.host); - let is_non_local = is_non_local_bind(&config.server.host); + let _is_non_local = is_non_local_bind(&config.server.host); // 验证端口范围 if config.server.port == 0 { diff --git a/src-tauri/src/config/import.rs b/src-tauri/src/config/import.rs index b9d5ce79e..eb411de85 100644 --- a/src-tauri/src/config/import.rs +++ b/src-tauri/src/config/import.rs @@ -375,6 +375,7 @@ impl ImportService { gemini_api_keys: imported.gemini_api_keys.clone(), vertex_api_keys: imported.vertex_api_keys.clone(), codex: Self::merge_credential_entries(¤t.codex, &imported.codex), + asr: imported.asr.clone(), } } diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index db485d4b9..ff9ac75e5 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -21,12 +21,48 @@ pub use hot_reload::{ pub use import::{ImportOptions, ImportService, ValidationResult}; pub use path_utils::{collapse_tilde, contains_tilde, expand_tilde}; pub use types::{ - generate_secure_api_key, AmpConfig, AmpModelMapping, ApiKeyEntry, Config, CredentialEntry, - CredentialPoolConfig, CustomProviderConfig, EndpointProvidersConfig, ExperimentalFeatures, - GeminiApiKeyEntry, InjectionRuleConfig, InjectionSettings, LoggingConfig, ModelInfo, - ModelsConfig, NativeAgentConfig, ProviderConfig, ProviderModelsConfig, ProvidersConfig, - QuotaExceededConfig, RemoteManagementConfig, RetrySettings, RoutingConfig, - ScreenshotChatConfig, ServerConfig, TlsConfig, VertexApiKeyEntry, VertexModelAlias, + generate_secure_api_key, + AmpConfig, + AmpModelMapping, + ApiKeyEntry, + AsrCredentialEntry, + // ASR 和语音输入相关类型 + AsrProviderType, + BaiduConfig, + Config, + CredentialEntry, + CredentialPoolConfig, + CustomProviderConfig, + EndpointProvidersConfig, + ExperimentalFeatures, + GeminiApiKeyEntry, + InjectionRuleConfig, + InjectionSettings, + LoggingConfig, + ModelInfo, + ModelsConfig, + NativeAgentConfig, + OpenAIAsrConfig, + ProviderConfig, + ProviderModelsConfig, + ProvidersConfig, + QuotaExceededConfig, + RemoteManagementConfig, + RetrySettings, + RoutingConfig, + ScreenshotChatConfig, + ServerConfig, + TlsConfig, + VertexApiKeyEntry, + VertexModelAlias, + VoiceInputConfig, + VoiceInstruction, + VoiceOutputConfig, + VoiceOutputMode, + VoiceProcessorConfig, + WhisperLocalConfig, + WhisperModelSize, + XunfeiConfig, DEFAULT_API_KEY, }; pub use yaml::{load_config, save_config, ConfigError, ConfigManager, YamlService}; diff --git a/src-tauri/src/config/types.rs b/src-tauri/src/config/types.rs index a91e66c25..3823c4c95 100644 --- a/src-tauri/src/config/types.rs +++ b/src-tauri/src/config/types.rs @@ -38,6 +38,143 @@ pub struct CredentialPoolConfig { /// Codex OAuth 凭证列表 #[serde(default, skip_serializing_if = "Vec::is_empty")] pub codex: Vec, + /// ASR 语音服务凭证列表 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub asr: Vec, +} + +// ============ ASR 语音服务配置类型 ============ + +/// ASR Provider 类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AsrProviderType { + /// 本地 Whisper(离线) + WhisperLocal, + /// 讯飞语音识别 + Xunfei, + /// 百度语音识别 + Baidu, + /// OpenAI Whisper API + OpenAI, +} + +impl Default for AsrProviderType { + fn default() -> Self { + Self::WhisperLocal + } +} + +/// Whisper 模型大小 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WhisperModelSize { + /// tiny - 最小,最快(~75MB) + Tiny, + /// base - 基础(~142MB) + Base, + /// small - 小型(~466MB) + Small, + /// medium - 中型(~1.5GB) + Medium, +} + +impl Default for WhisperModelSize { + fn default() -> Self { + Self::Base + } +} + +/// ASR 凭证条目 +/// +/// 用于语音识别服务的凭证管理 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AsrCredentialEntry { + /// 凭证 ID + pub id: String, + /// Provider 类型 + pub provider: AsrProviderType, + /// 显示名称 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// 是否为默认凭证 + #[serde(default)] + pub is_default: bool, + /// 是否禁用 + #[serde(default)] + pub disabled: bool, + /// 识别语言(如 "zh", "en", "auto") + #[serde(default = "default_asr_language")] + pub language: String, + /// Whisper 本地配置(仅 WhisperLocal) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub whisper_config: Option, + /// 讯飞配置(仅 Xunfei) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub xunfei_config: Option, + /// 百度配置(仅 Baidu) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub baidu_config: Option, + /// OpenAI 配置(仅 OpenAI) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openai_config: Option, +} + +fn default_asr_language() -> String { + "zh".to_string() +} + +/// Whisper 本地配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WhisperLocalConfig { + /// 模型大小 + #[serde(default)] + pub model: WhisperModelSize, + /// 模型文件路径(可选,默认自动下载) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_path: Option, +} + +impl Default for WhisperLocalConfig { + fn default() -> Self { + Self { + model: WhisperModelSize::default(), + model_path: None, + } + } +} + +/// 讯飞语音配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct XunfeiConfig { + /// App ID + pub app_id: String, + /// API Key + pub api_key: String, + /// API Secret + pub api_secret: String, +} + +/// 百度语音配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct BaiduConfig { + /// API Key + pub api_key: String, + /// Secret Key + pub secret_key: String, +} + +/// OpenAI ASR 配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct OpenAIAsrConfig { + /// API Key + pub api_key: String, + /// 自定义 Base URL(可选) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_url: Option, + /// 代理 URL(可选) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy_url: Option, } /// Gemini API Key 凭证条目 @@ -443,6 +580,201 @@ pub struct ExperimentalFeatures { /// 自动更新检查配置 #[serde(default)] pub update_check: UpdateCheckConfig, + /// 语音输入功能配置 + #[serde(default)] + pub voice_input: VoiceInputConfig, +} + +// ============ 语音输入功能配置类型 ============ + +/// 语音输入功能配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VoiceInputConfig { + /// 是否启用语音输入功能 + #[serde(default)] + pub enabled: bool, + /// 触发语音输入的全局快捷键 + #[serde(default = "default_voice_shortcut")] + pub shortcut: String, + /// 语音处理配置 + #[serde(default)] + pub processor: VoiceProcessorConfig, + /// 输出配置 + #[serde(default)] + pub output: VoiceOutputConfig, + /// 自定义指令列表 + #[serde(default)] + pub instructions: Vec, +} + +fn default_voice_shortcut() -> String { + "CommandOrControl+Shift+V".to_string() +} + +impl Default for VoiceInputConfig { + fn default() -> Self { + Self { + enabled: false, + shortcut: default_voice_shortcut(), + processor: VoiceProcessorConfig::default(), + output: VoiceOutputConfig::default(), + instructions: default_instructions(), + } + } +} + +/// 语音处理配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VoiceProcessorConfig { + /// 是否启用 AI 润色 + #[serde(default = "default_polish_enabled")] + pub polish_enabled: bool, + /// 润色使用的 LLM Provider(使用现有 Provider 系统) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub polish_provider: Option, + /// 润色使用的模型 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub polish_model: Option, + /// 默认指令 ID + #[serde(default = "default_instruction_id")] + pub default_instruction_id: String, +} + +fn default_polish_enabled() -> bool { + true +} + +fn default_instruction_id() -> String { + "default".to_string() +} + +impl Default for VoiceProcessorConfig { + fn default() -> Self { + Self { + polish_enabled: default_polish_enabled(), + polish_provider: None, + polish_model: None, + default_instruction_id: default_instruction_id(), + } + } +} + +/// 语音输出配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VoiceOutputConfig { + /// 输出模式 + #[serde(default)] + pub mode: VoiceOutputMode, + /// 输入延迟(毫秒),用于模拟键盘输入 + #[serde(default = "default_type_delay_ms")] + pub type_delay_ms: u32, +} + +fn default_type_delay_ms() -> u32 { + 10 +} + +impl Default for VoiceOutputConfig { + fn default() -> Self { + Self { + mode: VoiceOutputMode::default(), + type_delay_ms: default_type_delay_ms(), + } + } +} + +/// 语音输出模式 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VoiceOutputMode { + /// 模拟键盘输入 + Type, + /// 复制到剪贴板 + Clipboard, + /// 两者都做 + Both, +} + +impl Default for VoiceOutputMode { + fn default() -> Self { + Self::Type + } +} + +/// 语音处理指令 +/// +/// 定义不同的文本处理模式,如默认润色、翻译、邮件格式等 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VoiceInstruction { + /// 指令 ID + pub id: String, + /// 显示名称 + pub name: String, + /// 指令描述 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Prompt 模板(使用 {{text}} 作为占位符) + pub prompt: String, + /// 快捷键(可选) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shortcut: Option, + /// 是否为系统预设(不可删除) + #[serde(default)] + pub is_preset: bool, + /// 图标(可选,用于 UI 显示) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, +} + +/// 默认指令列表 +fn default_instructions() -> Vec { + vec![ + VoiceInstruction { + id: "default".to_string(), + name: "默认润色".to_string(), + description: Some("去除语气词、添加标点、修正语法".to_string()), + prompt: "请对以下语音转文字内容进行润色,去除语气词(如「嗯」「啊」「那个」等),添加合适的标点符号,修正明显的语法错误,但保持原意不变。只输出润色后的文本,不要添加任何解释:\n\n{{text}}".to_string(), + shortcut: None, + is_preset: true, + icon: Some("sparkles".to_string()), + }, + VoiceInstruction { + id: "translate_en".to_string(), + name: "翻译为英文".to_string(), + description: Some("将中文翻译为英文".to_string()), + prompt: "请将以下中文内容翻译为英文,保持专业、自然的表达。只输出翻译结果,不要添加任何解释:\n\n{{text}}".to_string(), + shortcut: None, + is_preset: true, + icon: Some("globe".to_string()), + }, + VoiceInstruction { + id: "email".to_string(), + name: "邮件格式".to_string(), + description: Some("整理为正式邮件格式".to_string()), + prompt: "请将以下内容整理为正式的邮件格式,包含适当的问候语和结束语,语气专业礼貌。只输出邮件内容,不要添加任何解释:\n\n{{text}}".to_string(), + shortcut: None, + is_preset: true, + icon: Some("mail".to_string()), + }, + VoiceInstruction { + id: "summary".to_string(), + name: "总结要点".to_string(), + description: Some("提取关键信息,生成简洁要点".to_string()), + prompt: "请总结以下内容的要点,用简洁的条目列出关键信息:\n\n{{text}}".to_string(), + shortcut: None, + is_preset: true, + icon: Some("list".to_string()), + }, + VoiceInstruction { + id: "raw".to_string(), + name: "原始输出".to_string(), + description: Some("不做任何处理,直接输出识别结果".to_string()), + prompt: "{{text}}".to_string(), + shortcut: None, + is_preset: true, + icon: Some("type".to_string()), + }, + ] } impl NativeAgentConfig { @@ -1624,5 +1956,104 @@ mod unit_tests { config.experimental.screenshot_chat.shortcut, "CommandOrControl+Shift+S" ); + // 语音输入测试 + assert!(!config.experimental.voice_input.enabled); + assert_eq!( + config.experimental.voice_input.shortcut, + "CommandOrControl+Shift+V" + ); + } + + #[test] + fn test_asr_credential_entry_serialization() { + let entry = AsrCredentialEntry { + id: "whisper-local".to_string(), + provider: AsrProviderType::WhisperLocal, + name: Some("本地 Whisper".to_string()), + is_default: true, + disabled: false, + language: "zh".to_string(), + whisper_config: Some(WhisperLocalConfig { + model: WhisperModelSize::Base, + model_path: None, + }), + xunfei_config: None, + baidu_config: None, + openai_config: None, + }; + let yaml = serde_yaml::to_string(&entry).unwrap(); + assert!(yaml.contains("provider: whisper_local")); + assert!(yaml.contains("is_default: true")); + + let parsed: AsrCredentialEntry = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(parsed, entry); + } + + #[test] + fn test_voice_input_config_default() { + let config = VoiceInputConfig::default(); + assert!(!config.enabled); + assert_eq!(config.shortcut, "CommandOrControl+Shift+V"); + assert!(config.processor.polish_enabled); + assert_eq!(config.processor.default_instruction_id, "default"); + assert_eq!(config.output.mode, VoiceOutputMode::Type); + assert!(!config.instructions.is_empty()); + } + + #[test] + fn test_voice_instruction_serialization() { + let instruction = VoiceInstruction { + id: "custom".to_string(), + name: "自定义指令".to_string(), + description: Some("测试指令".to_string()), + prompt: "处理: {{text}}".to_string(), + shortcut: Some("CommandOrControl+1".to_string()), + is_preset: false, + icon: None, + }; + let yaml = serde_yaml::to_string(&instruction).unwrap(); + assert!(yaml.contains("id: custom")); + assert!(yaml.contains("{{text}}")); + + let parsed: VoiceInstruction = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(parsed, instruction); + } + + #[test] + fn test_credential_pool_with_asr() { + let pool = CredentialPoolConfig { + kiro: vec![], + gemini: vec![], + qwen: vec![], + openai: vec![], + claude: vec![], + gemini_api_keys: vec![], + vertex_api_keys: vec![], + codex: vec![], + asr: vec![AsrCredentialEntry { + id: "xunfei-1".to_string(), + provider: AsrProviderType::Xunfei, + name: Some("讯飞语音".to_string()), + is_default: false, + disabled: false, + language: "zh".to_string(), + whisper_config: None, + xunfei_config: Some(XunfeiConfig { + app_id: "test_app_id".to_string(), + api_key: "test_api_key".to_string(), + api_secret: "test_api_secret".to_string(), + }), + baidu_config: None, + openai_config: None, + }], + }; + + let yaml = serde_yaml::to_string(&pool).unwrap(); + assert!(yaml.contains("asr:")); + assert!(yaml.contains("provider: xunfei")); + + let parsed: CredentialPoolConfig = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(parsed.asr.len(), 1); + assert_eq!(parsed.asr[0].provider, AsrProviderType::Xunfei); } } diff --git a/src-tauri/src/database/dao/general_chat.rs b/src-tauri/src/database/dao/general_chat.rs index d100e4f03..bdedc09c3 100644 --- a/src-tauri/src/database/dao/general_chat.rs +++ b/src-tauri/src/database/dao/general_chat.rs @@ -219,7 +219,7 @@ impl GeneralChatDao { before_id: Option<&str>, ) -> Result, rusqlite::Error> { let query = match (limit, before_id) { - (Some(lim), Some(bid)) => { + (Some(lim), Some(_bid)) => { format!( "SELECT id, session_id, role, content, blocks, status, created_at, metadata FROM general_chat_messages diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 12e662ce1..2d55ffde3 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -530,6 +530,36 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; + // ============================================================================ + // Workspace 相关表 + // ============================================================================ + + // Workspace 表 + // 存储 Workspace 元数据,用于组织和管理 AI Agent 的工作上下文 + conn.execute( + "CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + workspace_type TEXT NOT NULL DEFAULT 'persistent', + root_path TEXT NOT NULL UNIQUE, + is_default INTEGER DEFAULT 0, + settings_json TEXT DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )", + [], + )?; + + // 创建 workspaces 索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_workspaces_root_path ON workspaces(root_path)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_workspaces_is_default ON workspaces(is_default)", + [], + )?; + Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f6cab7053..5b151793d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,6 +45,8 @@ pub mod stream; pub mod terminal; pub mod translator; pub mod tray; +pub mod voice; +pub mod workspace; // 内部模块 mod commands; diff --git a/src-tauri/src/middleware/mod.rs b/src-tauri/src/middleware/mod.rs index dff7ccbe2..2dad8eb31 100644 --- a/src-tauri/src/middleware/mod.rs +++ b/src-tauri/src/middleware/mod.rs @@ -7,4 +7,4 @@ pub mod management_auth; #[cfg(test)] mod tests; -pub use management_auth::{ManagementAuthLayer, ManagementAuthService}; +pub use management_auth::ManagementAuthLayer; diff --git a/src-tauri/src/processor/mod.rs b/src-tauri/src/processor/mod.rs index 8b37fe401..8ba98715c 100644 --- a/src-tauri/src/processor/mod.rs +++ b/src-tauri/src/processor/mod.rs @@ -18,11 +18,6 @@ mod error; mod steps; pub use context::RequestContext; -pub use error::ProcessError; -pub use steps::{ - AuthStep, InjectionStep, PipelineStep, PluginPostStep, PluginPreStep, ProviderStep, - RoutingStep, TelemetryStep, -}; use crate::injection::Injector; use crate::plugin::PluginManager; diff --git a/src-tauri/src/processor/steps/mod.rs b/src-tauri/src/processor/steps/mod.rs index d399e5952..047765f84 100644 --- a/src-tauri/src/processor/steps/mod.rs +++ b/src-tauri/src/processor/steps/mod.rs @@ -10,10 +10,18 @@ mod routing; mod telemetry; mod traits; +// 这些类型目前未在外部使用,但保留以供将来扩展 +#[allow(unused_imports)] pub use auth::AuthStep; +#[allow(unused_imports)] pub use injection::InjectionStep; +#[allow(unused_imports)] pub use plugin::{PluginPostStep, PluginPreStep}; +#[allow(unused_imports)] pub use provider::ProviderStep; +#[allow(unused_imports)] pub use routing::RoutingStep; +#[allow(unused_imports)] pub use telemetry::TelemetryStep; +#[allow(unused_imports)] pub use traits::PipelineStep; diff --git a/src-tauri/src/router/mod.rs b/src-tauri/src/router/mod.rs index 9aa11e3f9..8953074cc 100644 --- a/src-tauri/src/router/mod.rs +++ b/src-tauri/src/router/mod.rs @@ -17,8 +17,6 @@ mod provider_router; mod route_registry; mod rules; -pub use amp_router::{AmpRouteMatch, AmpRouter}; -pub use mapper::{ModelInfo, ModelMapper}; -pub use provider_router::ProviderRouter; -pub use route_registry::{RegisteredRoute, RouteRegistry, RouteType}; -pub use rules::{RouteResult, Router}; +pub use amp_router::AmpRouter; +pub use mapper::ModelMapper; +pub use rules::Router; diff --git a/src-tauri/src/screenshot/window.rs b/src-tauri/src/screenshot/window.rs index fc97ee883..fcca9a26f 100644 --- a/src-tauri/src/screenshot/window.rs +++ b/src-tauri/src/screenshot/window.rs @@ -294,6 +294,161 @@ pub fn focus_floating_window(app: &AppHandle) -> Result<(), WindowError> { } } +/// 打开带预填文本的悬浮输入框 +/// +/// 用于语音识别完成后,将识别结果填入输入框 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// - `text`: 预填文本 +/// +/// # 返回 +/// 成功返回 Ok(()), 失败返回错误 +pub fn open_floating_window_with_text(app: &AppHandle, text: &str) -> Result<(), WindowError> { + info!("打开带预填文本的悬浮输入框"); + + // 构建窗口 URL,包含文本参数 + let encoded_text = urlencoding::encode(text); + let url = format!("/screenshot-chat?text={}", encoded_text); + + open_floating_window_with_url(app, &url) +} + +/// 打开语音模式的悬浮输入框 +/// +/// 自动开始录音,录音完成后填入文本 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// +/// # 返回 +/// 成功返回 Ok(()), 失败返回错误 +pub fn open_floating_window_voice_mode(app: &AppHandle) -> Result<(), WindowError> { + info!("打开语音模式的悬浮输入框"); + let url = "/screenshot-chat?voice=true"; + open_floating_window_with_url(app, url) +} + +/// 内部函数:打开带指定 URL 的悬浮窗口 +fn open_floating_window_with_url(app: &AppHandle, url: &str) -> Result<(), WindowError> { + debug!("悬浮窗口 URL: {}", url); + + // 检查是否是语音模式 + let is_voice_mode = url.contains("voice=true"); + + // 检查窗口是否已存在 + if let Some(window) = app.get_webview_window(FLOATING_WINDOW_LABEL) { + info!("悬浮窗口已存在,导航到新 URL 并显示"); + + // 计算窗口位置 + let (x, y) = calculate_window_position(app); + + // 设置窗口位置 + use tauri::LogicalPosition; + let _ = window.set_position(LogicalPosition::new(x, y)); + + // macOS: 设置窗口背景透明 + #[cfg(target_os = "macos")] + { + use objc::{msg_send, sel, sel_impl}; + if let Ok(ns_win) = window.ns_window() { + #[allow(deprecated, unexpected_cfgs)] + unsafe { + let ns_window = ns_win as id; + let clear_color = NSColor::clearColor(nil); + ns_window.setBackgroundColor_(clear_color); + let _: () = msg_send![ns_window, setOpaque: false]; + let _: () = msg_send![ns_window, setHasShadow: false]; + } + } + } + + // 导航到新 URL(强制刷新) + let js = format!("window.location.replace('{}');", url); + window + .eval(&js) + .map_err(|e| WindowError::OperationFailed(format!("导航失败: {}", e)))?; + + window + .show() + .map_err(|e| WindowError::OperationFailed(format!("显示窗口失败: {}", e)))?; + + window + .set_focus() + .map_err(|e| WindowError::OperationFailed(format!("聚焦窗口失败: {}", e)))?; + + // 如果是语音模式,额外发送事件确保前端收到 + if is_voice_mode { + use tauri::Emitter; + // 延迟发送事件,等待页面加载 + let window_clone = window.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(200)); + let _ = window_clone.emit("voice-start-recording", ()); + info!("[语音输入] 已发送开始录音事件"); + }); + } + + return Ok(()); + } + + // 窗口不存在,动态创建 + info!("动态创建悬浮窗口"); + + let (x, y) = calculate_window_position(app); + + #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] + let window = WebviewWindowBuilder::new(app, FLOATING_WINDOW_LABEL, WebviewUrl::App(url.into())) + .inner_size(WINDOW_WIDTH, WINDOW_HEIGHT) + .position(x, y) + .decorations(false) + .always_on_top(true) + .skip_taskbar(true) + .visible(true) + .focused(true) + .transparent(true) + .build() + .map_err(|e| WindowError::CreateFailed(format!("{}", e)))?; + + // macOS: 设置窗口背景透明 + #[cfg(target_os = "macos")] + { + use objc::{msg_send, sel, sel_impl}; + if let Ok(ns_win) = window.ns_window() { + #[allow(deprecated, unexpected_cfgs)] + unsafe { + let ns_window = ns_win as id; + let clear_color = NSColor::clearColor(nil); + ns_window.setBackgroundColor_(clear_color); + let _: () = msg_send![ns_window, setOpaque: false]; + let _: () = msg_send![ns_window, setHasShadow: false]; + } + } + } + + info!("悬浮窗口创建成功: {}", FLOATING_WINDOW_LABEL); + + Ok(()) +} + +/// 打开语音模式的悬浮输入框(别名,供语音模块调用) +pub fn open_floating_window_with_voice(app: &AppHandle) -> Result<(), WindowError> { + open_floating_window_voice_mode(app) +} + +/// 发送语音停止录音事件到截图输入框 +pub fn send_voice_stop_event(app: &AppHandle) -> Result<(), WindowError> { + use tauri::Emitter; + + if let Some(window) = app.get_webview_window(FLOATING_WINDOW_LABEL) { + window + .emit("voice-stop-recording", ()) + .map_err(|e| WindowError::OperationFailed(format!("发送停止录音事件失败: {}", e)))?; + info!("[语音输入] 已发送停止录音事件到截图输入框"); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/server/handlers/provider_calls.rs b/src-tauri/src/server/handlers/provider_calls.rs index 612bb4c50..1f4f13c8f 100644 --- a/src-tauri/src/server/handlers/provider_calls.rs +++ b/src-tauri/src/server/handlers/provider_calls.rs @@ -59,8 +59,8 @@ use crate::models::anthropic::AnthropicMessagesRequest; use crate::models::openai::ChatCompletionRequest; use crate::models::provider_pool_model::{CredentialData, ProviderCredential}; use crate::providers::{ - AntigravityApiError, AntigravityProvider, ClaudeCustomProvider, CodexProvider, KiroProvider, - OpenAICustomProvider, VertexProvider, + AntigravityProvider, ClaudeCustomProvider, CodexProvider, KiroProvider, OpenAICustomProvider, + VertexProvider, }; use crate::server::AppState; use crate::server_utils::{ @@ -69,7 +69,6 @@ use crate::server_utils::{ }; use crate::session::store_thought_signature; use crate::stream::{PipelineConfig, StreamPipeline}; -use crate::streaming::traits::StreamingProvider; use crate::streaming::{ StreamConfig, StreamContext, StreamError, StreamFormat as StreamingFormat, StreamManager, StreamResponse, diff --git a/src-tauri/src/server/mod.rs b/src-tauri/src/server/mod.rs index 516bb096e..0aed65fc7 100644 --- a/src-tauri/src/server/mod.rs +++ b/src-tauri/src/server/mod.rs @@ -33,7 +33,6 @@ use crate::services::provider_pool_service::ProviderPoolService; use crate::services::token_cache_service::TokenCacheService; use crate::websocket::{WsConfig, WsConnectionManager, WsStats}; use axum::{ - body::Body, extract::{DefaultBodyLimit, Path, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, @@ -999,14 +998,14 @@ async fn run_server( .route("/v1/chat/completions", post( |State(state): State, headers: HeaderMap, - Json(mut request): Json| async { + Json(request): Json| async { handlers::chat_completions(State(state), headers, Json(request)).await } )) .route("/v1/messages", post( |State(state): State, headers: HeaderMap, - Json(mut request): Json| async { + Json(request): Json| async { handlers::anthropic_messages(State(state), headers, Json(request)).await } )) diff --git a/src-tauri/src/services/context_memory_service.rs b/src-tauri/src/services/context_memory_service.rs index 9d4c813f1..d2a79b907 100644 --- a/src-tauri/src/services/context_memory_service.rs +++ b/src-tauri/src/services/context_memory_service.rs @@ -6,9 +6,9 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use tracing::{debug, error, info, warn}; +use tracing::{debug, info, warn}; /// 记忆文件类型 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/src-tauri/src/services/general_chat/session_service.rs b/src-tauri/src/services/general_chat/session_service.rs index 0be002684..a2baeae7d 100644 --- a/src-tauri/src/services/general_chat/session_service.rs +++ b/src-tauri/src/services/general_chat/session_service.rs @@ -11,7 +11,7 @@ //! - SQLite 数据库(通过 DatabaseService) //! - types 模块中的数据结构 -use super::types::{ChatMessage, ChatSession, ContentBlock, CreateMessageRequest, MessageRole}; +use super::types::{ChatMessage, ChatSession, ContentBlock, CreateMessageRequest}; use chrono::Utc; use uuid::Uuid; diff --git a/src-tauri/src/services/session_context_service.rs b/src-tauri/src/services/session_context_service.rs index 61b250ccd..926fab975 100644 --- a/src-tauri/src/services/session_context_service.rs +++ b/src-tauri/src/services/session_context_service.rs @@ -3,12 +3,12 @@ //! 提供会话上下文的持久化、恢复和智能管理功能,解决 AI 对话中的上下文丢失问题 use crate::database::dao::general_chat::GeneralChatDao; -use crate::services::general_chat::{ChatMessage, ChatSession, MessageRole}; +use crate::services::general_chat::{ChatMessage, MessageRole}; use rusqlite::Connection; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use tracing::{debug, error, info, warn}; +use tracing::{debug, info}; /// 会话上下文摘要 #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/services/tool_hooks_service.rs b/src-tauri/src/services/tool_hooks_service.rs index f7605813b..4b7c131a6 100644 --- a/src-tauri/src/services/tool_hooks_service.rs +++ b/src-tauri/src/services/tool_hooks_service.rs @@ -6,7 +6,7 @@ use crate::services::context_memory_service::{ContextMemoryService, MemoryEntry, use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use tracing::{debug, error, info, warn}; +use tracing::{debug, error, info}; /// 钩子触发时机 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src-tauri/src/streaming/manager.rs b/src-tauri/src/streaming/manager.rs index 8d79fb41e..43db177f6 100644 --- a/src-tauri/src/streaming/manager.rs +++ b/src-tauri/src/streaming/manager.rs @@ -14,10 +14,9 @@ //! - 需求 6.3: Provider 错误转发 //! - 需求 6.5: 可配置的流式响应超时 -use crate::streaming::converter::{StreamConverter, StreamFormat}; +use crate::streaming::converter::StreamFormat; use crate::streaming::error::StreamError; use crate::streaming::metrics::StreamMetrics; -use crate::streaming::traits::StreamResponse; use bytes::Bytes; use futures::{Stream, StreamExt}; use serde::{Deserialize, Serialize}; diff --git a/src-tauri/src/streaming/mod.rs b/src-tauri/src/streaming/mod.rs index 55d0906d8..5537f6a08 100644 --- a/src-tauri/src/streaming/mod.rs +++ b/src-tauri/src/streaming/mod.rs @@ -22,23 +22,8 @@ pub mod metrics; pub mod traits; // 重新导出核心类型 -pub use anthropic_sse::{AnthropicSseGenerator, ToolCallState}; -pub use aws_parser::{ - extract_content, extract_tool_calls, serialize_event, AwsEvent, AwsEventStreamParser, - ParserState, -}; -pub use converter::{ - extract_content_from_sse, extract_tool_calls_from_sse, ConverterState, PartialJsonAccumulator, - StreamConverter, StreamFormat, -}; +pub use converter::StreamFormat; pub use error::StreamError; -pub use manager::{ - collect_stream_content, create_flow_monitor_callback, with_timeout, FlowMonitorCallback, - ManagedStream, ManagedStreamWithCallback, StreamConfig, StreamContext, StreamEvent, - StreamManager, TimeoutStream, -}; +pub use manager::{with_timeout, StreamConfig, StreamContext, StreamManager}; pub use metrics::StreamMetrics; -pub use traits::{ - reqwest_stream_to_stream_response, StreamFormat as TraitsStreamFormat, StreamResponse, - StreamingProvider, -}; +pub use traits::{reqwest_stream_to_stream_response, StreamResponse}; diff --git a/src-tauri/src/streaming/traits.rs b/src-tauri/src/streaming/traits.rs index a2f675417..64814e138 100644 --- a/src-tauri/src/streaming/traits.rs +++ b/src-tauri/src/streaming/traits.rs @@ -9,8 +9,6 @@ //! - 需求 1.3: OpenAICustomProvider 流式支持 //! - 需求 1.4: AntigravityProvider 流式支持 -use crate::models::openai::ChatCompletionRequest; -use crate::providers::ProviderError; use crate::streaming::StreamError; use async_trait::async_trait; use bytes::Bytes; diff --git a/src-tauri/src/voice/README.md b/src-tauri/src/voice/README.md new file mode 100644 index 000000000..846d7155e --- /dev/null +++ b/src-tauri/src/voice/README.md @@ -0,0 +1,82 @@ +# voice/ - 语音输入模块 + +语音输入功能的 Tauri 后端模块,提供全局快捷键、悬浮窗、ASR 识别、LLM 润色等功能。 + +## 文件索引 + +| 文件 | 说明 | +|------|------| +| `mod.rs` | 模块入口,导出子模块 | +| `asr_service.rs` | ASR 服务,统一管理本地 Whisper 和云端 ASR | +| `commands.rs` | Tauri 命令,供前端调用 | +| `config.rs` | 配置管理,读写语音输入配置 | +| `output_service.rs` | 文字输出服务,模拟键盘输入和剪贴板 | +| `processor.rs` | LLM 润色处理,调用本地 API 服务器 | +| `recording_service.rs` | 录音服务,使用独立线程 + channel 通信 | +| `shortcut.rs` | 全局快捷键管理 | +| `window.rs` | 悬浮窗管理 | + +## 录音服务架构 + +由于 `cpal::Stream` 不实现 `Send` trait,无法直接在 Tauri 的 async 命令中使用。 +录音服务采用**独立线程 + channel 通信**的方案: + +``` +┌─────────────────┐ Command ┌─────────────────┐ +│ Tauri Command │ ───────────────> │ Recording │ +│ (async) │ │ Thread │ +│ │ <─────────────── │ (owns Stream) │ +└─────────────────┘ Response └─────────────────┘ +``` + +### 录音命令 + +| 命令 | 说明 | +|------|------| +| `start_recording` | 开始录音 | +| `stop_recording` | 停止录音,返回音频数据 | +| `cancel_recording` | 取消录音 | +| `get_recording_status` | 获取录音状态(是否录音中、音量、时长)| + +## 依赖关系 + +``` +voice/ +├── asr_service.rs ──→ voice-core (WhisperTranscriber, XunfeiClient) +├── output_service.rs ──→ voice-core (OutputHandler) +├── processor.rs ──→ 本地 API 服务器 (LLM 润色) +├── recording_service.rs ──→ cpal (音频采集) +└── commands.rs ──→ 上述所有服务 +``` + +## ASR 服务支持 + +| Provider | 状态 | 说明 | +|----------|------|------| +| Whisper Local | ✅ | 本地离线识别,需下载模型文件 | +| OpenAI Whisper | ✅ | 云端 API,支持自定义 base_url | +| 百度语音 | ✅ | 云端 API | +| 讯飞语音 | ✅ | WebSocket 流式识别 | + +### 云端回退机制 + +当云端 ASR 服务(OpenAI、百度、讯飞)失败时,系统会自动回退到本地 Whisper 进行识别: + +1. 首先尝试用户选择的云端服务 +2. 如果云端失败,记录警告日志 +3. 自动查找已配置的本地 Whisper 凭证 +4. 使用本地 Whisper 进行回退识别 +5. 如果回退也失败,返回详细错误信息 + +## Whisper 模型文件 + +模型文件存储路径:`~/Library/Application Support/proxycast/models/whisper/` + +下载地址:https://huggingface.co/ggerganov/whisper.cpp/tree/main + +| 模型 | 文件名 | 大小 | +|------|--------|------| +| tiny | `ggml-tiny.bin` | ~75MB | +| base | `ggml-base.bin` | ~142MB | +| small | `ggml-small.bin` | ~466MB | +| medium | `ggml-medium.bin` | ~1.5GB | diff --git a/src-tauri/src/voice/asr_service.rs b/src-tauri/src/voice/asr_service.rs new file mode 100644 index 000000000..2d534fc19 --- /dev/null +++ b/src-tauri/src/voice/asr_service.rs @@ -0,0 +1,470 @@ +//! ASR 服务 +//! +//! 统一管理语音识别服务,支持本地 Whisper 和云端 ASR。 +//! +//! ## 功能 +//! - 本地 Whisper 识别(离线、隐私) +//! - OpenAI Whisper API +//! - 百度语音识别 +//! - 讯飞语音识别(WebSocket 流式) +//! +//! ## 模型文件路径 +//! Whisper 模型文件存储在:`~/Library/Application Support/proxycast/models/whisper/` +//! +//! 支持的模型: +//! - `ggml-tiny.bin` (~75MB) +//! - `ggml-base.bin` (~142MB) +//! - `ggml-small.bin` (~466MB) +//! - `ggml-medium.bin` (~1.5GB) +//! +//! ## 使用示例 +//! ```rust,ignore +//! let credential = AsrService::get_default_credential()?.unwrap(); +//! let text = AsrService::transcribe(&credential, &audio_data, 16000).await?; +//! ``` + +use std::path::PathBuf; + +use crate::config::{load_config, AsrCredentialEntry, AsrProviderType, WhisperModelSize}; + +/// ASR 服务 +pub struct AsrService; + +impl AsrService { + /// 获取默认 ASR 凭证 + pub fn get_default_credential() -> Result, String> { + let config = load_config().map_err(|e| e.to_string())?; + Ok(config + .credential_pool + .asr + .into_iter() + .find(|c| c.is_default && !c.disabled)) + } + + /// 获取指定 ID 的 ASR 凭证 + pub fn get_credential(id: &str) -> Result, String> { + let config = load_config().map_err(|e| e.to_string())?; + Ok(config.credential_pool.asr.into_iter().find(|c| c.id == id)) + } + + /// 使用指定凭证进行语音识别 + /// + /// 当云端服务失败时,自动回退到本地 Whisper(需求 3.4) + pub async fn transcribe( + credential: &AsrCredentialEntry, + audio_data: &[u8], + sample_rate: u32, + ) -> Result { + // 如果是本地 Whisper,直接调用 + if matches!(credential.provider, AsrProviderType::WhisperLocal) { + return Self::transcribe_whisper_local(credential, audio_data, sample_rate).await; + } + + // 云端服务:先尝试云端,失败则回退到本地 Whisper + let cloud_result = match credential.provider { + AsrProviderType::OpenAI => { + Self::transcribe_openai(credential, audio_data, sample_rate).await + } + AsrProviderType::Baidu => { + Self::transcribe_baidu(credential, audio_data, sample_rate).await + } + AsrProviderType::Xunfei => { + Self::transcribe_xunfei(credential, audio_data, sample_rate).await + } + AsrProviderType::WhisperLocal => unreachable!(), // 已在上面处理 + }; + + // 云端成功,直接返回 + if cloud_result.is_ok() { + return cloud_result; + } + + // 云端失败,尝试回退到本地 Whisper + let cloud_error = cloud_result.unwrap_err(); + tracing::warn!( + "云端 ASR 服务 ({:?}) 失败: {},尝试回退到本地 Whisper", + credential.provider, + cloud_error + ); + + // 尝试获取本地 Whisper 凭证 + match Self::get_whisper_local_credential() { + Ok(Some(whisper_credential)) => { + tracing::info!("正在使用本地 Whisper 进行回退识别..."); + match Self::transcribe_whisper_local(&whisper_credential, audio_data, sample_rate) + .await + { + Ok(text) => { + tracing::info!("本地 Whisper 回退识别成功"); + Ok(text) + } + Err(whisper_error) => { + tracing::error!("本地 Whisper 回退也失败: {}", whisper_error); + // 返回原始云端错误,因为那是用户选择的服务 + Err(format!( + "云端服务失败: {};本地 Whisper 回退也失败: {}", + cloud_error, whisper_error + )) + } + } + } + Ok(None) => { + tracing::warn!("未找到本地 Whisper 凭证,无法回退"); + Err(format!( + "云端服务失败: {};未配置本地 Whisper,无法回退", + cloud_error + )) + } + Err(e) => { + tracing::error!("获取本地 Whisper 凭证失败: {}", e); + Err(format!( + "云端服务失败: {};获取本地 Whisper 凭证失败: {}", + cloud_error, e + )) + } + } + } + + /// 获取本地 Whisper 凭证(用于回退) + fn get_whisper_local_credential() -> Result, String> { + let config = load_config().map_err(|e| e.to_string())?; + Ok(config + .credential_pool + .asr + .into_iter() + .find(|c| matches!(c.provider, AsrProviderType::WhisperLocal) && !c.disabled)) + } + + /// 本地 Whisper 识别 + async fn transcribe_whisper_local( + credential: &AsrCredentialEntry, + audio_data: &[u8], + sample_rate: u32, + ) -> Result { + // 获取 Whisper 配置 + let whisper_config = credential + .whisper_config + .as_ref() + .ok_or("Whisper 本地配置缺失")?; + + // 获取模型文件路径 + let model_path = Self::get_whisper_model_path(&whisper_config.model)?; + + // 将 PCM 字节转换为 i16 采样 + let samples: Vec = audio_data + .chunks_exact(2) + .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + + // 检查音频数据是否有效 + if samples.is_empty() { + return Err("音频数据为空".to_string()); + } + + // 创建 AudioData + let audio = voice_core::types::AudioData::new(samples, sample_rate, 1); + + // 检查录音时长 + if !audio.is_valid() { + return Err("录音时间过短(需要至少 0.5 秒)".to_string()); + } + + // 转换模型大小枚举 + let model = Self::convert_model_size(&whisper_config.model); + + // 创建 Whisper 识别器 + let transcriber = + voice_core::WhisperTranscriber::new(model_path, model, &credential.language) + .map_err(|e| format!("Whisper 模型加载失败: {}", e))?; + + // 执行识别 + let result = transcriber + .transcribe(&audio) + .map_err(|e| format!("Whisper 识别失败: {}", e))?; + + Ok(result.text) + } + + /// 获取 Whisper 模型文件路径 + fn get_whisper_model_path(model_size: &WhisperModelSize) -> Result { + // 模型文件名 + let filename = match model_size { + WhisperModelSize::Tiny => "ggml-tiny.bin", + WhisperModelSize::Base => "ggml-base.bin", + WhisperModelSize::Small => "ggml-small.bin", + WhisperModelSize::Medium => "ggml-medium.bin", + }; + + // 模型存储目录:~/Library/Application Support/proxycast/models/whisper/ + let models_dir = dirs::data_dir() + .ok_or("无法获取数据目录")? + .join("proxycast") + .join("models") + .join("whisper"); + + let model_path = models_dir.join(filename); + + // 检查模型文件是否存在 + if !model_path.exists() { + return Err(format!( + "Whisper 模型文件不存在: {}\n请下载模型文件到: {}", + filename, + models_dir.display() + )); + } + + Ok(model_path) + } + + /// 转换模型大小枚举 + fn convert_model_size(size: &WhisperModelSize) -> voice_core::types::WhisperModel { + match size { + WhisperModelSize::Tiny => voice_core::types::WhisperModel::Tiny, + WhisperModelSize::Base => voice_core::types::WhisperModel::Base, + WhisperModelSize::Small => voice_core::types::WhisperModel::Small, + WhisperModelSize::Medium => voice_core::types::WhisperModel::Medium, + } + } + + /// OpenAI Whisper API 识别 + /// + /// 使用手动构建 multipart/form-data 请求 + async fn transcribe_openai( + credential: &AsrCredentialEntry, + audio_data: &[u8], + sample_rate: u32, + ) -> Result { + let config = credential.openai_config.as_ref().ok_or("OpenAI 配置缺失")?; + + // 构建 WAV 文件 + let wav_data = Self::build_wav(audio_data, sample_rate, 1)?; + + // 构建 multipart/form-data 请求体 + let boundary = format!("----WebKitFormBoundary{}", uuid::Uuid::new_v4().simple()); + let mut body = Vec::new(); + + // 添加 file 字段 + body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); + body.extend_from_slice( + b"Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n", + ); + body.extend_from_slice(b"Content-Type: audio/wav\r\n\r\n"); + body.extend_from_slice(&wav_data); + body.extend_from_slice(b"\r\n"); + + // 添加 model 字段 + body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); + body.extend_from_slice(b"Content-Disposition: form-data; name=\"model\"\r\n\r\n"); + body.extend_from_slice(b"whisper-1\r\n"); + + // 添加 language 字段 + body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); + body.extend_from_slice(b"Content-Disposition: form-data; name=\"language\"\r\n\r\n"); + body.extend_from_slice(credential.language.as_bytes()); + body.extend_from_slice(b"\r\n"); + + // 结束边界 + body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); + + // 构建请求 + let base_url = config + .base_url + .as_deref() + .unwrap_or("https://api.openai.com"); + let url = format!("{}/v1/audio/transcriptions", base_url); + + let client = reqwest::Client::new(); + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", config.api_key)) + .header( + "Content-Type", + format!("multipart/form-data; boundary={}", boundary), + ) + .body(body) + .send() + .await + .map_err(|e| format!("请求失败: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("OpenAI API 错误: {} - {}", status, body)); + } + + #[derive(serde::Deserialize)] + struct WhisperResponse { + text: String, + } + + let result: WhisperResponse = response + .json() + .await + .map_err(|e| format!("解析响应失败: {}", e))?; + + Ok(result.text) + } + + /// 百度语音识别 + async fn transcribe_baidu( + credential: &AsrCredentialEntry, + audio_data: &[u8], + sample_rate: u32, + ) -> Result { + let config = credential.baidu_config.as_ref().ok_or("百度配置缺失")?; + + // 获取 Access Token + let token_url = format!( + "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id={}&client_secret={}", + config.api_key, config.secret_key + ); + + let client = reqwest::Client::new(); + let token_resp = client + .post(&token_url) + .send() + .await + .map_err(|e| format!("获取 Token 失败: {}", e))?; + + #[derive(serde::Deserialize)] + struct TokenResponse { + access_token: String, + } + + let token: TokenResponse = token_resp + .json() + .await + .map_err(|e| format!("解析 Token 失败: {}", e))?; + + // 构建 WAV 并 Base64 编码 + let wav_data = Self::build_wav(audio_data, sample_rate, 1)?; + let speech = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &wav_data); + + #[derive(serde::Serialize)] + struct AsrRequest { + format: String, + rate: u32, + channel: u16, + cuid: String, + token: String, + speech: String, + len: usize, + } + + let request = AsrRequest { + format: "wav".to_string(), + rate: sample_rate, + channel: 1, + cuid: "proxycast".to_string(), + token: token.access_token, + speech, + len: wav_data.len(), + }; + + let response = client + .post("https://vop.baidu.com/server_api") + .json(&request) + .send() + .await + .map_err(|e| format!("请求失败: {}", e))?; + + #[derive(serde::Deserialize)] + struct AsrResponse { + err_no: i32, + err_msg: String, + #[serde(default)] + result: Vec, + } + + let result: AsrResponse = response + .json() + .await + .map_err(|e| format!("解析响应失败: {}", e))?; + + if result.err_no != 0 { + return Err(format!( + "百度 ASR 错误: {} - {}", + result.err_no, result.err_msg + )); + } + + Ok(result.result.join("")) + } + + /// 讯飞语音识别 + /// + /// 使用 WebSocket 流式识别,支持实时语音转文字 + async fn transcribe_xunfei( + credential: &AsrCredentialEntry, + audio_data: &[u8], + sample_rate: u32, + ) -> Result { + let config = credential.xunfei_config.as_ref().ok_or("讯飞配置缺失")?; + + // 将 PCM 字节转换为 i16 采样 + let samples: Vec = audio_data + .chunks_exact(2) + .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + + // 创建 AudioData + let audio = voice_core::types::AudioData::new(samples, sample_rate, 1); + + // 创建讯飞客户端 + // 讯飞语言代码转换:zh -> zh_cn, en -> en_us + let xunfei_language = match credential.language.as_str() { + "zh" => "zh_cn".to_string(), + "en" => "en_us".to_string(), + other => other.to_string(), + }; + + let client = voice_core::asr_client::XunfeiClient::new( + config.app_id.clone(), + config.api_key.clone(), + config.api_secret.clone(), + ) + .with_language(xunfei_language); + + // 调用识别 + use voice_core::asr_client::AsrClient; + let result = client + .transcribe(&audio) + .await + .map_err(|e| format!("讯飞识别失败: {}", e))?; + + Ok(result.text) + } + + /// 构建 WAV 文件 + fn build_wav(pcm_data: &[u8], sample_rate: u32, channels: u16) -> Result, String> { + let bits_per_sample: u16 = 16; + let byte_rate = sample_rate * u32::from(channels) * u32::from(bits_per_sample) / 8; + let block_align = channels * bits_per_sample / 8; + let data_size = pcm_data.len() as u32; + let file_size = 36 + data_size; + + let mut wav = Vec::with_capacity(44 + pcm_data.len()); + + // RIFF header + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&file_size.to_le_bytes()); + wav.extend_from_slice(b"WAVE"); + + // fmt chunk + wav.extend_from_slice(b"fmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); // chunk size + wav.extend_from_slice(&1u16.to_le_bytes()); // PCM format + wav.extend_from_slice(&channels.to_le_bytes()); + wav.extend_from_slice(&sample_rate.to_le_bytes()); + wav.extend_from_slice(&byte_rate.to_le_bytes()); + wav.extend_from_slice(&block_align.to_le_bytes()); + wav.extend_from_slice(&bits_per_sample.to_le_bytes()); + + // data chunk + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&data_size.to_le_bytes()); + wav.extend_from_slice(pcm_data); + + Ok(wav) + } +} diff --git a/src-tauri/src/voice/commands.rs b/src-tauri/src/voice/commands.rs new file mode 100644 index 000000000..eb4cc843d --- /dev/null +++ b/src-tauri/src/voice/commands.rs @@ -0,0 +1,359 @@ +//! 语音输入 Tauri 命令 +//! +//! 提供前端调用的语音输入相关命令 + +use crate::config::{VoiceInputConfig, VoiceInstruction}; +use tauri::{command, AppHandle}; + +use super::config; + +/// 获取语音输入配置 +#[command] +pub async fn get_voice_input_config() -> Result { + config::load_voice_config() +} + +/// 保存语音输入配置 +#[command] +pub async fn save_voice_input_config( + app: AppHandle, + voice_config: VoiceInputConfig, +) -> Result<(), String> { + let old_config = config::load_voice_config()?; + + // 如果快捷键变化,更新注册 + if old_config.shortcut != voice_config.shortcut { + super::shortcut::update(&app, &voice_config.shortcut)?; + } + + // 如果启用状态变化 + if old_config.enabled != voice_config.enabled { + if voice_config.enabled { + super::shortcut::register(&app, &voice_config.shortcut)?; + } else { + super::shortcut::unregister(&app)?; + } + } + + config::save_voice_config(voice_config) +} + +/// 获取指令列表 +#[command] +pub async fn get_voice_instructions() -> Result, String> { + config::get_instructions() +} + +/// 保存指令 +#[command] +pub async fn save_voice_instruction(instruction: VoiceInstruction) -> Result<(), String> { + let mut voice_config = config::load_voice_config()?; + + // 查找是否已存在 + if let Some(idx) = voice_config + .instructions + .iter() + .position(|i| i.id == instruction.id) + { + voice_config.instructions[idx] = instruction; + } else { + voice_config.instructions.push(instruction); + } + + config::save_voice_config(voice_config) +} + +/// 删除指令 +#[command] +pub async fn delete_voice_instruction(id: String) -> Result<(), String> { + let mut voice_config = config::load_voice_config()?; + + // 检查是否为预设指令 + if let Some(instruction) = voice_config.instructions.iter().find(|i| i.id == id) { + if instruction.is_preset { + return Err("无法删除预设指令".to_string()); + } + } + + voice_config.instructions.retain(|i| i.id != id); + config::save_voice_config(voice_config) +} + +/// 打开语音输入窗口 +#[command] +pub async fn open_voice_window(app: AppHandle) -> Result<(), String> { + super::window::open_voice_window(&app) +} + +/// 关闭语音输入窗口 +#[command] +pub async fn close_voice_window(app: AppHandle) -> Result<(), String> { + super::window::close_voice_window(&app) +} + +/// 语音识别结果 +#[derive(serde::Serialize)] +pub struct TranscribeResult { + /// 识别文本 + pub text: String, + /// 使用的 ASR 服务 + pub provider: String, +} + +/// 执行语音识别 +#[command] +pub async fn transcribe_audio( + audio_data: Vec, + sample_rate: u32, + credential_id: Option, +) -> Result { + use super::asr_service::AsrService; + + tracing::info!( + "[语音识别] 开始识别,音频大小: {} 字节,采样率: {}", + audio_data.len(), + sample_rate + ); + + // 检查音频数据是否有效 + if audio_data.is_empty() { + tracing::error!("[语音识别] 音频数据为空!"); + return Err("音频数据为空,请检查麦克风权限".to_string()); + } + + // 检查音频数据是否全为静音(全零) + let non_zero_count = audio_data.iter().filter(|&&b| b != 0).count(); + let non_zero_ratio = non_zero_count as f32 / audio_data.len() as f32; + tracing::info!( + "[语音识别] 非零字节比例: {:.2}% ({}/{})", + non_zero_ratio * 100.0, + non_zero_count, + audio_data.len() + ); + + if non_zero_ratio < 0.01 { + tracing::warn!("[语音识别] 音频数据几乎全为静音,可能是麦克风权限问题或未正确录音"); + } + + // 获取凭证 + let credential = if let Some(id) = credential_id { + tracing::info!("[语音识别] 使用指定凭证: {}", id); + AsrService::get_credential(&id)?.ok_or_else(|| format!("凭证不存在: {}", id))? + } else { + tracing::info!("[语音识别] 获取默认凭证..."); + match AsrService::get_default_credential() { + Ok(Some(cred)) => { + tracing::info!( + "[语音识别] 找到默认凭证: id={}, provider={:?}", + cred.id, + cred.provider + ); + cred + } + Ok(None) => { + // 打印所有 ASR 凭证用于调试 + if let Ok(config) = crate::config::load_config() { + tracing::error!( + "[语音识别] 未找到默认凭证,当前 ASR 凭证数量: {}", + config.credential_pool.asr.len() + ); + for (i, c) in config.credential_pool.asr.iter().enumerate() { + tracing::error!( + "[语音识别] 凭证 {}: id={}, is_default={}, disabled={}", + i, + c.id, + c.is_default, + c.disabled + ); + } + } + return Err("未配置语音识别服务。请在设置 → 凭证池 → ASR 中添加讯飞、百度或 OpenAI Whisper 凭证。".to_string()); + } + Err(e) => { + tracing::error!("[语音识别] 获取默认凭证失败: {}", e); + return Err(format!("获取凭证失败: {}", e)); + } + } + }; + + let provider_name = match credential.provider { + crate::config::AsrProviderType::WhisperLocal => "本地 Whisper", + crate::config::AsrProviderType::OpenAI => "OpenAI Whisper", + crate::config::AsrProviderType::Baidu => "百度语音", + crate::config::AsrProviderType::Xunfei => "讯飞语音", + }; + tracing::info!("[语音识别] 使用服务: {}", provider_name); + + // 执行识别 + let text = AsrService::transcribe(&credential, &audio_data, sample_rate).await?; + tracing::info!("[语音识别] 识别完成,文本长度: {} 字符", text.len()); + + Ok(TranscribeResult { + text, + provider: provider_name.to_string(), + }) +} + +/// 润色文本结果 +#[derive(serde::Serialize)] +pub struct PolishResult { + /// 润色后的文本 + pub text: String, + /// 使用的指令 + pub instruction_name: String, +} + +/// 润色文本 +#[command] +pub async fn polish_voice_text( + text: String, + instruction_id: Option, +) -> Result { + let voice_config = config::load_voice_config()?; + + // 获取指令 + let instruction_id = + instruction_id.unwrap_or_else(|| voice_config.processor.default_instruction_id.clone()); + + let instruction = voice_config + .instructions + .iter() + .find(|i| i.id == instruction_id) + .ok_or_else(|| format!("指令不存在: {}", instruction_id))?; + + // 如果是原始输出,直接返回 + if instruction_id == "raw" { + return Ok(PolishResult { + text, + instruction_name: instruction.name.clone(), + }); + } + + // 调用 LLM 润色 + let polished = super::processor::polish_text( + &text, + instruction, + voice_config.processor.polish_provider.as_deref(), + voice_config.processor.polish_model.as_deref(), + ) + .await?; + + Ok(PolishResult { + text: polished, + instruction_name: instruction.name.clone(), + }) +} + +/// 输出文本到系统 +/// +/// 根据配置的输出模式,将文字输出到当前焦点应用 +#[command] +pub async fn output_voice_text(text: String, mode: Option) -> Result<(), String> { + use crate::config::VoiceOutputMode; + + // 解析输出模式 + let output_mode = match mode.as_deref() { + Some("type") => VoiceOutputMode::Type, + Some("clipboard") => VoiceOutputMode::Clipboard, + Some("both") => VoiceOutputMode::Both, + None => { + // 使用配置的默认模式 + let config = config::load_voice_config()?; + config.output.mode + } + Some(other) => return Err(format!("未知的输出模式: {}", other)), + }; + + // 执行输出 + super::output_service::output_text(&text, output_mode)?; + + tracing::info!("[语音输出] 文本已输出: {} 字符", text.chars().count()); + Ok(()) +} + +// ============ 录音控制命令 ============ +// 使用独立线程 + channel 通信解决 cpal::Stream 不是 Send 的问题 + +use super::recording_service::RecordingServiceState; +use tauri::State; + +/// 开始录音 +#[command] +pub async fn start_recording( + recording_service: State<'_, RecordingServiceState>, +) -> Result<(), String> { + let mut service = recording_service.0.lock(); + service.start() +} + +/// 停止录音并返回音频数据 +/// +/// 返回的数据结构: +/// - audio_data: i16 样本的字节数组(小端序) +/// - sample_rate: 采样率 +/// - duration: 录音时长(秒) +#[command] +pub async fn stop_recording( + recording_service: State<'_, RecordingServiceState>, +) -> Result { + let mut service = recording_service.0.lock(); + let audio = service.stop()?; + + // 将 i16 样本转换为字节(小端序) + let bytes: Vec = audio + .samples + .iter() + .flat_map(|&s| s.to_le_bytes()) + .collect(); + + Ok(StopRecordingResult { + audio_data: bytes, + sample_rate: audio.sample_rate, + duration: audio.duration_secs, + }) +} + +/// 停止录音的返回结果 +#[derive(serde::Serialize)] +pub struct StopRecordingResult { + /// 音频数据(i16 样本的字节数组,小端序) + pub audio_data: Vec, + /// 采样率 + pub sample_rate: u32, + /// 录音时长(秒) + pub duration: f32, +} + +/// 取消录音 +#[command] +pub async fn cancel_recording( + recording_service: State<'_, RecordingServiceState>, +) -> Result<(), String> { + let mut service = recording_service.0.lock(); + service.cancel(); + Ok(()) +} + +/// 录音状态 +#[derive(serde::Serialize)] +pub struct RecordingStatus { + /// 是否正在录音 + pub is_recording: bool, + /// 当前音量级别(0-100) + pub volume: u32, + /// 录音时长(秒) + pub duration: f32, +} + +/// 获取录音状态 +#[command] +pub async fn get_recording_status( + recording_service: State<'_, RecordingServiceState>, +) -> Result { + let service = recording_service.0.lock(); + Ok(RecordingStatus { + is_recording: service.is_recording(), + volume: service.get_volume(), + duration: service.get_duration(), + }) +} diff --git a/src-tauri/src/voice/config.rs b/src-tauri/src/voice/config.rs new file mode 100644 index 000000000..663190226 --- /dev/null +++ b/src-tauri/src/voice/config.rs @@ -0,0 +1,43 @@ +//! 语音输入配置管理 +//! +//! 加载和保存语音输入相关配置 + +use crate::config::{ + load_config, save_config, AsrCredentialEntry, VoiceInputConfig, VoiceInstruction, +}; + +/// 加载语音输入配置 +pub fn load_voice_config() -> Result { + let config = load_config().map_err(|e| e.to_string())?; + Ok(config.experimental.voice_input) +} + +/// 保存语音输入配置 +pub fn save_voice_config(voice_config: VoiceInputConfig) -> Result<(), String> { + let mut config = load_config().map_err(|e| e.to_string())?; + config.experimental.voice_input = voice_config; + save_config(&config).map_err(|e| e.to_string())?; + Ok(()) +} + +/// 获取默认 ASR 凭证 +pub fn get_default_asr_credential() -> Result, String> { + let config = load_config().map_err(|e| e.to_string())?; + Ok(config + .credential_pool + .asr + .into_iter() + .find(|c| c.is_default && !c.disabled)) +} + +/// 获取指令列表 +pub fn get_instructions() -> Result, String> { + let config = load_config().map_err(|e| e.to_string())?; + Ok(config.experimental.voice_input.instructions) +} + +/// 获取指定 ID 的指令 +pub fn get_instruction(id: &str) -> Result, String> { + let instructions = get_instructions()?; + Ok(instructions.into_iter().find(|i| i.id == id)) +} diff --git a/src-tauri/src/voice/mod.rs b/src-tauri/src/voice/mod.rs new file mode 100644 index 000000000..413915817 --- /dev/null +++ b/src-tauri/src/voice/mod.rs @@ -0,0 +1,48 @@ +//! 语音输入模块 +//! +//! 提供系统级语音输入功能,包括: +//! - 全局快捷键触发 +//! - 悬浮窗口管理 +//! - 语音识别处理 +//! - 文本输出 + +pub mod asr_service; +pub mod commands; +pub mod config; +pub mod output_service; +pub mod processor; +pub mod recording_service; +pub mod shortcut; +pub mod window; + +use tauri::AppHandle; + +/// 初始化语音输入模块 +pub fn init(app: &AppHandle) -> Result<(), String> { + // 加载配置 + let config = config::load_voice_config()?; + + // 如果功能未启用,直接返回 + if !config.enabled { + tracing::info!("[语音输入] 功能未启用"); + return Ok(()); + } + + // 注册全局快捷键 + shortcut::register(app, &config.shortcut)?; + + tracing::info!("[语音输入] 模块初始化完成"); + Ok(()) +} + +/// 清理语音输入模块 +pub fn cleanup(app: &AppHandle) -> Result<(), String> { + // 注销快捷键 + shortcut::unregister(app)?; + + // 关闭悬浮窗口 + window::close_voice_window(app)?; + + tracing::info!("[语音输入] 模块已清理"); + Ok(()) +} diff --git a/src-tauri/src/voice/output_service.rs b/src-tauri/src/voice/output_service.rs new file mode 100644 index 000000000..02f282ab4 --- /dev/null +++ b/src-tauri/src/voice/output_service.rs @@ -0,0 +1,47 @@ +//! 文字输出服务 +//! +//! 提供模拟键盘输入和剪贴板输出功能 + +use crate::config::VoiceOutputMode; +use arboard::Clipboard; + +/// 输出文字到系统 +/// +/// 根据配置的输出模式,将文字输出到当前焦点应用 +pub fn output_text(text: &str, mode: VoiceOutputMode) -> Result<(), String> { + match mode { + VoiceOutputMode::Type => type_text(text), + VoiceOutputMode::Clipboard => copy_to_clipboard(text), + VoiceOutputMode::Both => { + copy_to_clipboard(text)?; + type_text(text) + } + } +} + +/// 模拟键盘输入文字 +fn type_text(text: &str) -> Result<(), String> { + use enigo::{Enigo, Keyboard, Settings}; + + let mut enigo = + Enigo::new(&Settings::default()).map_err(|e| format!("初始化键盘模拟器失败: {}", e))?; + + enigo + .text(text) + .map_err(|e| format!("键盘输入失败: {}", e))?; + + tracing::info!("[语音输出] 键盘输入完成: {} 字符", text.chars().count()); + Ok(()) +} + +/// 复制到剪贴板 +fn copy_to_clipboard(text: &str) -> Result<(), String> { + let mut clipboard = Clipboard::new().map_err(|e| format!("初始化剪贴板失败: {}", e))?; + + clipboard + .set_text(text) + .map_err(|e| format!("复制到剪贴板失败: {}", e))?; + + tracing::info!("[语音输出] 已复制到剪贴板: {} 字符", text.chars().count()); + Ok(()) +} diff --git a/src-tauri/src/voice/processor.rs b/src-tauri/src/voice/processor.rs new file mode 100644 index 000000000..4142ecabd --- /dev/null +++ b/src-tauri/src/voice/processor.rs @@ -0,0 +1,115 @@ +//! 语音处理器 +//! +//! 处理语音识别结果的 LLM 润色 + +use crate::config::VoiceInstruction; + +/// 处理文本(应用指令模板) +pub fn process_text(text: &str, instruction: &VoiceInstruction) -> String { + // 替换模板中的占位符 + instruction.prompt.replace("{{text}}", text) +} + +/// 使用 LLM 润色文本 +/// +/// 通过本地 API 服务器调用 LLM 进行文本润色 +pub async fn polish_text( + text: &str, + instruction: &VoiceInstruction, + _provider: Option<&str>, + model: Option<&str>, +) -> Result { + // 如果是原始输出指令,直接返回 + if instruction.id == "raw" { + return Ok(text.to_string()); + } + + // 构建 prompt + let prompt = process_text(text, instruction); + + // 调用本地 API 服务器 + let result = call_local_llm(&prompt, model).await?; + Ok(result) +} + +/// 调用本地 API 服务器进行 LLM 推理 +async fn call_local_llm(prompt: &str, model: Option<&str>) -> Result { + use crate::config::load_config; + + // 加载配置获取 API 地址和密钥 + let config = load_config().map_err(|e| e.to_string())?; + let base_url = format!("http://{}:{}", config.server.host, config.server.port); + let api_key = &config.server.api_key; + + // 使用配置的模型或默认模型 + let model_name = model.unwrap_or("claude-sonnet-4-20250514"); + + // 构建请求 + #[derive(serde::Serialize)] + struct Message { + role: String, + content: String, + } + + #[derive(serde::Serialize)] + struct ChatRequest { + model: String, + messages: Vec, + max_tokens: u32, + temperature: f32, + } + + let request = ChatRequest { + model: model_name.to_string(), + messages: vec![Message { + role: "user".to_string(), + content: prompt.to_string(), + }], + max_tokens: 2048, + temperature: 0.3, + }; + + // 发送请求 + let client = reqwest::Client::new(); + let response = client + .post(format!("{}/v1/chat/completions", base_url)) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| format!("请求失败: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("LLM API 错误: {} - {}", status, body)); + } + + // 解析响应 + #[derive(serde::Deserialize)] + struct Choice { + message: ResponseMessage, + } + + #[derive(serde::Deserialize)] + struct ResponseMessage { + content: Option, + } + + #[derive(serde::Deserialize)] + struct ChatResponse { + choices: Vec, + } + + let result: ChatResponse = response + .json() + .await + .map_err(|e| format!("解析响应失败: {}", e))?; + + result + .choices + .first() + .and_then(|c| c.message.content.clone()) + .ok_or_else(|| "LLM 返回空内容".to_string()) +} diff --git a/src-tauri/src/voice/recording_service.rs b/src-tauri/src/voice/recording_service.rs new file mode 100644 index 000000000..d281e048d --- /dev/null +++ b/src-tauri/src/voice/recording_service.rs @@ -0,0 +1,447 @@ +//! 录音服务 +//! +//! 管理录音状态,提供录音控制接口。 +//! +//! ## 线程安全设计 +//! +//! 由于 `cpal::Stream` 不实现 `Send` trait,无法直接在 Tauri 的 async 命令中使用。 +//! 本模块采用**独立线程 + channel 通信**的方案: +//! +//! ```text +//! ┌─────────────────┐ Command ┌─────────────────┐ +//! │ Tauri Command │ ───────────────> │ Recording │ +//! │ (async) │ │ Thread │ +//! │ │ <─────────────── │ (owns Stream) │ +//! └─────────────────┘ Response └─────────────────┘ +//! ``` +//! +//! - 录音线程拥有 `cpal::Stream`,在独立线程中运行 +//! - Tauri 命令通过 channel 发送控制指令 +//! - 录音线程通过 channel 返回结果 + +use parking_lot::Mutex; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::Instant; +use voice_core::types::AudioData; + +/// 录音控制命令 +#[derive(Debug)] +pub enum RecordingCommand { + /// 开始录音 + Start, + /// 停止录音 + Stop, + /// 取消录音 + Cancel, + /// 关闭录音线程 + Shutdown, +} + +/// 录音响应 +#[derive(Debug)] +pub enum RecordingResponse { + /// 操作成功 + Ok, + /// 停止录音成功,返回音频数据 + AudioData(AudioData), + /// 操作失败 + Error(String), +} + +/// 录音服务 +/// +/// 使用独立线程管理 cpal::Stream,通过 channel 与 Tauri 命令通信 +pub struct RecordingService { + /// 命令发送端 + command_tx: Option>, + /// 响应接收端 + response_rx: Option>, + /// 录音线程句柄 + thread_handle: Option>, + /// 是否正在录音(共享状态,用于快速查询) + is_recording: Arc, + /// 当前音量级别(共享状态,用于快速查询) + volume_level: Arc, + /// 录音开始时间(共享状态) + start_time: Arc>>, +} + +impl RecordingService { + /// 创建新的录音服务 + pub fn new() -> Self { + Self { + command_tx: None, + response_rx: None, + thread_handle: None, + is_recording: Arc::new(AtomicBool::new(false)), + volume_level: Arc::new(AtomicU32::new(0)), + start_time: Arc::new(Mutex::new(None)), + } + } + + /// 确保录音线程已启动 + fn ensure_thread_started(&mut self) { + if self.command_tx.is_some() { + return; + } + + let (cmd_tx, cmd_rx) = mpsc::channel::(); + let (resp_tx, resp_rx) = mpsc::channel::(); + + let is_recording = Arc::clone(&self.is_recording); + let volume_level = Arc::clone(&self.volume_level); + let start_time = Arc::clone(&self.start_time); + + let handle = thread::spawn(move || { + recording_thread_main(cmd_rx, resp_tx, is_recording, volume_level, start_time); + }); + + self.command_tx = Some(cmd_tx); + self.response_rx = Some(resp_rx); + self.thread_handle = Some(handle); + + tracing::info!("[录音服务] 录音线程已启动"); + } + + /// 开始录音 + pub fn start(&mut self) -> Result<(), String> { + self.ensure_thread_started(); + + let tx = self.command_tx.as_ref().ok_or("录音线程未启动")?; + let rx = self.response_rx.as_ref().ok_or("录音线程未启动")?; + + tx.send(RecordingCommand::Start) + .map_err(|e| format!("发送命令失败: {}", e))?; + + match rx.recv() { + Ok(RecordingResponse::Ok) => { + tracing::info!("[录音服务] 开始录音"); + Ok(()) + } + Ok(RecordingResponse::Error(e)) => Err(e), + Ok(_) => Err("意外的响应".to_string()), + Err(e) => Err(format!("接收响应失败: {}", e)), + } + } + + /// 停止录音并返回音频数据 + pub fn stop(&mut self) -> Result { + let tx = self.command_tx.as_ref().ok_or("录音线程未启动")?; + let rx = self.response_rx.as_ref().ok_or("录音线程未启动")?; + + tx.send(RecordingCommand::Stop) + .map_err(|e| format!("发送命令失败: {}", e))?; + + match rx.recv() { + Ok(RecordingResponse::AudioData(audio)) => { + tracing::info!("[录音服务] 停止录音,时长: {:.2}s", audio.duration_secs); + Ok(audio) + } + Ok(RecordingResponse::Error(e)) => Err(e), + Ok(_) => Err("意外的响应".to_string()), + Err(e) => Err(format!("接收响应失败: {}", e)), + } + } + + /// 取消录音 + pub fn cancel(&mut self) { + if let Some(tx) = &self.command_tx { + let _ = tx.send(RecordingCommand::Cancel); + // 不等待响应,直接返回 + if let Some(rx) = &self.response_rx { + let _ = rx.recv(); + } + tracing::info!("[录音服务] 取消录音"); + } + } + + /// 获取当前音量级别(0-100) + pub fn get_volume(&self) -> u32 { + self.volume_level.load(Ordering::SeqCst) + } + + /// 获取录音时长(秒) + pub fn get_duration(&self) -> f32 { + self.start_time + .lock() + .map(|t| t.elapsed().as_secs_f32()) + .unwrap_or(0.0) + } + + /// 是否正在录音 + pub fn is_recording(&self) -> bool { + self.is_recording.load(Ordering::SeqCst) + } + + /// 关闭录音服务 + pub fn shutdown(&mut self) { + if let Some(tx) = self.command_tx.take() { + let _ = tx.send(RecordingCommand::Shutdown); + } + if let Some(handle) = self.thread_handle.take() { + let _ = handle.join(); + } + self.response_rx = None; + tracing::info!("[录音服务] 已关闭"); + } +} + +impl Default for RecordingService { + fn default() -> Self { + Self::new() + } +} + +impl Drop for RecordingService { + fn drop(&mut self) { + self.shutdown(); + } +} + +/// 录音线程主函数 +/// +/// 在独立线程中运行,拥有 cpal::Stream +fn recording_thread_main( + cmd_rx: Receiver, + resp_tx: Sender, + is_recording: Arc, + volume_level: Arc, + start_time: Arc>>, +) { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + + // 录音数据缓冲区 + let samples: Arc>> = Arc::new(Mutex::new(Vec::new())); + // 当前活跃的音频流 + let mut active_stream: Option = None; + // 实际使用的采样率和声道数 + let mut actual_sample_rate: u32 = 16000; + #[allow(unused_assignments)] + let mut actual_channels: u16 = 1; + + tracing::debug!("[录音线程] 开始运行"); + + loop { + match cmd_rx.recv() { + Ok(RecordingCommand::Start) => { + // 如果已在录音,返回错误 + if is_recording.load(Ordering::SeqCst) { + let _ = resp_tx.send(RecordingResponse::Error("已在录音中".to_string())); + continue; + } + + // 清空缓冲区 + samples.lock().clear(); + + // 获取默认输入设备 + let host = cpal::default_host(); + let device = match host.default_input_device() { + Some(d) => d, + None => { + let _ = + resp_tx.send(RecordingResponse::Error("未找到麦克风设备".to_string())); + continue; + } + }; + + tracing::info!("[录音线程] 使用麦克风: {:?}", device.name()); + + // 获取设备支持的配置 + let supported_config = match device.default_input_config() { + Ok(c) => c, + Err(e) => { + let _ = resp_tx + .send(RecordingResponse::Error(format!("获取音频配置失败: {}", e))); + continue; + } + }; + + tracing::info!( + "[录音线程] 设备支持配置: 采样率={}, 声道={}", + supported_config.sample_rate().0, + supported_config.channels() + ); + + // 使用设备默认配置 + actual_sample_rate = supported_config.sample_rate().0; + actual_channels = supported_config.channels(); + + let config = cpal::StreamConfig { + channels: actual_channels, + sample_rate: supported_config.sample_rate(), + buffer_size: cpal::BufferSize::Default, + }; + + // 创建共享状态的克隆 + let samples_clone = Arc::clone(&samples); + let volume_clone = Arc::clone(&volume_level); + let is_rec_clone = Arc::clone(&is_recording); + let channels = actual_channels; + + // 回调计数器(用于调试) + let callback_count = Arc::new(AtomicU32::new(0)); + let callback_count_clone = Arc::clone(&callback_count); + + // 创建输入流 + let stream = match device.build_input_stream( + &config, + move |data: &[f32], _: &cpal::InputCallbackInfo| { + if !is_rec_clone.load(Ordering::SeqCst) { + return; + } + + // 增加回调计数 + let count = callback_count_clone.fetch_add(1, Ordering::SeqCst); + if count == 0 { + tracing::info!("[录音线程] 首次收到音频数据,数据长度: {}", data.len()); + } else if count % 100 == 0 { + tracing::debug!("[录音线程] 已收到 {} 次音频回调", count); + } + + // 计算音量级别 + let sum: f32 = data.iter().map(|s| s.abs()).sum(); + let avg = sum / data.len() as f32; + let level = (avg * 100.0).min(100.0) as u32; + volume_clone.store(level, Ordering::SeqCst); + + // 如果是多声道,转换为单声道 + let mono_data: Vec = if channels > 1 { + data.chunks(channels as usize) + .map(|chunk| chunk.iter().sum::() / channels as f32) + .collect() + } else { + data.to_vec() + }; + + // 转换为 i16 并存储 + let i16_samples: Vec = mono_data + .iter() + .map(|&s| (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16) + .collect(); + + samples_clone.lock().extend(i16_samples); + }, + |err| { + tracing::error!("[录音线程] 录音流错误: {}", err); + }, + None, + ) { + Ok(s) => s, + Err(e) => { + let _ = resp_tx + .send(RecordingResponse::Error(format!("创建音频流失败: {}", e))); + continue; + } + }; + + // 开始播放(录音) + if let Err(e) = stream.play() { + let _ = resp_tx.send(RecordingResponse::Error(format!("启动录音失败: {}", e))); + continue; + } + + // 保存流和状态 + active_stream = Some(stream); + is_recording.store(true, Ordering::SeqCst); + *start_time.lock() = Some(Instant::now()); + + let _ = resp_tx.send(RecordingResponse::Ok); + tracing::info!("[录音线程] 开始录音,采样率: {}", actual_sample_rate); + } + + Ok(RecordingCommand::Stop) => { + if !is_recording.load(Ordering::SeqCst) { + let _ = resp_tx.send(RecordingResponse::Error("未在录音中".to_string())); + continue; + } + + // 停止录音 + is_recording.store(false, Ordering::SeqCst); + + // 停止并释放流 + if let Some(stream) = active_stream.take() { + drop(stream); + } + + // 获取录音数据(已转换为单声道) + let audio_samples = samples.lock().clone(); + let audio = AudioData::new(audio_samples, actual_sample_rate, 1); + + // 重置开始时间 + *start_time.lock() = None; + volume_level.store(0, Ordering::SeqCst); + + // 检查录音时长 + if !audio.is_valid() { + let _ = resp_tx.send(RecordingResponse::Error( + "录音时间过短(需要至少 0.5 秒)".to_string(), + )); + continue; + } + + let _ = resp_tx.send(RecordingResponse::AudioData(audio)); + tracing::info!("[录音线程] 停止录音"); + } + + Ok(RecordingCommand::Cancel) => { + // 停止录音 + is_recording.store(false, Ordering::SeqCst); + + // 停止并释放流 + if let Some(stream) = active_stream.take() { + drop(stream); + } + + // 清空缓冲区 + samples.lock().clear(); + + // 重置状态 + *start_time.lock() = None; + volume_level.store(0, Ordering::SeqCst); + + let _ = resp_tx.send(RecordingResponse::Ok); + tracing::info!("[录音线程] 取消录音"); + } + + Ok(RecordingCommand::Shutdown) => { + // 清理资源 + is_recording.store(false, Ordering::SeqCst); + if let Some(stream) = active_stream.take() { + drop(stream); + } + tracing::info!("[录音线程] 收到关闭命令,退出"); + break; + } + + Err(_) => { + // channel 已关闭,退出线程 + tracing::info!("[录音线程] channel 已关闭,退出"); + break; + } + } + } +} + +/// 全局录音服务状态(Tauri State 包装) +pub struct RecordingServiceState(pub Arc>); + +impl RecordingServiceState { + /// 创建新的录音服务状态 + pub fn new() -> Self { + Self(Arc::new(Mutex::new(RecordingService::new()))) + } +} + +impl Default for RecordingServiceState { + fn default() -> Self { + Self::new() + } +} + +/// 创建录音服务状态 +pub fn create_recording_service_state() -> RecordingServiceState { + RecordingServiceState::new() +} diff --git a/src-tauri/src/voice/shortcut.rs b/src-tauri/src/voice/shortcut.rs new file mode 100644 index 000000000..63a8e7ec8 --- /dev/null +++ b/src-tauri/src/voice/shortcut.rs @@ -0,0 +1,157 @@ +//! 全局快捷键管理 +//! +//! 注册和处理语音输入的全局快捷键 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; +use tauri::AppHandle; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; +use tracing::{debug, error, info, warn}; + +/// 当前注册的快捷键 +static CURRENT_SHORTCUT: OnceLock>> = OnceLock::new(); + +/// 快捷键是否已注册 +static IS_REGISTERED: AtomicBool = AtomicBool::new(false); + +fn get_current_shortcut() -> &'static parking_lot::RwLock> { + CURRENT_SHORTCUT.get_or_init(|| parking_lot::RwLock::new(None)) +} + +/// 注册全局快捷键 +pub fn register(app: &AppHandle, shortcut_str: &str) -> Result<(), String> { + info!("[语音输入] 注册全局快捷键: {}", shortcut_str); + + // 解析快捷键 + let shortcut: Shortcut = shortcut_str + .parse() + .map_err(|e| format!("无效的快捷键: {}", e))?; + + // 获取全局快捷键管理器 + let global_shortcut = app.global_shortcut(); + + // 检查快捷键是否已被注册 + let is_already_registered = global_shortcut.is_registered(shortcut.clone()); + debug!( + "[语音输入] 快捷键 {} 是否已注册: {}", + shortcut_str, is_already_registered + ); + + if is_already_registered { + warn!("[语音输入] 快捷键已被注册: {}", shortcut_str); + // 如果是我们自己注册的,先注销 + if IS_REGISTERED.load(Ordering::SeqCst) { + info!("[语音输入] 尝试注销已有的快捷键"); + if let Err(e) = global_shortcut.unregister(shortcut.clone()) { + error!("[语音输入] 注销已有快捷键失败: {}", e); + } + } else { + return Err(format!("快捷键已被占用: {}", shortcut_str)); + } + } + + // 克隆 app handle 用于回调 + let app_clone = app.clone(); + + // 注册快捷键 + info!("[语音输入] 开始注册快捷键回调..."); + global_shortcut + .on_shortcut(shortcut.clone(), move |_app, _shortcut, event| { + if event.state == ShortcutState::Pressed { + info!("[语音输入] 快捷键按下"); + // 打开截图输入框(语音模式) + if let Err(e) = + crate::screenshot::window::open_floating_window_with_voice(&app_clone) + { + error!("[语音输入] 打开窗口失败: {}", e); + } + } else { + info!("[语音输入] 快捷键释放,发送停止录音事件"); + // 发送停止录音事件到前端 + if let Err(e) = crate::screenshot::window::send_voice_stop_event(&app_clone) { + error!("[语音输入] 发送停止录音事件失败: {}", e); + } + } + }) + .map_err(|e| { + error!("[语音输入] 注册快捷键失败: {}", e); + format!("注册快捷键失败: {}", e) + })?; + + // 更新状态 + IS_REGISTERED.store(true, Ordering::SeqCst); + *get_current_shortcut().write() = Some(shortcut_str.to_string()); + + info!("[语音输入] 快捷键已注册: {}", shortcut_str); + Ok(()) +} + +/// 注销全局快捷键 +pub fn unregister(app: &AppHandle) -> Result<(), String> { + let current = get_current_shortcut().read().clone(); + + if let Some(shortcut_str) = current { + info!("[语音输入] 注销全局快捷键: {}", shortcut_str); + + let shortcut: Shortcut = shortcut_str + .parse() + .map_err(|e| format!("解析快捷键失败: {}", e))?; + + let global_shortcut = app.global_shortcut(); + + if global_shortcut.is_registered(shortcut.clone()) { + global_shortcut + .unregister(shortcut) + .map_err(|e| format!("注销快捷键失败: {}", e))?; + } + + // 更新状态 + IS_REGISTERED.store(false, Ordering::SeqCst); + *get_current_shortcut().write() = None; + + info!("[语音输入] 快捷键已注销"); + } else { + debug!("[语音输入] 没有已注册的快捷键需要注销"); + } + + Ok(()) +} + +/// 更新快捷键 +pub fn update(app: &AppHandle, new_shortcut: &str) -> Result<(), String> { + info!("[语音输入] 更新快捷键: {}", new_shortcut); + + // 保存旧快捷键以便恢复 + let old_shortcut = get_current_shortcut().read().clone(); + + // 注销旧快捷键 + if let Err(e) = unregister(app) { + warn!("[语音输入] 注销旧快捷键失败: {}", e); + } + + // 注册新快捷键 + match register(app, new_shortcut) { + Ok(()) => { + info!("[语音输入] 快捷键更新成功: {}", new_shortcut); + Ok(()) + } + Err(e) => { + error!("[语音输入] 注册新快捷键失败: {}", e); + + // 尝试恢复旧快捷键 + if let Some(old) = old_shortcut { + warn!("[语音输入] 尝试恢复旧快捷键: {}", old); + if let Err(restore_err) = register(app, &old) { + error!("[语音输入] 恢复旧快捷键失败: {}", restore_err); + } + } + + Err(e) + } + } +} + +/// 检查快捷键是否已注册 +pub fn is_registered() -> bool { + IS_REGISTERED.load(Ordering::SeqCst) +} diff --git a/src-tauri/src/voice/window.rs b/src-tauri/src/voice/window.rs new file mode 100644 index 000000000..d86653a98 --- /dev/null +++ b/src-tauri/src/voice/window.rs @@ -0,0 +1,77 @@ +//! 语音输入悬浮窗口管理 +//! +//! 创建和管理语音输入的悬浮窗口 + +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; + +const VOICE_WINDOW_LABEL: &str = "voice-input"; +const VOICE_WINDOW_WIDTH: f64 = 400.0; +const VOICE_WINDOW_HEIGHT: f64 = 80.0; + +/// 打开语音输入窗口 +pub fn open_voice_window(app: &AppHandle) -> Result<(), String> { + // 检查窗口是否已存在 + if let Some(window) = app.get_webview_window(VOICE_WINDOW_LABEL) { + // 发送重置事件,让前端重新开始录音 + window + .emit("voice-reset", ()) + .map_err(|e| format!("发送重置事件失败: {}", e))?; + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + tracing::info!("[语音输入] 窗口已存在,发送重置事件"); + return Ok(()); + } + + // 创建新窗口 + let window = WebviewWindowBuilder::new( + app, + VOICE_WINDOW_LABEL, + WebviewUrl::App("/voice-input".into()), + ) + .title("语音输入") + .inner_size(VOICE_WINDOW_WIDTH, VOICE_WINDOW_HEIGHT) + .resizable(false) + .decorations(false) + .always_on_top(true) + .transparent(false) // 关闭透明,避免 macOS 上的渲染问题 + .skip_taskbar(true) + .center() + .build() + .map_err(|e| format!("创建窗口失败: {}", e))?; + + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + + tracing::info!("[语音输入] 窗口已打开"); + Ok(()) +} + +/// 关闭语音输入窗口 +pub fn close_voice_window(app: &AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window(VOICE_WINDOW_LABEL) { + window.close().map_err(|e| e.to_string())?; + tracing::info!("[语音输入] 窗口已关闭"); + } + Ok(()) +} + +/// 更新窗口状态(发送事件到前端) +pub fn update_window_state(app: &AppHandle, state: &str) -> Result<(), String> { + if let Some(window) = app.get_webview_window(VOICE_WINDOW_LABEL) { + window + .emit("voice-state-change", state) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// 发送停止录音事件到前端 +pub fn send_stop_recording_event(app: &AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window(VOICE_WINDOW_LABEL) { + window + .emit("voice-stop-recording", ()) + .map_err(|e| format!("发送停止录音事件失败: {}", e))?; + tracing::info!("[语音输入] 已发送停止录音事件"); + } + Ok(()) +} diff --git a/src-tauri/src/websocket/mod.rs b/src-tauri/src/websocket/mod.rs index eb921b146..b94067a86 100644 --- a/src-tauri/src/websocket/mod.rs +++ b/src-tauri/src/websocket/mod.rs @@ -12,16 +12,11 @@ mod processor; mod stream; mod types; -pub use handler::{parse_message, serialize_message, ws_handler, WsHandlerState}; -pub use lifecycle::{ - ConnectionLifecycle, GracefulShutdown, HeartbeatManager, LifecycleState, ResourceCleaner, -}; +pub use handler::ws_handler; pub use processor::MessageProcessor; -pub use stream::{BackpressureController, StreamForwarder}; pub use types::{ - KiroTokenInfo, WsApiRequest, WsApiResponse, WsConfig, WsConnection, WsConnectionStatus, - WsEndpoint, WsError, WsErrorCode, WsFlowEvent, WsKiroEvent, WsMessage, WsStats, - WsStatsSnapshot, WsStreamChunk, WsStreamEnd, + KiroTokenInfo, WsApiRequest, WsApiResponse, WsConfig, WsConnection, WsEndpoint, WsError, + WsFlowEvent, WsKiroEvent, WsMessage, WsStats, WsStatsSnapshot, WsStreamChunk, WsStreamEnd, }; use dashmap::DashMap; diff --git a/src-tauri/src/workspace/README.md b/src-tauri/src/workspace/README.md new file mode 100644 index 000000000..31169d5aa --- /dev/null +++ b/src-tauri/src/workspace/README.md @@ -0,0 +1,57 @@ +# Workspace 模块 + +Workspace 是 ProxyCast 应用层的概念,用于组织和管理 AI Agent 的工作上下文。 + +## 概述 + +Workspace 是对 Aster 框架 `Session.working_dir` 的命名和配置包装,不修改 Aster 框架本身。 + +## 设计原则 + +- **读共享,写隔离** - 只读操作可以共享环境,写操作需要隔离 +- **最小有效 context** - 只传递必要的 context,避免 context pollution +- **Workspace = 边界** - 文件系统边界 + context 边界 + 配置边界 + +## 文件索引 + +| 文件 | 说明 | +|------|------| +| `mod.rs` | 模块入口,导出公共类型 | +| `types.rs` | 类型定义(Workspace, WorkspaceSettings 等) | +| `manager.rs` | WorkspaceManager 实现 CRUD 操作 | + +## 数据模型 + +```rust +pub struct Workspace { + pub id: WorkspaceId, + pub name: String, + pub workspace_type: WorkspaceType, + pub root_path: PathBuf, + pub is_default: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub settings: WorkspaceSettings, +} +``` + +## 使用示例 + +```rust +use crate::workspace::WorkspaceManager; + +let manager = WorkspaceManager::new(db); + +// 创建 workspace +let ws = manager.create("my-project".to_string(), PathBuf::from("/path/to/project"))?; + +// 设置为默认 +manager.set_default(&ws.id)?; + +// 列出所有 workspace +let list = manager.list()?; +``` + +## 相关文档 + +- [Workspace 设计文档](../../../docs/aiprompts/workspace.md) diff --git a/src-tauri/src/workspace/manager.rs b/src-tauri/src/workspace/manager.rs new file mode 100644 index 000000000..a48e93a40 --- /dev/null +++ b/src-tauri/src/workspace/manager.rs @@ -0,0 +1,304 @@ +//! Workspace 管理器 +//! +//! 提供 Workspace 的 CRUD 操作和与 Aster Session 的关联。 + +use super::types::{Workspace, WorkspaceId, WorkspaceSettings, WorkspaceType, WorkspaceUpdate}; +use crate::database::DbConnection; +use chrono::Utc; +use rusqlite::params; +use std::path::PathBuf; +use uuid::Uuid; + +/// Workspace 管理器 +#[derive(Clone)] +pub struct WorkspaceManager { + db: DbConnection, +} + +impl WorkspaceManager { + /// 创建新的 WorkspaceManager + pub fn new(db: DbConnection) -> Self { + Self { db } + } + + /// 创建新 workspace + pub fn create(&self, name: String, root_path: PathBuf) -> Result { + self.create_with_type(name, root_path, WorkspaceType::Persistent) + } + + /// 创建指定类型的 workspace + pub fn create_with_type( + &self, + name: String, + root_path: PathBuf, + workspace_type: WorkspaceType, + ) -> Result { + let now = Utc::now(); + let id = Uuid::new_v4().to_string(); + let root_path_str = root_path.to_str().ok_or("无效的路径")?.to_string(); + + let workspace = Workspace { + id: id.clone(), + name, + workspace_type, + root_path, + is_default: false, + created_at: now, + updated_at: now, + settings: WorkspaceSettings::default(), + }; + + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + // 检查路径是否已存在 + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM workspaces WHERE root_path = ?)", + params![&root_path_str], + |row| row.get(0), + ) + .map_err(|e| format!("检查路径失败: {}", e))?; + + if exists { + return Err(format!("路径已存在: {}", root_path_str)); + } + + let settings_json = + serde_json::to_string(&workspace.settings).map_err(|e| e.to_string())?; + + conn.execute( + "INSERT INTO workspaces (id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + params![ + &workspace.id, + &workspace.name, + workspace.workspace_type.as_str(), + &root_path_str, + workspace.is_default, + &settings_json, + workspace.created_at.timestamp_millis(), + workspace.updated_at.timestamp_millis(), + ], + ) + .map_err(|e| format!("创建 workspace 失败: {}", e))?; + + tracing::info!( + "[Workspace] 创建: id={}, name={}, path={}", + workspace.id, + workspace.name, + root_path_str + ); + + Ok(workspace) + } + + /// 获取 workspace + pub fn get(&self, id: &WorkspaceId) -> Result, String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let result = conn.query_row( + "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at + FROM workspaces WHERE id = ?", + params![id], + |row| { + Ok(Self::row_to_workspace(row)?) + }, + ); + + match result { + Ok(workspace) => Ok(Some(workspace)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("获取 workspace 失败: {}", e)), + } + } + + /// 通过路径获取 workspace + pub fn get_by_path(&self, root_path: &PathBuf) -> Result, String> { + let root_path_str = root_path.to_str().ok_or("无效的路径")?; + + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let result = conn.query_row( + "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at + FROM workspaces WHERE root_path = ?", + params![root_path_str], + |row| { + Ok(Self::row_to_workspace(row)?) + }, + ); + + match result { + Ok(workspace) => Ok(Some(workspace)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("获取 workspace 失败: {}", e)), + } + } + + /// 列出所有 workspace + pub fn list(&self) -> Result, String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let mut stmt = conn + .prepare( + "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at + FROM workspaces ORDER BY updated_at DESC", + ) + .map_err(|e| format!("准备查询失败: {}", e))?; + + let workspaces = stmt + .query_map([], |row| Ok(Self::row_to_workspace(row)?)) + .map_err(|e| format!("查询失败: {}", e))? + .collect::, _>>() + .map_err(|e| format!("解析结果失败: {}", e))?; + + Ok(workspaces) + } + + /// 更新 workspace + pub fn update(&self, id: &WorkspaceId, updates: WorkspaceUpdate) -> Result { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + let now = Utc::now().timestamp_millis(); + + // 构建更新语句 + let mut set_clauses = vec!["updated_at = ?"]; + let mut params_vec: Vec> = vec![Box::new(now)]; + + if let Some(ref name) = updates.name { + set_clauses.push("name = ?"); + params_vec.push(Box::new(name.clone())); + } + + if let Some(ref settings) = updates.settings { + let settings_json = serde_json::to_string(settings).map_err(|e| e.to_string())?; + set_clauses.push("settings_json = ?"); + params_vec.push(Box::new(settings_json)); + } + + params_vec.push(Box::new(id.clone())); + + let sql = format!( + "UPDATE workspaces SET {} WHERE id = ?", + set_clauses.join(", ") + ); + + let params_refs: Vec<&dyn rusqlite::ToSql> = + params_vec.iter().map(|p| p.as_ref()).collect(); + + conn.execute(&sql, params_refs.as_slice()) + .map_err(|e| format!("更新 workspace 失败: {}", e))?; + + drop(conn); + + self.get(id)?.ok_or_else(|| "Workspace 不存在".to_string()) + } + + /// 删除 workspace + pub fn delete(&self, id: &WorkspaceId) -> Result { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let affected = conn + .execute("DELETE FROM workspaces WHERE id = ?", params![id]) + .map_err(|e| format!("删除 workspace 失败: {}", e))?; + + if affected > 0 { + tracing::info!("[Workspace] 删除: id={}", id); + } + + Ok(affected > 0) + } + + /// 设置默认 workspace + pub fn set_default(&self, id: &WorkspaceId) -> Result<(), String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + // 先清除所有默认标记 + conn.execute("UPDATE workspaces SET is_default = 0", []) + .map_err(|e| format!("清除默认标记失败: {}", e))?; + + // 设置新的默认 + let affected = conn + .execute( + "UPDATE workspaces SET is_default = 1, updated_at = ? WHERE id = ?", + params![Utc::now().timestamp_millis(), id], + ) + .map_err(|e| format!("设置默认 workspace 失败: {}", e))?; + + if affected == 0 { + return Err("Workspace 不存在".to_string()); + } + + tracing::info!("[Workspace] 设置默认: id={}", id); + Ok(()) + } + + /// 获取默认 workspace + pub fn get_default(&self) -> Result, String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let result = conn.query_row( + "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at + FROM workspaces WHERE is_default = 1", + [], + |row| { + Ok(Self::row_to_workspace(row)?) + }, + ); + + match result { + Ok(workspace) => Ok(Some(workspace)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("获取默认 workspace 失败: {}", e)), + } + } + + /// 从数据库行解析 Workspace + fn row_to_workspace(row: &rusqlite::Row) -> Result { + let id: String = row.get(0)?; + let name: String = row.get(1)?; + let workspace_type_str: String = row.get(2)?; + let root_path_str: String = row.get(3)?; + let is_default: bool = row.get(4)?; + let settings_json: String = row.get(5)?; + let created_at_ms: i64 = row.get(6)?; + let updated_at_ms: i64 = row.get(7)?; + + let settings: WorkspaceSettings = serde_json::from_str(&settings_json).unwrap_or_default(); + + Ok(Workspace { + id, + name, + workspace_type: WorkspaceType::from_str(&workspace_type_str), + root_path: PathBuf::from(root_path_str), + is_default, + created_at: chrono::DateTime::from_timestamp_millis(created_at_ms) + .unwrap_or_else(Utc::now), + updated_at: chrono::DateTime::from_timestamp_millis(updated_at_ms) + .unwrap_or_else(Utc::now), + settings, + }) + } +} diff --git a/src-tauri/src/workspace/mod.rs b/src-tauri/src/workspace/mod.rs new file mode 100644 index 000000000..c779171c2 --- /dev/null +++ b/src-tauri/src/workspace/mod.rs @@ -0,0 +1,20 @@ +//! Workspace 管理模块 +//! +//! Workspace 是 ProxyCast 应用层的概念,用于组织和管理 AI Agent 的工作上下文。 +//! 它是对 Aster 框架 `Session.working_dir` 的命名和配置包装。 +//! +//! ## 核心功能 +//! - Workspace CRUD 操作 +//! - 与 Aster Session 通过 working_dir 关联 +//! - Workspace 级别的配置管理 +//! +//! ## 设计原则 +//! - 读共享,写隔离 +//! - 最小有效 context +//! - Workspace = 边界(文件系统 + context + 配置) + +mod manager; +mod types; + +pub use manager::WorkspaceManager; +pub use types::{Workspace, WorkspaceId, WorkspaceSettings, WorkspaceType, WorkspaceUpdate}; diff --git a/src-tauri/src/workspace/types.rs b/src-tauri/src/workspace/types.rs new file mode 100644 index 000000000..bfab0e21d --- /dev/null +++ b/src-tauri/src/workspace/types.rs @@ -0,0 +1,95 @@ +//! Workspace 类型定义 +//! +//! 定义 Workspace 相关的数据结构和类型。 + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Workspace 唯一标识 +pub type WorkspaceId = String; + +/// Workspace 类型 +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceType { + /// 持久化 workspace + #[default] + Persistent, + /// 临时 workspace(自动清理) + Temporary, +} + +impl WorkspaceType { + pub fn as_str(&self) -> &'static str { + match self { + WorkspaceType::Persistent => "persistent", + WorkspaceType::Temporary => "temporary", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "temporary" => WorkspaceType::Temporary, + _ => WorkspaceType::Persistent, + } + } +} + +/// Workspace 级别设置 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct WorkspaceSettings { + /// Workspace 级 MCP 配置 + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_config: Option, + /// 默认 provider + #[serde(skip_serializing_if = "Option::is_none")] + pub default_provider: Option, + /// 自动压缩 context + #[serde(default)] + pub auto_compact: bool, +} + +/// Workspace 元数据 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workspace { + /// 唯一标识 + pub id: WorkspaceId, + /// 显示名称 + pub name: String, + /// Workspace 类型 + pub workspace_type: WorkspaceType, + /// 根目录路径(对应 Aster Session.working_dir) + pub root_path: PathBuf, + /// 是否为默认 workspace + pub is_default: bool, + /// 创建时间 + pub created_at: DateTime, + /// 更新时间 + pub updated_at: DateTime, + /// Workspace 级别设置 + pub settings: WorkspaceSettings, +} + +/// Workspace 更新请求 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct WorkspaceUpdate { + /// 新名称 + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// 新设置 + #[serde(skip_serializing_if = "Option::is_none")] + pub settings: Option, +} + +/// Workspace 创建请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceCreateRequest { + /// 显示名称 + pub name: String, + /// 根目录路径 + pub root_path: String, + /// Workspace 类型(可选,默认 persistent) + #[serde(default)] + pub workspace_type: WorkspaceType, +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ffa6a0adc..95f56eb97 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.48.3", + "version": "0.49.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/RootRouter.tsx b/src/RootRouter.tsx index 97db45555..783ae3568 100644 --- a/src/RootRouter.tsx +++ b/src/RootRouter.tsx @@ -11,14 +11,14 @@ import { Toaster } from "./components/ui/sonner"; /** * 根据 URL 路径渲染对应的组件 * - * - /screenshot-chat: 截图对话悬浮窗口(独立 Tauri 窗口) + * - /screenshot-chat: 截图对话悬浮窗口(独立 Tauri 窗口,支持语音模式) * - /update-notification: 更新提醒悬浮窗口(独立 Tauri 窗口) * - 其他: 主应用 */ export function RootRouter() { const pathname = window.location.pathname; - // 截图对话悬浮窗口路由 + // 截图对话悬浮窗口路由(也用于语音输入) if (pathname === "/screenshot-chat") { return ; } diff --git a/src/components/README.md b/src/components/README.md index 456bc240c..f9bb61b83 100644 --- a/src/components/README.md +++ b/src/components/README.md @@ -32,6 +32,7 @@ React 组件层,包含 UI 组件和业务组件。 - `widgets/` - 右侧小部件栏组件(移植自 Waveterm) - `ui/` - 通用 UI 组件(按钮、输入框等) - `websocket/` - WebSocket 管理组件 +- `workspace/` - Workspace 工作目录管理组件 - `AppSidebar.tsx` - 全局图标侧边栏(类似 cherry-studio) - `ConfirmDialog.tsx` - 确认对话框 - `HelpTip.tsx` - 帮助提示组件 diff --git a/src/components/provider-pool/ProviderPoolPage.tsx b/src/components/provider-pool/ProviderPoolPage.tsx index d229bff32..991c97f03 100644 --- a/src/components/provider-pool/ProviderPoolPage.tsx +++ b/src/components/provider-pool/ProviderPoolPage.tsx @@ -38,6 +38,7 @@ import { ApiKeyProviderSection, AddCustomProviderModal } from "./api-key"; import type { ApiKeyProviderSectionRef } from "./api-key"; import { RelayProvidersSection } from "./RelayProvidersSection"; import { ModelRegistryTab } from "./ModelRegistryTab"; +import { AsrProviderSection } from "@/components/voice"; import type { AddCustomProviderRequest } from "@/lib/api/apiKeyProvider"; import { getLocalKiroCredentialUuid, @@ -82,7 +83,7 @@ const isConfigTab = (tab: TabType): tab is ConfigTabType => { }; // 分类类型 -type CategoryType = "oauth" | "apikey" | "connect" | "models"; +type CategoryType = "oauth" | "apikey" | "connect" | "models" | "voice"; export const ProviderPoolPage = forwardRef( (_props, ref) => { @@ -425,6 +426,19 @@ export const ProviderPoolPage = forwardRef( > 模型库 + {/* OAuth 凭证分类 - Provider 选择图标网格 */} @@ -490,6 +504,13 @@ export const ProviderPoolPage = forwardRef( {/* 模型库分类 */} {activeCategory === "models" && } + {/* 语音服务分类 */} + {activeCategory === "voice" && ( +
+ +
+ )} + {/* OAuth 凭证内容 - 卡片布局 */} {activeCategory === "oauth" && !isConfigTab(activeTab) && diff --git a/src/components/settings/ExperimentalSettings.tsx b/src/components/settings/ExperimentalSettings.tsx index e2d9065c2..12cc0b34b 100644 --- a/src/components/settings/ExperimentalSettings.tsx +++ b/src/components/settings/ExperimentalSettings.tsx @@ -18,6 +18,12 @@ import { } from "@/hooks/useTauri"; import { ShortcutSettings } from "@/components/screenshot-chat/ShortcutSettings"; import { UpdateCheckSettings } from "./UpdateNotification"; +import { VoiceSettings } from "@/components/voice"; +import { + getVoiceInputConfig, + saveVoiceInputConfig, + VoiceInputConfig, +} from "@/lib/api/asrProvider"; // ============================================================ // 组件 @@ -26,6 +32,7 @@ import { UpdateCheckSettings } from "./UpdateNotification"; export function ExperimentalSettings() { // 状态 const [config, setConfig] = useState(null); + const [voiceConfig, setVoiceConfig] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -42,8 +49,12 @@ export function ExperimentalSettings() { setLoading(true); setError(null); try { - const experimentalConfig = await getExperimentalConfig(); + const [experimentalConfig, voiceInputConfig] = await Promise.all([ + getExperimentalConfig(), + getVoiceInputConfig(), + ]); setConfig(experimentalConfig); + setVoiceConfig(voiceInputConfig); } catch (err) { console.error("加载实验室配置失败:", err); setError(err instanceof Error ? err.message : "加载配置失败"); @@ -54,6 +65,19 @@ export function ExperimentalSettings() { shortcut: "CommandOrControl+Alt+Q", }, }); + setVoiceConfig({ + enabled: false, + shortcut: "CommandOrControl+Shift+V", + processor: { + polish_enabled: true, + default_instruction_id: "default", + }, + output: { + mode: "type", + type_delay_ms: 10, + }, + instructions: [], + }); } finally { setLoading(false); } @@ -127,6 +151,32 @@ export function ExperimentalSettings() { } }, []); + // 更新语音输入配置 + const handleVoiceConfigChange = useCallback( + async (newConfig: VoiceInputConfig) => { + setSaving(true); + setMessage(null); + try { + await saveVoiceInputConfig(newConfig); + setVoiceConfig(newConfig); + setMessage({ + type: "success", + text: newConfig.enabled ? "语音输入功能已启用" : "语音输入功能已禁用", + }); + setTimeout(() => setMessage(null), 2000); + } catch (err) { + console.error("保存语音配置失败:", err); + setMessage({ + type: "error", + text: err instanceof Error ? err.message : "保存失败", + }); + } finally { + setSaving(false); + } + }, + [], + ); + // 加载中状态 if (loading) { return ( @@ -263,6 +313,18 @@ export function ExperimentalSettings() { + {/* 语音输入功能 */} + {voiceConfig && ( +
+ +
+ )} + {/* 更多实验功能占位 */}

更多实验功能即将推出...

diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 5553ab761..c081960d6 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -18,10 +18,25 @@ const DropdownMenuContext = createContext( interface DropdownMenuProps { children: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; } -const DropdownMenu: React.FC = ({ children }) => { - const [open, setOpen] = useState(false); +const DropdownMenu: React.FC = ({ + children, + open: controlledOpen, + onOpenChange, +}) => { + const [internalOpen, setInternalOpen] = useState(false); + + // 支持受控和非受控模式 + const open = controlledOpen !== undefined ? controlledOpen : internalOpen; + const setOpen = (value: boolean) => { + if (controlledOpen === undefined) { + setInternalOpen(value); + } + onOpenChange?.(value); + }; return ( @@ -143,9 +158,20 @@ const DropdownMenuItem: React.FC = ({ ); }; +interface DropdownMenuSeparatorProps { + className?: string; +} + +const DropdownMenuSeparator: React.FC = ({ + className, +}) => { + return
; +}; + export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, }; diff --git a/src/components/voice/AddAsrCredentialModal.tsx b/src/components/voice/AddAsrCredentialModal.tsx new file mode 100644 index 000000000..0fd3c7091 --- /dev/null +++ b/src/components/voice/AddAsrCredentialModal.tsx @@ -0,0 +1,366 @@ +/** + * @file 添加 ASR 凭证模态框 + * @description 支持添加不同类型的 ASR 服务凭证 + * @module components/voice/AddAsrCredentialModal + */ + +import { useState } from "react"; +import { X, Cpu, Cloud, Sparkles } from "lucide-react"; +import type { + AsrProviderType, + WhisperModelSize, + AsrCredentialEntry, +} from "./types"; +import { ASR_PROVIDERS, WHISPER_MODELS, addAsrCredential } from "./types"; + +interface AddAsrCredentialModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +/** Provider 图标 */ +const ProviderIcon = ({ type }: { type: AsrProviderType }) => { + switch (type) { + case "whisper_local": + return ; + case "openai": + return ; + default: + return ; + } +}; + +export function AddAsrCredentialModal({ + isOpen, + onClose, + onSuccess, +}: AddAsrCredentialModalProps) { + const [selectedProvider, setSelectedProvider] = + useState(null); + const [name, setName] = useState(""); + const [language, setLanguage] = useState("zh"); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Whisper 配置 + const [whisperModel, setWhisperModel] = useState("base"); + + // 讯飞配置 + const [xunfeiAppId, setXunfeiAppId] = useState(""); + const [xunfeiApiKey, setXunfeiApiKey] = useState(""); + const [xunfeiApiSecret, setXunfeiApiSecret] = useState(""); + + // 百度配置 + const [baiduApiKey, setBaiduApiKey] = useState(""); + const [baiduSecretKey, setBaiduSecretKey] = useState(""); + + // OpenAI 配置 + const [openaiApiKey, setOpenaiApiKey] = useState(""); + const [openaiBaseUrl, setOpenaiBaseUrl] = useState(""); + + const resetForm = () => { + setSelectedProvider(null); + setName(""); + setLanguage("zh"); + setWhisperModel("base"); + setXunfeiAppId(""); + setXunfeiApiKey(""); + setXunfeiApiSecret(""); + setBaiduApiKey(""); + setBaiduSecretKey(""); + setOpenaiApiKey(""); + setOpenaiBaseUrl(""); + setError(null); + }; + + const handleClose = () => { + resetForm(); + onClose(); + }; + + const handleSubmit = async () => { + if (!selectedProvider) return; + + setSubmitting(true); + setError(null); + + try { + const entry: Omit = { + provider: selectedProvider, + name: name || undefined, + is_default: false, + disabled: false, + language, + whisper_config: + selectedProvider === "whisper_local" + ? { model: whisperModel } + : undefined, + xunfei_config: + selectedProvider === "xunfei" + ? { + app_id: xunfeiAppId, + api_key: xunfeiApiKey, + api_secret: xunfeiApiSecret, + } + : undefined, + baidu_config: + selectedProvider === "baidu" + ? { api_key: baiduApiKey, secret_key: baiduSecretKey } + : undefined, + openai_config: + selectedProvider === "openai" + ? { + api_key: openaiApiKey, + base_url: openaiBaseUrl || undefined, + } + : undefined, + }; + + console.log("[ASR] 添加凭证:", JSON.stringify(entry, null, 2)); + await addAsrCredential(entry); + handleClose(); + onSuccess(); + } catch (e) { + console.error("[ASR] 添加失败:", e); + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSubmitting(false); + } + }; + + const isFormValid = () => { + if (!selectedProvider) return false; + switch (selectedProvider) { + case "whisper_local": + return true; + case "xunfei": + return xunfeiAppId && xunfeiApiKey && xunfeiApiSecret; + case "baidu": + return baiduApiKey && baiduSecretKey; + case "openai": + return !!openaiApiKey; + default: + return false; + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+

添加语音服务

+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Provider 选择 */} + {!selectedProvider ? ( +
+

+ 选择语音识别服务 +

+ {ASR_PROVIDERS.map((provider) => ( + + ))} +
+ ) : ( +
+ {/* 返回按钮 */} + + + {/* 通用字段 */} +
+ + setName(e.target.value)} + placeholder="自定义名称" + className="w-full rounded-lg border bg-background px-3 py-2" + /> +
+ +
+ + +
+ + {/* Provider 特定字段 */} + {selectedProvider === "whisper_local" && ( +
+ + +
+ )} + + {selectedProvider === "xunfei" && ( + <> +
+ + setXunfeiAppId(e.target.value)} + className="w-full rounded-lg border bg-background px-3 py-2" + /> +
+
+ + setXunfeiApiKey(e.target.value)} + className="w-full rounded-lg border bg-background px-3 py-2" + /> +
+
+ + setXunfeiApiSecret(e.target.value)} + className="w-full rounded-lg border bg-background px-3 py-2" + /> +
+ + )} + + {selectedProvider === "baidu" && ( + <> +
+ + setBaiduApiKey(e.target.value)} + className="w-full rounded-lg border bg-background px-3 py-2" + /> +
+
+ + setBaiduSecretKey(e.target.value)} + className="w-full rounded-lg border bg-background px-3 py-2" + /> +
+ + )} + + {selectedProvider === "openai" && ( + <> +
+ + setOpenaiApiKey(e.target.value)} + className="w-full rounded-lg border bg-background px-3 py-2" + /> +
+
+ + setOpenaiBaseUrl(e.target.value)} + placeholder="https://api.openai.com/v1" + className="w-full rounded-lg border bg-background px-3 py-2" + /> +
+ + )} + + {/* 提交按钮 */} +
+ + +
+
+ )} +
+
+ ); +} diff --git a/src/components/voice/AsrCredentialCard.tsx b/src/components/voice/AsrCredentialCard.tsx new file mode 100644 index 000000000..5cf1260bc --- /dev/null +++ b/src/components/voice/AsrCredentialCard.tsx @@ -0,0 +1,170 @@ +/** + * @file ASR 凭证卡片组件 + * @description 显示单个 ASR 凭证的信息和操作按钮 + * @module components/voice/AsrCredentialCard + */ + +import { useState } from "react"; +import { + Star, + Trash2, + ToggleLeft, + ToggleRight, + Activity, + Cpu, + Cloud, + Sparkles, +} from "lucide-react"; +import type { AsrCredentialEntry, AsrProviderType } from "./types"; +import { ASR_PROVIDERS } from "./types"; + +interface AsrCredentialCardProps { + credential: AsrCredentialEntry; + onSetDefault: () => void; + onToggle: () => void; + onDelete: () => void; + onTest: () => Promise<{ success: boolean; message: string }>; +} + +/** 获取 Provider 图标 */ +const ProviderIcon = ({ type }: { type: AsrProviderType }) => { + switch (type) { + case "whisper_local": + return ; + case "openai": + return ; + default: + return ; + } +}; + +/** 获取 Provider 标签 */ +const getProviderLabel = (type: AsrProviderType): string => { + return ASR_PROVIDERS.find((p) => p.type === type)?.label || type; +}; + +export function AsrCredentialCard({ + credential, + onSetDefault, + onToggle, + onDelete, + onTest, +}: AsrCredentialCardProps) { + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ + success: boolean; + message: string; + } | null>(null); + + const handleTest = async () => { + setTesting(true); + setTestResult(null); + try { + const result = await onTest(); + setTestResult(result); + } catch (e) { + setTestResult({ + success: false, + message: e instanceof Error ? e.message : "测试失败", + }); + } finally { + setTesting(false); + } + }; + + return ( +
+
+ {/* 左侧:图标和信息 */} +
+
+ +
+
+
+ + {credential.name || getProviderLabel(credential.provider)} + + {credential.is_default && ( + + 默认 + + )} +
+
+ {getProviderLabel(credential.provider)} + · + 语言: {credential.language} +
+
+
+ + {/* 右侧:操作按钮 */} +
+ {!credential.is_default && !credential.disabled && ( + + )} + + + +
+
+ + {/* 测试结果 */} + {testResult && ( +
+ {testResult.message} +
+ )} +
+ ); +} diff --git a/src/components/voice/AsrProviderSection.tsx b/src/components/voice/AsrProviderSection.tsx new file mode 100644 index 000000000..dca182776 --- /dev/null +++ b/src/components/voice/AsrProviderSection.tsx @@ -0,0 +1,218 @@ +/** + * @file ASR Provider 管理区域 + * @description 显示 ASR 凭证列表和管理操作 + * @module components/voice/AsrProviderSection + */ + +import { useState, useEffect, useCallback } from "react"; +import { Plus, RefreshCw, Cpu, Cloud, Sparkles } from "lucide-react"; +import { AsrCredentialCard } from "./AsrCredentialCard"; +import { AddAsrCredentialModal } from "./AddAsrCredentialModal"; +import type { AsrCredentialEntry, AsrProviderType } from "./types"; +import { + getAsrCredentials, + deleteAsrCredential, + setDefaultAsrCredential, + testAsrCredential, + updateAsrCredential, + ASR_PROVIDERS, +} from "./types"; + +/** Provider 图标 */ +const ProviderIcon = ({ type }: { type: AsrProviderType }) => { + switch (type) { + case "whisper_local": + return ; + case "openai": + return ; + default: + return ; + } +}; + +export function AsrProviderSection() { + const [credentials, setCredentials] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [addModalOpen, setAddModalOpen] = useState(false); + const [selectedType, setSelectedType] = useState( + null, + ); + + const fetchCredentials = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await getAsrCredentials(); + setCredentials(data); + } catch (e) { + setError(e instanceof Error ? e.message : "加载失败"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchCredentials(); + }, [fetchCredentials]); + + const handleSetDefault = async (id: string) => { + try { + await setDefaultAsrCredential(id); + await fetchCredentials(); + } catch (e) { + setError(e instanceof Error ? e.message : "设置默认失败"); + } + }; + + const handleToggle = async (credential: AsrCredentialEntry) => { + try { + await updateAsrCredential({ + ...credential, + disabled: !credential.disabled, + }); + await fetchCredentials(); + } catch (e) { + setError(e instanceof Error ? e.message : "切换状态失败"); + } + }; + + const handleDelete = async (id: string) => { + try { + await deleteAsrCredential(id); + await fetchCredentials(); + } catch (e) { + setError(e instanceof Error ? e.message : "删除失败"); + } + }; + + const handleTest = async (id: string) => { + return testAsrCredential(id); + }; + + // 按类型分组 + const getCredentialsByType = (type: AsrProviderType) => { + return credentials.filter((c) => c.provider === type); + }; + + // 当前选中类型的凭证 + const currentCredentials = selectedType + ? getCredentialsByType(selectedType) + : credentials; + + return ( +
+ {error && ( +
+ {error} +
+ )} + + {/* Provider 类型选择 */} +
+ + {ASR_PROVIDERS.map((provider) => { + const count = getCredentialsByType(provider.type).length; + return ( + + ); + })} +
+ + {/* 操作栏 */} +
+
+ {credentials.length > 0 + ? `共 ${credentials.length} 个语音服务` + : "暂无语音服务"} +
+
+ + +
+
+ + {/* 凭证列表 */} + {loading ? ( +
+ +
+ ) : currentCredentials.length === 0 ? ( +
+

暂无语音服务

+

点击"添加服务"按钮添加语音识别服务

+ +
+ ) : ( +
+ {currentCredentials.map((credential) => ( + handleSetDefault(credential.id)} + onToggle={() => handleToggle(credential)} + onDelete={() => handleDelete(credential.id)} + onTest={() => handleTest(credential.id)} + /> + ))} +
+ )} + + {/* 添加模态框 */} + setAddModalOpen(false)} + onSuccess={fetchCredentials} + /> +
+ ); +} diff --git a/src/components/voice/InstructionEditor.tsx b/src/components/voice/InstructionEditor.tsx new file mode 100644 index 000000000..f420252a6 --- /dev/null +++ b/src/components/voice/InstructionEditor.tsx @@ -0,0 +1,735 @@ +/** + * @file InstructionEditor.tsx + * @description 自定义指令编辑器组件 - 管理语音输入的处理指令 + * @module components/voice/InstructionEditor + * + * 需求: 5.2-5.5 + * - 5.2: 用户应能创建自定义指令 + * - 5.3: 自定义指令应包含:名称、Prompt 模板、快捷键(可选) + * - 5.4: 用户应能为不同指令设置独立的快捷键 + * - 5.5: 悬浮窗应显示当前激活的指令模式 + */ + +import React, { useState, useCallback, useEffect, useRef } from "react"; +import { + Plus, + Pencil, + Trash2, + Keyboard, + Check, + X, + AlertCircle, + Sparkles, + MessageSquare, + Languages, + Terminal, + Mail, + FileText, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { VoiceInstruction } from "./types"; +import { + getVoiceInstructions, + saveVoiceInstruction, + deleteVoiceInstruction, +} from "./types"; + +// ============================================================ +// 类型定义 +// ============================================================ + +interface InstructionEditorProps { + /** 当前默认指令 ID */ + defaultInstructionId?: string; + /** 默认指令变更回调 */ + onDefaultChange?: (id: string) => void; + /** 是否禁用 */ + disabled?: boolean; +} + +interface EditingInstruction { + id?: string; + name: string; + description: string; + prompt: string; + shortcut: string; + icon: string; + isPreset: boolean; +} + +// ============================================================ +// 辅助函数 +// ============================================================ + +/** 预设指令图标映射 */ +const PRESET_ICONS: Record = { + default: , + "translate-en": , + "translate-zh": , + command: , + email: , + professional: , +}; + +/** 获取指令图标 */ +function getInstructionIcon(instruction: VoiceInstruction): React.ReactNode { + if (instruction.icon && PRESET_ICONS[instruction.icon]) { + return PRESET_ICONS[instruction.icon]; + } + if (PRESET_ICONS[instruction.id]) { + return PRESET_ICONS[instruction.id]; + } + return ; +} + +/** + * 将 KeyboardEvent 转换为 Tauri 快捷键格式 + */ +function keyEventToShortcut(e: KeyboardEvent): string | null { + const modifiers: string[] = []; + + if (e.metaKey || e.ctrlKey) { + modifiers.push("CommandOrControl"); + } + if (e.altKey) { + modifiers.push("Alt"); + } + if (e.shiftKey) { + modifiers.push("Shift"); + } + + let key = e.key; + + // 忽略单独的修饰键 + if (["Control", "Meta", "Alt", "Shift"].includes(key)) { + return null; + } + + // 转换特殊键名 + const keyMap: Record = { + " ": "Space", + ArrowUp: "Up", + ArrowDown: "Down", + ArrowLeft: "Left", + ArrowRight: "Right", + Escape: "Escape", + Enter: "Enter", + Backspace: "Backspace", + Delete: "Delete", + Tab: "Tab", + }; + + if (keyMap[key]) { + key = keyMap[key]; + } else if (key.length === 1) { + key = key.toUpperCase(); + } else if (key.startsWith("F") && /^F\d+$/.test(key)) { + // 功能键保持原样 + } else { + key = key.charAt(0).toUpperCase() + key.slice(1); + } + + // 必须有至少一个修饰键 + if (modifiers.length === 0) { + return null; + } + + return [...modifiers, key].join("+"); +} + +/** + * 格式化快捷键显示 + */ +function formatShortcutDisplay(shortcut: string): string { + if (!shortcut) return ""; + return shortcut + .replace( + "CommandOrControl", + navigator.platform.includes("Mac") ? "⌘" : "Ctrl", + ) + .replace("Shift", navigator.platform.includes("Mac") ? "⇧" : "Shift") + .replace("Alt", navigator.platform.includes("Mac") ? "⌥" : "Alt") + .replace(/\+/g, " + "); +} + +// ============================================================ +// 子组件:快捷键录制器 +// ============================================================ + +interface ShortcutRecorderProps { + value: string; + onChange: (shortcut: string) => void; + disabled?: boolean; +} + +function ShortcutRecorder({ + value, + onChange, + disabled, +}: ShortcutRecorderProps) { + const [isRecording, setIsRecording] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + if (!isRecording) return; + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === "Escape") { + setIsRecording(false); + return; + } + + // Backspace 清除快捷键 + if (e.key === "Backspace" && !e.metaKey && !e.ctrlKey && !e.altKey) { + onChange(""); + setIsRecording(false); + return; + } + + const shortcut = keyEventToShortcut(e); + if (shortcut) { + onChange(shortcut); + setIsRecording(false); + } + }; + + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [isRecording, onChange]); + + useEffect(() => { + if (isRecording && inputRef.current) { + inputRef.current.focus(); + } + }, [isRecording]); + + return ( +
+
!disabled && setIsRecording(true)} + className={cn( + "flex-1 px-3 py-2 rounded border text-sm font-mono cursor-pointer transition-colors", + isRecording + ? "border-primary bg-primary/5 ring-2 ring-primary/20" + : "bg-muted/50 hover:border-primary/50", + disabled && "opacity-50 cursor-not-allowed", + )} + > + {isRecording ? ( + 按下快捷键... + ) : value ? ( + {formatShortcutDisplay(value)} + ) : ( + 点击设置快捷键 + )} +
+ {value && !isRecording && ( + + )} + {isRecording && ( + + )} +
+ ); +} + +// ============================================================ +// 子组件:指令卡片 +// ============================================================ + +interface InstructionCardProps { + instruction: VoiceInstruction; + isDefault: boolean; + onEdit: () => void; + onDelete: () => void; + onSetDefault: () => void; + disabled?: boolean; +} + +function InstructionCard({ + instruction, + isDefault, + onEdit, + onDelete, + onSetDefault, + disabled, +}: InstructionCardProps) { + return ( +
+
+ {/* 左侧:图标和信息 */} +
+
+ {getInstructionIcon(instruction)} +
+
+
+ {instruction.name} + {instruction.is_preset && ( + + 预设 + + )} + {isDefault && ( + + 默认 + + )} +
+ {instruction.description && ( +

+ {instruction.description} +

+ )} + {instruction.shortcut && ( +
+ + + {formatShortcutDisplay(instruction.shortcut)} + +
+ )} +
+
+ + {/* 右侧:操作按钮 */} +
+ {!isDefault && ( + + )} + + {!instruction.is_preset && ( + + )} +
+
+ + {/* Prompt 预览 */} +
+ Prompt: + {instruction.prompt} +
+
+ ); +} + +// ============================================================ +// 子组件:编辑表单 +// ============================================================ + +interface EditFormProps { + instruction: EditingInstruction; + onChange: (instruction: EditingInstruction) => void; + onSave: () => void; + onCancel: () => void; + saving: boolean; + error: string | null; +} + +function EditForm({ + instruction, + onChange, + onSave, + onCancel, + saving, + error, +}: EditFormProps) { + const isNew = !instruction.id; + const isValid = instruction.name.trim() && instruction.prompt.trim(); + + return ( +
+
+

+ {isNew ? "添加指令" : instruction.isPreset ? "查看指令" : "编辑指令"} +

+ +
+ + {error && ( +
+ + {error} +
+ )} + + {/* 名称 */} +
+ + onChange({ ...instruction, name: e.target.value })} + disabled={instruction.isPreset} + placeholder="指令名称" + className="w-full rounded-lg border bg-background px-3 py-2 text-sm disabled:opacity-50" + /> +
+ + {/* 描述 */} +
+ + + onChange({ ...instruction, description: e.target.value }) + } + disabled={instruction.isPreset} + placeholder="简短描述" + className="w-full rounded-lg border bg-background px-3 py-2 text-sm disabled:opacity-50" + /> +
+ + {/* Prompt 模板 */} +
+ +