mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: 语音输入合并到截图输入框,版本更新至 0.49.0
- 将语音输入功能合并到截图输入框(pill-shaped 浮动栏) - 添加麦克风按钮(32x32px)支持点击录音 - 支持录音、转写、润色三种状态显示 - 修复讯飞 ASR Broken pipe 错误 - 清理旧的语音输入 UI 文件 - 修复 DropdownMenu 组件支持受控模式 - 修复 StreamResponse 导出问题 注:部分编译错误待后续修复
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
## 更新提醒
|
||||
|
||||
@@ -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<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub settings: WorkspaceSettings,
|
||||
}
|
||||
|
||||
/// Workspace 级别设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct WorkspaceSettings {
|
||||
pub mcp_config: Option<serde_json::Value>, // workspace 级 MCP 配置
|
||||
pub default_provider: Option<String>, // 默认 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>;
|
||||
|
||||
/// 获取 workspace
|
||||
pub async fn get(&self, id: &WorkspaceId) -> Result<Workspace>;
|
||||
|
||||
/// 列出所有 workspace
|
||||
pub async fn list(&self) -> Result<Vec<Workspace>>;
|
||||
|
||||
/// 更新 workspace
|
||||
pub async fn update(&self, id: &WorkspaceId, updates: WorkspaceUpdate) -> Result<Workspace>;
|
||||
|
||||
/// 删除 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<Option<Workspace>>;
|
||||
|
||||
/// 获取 workspace 下的所有 sessions(通过 working_dir 关联)
|
||||
pub async fn list_sessions(&self, workspace_id: &WorkspaceId) -> Result<Vec<Session>> {
|
||||
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<Session> {
|
||||
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<Workspace, String>;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn workspace_list() -> Result<Vec<Workspace>, String>;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn workspace_get(id: String) -> Result<Workspace, String>;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn workspace_update(id: String, updates: WorkspaceUpdate) -> Result<Workspace, String>;
|
||||
|
||||
#[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<Vec<SessionInfo>, String>;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn workspace_create_session(workspace_id: String, name: String) -> Result<String, String>;
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
~/.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<Workspace[]>([]);
|
||||
const [current, setCurrent] = useState<Workspace | null>(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 (
|
||||
<Select value={current?.id} onValueChange={switchWorkspace}>
|
||||
{workspaces.map(ws => (
|
||||
<SelectItem key={ws.id} value={ws.id}>
|
||||
<FolderIcon /> {ws.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__new__">
|
||||
<PlusIcon /> 添加工作目录...
|
||||
</SelectItem>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 实现优先级
|
||||
|
||||
### 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 管理
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.48.4",
|
||||
"version": "0.49.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+743
-101
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -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
|
||||
|
||||
@@ -22,5 +22,9 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>ProxyCast 需要访问麦克风以使用语音输入功能</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>ProxyCast 需要控制其他应用以输入识别的文本</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -35,6 +35,11 @@
|
||||
"name": "binaries/aster-server",
|
||||
"sidecar": true,
|
||||
"args": true
|
||||
},
|
||||
{
|
||||
"name": "open",
|
||||
"cmd": "open",
|
||||
"args": true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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)
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 百度 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<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<TranscribeResult> {
|
||||
// 需要可变引用来缓存 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 {
|
||||
"百度语音"
|
||||
}
|
||||
}
|
||||
@@ -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<TranscribeResult>;
|
||||
|
||||
/// 获取服务名称
|
||||
fn name(&self) -> &'static str;
|
||||
}
|
||||
|
||||
pub use baidu::BaiduClient;
|
||||
pub use openai::OpenAIWhisperClient;
|
||||
pub use xunfei::XunfeiClient;
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// OpenAI Whisper 客户端
|
||||
pub struct OpenAIWhisperClient {
|
||||
api_key: String,
|
||||
api_host: String,
|
||||
model: String,
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
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<TranscribeResult> {
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<Sha256>;
|
||||
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<TranscribeResult> {
|
||||
// 生成鉴权 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<u8> = 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<XunfeiResponse> = Vec::new();
|
||||
|
||||
while let Some(msg) = read.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
tracing::debug!("收到讯飞响应: {}", text);
|
||||
|
||||
match serde_json::from_str::<XunfeiResponse>(&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<VoiceError> = 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<XunfeiBusiness>,
|
||||
/// 数据
|
||||
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<String>,
|
||||
/// 是否添加标点(1: 添加)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
ptt: Option<u8>,
|
||||
}
|
||||
|
||||
/// 数据参数
|
||||
#[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<String>,
|
||||
/// 会话 ID
|
||||
#[allow(dead_code)]
|
||||
sid: Option<String>,
|
||||
/// 数据
|
||||
data: Option<XunfeiResponseData>,
|
||||
}
|
||||
|
||||
/// 响应数据
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct XunfeiResponseData {
|
||||
/// 状态(0: 首帧,1: 中间帧,2: 尾帧)
|
||||
status: u8,
|
||||
/// 识别结果
|
||||
result: Option<XunfeiResult>,
|
||||
}
|
||||
|
||||
/// 识别结果
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct XunfeiResult {
|
||||
/// 词列表
|
||||
ws: Vec<XunfeiWord>,
|
||||
/// 是否是最终结果
|
||||
#[allow(dead_code)]
|
||||
ls: Option<bool>,
|
||||
}
|
||||
|
||||
/// 词
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct XunfeiWord {
|
||||
/// 候选词列表
|
||||
cw: Vec<XunfeiCandidate>,
|
||||
}
|
||||
|
||||
/// 候选词
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct XunfeiCandidate {
|
||||
/// 词内容
|
||||
w: String,
|
||||
}
|
||||
@@ -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<T> = std::result::Result<T, VoiceError>;
|
||||
@@ -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::*;
|
||||
@@ -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<Self> {
|
||||
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("创建输出处理器失败")
|
||||
}
|
||||
}
|
||||
@@ -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<Mutex<Vec<i16>>>,
|
||||
/// 当前音量级别(0-100)
|
||||
volume_level: Arc<AtomicU32>,
|
||||
/// 是否正在录音
|
||||
is_recording: Arc<AtomicBool>,
|
||||
/// 录音开始时间
|
||||
start_time: Option<Instant>,
|
||||
/// 音频流(录音时持有)
|
||||
stream: Option<cpal::Stream>,
|
||||
/// 采样率
|
||||
sample_rate: u32,
|
||||
}
|
||||
|
||||
impl AudioRecorder {
|
||||
/// 创建新的录音器
|
||||
pub fn new() -> Result<Self> {
|
||||
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<i16> =
|
||||
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<AudioData> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
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<TranscribeResult> {
|
||||
// 转换为 f32 采样
|
||||
let samples: Vec<f32> = 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! 类型定义
|
||||
//!
|
||||
//! 定义语音输入相关的核心类型。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 音频数据
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioData {
|
||||
/// PCM 采样数据(16-bit signed)
|
||||
pub samples: Vec<i16>,
|
||||
/// 采样率(默认 16000)
|
||||
pub sample_rate: u32,
|
||||
/// 声道数(默认 1)
|
||||
pub channels: u16,
|
||||
/// 录音时长(秒)
|
||||
pub duration_secs: f32,
|
||||
}
|
||||
|
||||
impl AudioData {
|
||||
/// 创建新的音频数据
|
||||
pub fn new(samples: Vec<i16>, 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<u8> {
|
||||
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<String>,
|
||||
/// 置信度(0.0 - 1.0)
|
||||
pub confidence: Option<f32>,
|
||||
/// 分段信息
|
||||
pub segments: Vec<Segment>,
|
||||
}
|
||||
|
||||
/// 识别分段
|
||||
#[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
|
||||
}
|
||||
}
|
||||
@@ -14,5 +14,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -32,6 +32,7 @@ Tauri 后端核心代码,处理系统级功能和 API 服务。
|
||||
- `terminal/` - 终端核心模块(PTY 管理、会话管理)
|
||||
- `tray/` - 系统托盘
|
||||
- `websocket/` - WebSocket 支持
|
||||
- `workspace/` - Workspace 工作目录管理
|
||||
- `lib.rs` - 库入口
|
||||
- `main.rs` - 应用入口
|
||||
- `logger.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;
|
||||
|
||||
|
||||
@@ -219,8 +219,8 @@ impl CredentialBridge {
|
||||
async fn get_kiro_token(
|
||||
&self,
|
||||
creds_path: &str,
|
||||
db: &DbConnection,
|
||||
uuid: &str,
|
||||
_db: &DbConnection,
|
||||
_uuid: &str,
|
||||
) -> Result<String, CredentialBridgeError> {
|
||||
use crate::providers::kiro::KiroProvider;
|
||||
|
||||
|
||||
@@ -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<parking_lot::RwLock<telemetry::StatsAggregator>>,
|
||||
pub shared_tokens: Arc<parking_lot::RwLock<telemetry::TokenTracker>>,
|
||||
@@ -265,6 +267,9 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
|
||||
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<AppStates, String> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/// 获取配置
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<Vec<AsrCredentialEntry>, 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<String>,
|
||||
#[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<WhisperLocalConfig>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub xunfei_config: Option<XunfeiConfig>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub baidu_config: Option<BaiduConfig>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub openai_config: Option<OpenAIAsrConfig>,
|
||||
}
|
||||
|
||||
fn default_language() -> String {
|
||||
"zh".to_string()
|
||||
}
|
||||
|
||||
/// 添加 ASR 凭证
|
||||
#[command]
|
||||
pub async fn add_asr_credential(
|
||||
entry: AddAsrCredentialRequest,
|
||||
) -> Result<AsrCredentialEntry, String> {
|
||||
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<TestResult, String> {
|
||||
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,
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 字符串
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RwLock<Option<WorkspaceManager>>>);
|
||||
|
||||
/// 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<Workspace> 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<String>,
|
||||
}
|
||||
|
||||
/// 更新 workspace 请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateWorkspaceRequest {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub settings: Option<WorkspaceSettings>,
|
||||
}
|
||||
|
||||
// ==================== Tauri 命令 ====================
|
||||
|
||||
/// 创建新 workspace
|
||||
#[tauri::command]
|
||||
pub async fn workspace_create(
|
||||
db: State<'_, DbConnection>,
|
||||
request: CreateWorkspaceRequest,
|
||||
) -> Result<WorkspaceListItem, String> {
|
||||
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<Vec<WorkspaceListItem>, 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<Option<WorkspaceListItem>, 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<WorkspaceListItem, String> {
|
||||
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<bool, String> {
|
||||
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<Option<WorkspaceListItem>, 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<Option<WorkspaceListItem>, String> {
|
||||
let manager = WorkspaceManager::new(db.inner().clone());
|
||||
let workspace = manager.get_by_path(&PathBuf::from(&root_path))?;
|
||||
Ok(workspace.map(|ws| ws.into()))
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -38,6 +38,143 @@ pub struct CredentialPoolConfig {
|
||||
/// Codex OAuth 凭证列表
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub codex: Vec<CredentialEntry>,
|
||||
/// ASR 语音服务凭证列表
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub asr: Vec<AsrCredentialEntry>,
|
||||
}
|
||||
|
||||
// ============ 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<String>,
|
||||
/// 是否为默认凭证
|
||||
#[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<WhisperLocalConfig>,
|
||||
/// 讯飞配置(仅 Xunfei)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub xunfei_config: Option<XunfeiConfig>,
|
||||
/// 百度配置(仅 Baidu)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub baidu_config: Option<BaiduConfig>,
|
||||
/// OpenAI 配置(仅 OpenAI)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub openai_config: Option<OpenAIAsrConfig>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
/// 代理 URL(可选)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<VoiceInstruction>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
/// 润色使用的模型
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub polish_model: Option<String>,
|
||||
/// 默认指令 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<String>,
|
||||
/// Prompt 模板(使用 {{text}} 作为占位符)
|
||||
pub prompt: String,
|
||||
/// 快捷键(可选)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub shortcut: Option<String>,
|
||||
/// 是否为系统预设(不可删除)
|
||||
#[serde(default)]
|
||||
pub is_preset: bool,
|
||||
/// 图标(可选,用于 UI 显示)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
/// 默认指令列表
|
||||
fn default_instructions() -> Vec<VoiceInstruction> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ impl GeneralChatDao {
|
||||
before_id: Option<&str>,
|
||||
) -> Result<Vec<ChatMessage>, 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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ pub mod stream;
|
||||
pub mod terminal;
|
||||
pub mod translator;
|
||||
pub mod tray;
|
||||
pub mod voice;
|
||||
pub mod workspace;
|
||||
|
||||
// 内部模块
|
||||
mod commands;
|
||||
|
||||
@@ -7,4 +7,4 @@ pub mod management_auth;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use management_auth::{ManagementAuthLayer, ManagementAuthService};
|
||||
pub use management_auth::ManagementAuthLayer;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(mut request): Json<crate::models::openai::ChatCompletionRequest>| async {
|
||||
Json(request): Json<crate::models::openai::ChatCompletionRequest>| async {
|
||||
handlers::chat_completions(State(state), headers, Json(request)).await
|
||||
}
|
||||
))
|
||||
.route("/v1/messages", post(
|
||||
|State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(mut request): Json<AnthropicMessagesRequest>| async {
|
||||
Json(request): Json<AnthropicMessagesRequest>| async {
|
||||
handlers::anthropic_messages(State(state), headers, Json(request)).await
|
||||
}
|
||||
))
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
|
||||
@@ -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<Option<AsrCredentialEntry>, 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<Option<AsrCredentialEntry>, 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<String, String> {
|
||||
// 如果是本地 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<Option<AsrCredentialEntry>, 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<String, String> {
|
||||
// 获取 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<i16> = 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<PathBuf, String> {
|
||||
// 模型文件名
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<String, String> {
|
||||
let config = credential.xunfei_config.as_ref().ok_or("讯飞配置缺失")?;
|
||||
|
||||
// 将 PCM 字节转换为 i16 采样
|
||||
let samples: Vec<i16> = 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<Vec<u8>, 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)
|
||||
}
|
||||
}
|
||||
@@ -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<VoiceInputConfig, String> {
|
||||
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<Vec<VoiceInstruction>, 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<u8>,
|
||||
sample_rate: u32,
|
||||
credential_id: Option<String>,
|
||||
) -> Result<TranscribeResult, String> {
|
||||
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<String>,
|
||||
) -> Result<PolishResult, String> {
|
||||
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<String>) -> 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<StopRecordingResult, String> {
|
||||
let mut service = recording_service.0.lock();
|
||||
let audio = service.stop()?;
|
||||
|
||||
// 将 i16 样本转换为字节(小端序)
|
||||
let bytes: Vec<u8> = 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<u8>,
|
||||
/// 采样率
|
||||
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<RecordingStatus, String> {
|
||||
let service = recording_service.0.lock();
|
||||
Ok(RecordingStatus {
|
||||
is_recording: service.is_recording(),
|
||||
volume: service.get_volume(),
|
||||
duration: service.get_duration(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! 语音输入配置管理
|
||||
//!
|
||||
//! 加载和保存语音输入相关配置
|
||||
|
||||
use crate::config::{
|
||||
load_config, save_config, AsrCredentialEntry, VoiceInputConfig, VoiceInstruction,
|
||||
};
|
||||
|
||||
/// 加载语音输入配置
|
||||
pub fn load_voice_config() -> Result<VoiceInputConfig, String> {
|
||||
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<Option<AsrCredentialEntry>, 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<Vec<VoiceInstruction>, 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<Option<VoiceInstruction>, String> {
|
||||
let instructions = get_instructions()?;
|
||||
Ok(instructions.into_iter().find(|i| i.id == id))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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<String, String> {
|
||||
// 如果是原始输出指令,直接返回
|
||||
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<String, String> {
|
||||
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<Message>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ChatResponse {
|
||||
choices: Vec<Choice>,
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -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<Sender<RecordingCommand>>,
|
||||
/// 响应接收端
|
||||
response_rx: Option<Receiver<RecordingResponse>>,
|
||||
/// 录音线程句柄
|
||||
thread_handle: Option<JoinHandle<()>>,
|
||||
/// 是否正在录音(共享状态,用于快速查询)
|
||||
is_recording: Arc<AtomicBool>,
|
||||
/// 当前音量级别(共享状态,用于快速查询)
|
||||
volume_level: Arc<AtomicU32>,
|
||||
/// 录音开始时间(共享状态)
|
||||
start_time: Arc<Mutex<Option<Instant>>>,
|
||||
}
|
||||
|
||||
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::<RecordingCommand>();
|
||||
let (resp_tx, resp_rx) = mpsc::channel::<RecordingResponse>();
|
||||
|
||||
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<AudioData, String> {
|
||||
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<RecordingCommand>,
|
||||
resp_tx: Sender<RecordingResponse>,
|
||||
is_recording: Arc<AtomicBool>,
|
||||
volume_level: Arc<AtomicU32>,
|
||||
start_time: Arc<Mutex<Option<Instant>>>,
|
||||
) {
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
|
||||
// 录音数据缓冲区
|
||||
let samples: Arc<Mutex<Vec<i16>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
// 当前活跃的音频流
|
||||
let mut active_stream: Option<cpal::Stream> = 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<f32> = if channels > 1 {
|
||||
data.chunks(channels as usize)
|
||||
.map(|chunk| chunk.iter().sum::<f32>() / channels as f32)
|
||||
.collect()
|
||||
} else {
|
||||
data.to_vec()
|
||||
};
|
||||
|
||||
// 转换为 i16 并存储
|
||||
let i16_samples: Vec<i16> = 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<Mutex<RecordingService>>);
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -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<parking_lot::RwLock<Option<String>>> = OnceLock::new();
|
||||
|
||||
/// 快捷键是否已注册
|
||||
static IS_REGISTERED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn get_current_shortcut() -> &'static parking_lot::RwLock<Option<String>> {
|
||||
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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
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)
|
||||
@@ -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<Workspace, String> {
|
||||
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<Workspace, String> {
|
||||
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<Option<Workspace>, 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<Option<Workspace>, 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<Vec<Workspace>, 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::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("解析结果失败: {}", e))?;
|
||||
|
||||
Ok(workspaces)
|
||||
}
|
||||
|
||||
/// 更新 workspace
|
||||
pub fn update(&self, id: &WorkspaceId, updates: WorkspaceUpdate) -> Result<Workspace, String> {
|
||||
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<Box<dyn rusqlite::ToSql>> = 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<bool, String> {
|
||||
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<Option<Workspace>, 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<Workspace, rusqlite::Error> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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<serde_json::Value>,
|
||||
/// 默认 provider
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_provider: Option<String>,
|
||||
/// 自动压缩 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<Utc>,
|
||||
/// 更新时间
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// Workspace 级别设置
|
||||
pub settings: WorkspaceSettings,
|
||||
}
|
||||
|
||||
/// Workspace 更新请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct WorkspaceUpdate {
|
||||
/// 新名称
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// 新设置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub settings: Option<WorkspaceSettings>,
|
||||
}
|
||||
|
||||
/// Workspace 创建请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceCreateRequest {
|
||||
/// 显示名称
|
||||
pub name: String,
|
||||
/// 根目录路径
|
||||
pub root_path: String,
|
||||
/// Workspace 类型(可选,默认 persistent)
|
||||
#[serde(default)]
|
||||
pub workspace_type: WorkspaceType,
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+2
-2
@@ -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 <ScreenshotChatPage />;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ React 组件层,包含 UI 组件和业务组件。
|
||||
- `widgets/` - 右侧小部件栏组件(移植自 Waveterm)
|
||||
- `ui/` - 通用 UI 组件(按钮、输入框等)
|
||||
- `websocket/` - WebSocket 管理组件
|
||||
- `workspace/` - Workspace 工作目录管理组件
|
||||
- `AppSidebar.tsx` - 全局图标侧边栏(类似 cherry-studio)
|
||||
- `ConfirmDialog.tsx` - 确认对话框
|
||||
- `HelpTip.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<ProviderPoolPageRef>(
|
||||
(_props, ref) => {
|
||||
@@ -425,6 +426,19 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
>
|
||||
模型库
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveCategory("voice");
|
||||
}}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
||||
activeCategory === "voice"
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
data-testid="voice-category-tab"
|
||||
>
|
||||
语音服务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* OAuth 凭证分类 - Provider 选择图标网格 */}
|
||||
@@ -490,6 +504,13 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
{/* 模型库分类 */}
|
||||
{activeCategory === "models" && <ModelRegistryTab />}
|
||||
|
||||
{/* 语音服务分类 */}
|
||||
{activeCategory === "voice" && (
|
||||
<div className="min-h-[400px]" data-testid="voice-section">
|
||||
<AsrProviderSection />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth 凭证内容 - 卡片布局 */}
|
||||
{activeCategory === "oauth" &&
|
||||
!isConfigTab(activeTab) &&
|
||||
|
||||
@@ -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<ExperimentalFeatures | null>(null);
|
||||
const [voiceConfig, setVoiceConfig] = useState<VoiceInputConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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() {
|
||||
<UpdateCheckSettings />
|
||||
</div>
|
||||
|
||||
{/* 语音输入功能 */}
|
||||
{voiceConfig && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<VoiceSettings
|
||||
config={voiceConfig}
|
||||
onConfigChange={handleVoiceConfigChange}
|
||||
onValidateShortcut={handleValidateShortcut}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 更多实验功能占位 */}
|
||||
<div className="rounded-lg border border-dashed p-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">更多实验功能即将推出...</p>
|
||||
|
||||
@@ -18,10 +18,25 @@ const DropdownMenuContext = createContext<DropdownMenuContextType | undefined>(
|
||||
|
||||
interface DropdownMenuProps {
|
||||
children: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const DropdownMenu: React.FC<DropdownMenuProps> = ({ children }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const DropdownMenu: React.FC<DropdownMenuProps> = ({
|
||||
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 (
|
||||
<DropdownMenuContext.Provider value={{ open, setOpen }}>
|
||||
@@ -143,9 +158,20 @@ const DropdownMenuItem: React.FC<DropdownMenuItemProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface DropdownMenuSeparatorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DropdownMenuSeparator: React.FC<DropdownMenuSeparatorProps> = ({
|
||||
className,
|
||||
}) => {
|
||||
return <div className={cn("my-1 h-px bg-gray-200", className)} />;
|
||||
};
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
};
|
||||
|
||||
@@ -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 <Cpu className="h-5 w-5" />;
|
||||
case "openai":
|
||||
return <Sparkles className="h-5 w-5" />;
|
||||
default:
|
||||
return <Cloud className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
export function AddAsrCredentialModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: AddAsrCredentialModalProps) {
|
||||
const [selectedProvider, setSelectedProvider] =
|
||||
useState<AsrProviderType | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [language, setLanguage] = useState("zh");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Whisper 配置
|
||||
const [whisperModel, setWhisperModel] = useState<WhisperModelSize>("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<AsrCredentialEntry, "id"> = {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-background p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">添加语音服务</h3>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-lg p-1 hover:bg-muted"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider 选择 */}
|
||||
{!selectedProvider ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
选择语音识别服务
|
||||
</p>
|
||||
{ASR_PROVIDERS.map((provider) => (
|
||||
<button
|
||||
key={provider.type}
|
||||
onClick={() => setSelectedProvider(provider.type)}
|
||||
className="flex w-full items-center gap-3 rounded-lg border p-3 hover:border-primary hover:bg-muted"
|
||||
>
|
||||
<div className="rounded-lg bg-muted p-2">
|
||||
<ProviderIcon type={provider.type} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">{provider.label}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{provider.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* 返回按钮 */}
|
||||
<button
|
||||
onClick={() => setSelectedProvider(null)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← 返回选择
|
||||
</button>
|
||||
|
||||
{/* 通用字段 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
名称(可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="自定义名称"
|
||||
className="w-full rounded-lg border bg-background px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">识别语言</label>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2"
|
||||
>
|
||||
<option value="zh">中文</option>
|
||||
<option value="en">英文</option>
|
||||
<option value="auto">自动检测</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Provider 特定字段 */}
|
||||
{selectedProvider === "whisper_local" && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
模型大小
|
||||
</label>
|
||||
<select
|
||||
value={whisperModel}
|
||||
onChange={(e) =>
|
||||
setWhisperModel(e.target.value as WhisperModelSize)
|
||||
}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2"
|
||||
>
|
||||
{WHISPER_MODELS.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label} ({m.size}, {m.speed})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProvider === "xunfei" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
App ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={xunfeiAppId}
|
||||
onChange={(e) => setXunfeiAppId(e.target.value)}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={xunfeiApiKey}
|
||||
onChange={(e) => setXunfeiApiKey(e.target.value)}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
API Secret
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={xunfeiApiSecret}
|
||||
onChange={(e) => setXunfeiApiSecret(e.target.value)}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedProvider === "baidu" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={baiduApiKey}
|
||||
onChange={(e) => setBaiduApiKey(e.target.value)}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Secret Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={baiduSecretKey}
|
||||
onChange={(e) => setBaiduSecretKey(e.target.value)}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedProvider === "openai" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={openaiApiKey}
|
||||
onChange={(e) => setOpenaiApiKey(e.target.value)}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Base URL(可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={openaiBaseUrl}
|
||||
onChange={(e) => setOpenaiBaseUrl(e.target.value)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className="w-full rounded-lg border bg-background px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-lg border px-4 py-2 hover:bg-muted"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isFormValid() || submitting}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "添加中..." : "添加"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <Cpu className="h-5 w-5" />;
|
||||
case "openai":
|
||||
return <Sparkles className="h-5 w-5" />;
|
||||
default:
|
||||
return <Cloud className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
/** 获取 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 (
|
||||
<div
|
||||
className={`rounded-lg border p-4 transition-colors ${
|
||||
credential.disabled
|
||||
? "border-border bg-muted/50 opacity-60"
|
||||
: credential.is_default
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-card hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
{/* 左侧:图标和信息 */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`rounded-lg p-2 ${
|
||||
credential.is_default
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<ProviderIcon type={credential.provider} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{credential.name || getProviderLabel(credential.provider)}
|
||||
</span>
|
||||
{credential.is_default && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
<span>{getProviderLabel(credential.provider)}</span>
|
||||
<span className="mx-2">·</span>
|
||||
<span>语言: {credential.language}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{!credential.is_default && !credential.disabled && (
|
||||
<button
|
||||
onClick={onSetDefault}
|
||||
className="rounded-lg p-2 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="设为默认"
|
||||
>
|
||||
<Star className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={testing || credential.disabled}
|
||||
className="rounded-lg p-2 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||
title="测试连接"
|
||||
>
|
||||
<Activity className={`h-4 w-4 ${testing ? "animate-pulse" : ""}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="rounded-lg p-2 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title={credential.disabled ? "启用" : "禁用"}
|
||||
>
|
||||
{credential.disabled ? (
|
||||
<ToggleLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<ToggleRight className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="rounded-lg p-2 text-muted-foreground hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-950"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 测试结果 */}
|
||||
{testResult && (
|
||||
<div
|
||||
className={`mt-3 rounded-lg px-3 py-2 text-sm ${
|
||||
testResult.success
|
||||
? "bg-green-50 text-green-700 dark:bg-green-950/30 dark:text-green-400"
|
||||
: "bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <Cpu className="h-5 w-5" />;
|
||||
case "openai":
|
||||
return <Sparkles className="h-5 w-5" />;
|
||||
default:
|
||||
return <Cloud className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
export function AsrProviderSection() {
|
||||
const [credentials, setCredentials] = useState<AsrCredentialEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState<AsrProviderType | null>(
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500 bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider 类型选择 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedType(null)}
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
|
||||
selectedType === null
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
{credentials.length > 0 && (
|
||||
<span className="rounded-full bg-muted px-1.5 text-xs">
|
||||
{credentials.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{ASR_PROVIDERS.map((provider) => {
|
||||
const count = getCredentialsByType(provider.type).length;
|
||||
return (
|
||||
<button
|
||||
key={provider.type}
|
||||
onClick={() => setSelectedType(provider.type)}
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
|
||||
selectedType === provider.type
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderIcon type={provider.type} />
|
||||
{provider.label}
|
||||
{count > 0 && (
|
||||
<span className="rounded-full bg-muted px-1.5 text-xs">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{credentials.length > 0
|
||||
? `共 ${credentials.length} 个语音服务`
|
||||
: "暂无语音服务"}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={fetchCredentials}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1 rounded-lg border px-3 py-1.5 text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加服务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 凭证列表 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : currentCredentials.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-muted-foreground">
|
||||
<p className="text-lg">暂无语音服务</p>
|
||||
<p className="mt-1 text-sm">点击"添加服务"按钮添加语音识别服务</p>
|
||||
<button
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
className="mt-4 flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加第一个服务
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{currentCredentials.map((credential) => (
|
||||
<AsrCredentialCard
|
||||
key={credential.id}
|
||||
credential={credential}
|
||||
onSetDefault={() => handleSetDefault(credential.id)}
|
||||
onToggle={() => handleToggle(credential)}
|
||||
onDelete={() => handleDelete(credential.id)}
|
||||
onTest={() => handleTest(credential.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加模态框 */}
|
||||
<AddAsrCredentialModal
|
||||
isOpen={addModalOpen}
|
||||
onClose={() => setAddModalOpen(false)}
|
||||
onSuccess={fetchCredentials}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, React.ReactNode> = {
|
||||
default: <MessageSquare className="h-4 w-4" />,
|
||||
"translate-en": <Languages className="h-4 w-4" />,
|
||||
"translate-zh": <Languages className="h-4 w-4" />,
|
||||
command: <Terminal className="h-4 w-4" />,
|
||||
email: <Mail className="h-4 w-4" />,
|
||||
professional: <FileText className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
/** 获取指令图标 */
|
||||
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 <Sparkles className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 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<string, string> = {
|
||||
" ": "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<HTMLDivElement>(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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
ref={inputRef}
|
||||
tabIndex={isRecording ? 0 : -1}
|
||||
onClick={() => !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 ? (
|
||||
<span className="text-muted-foreground">按下快捷键...</span>
|
||||
) : value ? (
|
||||
<span>{formatShortcutDisplay(value)}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">点击设置快捷键</span>
|
||||
)}
|
||||
</div>
|
||||
{value && !isRecording && (
|
||||
<button
|
||||
onClick={() => onChange("")}
|
||||
disabled={disabled}
|
||||
className="p-2 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="清除快捷键"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{isRecording && (
|
||||
<button
|
||||
onClick={() => setIsRecording(false)}
|
||||
className="p-2 rounded text-muted-foreground hover:bg-muted"
|
||||
title="取消"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 子组件:指令卡片
|
||||
// ============================================================
|
||||
|
||||
interface InstructionCardProps {
|
||||
instruction: VoiceInstruction;
|
||||
isDefault: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onSetDefault: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function InstructionCard({
|
||||
instruction,
|
||||
isDefault,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSetDefault,
|
||||
disabled,
|
||||
}: InstructionCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border p-4 transition-colors",
|
||||
isDefault
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-card hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
{/* 左侧:图标和信息 */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg p-2",
|
||||
isDefault
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{getInstructionIcon(instruction)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{instruction.name}</span>
|
||||
{instruction.is_preset && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
预设
|
||||
</span>
|
||||
)}
|
||||
{isDefault && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{instruction.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground truncate">
|
||||
{instruction.description}
|
||||
</p>
|
||||
)}
|
||||
{instruction.shortcut && (
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Keyboard className="h-3 w-3" />
|
||||
<span className="font-mono">
|
||||
{formatShortcutDisplay(instruction.shortcut)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{!isDefault && (
|
||||
<button
|
||||
onClick={onSetDefault}
|
||||
disabled={disabled}
|
||||
className="rounded-lg p-2 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||
title="设为默认"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onEdit}
|
||||
disabled={disabled}
|
||||
className="rounded-lg p-2 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
{!instruction.is_preset && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={disabled}
|
||||
className="rounded-lg p-2 text-muted-foreground hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-950 disabled:opacity-50"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompt 预览 */}
|
||||
<div className="mt-3 rounded bg-muted/50 p-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium">Prompt: </span>
|
||||
<span className="line-clamp-2">{instruction.prompt}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 子组件:编辑表单
|
||||
// ============================================================
|
||||
|
||||
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 (
|
||||
<div className="rounded-lg border border-primary bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">
|
||||
{isNew ? "添加指令" : instruction.isPreset ? "查看指令" : "编辑指令"}
|
||||
</h4>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded-lg p-1 hover:bg-muted"
|
||||
title="取消"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-400">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={instruction.name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">描述(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={instruction.description}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Prompt 模板 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Prompt 模板</label>
|
||||
<textarea
|
||||
value={instruction.prompt}
|
||||
onChange={(e) => onChange({ ...instruction, prompt: e.target.value })}
|
||||
disabled={instruction.isPreset}
|
||||
placeholder="输入 AI 润色的 Prompt 模板..."
|
||||
rows={4}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm resize-none disabled:opacity-50"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
AI 将使用此 Prompt 对语音识别结果进行润色处理
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 快捷键 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">快捷键(可选)</label>
|
||||
<ShortcutRecorder
|
||||
value={instruction.shortcut}
|
||||
onChange={(shortcut) => onChange({ ...instruction, shortcut })}
|
||||
disabled={instruction.isPreset}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
设置快捷键可快速切换到此指令模式
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border px-4 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
{!instruction.isPreset && (
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!isValid || saving}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "保存中..." : isNew ? "添加" : "保存"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主组件
|
||||
// ============================================================
|
||||
|
||||
export function InstructionEditor({
|
||||
defaultInstructionId,
|
||||
onDefaultChange,
|
||||
disabled = false,
|
||||
}: InstructionEditorProps) {
|
||||
// 状态
|
||||
const [instructions, setInstructions] = useState<VoiceInstruction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingInstruction, setEditingInstruction] =
|
||||
useState<EditingInstruction | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
// 加载指令列表
|
||||
const loadInstructions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await getVoiceInstructions();
|
||||
setInstructions(list);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadInstructions();
|
||||
}, [loadInstructions]);
|
||||
|
||||
// 开始添加新指令
|
||||
const handleAdd = useCallback(() => {
|
||||
setEditingInstruction({
|
||||
name: "",
|
||||
description: "",
|
||||
prompt: "",
|
||||
shortcut: "",
|
||||
icon: "",
|
||||
isPreset: false,
|
||||
});
|
||||
setSaveError(null);
|
||||
}, []);
|
||||
|
||||
// 开始编辑指令
|
||||
const handleEdit = useCallback((instruction: VoiceInstruction) => {
|
||||
setEditingInstruction({
|
||||
id: instruction.id,
|
||||
name: instruction.name,
|
||||
description: instruction.description || "",
|
||||
prompt: instruction.prompt,
|
||||
shortcut: instruction.shortcut || "",
|
||||
icon: instruction.icon || "",
|
||||
isPreset: instruction.is_preset,
|
||||
});
|
||||
setSaveError(null);
|
||||
}, []);
|
||||
|
||||
// 取消编辑
|
||||
const handleCancel = useCallback(() => {
|
||||
setEditingInstruction(null);
|
||||
setSaveError(null);
|
||||
}, []);
|
||||
|
||||
// 保存指令
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!editingInstruction) return;
|
||||
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
const instruction: VoiceInstruction = {
|
||||
id: editingInstruction.id || `custom-${Date.now()}`,
|
||||
name: editingInstruction.name.trim(),
|
||||
description: editingInstruction.description.trim() || undefined,
|
||||
prompt: editingInstruction.prompt.trim(),
|
||||
shortcut: editingInstruction.shortcut || undefined,
|
||||
is_preset: false,
|
||||
icon: editingInstruction.icon || undefined,
|
||||
};
|
||||
|
||||
await saveVoiceInstruction(instruction);
|
||||
await loadInstructions();
|
||||
setEditingInstruction(null);
|
||||
} catch (e) {
|
||||
setSaveError(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [editingInstruction, loadInstructions]);
|
||||
|
||||
// 删除指令
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
if (!confirm("确定要删除此指令吗?")) return;
|
||||
|
||||
try {
|
||||
await deleteVoiceInstruction(id);
|
||||
await loadInstructions();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "删除失败");
|
||||
}
|
||||
},
|
||||
[loadInstructions],
|
||||
);
|
||||
|
||||
// 设为默认
|
||||
const handleSetDefault = useCallback(
|
||||
(id: string) => {
|
||||
onDefaultChange?.(id);
|
||||
},
|
||||
[onDefaultChange],
|
||||
);
|
||||
|
||||
// 分离预设和自定义指令
|
||||
const presetInstructions = instructions.filter((i) => i.is_preset);
|
||||
const customInstructions = instructions.filter((i) => !i.is_preset);
|
||||
|
||||
// 渲染
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-400">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={loadInstructions}
|
||||
className="ml-auto text-red-600 hover:underline"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 标题和添加按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">自定义指令</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
管理语音输入的 AI 润色指令
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={disabled || !!editingInstruction}
|
||||
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加指令
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 编辑表单 */}
|
||||
{editingInstruction && (
|
||||
<EditForm
|
||||
instruction={editingInstruction}
|
||||
onChange={setEditingInstruction}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
saving={saving}
|
||||
error={saveError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 预设指令 */}
|
||||
{presetInstructions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
预设指令
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{presetInstructions.map((instruction) => (
|
||||
<InstructionCard
|
||||
key={instruction.id}
|
||||
instruction={instruction}
|
||||
isDefault={instruction.id === defaultInstructionId}
|
||||
onEdit={() => handleEdit(instruction)}
|
||||
onDelete={() => {}}
|
||||
onSetDefault={() => handleSetDefault(instruction.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 自定义指令 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
自定义指令
|
||||
</h4>
|
||||
{customInstructions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{customInstructions.map((instruction) => (
|
||||
<InstructionCard
|
||||
key={instruction.id}
|
||||
instruction={instruction}
|
||||
isDefault={instruction.id === defaultInstructionId}
|
||||
onEdit={() => handleEdit(instruction)}
|
||||
onDelete={() => handleDelete(instruction.id)}
|
||||
onSetDefault={() => handleSetDefault(instruction.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center">
|
||||
<Sparkles className="mx-auto h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
还没有自定义指令
|
||||
</p>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={disabled || !!editingInstruction}
|
||||
className="mt-3 text-sm text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
创建第一个指令
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InstructionEditor;
|
||||
@@ -0,0 +1,37 @@
|
||||
# 语音组件
|
||||
|
||||
语音输入功能相关的 React 组件。
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `types.ts` | 类型定义和常量 |
|
||||
| `AsrCredentialCard.tsx` | ASR 凭证卡片组件 |
|
||||
| `AddAsrCredentialModal.tsx` | 添加 ASR 凭证模态框 |
|
||||
| `AsrProviderSection.tsx` | ASR Provider 管理区域 |
|
||||
| `VoiceSettings.tsx` | 语音输入设置组件 |
|
||||
| `InstructionEditor.tsx` | 自定义指令编辑器组件 |
|
||||
| `index.ts` | 模块导出 |
|
||||
|
||||
## 使用方式
|
||||
|
||||
```tsx
|
||||
import { AsrProviderSection, InstructionEditor } from "@/components/voice";
|
||||
|
||||
// 在凭证池页面中使用 ASR 管理
|
||||
<AsrProviderSection />
|
||||
|
||||
// 在设置页面中使用指令编辑器
|
||||
<InstructionEditor
|
||||
defaultInstructionId="default"
|
||||
onDefaultChange={(id) => console.log("默认指令:", id)}
|
||||
/>
|
||||
```
|
||||
|
||||
## 支持的 ASR Provider
|
||||
|
||||
- **本地 Whisper** - 离线语音识别
|
||||
- **讯飞语音** - 讯飞开放平台
|
||||
- **百度语音** - 百度 AI 开放平台
|
||||
- **OpenAI Whisper** - OpenAI Whisper API
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* @file VoiceSettings.tsx
|
||||
* @description 语音输入设置组件 - 在实验室设置中显示
|
||||
* @module components/voice/VoiceSettings
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Mic, AlertTriangle, Settings2, Sparkles } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ShortcutSettings } from "@/components/screenshot-chat/ShortcutSettings";
|
||||
import { VoiceInputConfig } from "@/lib/api/asrProvider";
|
||||
|
||||
interface VoiceSettingsProps {
|
||||
config: VoiceInputConfig;
|
||||
onConfigChange: (config: VoiceInputConfig) => Promise<void>;
|
||||
onValidateShortcut: (shortcut: string) => Promise<boolean>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function VoiceSettings({
|
||||
config,
|
||||
onConfigChange,
|
||||
onValidateShortcut,
|
||||
disabled = false,
|
||||
}: VoiceSettingsProps) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const isMacOS = navigator.userAgent.includes("Mac");
|
||||
|
||||
// 切换功能开关
|
||||
const handleToggle = useCallback(async () => {
|
||||
if (disabled || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onConfigChange({
|
||||
...config,
|
||||
enabled: !config.enabled,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [config, onConfigChange, disabled, saving]);
|
||||
|
||||
// 更新快捷键
|
||||
const handleShortcutChange = useCallback(
|
||||
async (newShortcut: string) => {
|
||||
if (disabled || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onConfigChange({
|
||||
...config,
|
||||
shortcut: newShortcut,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[config, onConfigChange, disabled, saving],
|
||||
);
|
||||
|
||||
// 切换 AI 润色
|
||||
const handleTogglePolish = useCallback(async () => {
|
||||
if (disabled || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onConfigChange({
|
||||
...config,
|
||||
processor: {
|
||||
...config.processor,
|
||||
polish_enabled: !config.processor.polish_enabled,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [config, onConfigChange, disabled, saving]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 标题和开关 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<Mic className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">语音输入</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
使用全局快捷键进行语音输入,支持 AI 润色
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={handleToggle}
|
||||
disabled={disabled || saving}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"w-9 h-5 rounded-full transition-colors",
|
||||
"bg-muted peer-checked:bg-primary",
|
||||
"after:content-[''] after:absolute after:top-0.5 after:left-0.5",
|
||||
"after:bg-white after:rounded-full after:h-4 after:w-4",
|
||||
"after:transition-transform peer-checked:after:translate-x-4",
|
||||
(disabled || saving) && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 功能启用时显示详细设置 */}
|
||||
{config.enabled && (
|
||||
<>
|
||||
{/* 快捷键设置 */}
|
||||
<div className="pt-3 border-t">
|
||||
<ShortcutSettings
|
||||
currentShortcut={config.shortcut}
|
||||
onShortcutChange={handleShortcutChange}
|
||||
onValidate={onValidateShortcut}
|
||||
disabled={disabled || saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI 润色设置 */}
|
||||
<div className="pt-3 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<span className="text-sm">AI 润色</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
自动去除语气词、添加标点、修正语法
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.processor.polish_enabled}
|
||||
onChange={handleTogglePolish}
|
||||
disabled={disabled || saving}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"w-9 h-5 rounded-full transition-colors",
|
||||
"bg-muted peer-checked:bg-primary",
|
||||
"after:content-[''] after:absolute after:top-0.5 after:left-0.5",
|
||||
"after:bg-white after:rounded-full after:h-4 after:w-4",
|
||||
"after:transition-transform peer-checked:after:translate-x-4",
|
||||
(disabled || saving) && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ASR 服务管理入口 */}
|
||||
<div className="pt-3 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">语音识别服务</span>
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// TODO: 导航到凭证池页面的语音服务标签
|
||||
}}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
管理 ASR 凭证
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* macOS 麦克风权限警告 */}
|
||||
{isMacOS && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs flex-1">
|
||||
<p className="font-medium text-amber-800 dark:text-amber-300">
|
||||
需要麦克风权限
|
||||
</p>
|
||||
<p className="text-amber-700 dark:text-amber-400 mt-0.5">
|
||||
语音输入功能需要麦克风权限才能正常工作。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
// 使用 Command.create 执行 open 命令打开系统偏好设置
|
||||
const { Command } = await import(
|
||||
"@tauri-apps/plugin-shell"
|
||||
);
|
||||
const cmd = Command.create("open", [
|
||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone",
|
||||
]);
|
||||
const output = await cmd.execute();
|
||||
if (output.code !== 0) {
|
||||
console.error("打开系统设置失败:", output.stderr);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("打开系统设置失败:", err);
|
||||
}
|
||||
}}
|
||||
className="mt-2 inline-flex items-center gap-1 px-2 py-1 rounded bg-amber-200 dark:bg-amber-800 text-amber-800 dark:text-amber-200 hover:bg-amber-300 dark:hover:bg-amber-700 transition-colors cursor-pointer"
|
||||
>
|
||||
打开系统设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VoiceSettings;
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @file 语音组件导出
|
||||
* @description 导出所有语音相关组件
|
||||
* @module components/voice
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
export { AsrCredentialCard } from "./AsrCredentialCard";
|
||||
export { AddAsrCredentialModal } from "./AddAsrCredentialModal";
|
||||
export { AsrProviderSection } from "./AsrProviderSection";
|
||||
export { VoiceSettings } from "./VoiceSettings";
|
||||
export { InstructionEditor } from "./InstructionEditor";
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file 语音组件类型定义
|
||||
* @description 导出所有语音相关的类型,供组件使用
|
||||
* @module components/voice/types
|
||||
*/
|
||||
|
||||
// 从 API 模块重新导出类型
|
||||
export type {
|
||||
AsrProviderType,
|
||||
WhisperModelSize,
|
||||
WhisperLocalConfig,
|
||||
XunfeiConfig,
|
||||
BaiduConfig,
|
||||
OpenAIAsrConfig,
|
||||
AsrCredentialEntry,
|
||||
VoiceOutputMode,
|
||||
VoiceProcessorConfig,
|
||||
VoiceOutputConfig,
|
||||
VoiceInstruction,
|
||||
VoiceInputConfig,
|
||||
} from "@/lib/api/asrProvider";
|
||||
|
||||
// 导出 API 函数
|
||||
export {
|
||||
getAsrCredentials,
|
||||
addAsrCredential,
|
||||
updateAsrCredential,
|
||||
deleteAsrCredential,
|
||||
setDefaultAsrCredential,
|
||||
testAsrCredential,
|
||||
getVoiceInputConfig,
|
||||
saveVoiceInputConfig,
|
||||
getVoiceInstructions,
|
||||
saveVoiceInstruction,
|
||||
deleteVoiceInstruction,
|
||||
} from "@/lib/api/asrProvider";
|
||||
|
||||
/** ASR Provider 显示信息 */
|
||||
export interface AsrProviderInfo {
|
||||
type: import("@/lib/api/asrProvider").AsrProviderType;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
requiresCredentials: boolean;
|
||||
}
|
||||
|
||||
/** ASR Provider 列表 */
|
||||
export const ASR_PROVIDERS: AsrProviderInfo[] = [
|
||||
{
|
||||
type: "whisper_local",
|
||||
label: "本地 Whisper",
|
||||
description: "离线语音识别,无需网络",
|
||||
icon: "cpu",
|
||||
requiresCredentials: false,
|
||||
},
|
||||
{
|
||||
type: "xunfei",
|
||||
label: "讯飞语音",
|
||||
description: "讯飞开放平台语音识别",
|
||||
icon: "cloud",
|
||||
requiresCredentials: true,
|
||||
},
|
||||
{
|
||||
type: "baidu",
|
||||
label: "百度语音",
|
||||
description: "百度 AI 开放平台语音识别",
|
||||
icon: "cloud",
|
||||
requiresCredentials: true,
|
||||
},
|
||||
{
|
||||
type: "openai",
|
||||
label: "OpenAI Whisper",
|
||||
description: "OpenAI Whisper API",
|
||||
icon: "sparkles",
|
||||
requiresCredentials: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Whisper 模型选项 */
|
||||
export const WHISPER_MODELS = [
|
||||
{ value: "tiny", label: "Tiny", size: "~75MB", speed: "最快" },
|
||||
{ value: "base", label: "Base", size: "~142MB", speed: "快" },
|
||||
{ value: "small", label: "Small", size: "~466MB", speed: "中等" },
|
||||
{ value: "medium", label: "Medium", size: "~1.5GB", speed: "较慢" },
|
||||
] as const;
|
||||
@@ -0,0 +1,29 @@
|
||||
# Workspace 组件
|
||||
|
||||
Workspace 相关的 React 组件。
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `index.ts` | 组件导出 |
|
||||
| `WorkspaceSelector.tsx` | Workspace 选择器下拉组件 |
|
||||
|
||||
## 组件
|
||||
|
||||
### WorkspaceSelector
|
||||
|
||||
Workspace 选择器组件,用于切换和管理工作目录。
|
||||
|
||||
```tsx
|
||||
import { WorkspaceSelector } from '@/components/workspace';
|
||||
|
||||
<WorkspaceSelector
|
||||
onSelect={(workspace) => console.log('选中:', workspace)}
|
||||
onAddClick={() => openAddDialog()}
|
||||
/>
|
||||
```
|
||||
|
||||
## 相关 Hook
|
||||
|
||||
- `useWorkspace` - Workspace 管理 Hook
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @file WorkspaceSelector.tsx
|
||||
* @description Workspace 选择器组件,用于切换和管理工作目录
|
||||
* @module components/workspace/WorkspaceSelector
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { FolderOpen, Plus, Check, ChevronDown } from "lucide-react";
|
||||
import { useWorkspace, type Workspace } from "@/hooks/useWorkspace";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface WorkspaceSelectorProps {
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
/** 选择 Workspace 后的回调 */
|
||||
onSelect?: (workspace: Workspace) => void;
|
||||
/** 点击添加按钮的回调 */
|
||||
onAddClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace 选择器组件
|
||||
*/
|
||||
export function WorkspaceSelector({
|
||||
className,
|
||||
onSelect,
|
||||
onAddClick,
|
||||
}: WorkspaceSelectorProps) {
|
||||
const { workspaces, currentWorkspace, loading, setDefault } = useWorkspace();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleSelect = async (workspace: Workspace) => {
|
||||
if (workspace.id !== currentWorkspace?.id) {
|
||||
await setDefault(workspace.id);
|
||||
onSelect?.(workspace);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleAddClick = () => {
|
||||
setOpen(false);
|
||||
onAddClick?.();
|
||||
};
|
||||
|
||||
// 获取显示名称(路径的最后一部分)
|
||||
const getDisplayName = (workspace: Workspace) => {
|
||||
const parts = workspace.rootPath.split("/");
|
||||
return parts[parts.length - 1] || workspace.name;
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("gap-2 px-2", className)}
|
||||
disabled={loading}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="max-w-[120px] truncate">
|
||||
{currentWorkspace
|
||||
? getDisplayName(currentWorkspace)
|
||||
: "选择工作目录"}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[240px]">
|
||||
{workspaces.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
|
||||
暂无工作目录
|
||||
</div>
|
||||
) : (
|
||||
workspaces.map((workspace) => (
|
||||
<DropdownMenuItem
|
||||
key={workspace.id}
|
||||
onClick={() => handleSelect(workspace)}
|
||||
className="gap-2"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{getDisplayName(workspace)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{workspace.rootPath}
|
||||
</div>
|
||||
</div>
|
||||
{workspace.id === currentWorkspace?.id && (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleAddClick} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>添加工作目录...</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkspaceSelector;
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description Workspace 组件导出
|
||||
* @module components/workspace
|
||||
*/
|
||||
|
||||
export { WorkspaceSelector } from "./WorkspaceSelector";
|
||||
export type { WorkspaceSelectorProps } from "./WorkspaceSelector";
|
||||
@@ -29,6 +29,7 @@ React 自定义 Hooks,封装业务逻辑和状态管理。
|
||||
- `useSwitch.ts` - 开关状态 Hook
|
||||
- `useTauri.ts` - Tauri 通用 Hook
|
||||
- `useWindowResize.ts` - 窗口大小 Hook
|
||||
- `useWorkspace.ts` - Workspace 工作目录管理 Hook
|
||||
|
||||
## 更新提醒
|
||||
|
||||
|
||||
@@ -3,7 +3,15 @@ export { useConfigEvents } from "./useConfigEvents";
|
||||
export { useDeepLink } from "./useDeepLink";
|
||||
export { useModelRegistry } from "./useModelRegistry";
|
||||
export { useSound } from "./useSound";
|
||||
export { useWorkspace } from "./useWorkspace";
|
||||
export type { UseSoundReturn } from "./useSound";
|
||||
export type {
|
||||
Workspace,
|
||||
WorkspaceSettings,
|
||||
CreateWorkspaceRequest,
|
||||
UpdateWorkspaceRequest,
|
||||
UseWorkspaceReturn,
|
||||
} from "./useWorkspace";
|
||||
export type {
|
||||
ConnectPayload,
|
||||
RelayInfo,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* @file useWorkspace.ts
|
||||
* @description Workspace 管理 Hook,提供 Workspace CRUD 操作
|
||||
* @module hooks/useWorkspace
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
/** Workspace 列表项 */
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceType: "persistent" | "temporary";
|
||||
rootPath: string;
|
||||
isDefault: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** Workspace 设置 */
|
||||
export interface WorkspaceSettings {
|
||||
mcpConfig?: Record<string, unknown>;
|
||||
defaultProvider?: string;
|
||||
autoCompact?: boolean;
|
||||
}
|
||||
|
||||
/** 创建 Workspace 请求 */
|
||||
export interface CreateWorkspaceRequest {
|
||||
name: string;
|
||||
rootPath: string;
|
||||
workspaceType?: "persistent" | "temporary";
|
||||
}
|
||||
|
||||
/** 更新 Workspace 请求 */
|
||||
export interface UpdateWorkspaceRequest {
|
||||
name?: string;
|
||||
settings?: WorkspaceSettings;
|
||||
}
|
||||
|
||||
/** Hook 返回类型 */
|
||||
export interface UseWorkspaceReturn {
|
||||
/** Workspace 列表 */
|
||||
workspaces: Workspace[];
|
||||
/** 当前默认 Workspace */
|
||||
currentWorkspace: Workspace | null;
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 刷新列表 */
|
||||
refresh: () => Promise<void>;
|
||||
/** 创建 Workspace */
|
||||
create: (request: CreateWorkspaceRequest) => Promise<Workspace>;
|
||||
/** 更新 Workspace */
|
||||
update: (id: string, request: UpdateWorkspaceRequest) => Promise<Workspace>;
|
||||
/** 删除 Workspace */
|
||||
remove: (id: string) => Promise<boolean>;
|
||||
/** 设置默认 Workspace */
|
||||
setDefault: (id: string) => Promise<void>;
|
||||
/** 通过路径获取 Workspace */
|
||||
getByPath: (rootPath: string) => Promise<Workspace | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace 管理 Hook
|
||||
*/
|
||||
export function useWorkspace(): UseWorkspaceReturn {
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [currentWorkspace, setCurrentWorkspace] = useState<Workspace | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/** 刷新 Workspace 列表 */
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [list, defaultWs] = await Promise.all([
|
||||
invoke<Workspace[]>("workspace_list"),
|
||||
invoke<Workspace | null>("workspace_get_default"),
|
||||
]);
|
||||
|
||||
setWorkspaces(list);
|
||||
setCurrentWorkspace(defaultWs);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 创建 Workspace */
|
||||
const create = useCallback(
|
||||
async (request: CreateWorkspaceRequest): Promise<Workspace> => {
|
||||
const workspace = await invoke<Workspace>("workspace_create", {
|
||||
request,
|
||||
});
|
||||
await refresh();
|
||||
return workspace;
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 更新 Workspace */
|
||||
const update = useCallback(
|
||||
async (id: string, request: UpdateWorkspaceRequest): Promise<Workspace> => {
|
||||
const workspace = await invoke<Workspace>("workspace_update", {
|
||||
id,
|
||||
request,
|
||||
});
|
||||
await refresh();
|
||||
return workspace;
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 删除 Workspace */
|
||||
const remove = useCallback(
|
||||
async (id: string): Promise<boolean> => {
|
||||
const result = await invoke<boolean>("workspace_delete", { id });
|
||||
await refresh();
|
||||
return result;
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 设置默认 Workspace */
|
||||
const setDefault = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
await invoke("workspace_set_default", { id });
|
||||
await refresh();
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 通过路径获取 Workspace */
|
||||
const getByPath = useCallback(
|
||||
async (rootPath: string): Promise<Workspace | null> => {
|
||||
return invoke<Workspace | null>("workspace_get_by_path", { rootPath });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
workspaces,
|
||||
currentWorkspace,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setDefault,
|
||||
getByPath,
|
||||
};
|
||||
}
|
||||
|
||||
export default useWorkspace;
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* ASR Provider 类型定义
|
||||
*
|
||||
* 定义语音识别服务相关的类型,与 Rust 后端保持一致。
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
// ============ ASR Provider 类型 ============
|
||||
|
||||
/** ASR Provider 类型 */
|
||||
export type AsrProviderType = "whisper_local" | "xunfei" | "baidu" | "openai";
|
||||
|
||||
/** Whisper 模型大小 */
|
||||
export type WhisperModelSize = "tiny" | "base" | "small" | "medium";
|
||||
|
||||
/** Whisper 本地配置 */
|
||||
export interface WhisperLocalConfig {
|
||||
model: WhisperModelSize;
|
||||
model_path?: string;
|
||||
}
|
||||
|
||||
/** 讯飞配置 */
|
||||
export interface XunfeiConfig {
|
||||
app_id: string;
|
||||
api_key: string;
|
||||
api_secret: string;
|
||||
}
|
||||
|
||||
/** 百度配置 */
|
||||
export interface BaiduConfig {
|
||||
api_key: string;
|
||||
secret_key: string;
|
||||
}
|
||||
|
||||
/** OpenAI ASR 配置 */
|
||||
export interface OpenAIAsrConfig {
|
||||
api_key: string;
|
||||
base_url?: string;
|
||||
proxy_url?: string;
|
||||
}
|
||||
|
||||
/** ASR 凭证条目 */
|
||||
export interface AsrCredentialEntry {
|
||||
id: string;
|
||||
provider: AsrProviderType;
|
||||
name?: string;
|
||||
is_default: boolean;
|
||||
disabled: boolean;
|
||||
language: string;
|
||||
whisper_config?: WhisperLocalConfig;
|
||||
xunfei_config?: XunfeiConfig;
|
||||
baidu_config?: BaiduConfig;
|
||||
openai_config?: OpenAIAsrConfig;
|
||||
}
|
||||
|
||||
// ============ 语音输入配置类型 ============
|
||||
|
||||
/** 语音输出模式 */
|
||||
export type VoiceOutputMode = "type" | "clipboard" | "both";
|
||||
|
||||
/** 语音处理配置 */
|
||||
export interface VoiceProcessorConfig {
|
||||
polish_enabled: boolean;
|
||||
polish_provider?: string;
|
||||
polish_model?: string;
|
||||
default_instruction_id: string;
|
||||
}
|
||||
|
||||
/** 语音输出配置 */
|
||||
export interface VoiceOutputConfig {
|
||||
mode: VoiceOutputMode;
|
||||
type_delay_ms: number;
|
||||
}
|
||||
|
||||
/** 语音处理指令 */
|
||||
export interface VoiceInstruction {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
prompt: string;
|
||||
shortcut?: string;
|
||||
is_preset: boolean;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/** 语音输入功能配置 */
|
||||
export interface VoiceInputConfig {
|
||||
enabled: boolean;
|
||||
shortcut: string;
|
||||
processor: VoiceProcessorConfig;
|
||||
output: VoiceOutputConfig;
|
||||
instructions: VoiceInstruction[];
|
||||
}
|
||||
|
||||
// ============ Tauri 命令封装 ============
|
||||
|
||||
/** 获取 ASR 凭证列表 */
|
||||
export async function getAsrCredentials(): Promise<AsrCredentialEntry[]> {
|
||||
return invoke<AsrCredentialEntry[]>("get_asr_credentials");
|
||||
}
|
||||
|
||||
/** 添加 ASR 凭证 */
|
||||
export async function addAsrCredential(
|
||||
entry: Omit<AsrCredentialEntry, "id">,
|
||||
): Promise<AsrCredentialEntry> {
|
||||
return invoke<AsrCredentialEntry>("add_asr_credential", { entry });
|
||||
}
|
||||
|
||||
/** 更新 ASR 凭证 */
|
||||
export async function updateAsrCredential(
|
||||
entry: AsrCredentialEntry,
|
||||
): Promise<void> {
|
||||
return invoke("update_asr_credential", { entry });
|
||||
}
|
||||
|
||||
/** 删除 ASR 凭证 */
|
||||
export async function deleteAsrCredential(id: string): Promise<void> {
|
||||
return invoke("delete_asr_credential", { id });
|
||||
}
|
||||
|
||||
/** 设置默认 ASR 凭证 */
|
||||
export async function setDefaultAsrCredential(id: string): Promise<void> {
|
||||
return invoke("set_default_asr_credential", { id });
|
||||
}
|
||||
|
||||
/** 测试 ASR 凭证连通性 */
|
||||
export async function testAsrCredential(
|
||||
id: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return invoke("test_asr_credential", { id });
|
||||
}
|
||||
|
||||
// ============ 语音输入配置命令 ============
|
||||
|
||||
/** 获取语音输入配置 */
|
||||
export async function getVoiceInputConfig(): Promise<VoiceInputConfig> {
|
||||
return invoke<VoiceInputConfig>("get_voice_input_config");
|
||||
}
|
||||
|
||||
/** 保存语音输入配置 */
|
||||
export async function saveVoiceInputConfig(
|
||||
config: VoiceInputConfig,
|
||||
): Promise<void> {
|
||||
return invoke("save_voice_input_config", { voiceConfig: config });
|
||||
}
|
||||
|
||||
/** 获取指令列表 */
|
||||
export async function getVoiceInstructions(): Promise<VoiceInstruction[]> {
|
||||
return invoke<VoiceInstruction[]>("get_voice_instructions");
|
||||
}
|
||||
|
||||
/** 保存指令 */
|
||||
export async function saveVoiceInstruction(
|
||||
instruction: VoiceInstruction,
|
||||
): Promise<void> {
|
||||
return invoke("save_voice_instruction", { instruction });
|
||||
}
|
||||
|
||||
/** 删除指令 */
|
||||
export async function deleteVoiceInstruction(id: string): Promise<void> {
|
||||
return invoke("delete_voice_instruction", { id });
|
||||
}
|
||||
|
||||
// ============ 语音识别和润色命令 ============
|
||||
|
||||
/** 语音识别结果 */
|
||||
export interface TranscribeResult {
|
||||
text: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
/** 润色结果 */
|
||||
export interface PolishResult {
|
||||
text: string;
|
||||
instruction_name: string;
|
||||
}
|
||||
|
||||
/** 执行语音识别 */
|
||||
export async function transcribeAudio(
|
||||
audioData: Uint8Array,
|
||||
sampleRate: number,
|
||||
credentialId?: string,
|
||||
): Promise<TranscribeResult> {
|
||||
return invoke<TranscribeResult>("transcribe_audio", {
|
||||
audioData: Array.from(audioData),
|
||||
sampleRate,
|
||||
credentialId,
|
||||
});
|
||||
}
|
||||
|
||||
/** 润色文本 */
|
||||
export async function polishVoiceText(
|
||||
text: string,
|
||||
instructionId?: string,
|
||||
): Promise<PolishResult> {
|
||||
return invoke<PolishResult>("polish_voice_text", {
|
||||
text,
|
||||
instructionId,
|
||||
});
|
||||
}
|
||||
|
||||
/** 打开语音输入窗口 */
|
||||
export async function openVoiceWindow(): Promise<void> {
|
||||
return invoke("open_voice_window");
|
||||
}
|
||||
|
||||
/** 关闭语音输入窗口 */
|
||||
export async function closeVoiceWindow(): Promise<void> {
|
||||
return invoke("close_voice_window");
|
||||
}
|
||||
|
||||
/** 输出文本到系统 */
|
||||
export async function outputVoiceText(
|
||||
text: string,
|
||||
mode?: "type" | "clipboard" | "both",
|
||||
): Promise<void> {
|
||||
return invoke("output_voice_text", { text, mode });
|
||||
}
|
||||
|
||||
// ============ 录音控制命令 ============
|
||||
|
||||
/** 录音状态 */
|
||||
export interface RecordingStatus {
|
||||
/** 是否正在录音 */
|
||||
is_recording: boolean;
|
||||
/** 当前音量级别(0-100) */
|
||||
volume: number;
|
||||
/** 录音时长(秒) */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** 停止录音结果 */
|
||||
export interface StopRecordingResult {
|
||||
/** 音频数据(i16 样本的字节数组,小端序) */
|
||||
audio_data: number[];
|
||||
/** 采样率 */
|
||||
sample_rate: number;
|
||||
/** 录音时长(秒) */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** 开始录音 */
|
||||
export async function startRecording(): Promise<void> {
|
||||
return invoke("start_recording");
|
||||
}
|
||||
|
||||
/** 停止录音并返回音频数据 */
|
||||
export async function stopRecording(): Promise<StopRecordingResult> {
|
||||
return invoke<StopRecordingResult>("stop_recording");
|
||||
}
|
||||
|
||||
/** 取消录音 */
|
||||
export async function cancelRecording(): Promise<void> {
|
||||
return invoke("cancel_recording");
|
||||
}
|
||||
|
||||
/** 获取录音状态 */
|
||||
export async function getRecordingStatus(): Promise<RecordingStatus> {
|
||||
return invoke<RecordingStatus>("get_recording_status");
|
||||
}
|
||||
|
||||
/** 打开带预填文本的输入框 */
|
||||
export async function openInputWithText(text: string): Promise<void> {
|
||||
return invoke("open_input_with_text", { text });
|
||||
}
|
||||
@@ -136,6 +136,39 @@ body,
|
||||
color: #5c3dbd;
|
||||
}
|
||||
|
||||
/* 录音状态标签 */
|
||||
.screenshot-attachment.recording {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.screenshot-attachment.recording svg:first-child {
|
||||
color: #ef4444;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 处理状态标签 */
|
||||
.screenshot-attachment.processing {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.screenshot-attachment.processing svg:first-child {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* 输入框 */
|
||||
.screenshot-input {
|
||||
flex: 1;
|
||||
@@ -184,6 +217,26 @@ body,
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* 麦克风按钮 */
|
||||
.screenshot-mic-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.screenshot-mic-btn:hover {
|
||||
background: rgba(124, 77, 255, 0.1);
|
||||
color: #7c4dff;
|
||||
}
|
||||
|
||||
/* 工具按钮(+号菜单风格) */
|
||||
.screenshot-tools-btn {
|
||||
display: flex;
|
||||
@@ -277,6 +330,15 @@ body,
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.screenshot-mic-btn {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.screenshot-mic-btn:hover {
|
||||
background: rgba(124, 77, 255, 0.2);
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.screenshot-tools-btn {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
* @file screenshot-chat.tsx
|
||||
* @description 截图对话悬浮窗口 - 参考 Google Gemini 浮动栏设计
|
||||
* 半透明药丸形状,简洁的输入界面
|
||||
* 支持语音输入模式
|
||||
* @module pages/screenshot-chat
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { Image as ImageIcon, ArrowUp, X, GripVertical } from "lucide-react";
|
||||
import {
|
||||
Image as ImageIcon,
|
||||
ArrowUp,
|
||||
X,
|
||||
GripVertical,
|
||||
Mic,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import "./screenshot-chat.css";
|
||||
|
||||
@@ -45,33 +53,256 @@ function getImagePathFromUrl(): string | null {
|
||||
return imagePath ? decodeURIComponent(imagePath) : null;
|
||||
}
|
||||
|
||||
function getPrefilledTextFromUrl(): string {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const text = params.get("text");
|
||||
return text ? decodeURIComponent(text) : "";
|
||||
}
|
||||
|
||||
function getVoiceModeFromUrl(): boolean {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get("voice") === "true";
|
||||
}
|
||||
|
||||
/** 语音状态 */
|
||||
type VoiceState = "idle" | "recording" | "transcribing" | "polishing";
|
||||
|
||||
export function ScreenshotChatPage() {
|
||||
const [imagePath, setImagePath] = useState<string | null>(null);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [voiceState, setVoiceState] = useState<VoiceState>("idle");
|
||||
const [voiceMode, setVoiceMode] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 从 URL 获取图片路径
|
||||
// 开始语音模式
|
||||
const startVoiceMode = useCallback(async () => {
|
||||
if (voiceState !== "idle") {
|
||||
console.log("[语音输入] 已在录音状态,跳过");
|
||||
return;
|
||||
}
|
||||
setVoiceMode(true);
|
||||
setVoiceState("recording");
|
||||
setInputValue(""); // 清空之前的输入
|
||||
try {
|
||||
const { startRecording } = await import("@/lib/api/asrProvider");
|
||||
await startRecording();
|
||||
console.log("[语音输入] 开始录音成功");
|
||||
} catch (err) {
|
||||
console.error("[语音输入] 开始录音失败:", err);
|
||||
setVoiceState("idle");
|
||||
setVoiceMode(false);
|
||||
}
|
||||
}, [voiceState]);
|
||||
|
||||
// 从 URL 获取图片路径、预填文本和语音模式
|
||||
useEffect(() => {
|
||||
const path = getImagePathFromUrl();
|
||||
if (path) {
|
||||
setImagePath(path);
|
||||
}
|
||||
}, []);
|
||||
const prefilledText = getPrefilledTextFromUrl();
|
||||
if (prefilledText) {
|
||||
setInputValue(prefilledText);
|
||||
}
|
||||
const isVoiceMode = getVoiceModeFromUrl();
|
||||
console.log("[语音输入] URL 参数 voice=", isVoiceMode);
|
||||
if (isVoiceMode) {
|
||||
startVoiceMode();
|
||||
}
|
||||
}, [startVoiceMode]);
|
||||
|
||||
// 自动聚焦
|
||||
// 监听后端发送的开始录音事件(窗口已存在时使用)
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
unlisten = await listen("voice-start-recording", () => {
|
||||
console.log("[语音输入] 收到开始录音事件");
|
||||
startVoiceMode();
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[语音输入] 监听开始录音事件失败:", err);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [startVoiceMode]);
|
||||
|
||||
// 自动聚焦(非语音模式时)
|
||||
useEffect(() => {
|
||||
if (!voiceMode) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [voiceMode]);
|
||||
|
||||
// 手动停止语音录音(点击按钮)
|
||||
const stopVoiceRecording = async () => {
|
||||
if (voiceState !== "recording") return;
|
||||
|
||||
setVoiceState("transcribing");
|
||||
try {
|
||||
const {
|
||||
stopRecording,
|
||||
transcribeAudio,
|
||||
polishVoiceText,
|
||||
getVoiceInputConfig,
|
||||
} = await import("@/lib/api/asrProvider");
|
||||
|
||||
const result = await stopRecording();
|
||||
console.log(
|
||||
"[语音输入] 录音完成,时长:",
|
||||
result.duration.toFixed(2),
|
||||
"秒",
|
||||
);
|
||||
|
||||
if (result.duration < 0.5) {
|
||||
console.log("[语音输入] 录音时间过短");
|
||||
setVoiceState("idle");
|
||||
setVoiceMode(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const audioData = new Uint8Array(result.audio_data);
|
||||
const transcribeResult = await transcribeAudio(
|
||||
audioData,
|
||||
result.sample_rate,
|
||||
);
|
||||
console.log("[语音识别] 结果:", transcribeResult.text);
|
||||
|
||||
if (!transcribeResult.text.trim()) {
|
||||
setVoiceState("idle");
|
||||
setVoiceMode(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否启用润色
|
||||
let finalText = transcribeResult.text;
|
||||
try {
|
||||
const config = await getVoiceInputConfig();
|
||||
if (config.processor.polish_enabled) {
|
||||
setVoiceState("polishing");
|
||||
const polished = await polishVoiceText(transcribeResult.text);
|
||||
finalText = polished.text;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[语音润色] 失败:", e);
|
||||
}
|
||||
|
||||
setInputValue(finalText);
|
||||
setVoiceState("idle");
|
||||
setVoiceMode(false);
|
||||
inputRef.current?.focus();
|
||||
} catch (err) {
|
||||
console.error("[语音识别] 失败:", err);
|
||||
setVoiceState("idle");
|
||||
setVoiceMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听快捷键释放事件
|
||||
useEffect(() => {
|
||||
if (!voiceMode) return;
|
||||
|
||||
const setupStopListener = async () => {
|
||||
try {
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
const unlisten = await listen("voice-stop-recording", async () => {
|
||||
console.log("[语音输入] 收到停止录音事件");
|
||||
// 直接在这里执行停止录音逻辑,避免闭包问题
|
||||
setVoiceState("transcribing");
|
||||
try {
|
||||
const {
|
||||
stopRecording,
|
||||
transcribeAudio,
|
||||
polishVoiceText,
|
||||
getVoiceInputConfig,
|
||||
} = await import("@/lib/api/asrProvider");
|
||||
|
||||
const result = await stopRecording();
|
||||
console.log(
|
||||
"[语音输入] 录音完成,时长:",
|
||||
result.duration.toFixed(2),
|
||||
"秒",
|
||||
);
|
||||
|
||||
if (result.duration < 0.5) {
|
||||
console.log("[语音输入] 录音时间过短");
|
||||
setVoiceState("idle");
|
||||
setVoiceMode(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const audioData = new Uint8Array(result.audio_data);
|
||||
const transcribeResult = await transcribeAudio(
|
||||
audioData,
|
||||
result.sample_rate,
|
||||
);
|
||||
console.log("[语音识别] 结果:", transcribeResult.text);
|
||||
|
||||
if (!transcribeResult.text.trim()) {
|
||||
setVoiceState("idle");
|
||||
setVoiceMode(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否启用润色
|
||||
let finalText = transcribeResult.text;
|
||||
try {
|
||||
const config = await getVoiceInputConfig();
|
||||
if (config.processor.polish_enabled) {
|
||||
setVoiceState("polishing");
|
||||
const polished = await polishVoiceText(transcribeResult.text);
|
||||
finalText = polished.text;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[语音润色] 失败:", e);
|
||||
}
|
||||
|
||||
setInputValue(finalText);
|
||||
setVoiceState("idle");
|
||||
setVoiceMode(false);
|
||||
inputRef.current?.focus();
|
||||
} catch (err) {
|
||||
console.error("[语音识别] 失败:", err);
|
||||
setVoiceState("idle");
|
||||
setVoiceMode(false);
|
||||
}
|
||||
});
|
||||
return unlisten;
|
||||
} catch (err) {
|
||||
console.error("[语音输入] 监听停止录音事件失败:", err);
|
||||
return () => {};
|
||||
}
|
||||
};
|
||||
|
||||
const unlistenPromise = setupStopListener();
|
||||
return () => {
|
||||
unlistenPromise.then((unlisten) => unlisten());
|
||||
};
|
||||
}, [voiceMode]);
|
||||
|
||||
// 关闭窗口
|
||||
const handleClose = useCallback(async () => {
|
||||
// 如果正在录音,先取消
|
||||
if (voiceState === "recording") {
|
||||
try {
|
||||
const { cancelRecording } = await import("@/lib/api/asrProvider");
|
||||
await cancelRecording();
|
||||
} catch (err) {
|
||||
console.error("[语音输入] 取消录音失败:", err);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await getCurrentWindow().close();
|
||||
} catch (err) {
|
||||
console.error("关闭窗口失败:", err);
|
||||
}
|
||||
}, []);
|
||||
}, [voiceState]);
|
||||
|
||||
// ESC 关闭窗口
|
||||
useEffect(() => {
|
||||
@@ -141,6 +372,31 @@ export function ScreenshotChatPage() {
|
||||
{/* Logo */}
|
||||
<Logo />
|
||||
|
||||
{/* 语音录音状态标签 */}
|
||||
{voiceState === "recording" && (
|
||||
<div className="screenshot-attachment recording">
|
||||
<Mic size={12} />
|
||||
<span>录音中...</span>
|
||||
<button
|
||||
className="screenshot-attachment-remove"
|
||||
onClick={stopVoiceRecording}
|
||||
title="停止录音"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 语音识别/润色状态 */}
|
||||
{(voiceState === "transcribing" || voiceState === "polishing") && (
|
||||
<div className="screenshot-attachment processing">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
<span>
|
||||
{voiceState === "transcribing" ? "识别中..." : "润色中..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片附件标签 */}
|
||||
{imagePath && (
|
||||
<div className="screenshot-attachment">
|
||||
@@ -161,15 +417,30 @@ export function ScreenshotChatPage() {
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="screenshot-input"
|
||||
placeholder="Ask anything..."
|
||||
placeholder={
|
||||
voiceState === "recording"
|
||||
? "点击 × 或松开快捷键停止录音"
|
||||
: "Ask anything..."
|
||||
}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || voiceState !== "idle"}
|
||||
/>
|
||||
|
||||
{/* 右侧按钮组 */}
|
||||
<div className="screenshot-actions">
|
||||
{/* 麦克风按钮 - 点击开始录音 */}
|
||||
{voiceState === "idle" && (
|
||||
<button
|
||||
className="screenshot-mic-btn"
|
||||
onClick={startVoiceMode}
|
||||
title="语音输入"
|
||||
>
|
||||
<Mic size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
className="screenshot-close-btn"
|
||||
|
||||
Reference in New Issue
Block a user