feat: 语音输入合并到截图输入框,版本更新至 0.49.0

- 将语音输入功能合并到截图输入框(pill-shaped 浮动栏)
- 添加麦克风按钮(32x32px)支持点击录音
- 支持录音、转写、润色三种状态显示
- 修复讯飞 ASR Broken pipe 错误
- 清理旧的语音输入 UI 文件
- 修复 DropdownMenu 组件支持受控模式
- 修复 StreamResponse 导出问题

注:部分编译错误待后续修复
This commit is contained in:
coso
2026-01-31 00:01:20 +08:00
parent 2a7ed06d21
commit 562464f53f
94 changed files with 8961 additions and 196 deletions
+4
View File
@@ -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
```
## 更新提醒
+317
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.48.4",
"version": "0.49.0",
"type": "module",
"repository": {
"type": "git",
+743 -101
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -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
+4
View File
@@ -22,5 +22,9 @@
</array>
</dict>
</array>
<key>NSMicrophoneUsageDescription</key>
<string>ProxyCast 需要访问麦克风以使用语音输入功能</string>
<key>NSAppleEventsUsageDescription</key>
<string>ProxyCast 需要控制其他应用以输入识别的文本</string>
</dict>
</plist>
+5
View File
@@ -35,6 +35,11 @@
"name": "binaries/aster-server",
"sidecar": true,
"args": true
},
{
"name": "open",
"cmd": "open",
"args": true
}
]
},
+62
View File
@@ -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"] }
+55
View File
@@ -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,
}
+68
View File
@@ -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>;
+17
View File
@@ -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::*;
+66
View File
@@ -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("创建输出处理器失败")
}
}
+198
View File
@@ -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
}
}
+141
View File
@@ -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
}
}
+2
View File
@@ -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>
+1
View File
@@ -32,6 +32,7 @@ Tauri 后端核心代码,处理系统级功能和 API 服务。
- `terminal/` - 终端核心模块(PTY 管理、会话管理)
- `tray/` - 系统托盘
- `websocket/` - WebSocket 支持
- `workspace/` - Workspace 工作目录管理
- `lib.rs` - 库入口
- `main.rs` - 应用入口
- `logger.rs` - 日志配置
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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;
+7 -1
View File
@@ -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,
+2 -2
View File
@@ -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,
};
/// 获取配置
+49 -2
View File
@@ -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");
+230
View File
@@ -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,
}
-1
View File
@@ -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};
+1 -2
View File
@@ -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;
+2
View File
@@ -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
View File
@@ -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)]
+18
View File
@@ -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 -2
View File
@@ -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;
+168
View File
@@ -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()))
}
+1
View File
@@ -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(),
}
}
+2 -2
View File
@@ -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 {
+1
View File
@@ -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(&current.codex, &imported.codex),
asr: imported.asr.clone(),
}
}
+42 -6
View File
@@ -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};
+431
View File
@@ -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);
}
}
+1 -1
View File
@@ -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
+30
View File
@@ -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(())
}
+2
View File
@@ -45,6 +45,8 @@ pub mod stream;
pub mod terminal;
pub mod translator;
pub mod tray;
pub mod voice;
pub mod workspace;
// 内部模块
mod commands;
+1 -1
View File
@@ -7,4 +7,4 @@ pub mod management_auth;
#[cfg(test)]
mod tests;
pub use management_auth::{ManagementAuthLayer, ManagementAuthService};
pub use management_auth::ManagementAuthLayer;
-5
View File
@@ -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;
+8
View File
@@ -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;
+3 -5
View File
@@ -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;
+155
View File
@@ -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,
+2 -3
View File
@@ -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)]
+1 -1
View File
@@ -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)]
+1 -2
View File
@@ -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};
+3 -18
View File
@@ -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};
-2
View File
@@ -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;
+82
View File
@@ -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 |
+470
View File
@@ -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)
}
}
+359
View File
@@ -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(),
})
}
+43
View File
@@ -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))
}
+48
View File
@@ -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(())
}
+47
View File
@@ -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(())
}
+115
View File
@@ -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())
}
+447
View File
@@ -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()
}
+157
View File
@@ -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)
}
+77
View File
@@ -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(())
}
+3 -8
View File
@@ -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;
+57
View File
@@ -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)
+304
View File
@@ -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,
})
}
}
+20
View File
@@ -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};
+95
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 />;
}
+1
View File
@@ -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>
+28 -2
View File
@@ -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>
);
}
+170
View File
@@ -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>
);
}
+218
View File
@@ -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>
);
}
+735
View File
@@ -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;
+37
View File
@@ -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
+223
View File
@@ -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;
+12
View File
@@ -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";
+85
View File
@@ -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;
+29
View File
@@ -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;
+8
View File
@@ -0,0 +1,8 @@
/**
* @file index.ts
* @description Workspace 组件导出
* @module components/workspace
*/
export { WorkspaceSelector } from "./WorkspaceSelector";
export type { WorkspaceSelectorProps } from "./WorkspaceSelector";
+1
View File
@@ -29,6 +29,7 @@ React 自定义 Hooks,封装业务逻辑和状态管理。
- `useSwitch.ts` - 开关状态 Hook
- `useTauri.ts` - Tauri 通用 Hook
- `useWindowResize.ts` - 窗口大小 Hook
- `useWorkspace.ts` - Workspace 工作目录管理 Hook
## 更新提醒
+8
View File
@@ -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,
+167
View File
@@ -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;
+266
View File
@@ -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 });
}
+62
View File
@@ -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;
}
+280 -9
View File
@@ -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"