diff --git a/package.json b/package.json index b62e0d4ff..cc79d9825 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.49.0", + "version": "0.50.0", "type": "module", "repository": { "type": "git", diff --git a/public/sounds/recording-start.wav b/public/sounds/recording-start.wav new file mode 100644 index 000000000..b73400b73 Binary files /dev/null and b/public/sounds/recording-start.wav differ diff --git a/public/sounds/recording-stop.wav b/public/sounds/recording-stop.wav new file mode 100644 index 000000000..775d04648 Binary files /dev/null and b/public/sounds/recording-stop.wav differ diff --git a/scripts/test-newapi-image.ts b/scripts/test-newapi-image.ts new file mode 100644 index 000000000..dcf09343b --- /dev/null +++ b/scripts/test-newapi-image.ts @@ -0,0 +1,137 @@ +/** + * 测试 New API 图片生成 + * 运行: npx tsx scripts/test-newapi-image.ts + * + * 生成的图片保存到 scripts/test-output/ 目录 + */ + +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const API_HOST = "http://127.0.0.1:8045"; +const API_KEY = "sk-1b2d91c47f4d455485fdd8f4fe52883e"; +const MODEL = "gemini-3-pro-image"; +const OUTPUT_DIR = path.join(__dirname, "test-output"); + +// 确保输出目录存在 +if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); +} + +/** + * 从 base64 数据保存图片 + */ +function saveBase64Image(base64Data: string, filename: string): string { + const buffer = Buffer.from(base64Data, "base64"); + const filepath = path.join(OUTPUT_DIR, filename); + fs.writeFileSync(filepath, buffer); + return filepath; +} + +/** + * 从 URL 下载并保存图片 + */ +async function downloadImage(url: string, filename: string): Promise { + const response = await fetch(url); + const buffer = Buffer.from(await response.arrayBuffer()); + const filepath = path.join(OUTPUT_DIR, filename); + fs.writeFileSync(filepath, buffer); + return filepath; +} + +/** + * 提取图片 URL 或 base64 + */ +function extractImageData(content: string): { type: "url" | "base64"; data: string } | null { + // 方式 1: Markdown 图片格式 ![...](url) + const mdMatch = content.match(/!\[.*?\]\((https?:\/\/[^\)]+)\)/); + if (mdMatch) return { type: "url", data: mdMatch[1] }; + + // 方式 2: 纯图片 URL + const urlMatch = content.match(/(https?:\/\/[^\s\)\"\'<>]+\.(png|jpg|jpeg|gif|webp)[^\s\)\"\'<>]*)/i); + if (urlMatch) return { type: "url", data: urlMatch[1] }; + + // 方式 3: 任意 https URL (可能是图片) + const anyUrlMatch = content.match(/(https?:\/\/[^\s\)\"\'<>]+)/); + if (anyUrlMatch) return { type: "url", data: anyUrlMatch[1] }; + + // 方式 4: base64 数据 + const base64Match = content.match(/data:image\/[^;]+;base64,([A-Za-z0-9+/=]+)/); + if (base64Match) return { type: "base64", data: base64Match[1] }; + + return null; +} + +async function testImageGen() { + console.log("=== 测试 New API 图片生成 ===\n"); + console.log("输出目录:", OUTPUT_DIR); + + const request = { + model: MODEL, + messages: [{ role: "user", content: "Draw a cute cat" }], + temperature: 0.7, + stream: false, + size: "1024x1024", + }; + + console.log("\n请求:", JSON.stringify(request, null, 2)); + console.log("\n发送请求到:", `${API_HOST}/v1/chat/completions`); + + try { + const response = await fetch(`${API_HOST}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${API_KEY}`, + }, + body: JSON.stringify(request), + }); + + console.log("\n响应状态:", response.status); + + const data = await response.json(); + + // 保存完整响应到 JSON 文件(避免 console 输出过长) + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const responseFile = path.join(OUTPUT_DIR, `response-${timestamp}.json`); + fs.writeFileSync(responseFile, JSON.stringify(data, null, 2)); + console.log("\n完整响应已保存到:", responseFile); + + // 提取消息内容 + const content = data.choices?.[0]?.message?.content || ""; + const contentPreview = content.length > 200 ? content.slice(0, 200) + "..." : content; + console.log("\n消息内容预览:", contentPreview); + + // 尝试提取并保存图片 + const imageData = extractImageData(content); + + if (imageData) { + console.log("\n检测到图片数据,类型:", imageData.type); + + const filename = `image-${timestamp}.png`; + let savedPath: string; + + if (imageData.type === "base64") { + savedPath = saveBase64Image(imageData.data, filename); + } else { + console.log("图片 URL:", imageData.data); + savedPath = await downloadImage(imageData.data, filename); + } + + console.log("\n✅ 图片已保存到:", savedPath); + console.log("\n可以在 Kiro 中打开查看图片"); + } else { + console.log("\n❌ 未能从响应中提取图片数据"); + console.log("请检查 response JSON 文件查看完整响应格式"); + } + + } catch (error) { + console.error("\n❌ 请求失败:", error); + } +} + +testImageGen(); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 77af492cf..d3871ae0f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -202,7 +202,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aster" -version = "0.4.5" +version = "0.4.6" dependencies = [ "ahash", "anyhow", @@ -2088,7 +2088,7 @@ dependencies = [ "dtoa-short", "itoa", "matches", - "phf 0.10.1", + "phf 0.8.0", "proc-macro2", "quote", "smallvec", @@ -2104,7 +2104,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.3", + "phf 0.8.0", "smallvec", ] @@ -3964,7 +3964,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.56.0", ] [[package]] @@ -5292,7 +5292,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.114", @@ -6009,7 +6009,9 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ + "phf_macros 0.8.0", "phf_shared 0.8.0", + "proc-macro-hack", ] [[package]] @@ -6018,9 +6020,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ - "phf_macros 0.10.0", "phf_shared 0.10.0", - "proc-macro-hack", ] [[package]] @@ -6115,12 +6115,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.10.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", + "phf_generator 0.8.0", + "phf_shared 0.8.0", "proc-macro-hack", "proc-macro2", "quote", @@ -6512,7 +6512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.114", @@ -6520,7 +6520,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.49.0" +version = "0.50.0" dependencies = [ "anyhow", "arboard", @@ -6602,7 +6602,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.49.0" +version = "0.50.0" dependencies = [ "chrono", "dirs 5.0.1", @@ -6618,7 +6618,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.49.0" +version = "0.50.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -7936,7 +7936,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs 6.0.0", + "dirs 4.0.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e6d89b24d..98a549a25 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.49.0" +version = "0.50.0" edition = "2021" authors = ["you"] repository = "https://github.com/aiclientproxy/proxycast" @@ -164,7 +164,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.49.0" +version = "0.50.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index b25fea619..fbf6c3c3a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "https://schemas.tauri.app/config/2/capability", "identifier": "default", "description": "Default capabilities for ProxyCast", - "windows": ["main", "screenshot-chat", "update-notification"], + "windows": ["main", "smart-input", "update-notification"], "permissions": [ "core:default", "core:webview:default", diff --git a/src-tauri/crates/voice-core/src/asr_client/xunfei.rs b/src-tauri/crates/voice-core/src/asr_client/xunfei.rs index 8e918f5de..214b90941 100644 --- a/src-tauri/crates/voice-core/src/asr_client/xunfei.rs +++ b/src-tauri/crates/voice-core/src/asr_client/xunfei.rs @@ -30,6 +30,39 @@ use crate::types::{AudioData, Segment, TranscribeResult}; /// 讯飞建议每帧发送 1280 字节(约 40ms 的 16kHz 16bit 单声道音频) const FRAME_SIZE: usize = 1280; +/// 简单的线性插值重采样 +/// +/// 将音频从源采样率转换到目标采样率 +fn resample(samples: &[i16], from_rate: u32, to_rate: u32) -> Vec { + if from_rate == to_rate { + return samples.to_vec(); + } + + let ratio = from_rate as f64 / to_rate as f64; + let new_len = (samples.len() as f64 / ratio) as usize; + let mut result = Vec::with_capacity(new_len); + + for i in 0..new_len { + let src_idx = i as f64 * ratio; + let idx_floor = src_idx.floor() as usize; + let idx_ceil = (idx_floor + 1).min(samples.len() - 1); + let frac = src_idx - idx_floor as f64; + + // 线性插值 + let sample = if idx_floor < samples.len() { + let s1 = samples[idx_floor] as f64; + let s2 = samples[idx_ceil] as f64; + (s1 + (s2 - s1) * frac) as i16 + } else { + 0 + }; + + result.push(sample); + } + + result +} + /// 讯飞客户端 pub struct XunfeiClient { app_id: String, @@ -158,24 +191,78 @@ impl XunfeiClient { } } - /// 解析识别结果 + /// 解析识别结果(支持动态修正) + /// + /// 动态修正说明: + /// - pgs="apd": 追加到之前的结果 + /// - pgs="rpl": 替换之前的部分结果,替换范围由 rg 字段指定 fn parse_result(responses: &[XunfeiResponse]) -> TranscribeResult { - let mut full_text = String::new(); - let mut segments = Vec::new(); + // 使用 HashMap 存储每个 sn 对应的文本,支持动态修正替换 + let mut sn_texts: std::collections::HashMap = std::collections::HashMap::new(); + let mut max_sn: i32 = 0; for resp in responses { if let Some(ref data) = resp.data { if let Some(ref result) = data.result { - // 拼接所有词 + let sn = result.sn.unwrap_or(0); + max_sn = max_sn.max(sn); + + // 提取当前结果的文本 + let mut current_text = String::new(); for ws in &result.ws { for cw in &ws.cw { - full_text.push_str(&cw.w); + current_text.push_str(&cw.w); + } + } + + // 根据 pgs 字段处理 + match result.pgs.as_deref() { + Some("rpl") => { + // 替换模式:删除 rg 范围内的结果,然后添加当前结果 + if let Some(ref rg) = result.rg { + if rg.len() >= 2 { + let start = rg[0]; + let end = rg[1]; + // 删除 [start, end] 范围内的所有 sn + for i in start..=end { + sn_texts.remove(&i); + } + tracing::debug!( + "动态修正替换: sn={}, rg=[{}, {}], text={}", + sn, + start, + end, + current_text + ); + } + } + sn_texts.insert(sn, current_text); + } + Some("apd") | None => { + // 追加模式或无标识:直接添加 + sn_texts.insert(sn, current_text); + } + _ => { + // 其他情况也直接添加 + sn_texts.insert(sn, current_text); } } } } } + // 按 sn 顺序拼接最终文本 + let mut full_text = String::new(); + let mut sorted_sns: Vec = sn_texts.keys().cloned().collect(); + sorted_sns.sort(); + + for sn in sorted_sns { + if let Some(text) = sn_texts.get(&sn) { + full_text.push_str(text); + } + } + + let mut segments = Vec::new(); // 如果有文本,创建一个整体的 segment if !full_text.is_empty() { segments.push(Segment { @@ -215,8 +302,33 @@ impl AsrClient for XunfeiClient { let (mut write, mut read) = ws_stream.split(); + // 如果采样率不是 16000,需要重采样 + let samples_16k = if audio.sample_rate != 16000 { + tracing::info!( + "重采样: {}Hz -> 16000Hz (原始样本数: {})", + audio.sample_rate, + audio.samples.len() + ); + resample(&audio.samples, audio.sample_rate, 16000) + } else { + audio.samples.clone() + }; + + tracing::info!("重采样后样本数: {}", samples_16k.len()); + + // 检查重采样后的音频数据是否有效 + let non_zero_count = samples_16k.iter().filter(|&&s| s != 0).count(); + let max_sample = samples_16k.iter().map(|&s| s.abs()).max().unwrap_or(0); + tracing::info!( + "重采样后音频检查: 非零样本={}/{} ({:.1}%), 最大振幅={}", + non_zero_count, + samples_16k.len(), + non_zero_count as f64 / samples_16k.len().max(1) as f64 * 100.0, + max_sample + ); + // 将音频数据转换为字节(16-bit PCM) - let audio_bytes: Vec = audio.samples.iter().flat_map(|s| s.to_le_bytes()).collect(); + let audio_bytes: Vec = samples_16k.iter().flat_map(|s| s.to_le_bytes()).collect(); // 分帧发送音频数据 let chunks: Vec<&[u8]> = audio_bytes.chunks(FRAME_SIZE).collect(); @@ -447,11 +559,17 @@ struct XunfeiResponseData { /// 识别结果 #[derive(Debug, Deserialize)] struct XunfeiResult { + /// 返回结果的序号 + sn: Option, /// 词列表 ws: Vec, /// 是否是最终结果 #[allow(dead_code)] ls: Option, + /// 动态修正标识:apd=追加,rpl=替换 + pgs: Option, + /// 替换范围 [start, end],当 pgs=rpl 时有效 + rg: Option>, } /// 词 diff --git a/src-tauri/crates/voice-core/tests/asr_e2e_tests.rs b/src-tauri/crates/voice-core/tests/asr_e2e_tests.rs new file mode 100644 index 000000000..b17e0cc15 --- /dev/null +++ b/src-tauri/crates/voice-core/tests/asr_e2e_tests.rs @@ -0,0 +1,315 @@ +//! ASR 服务端到端测试 +//! +//! 测试各个 ASR 服务的连接和基本功能。 +//! +//! ## 运行测试 +//! +//! 需要设置环境变量: +//! +//! ```bash +//! # 讯飞 +//! export XUNFEI_APP_ID=xxx +//! export XUNFEI_API_KEY=xxx +//! export XUNFEI_API_SECRET=xxx +//! +//! # 百度 +//! export BAIDU_API_KEY=xxx +//! export BAIDU_SECRET_KEY=xxx +//! +//! # OpenAI +//! export OPENAI_API_KEY=xxx +//! +//! # 运行测试 +//! cargo test --package voice-core --test asr_e2e_tests -- --nocapture +//! ``` + +use voice_core::asr_client::{AsrClient, XunfeiClient}; +use voice_core::types::AudioData; + +/// 生成测试用的静音音频数据 +/// 16kHz, 16-bit, 单声道, 1秒 +fn generate_silence_audio(duration_secs: f32) -> AudioData { + let sample_rate = 16000u32; + let num_samples = (sample_rate as f32 * duration_secs) as usize; + let samples = vec![0i16; num_samples]; + AudioData::new(samples, sample_rate, 1) +} + +/// 生成测试用的正弦波音频数据(模拟有声音的音频) +/// 16kHz, 16-bit, 单声道 +fn generate_sine_wave_audio(duration_secs: f32, frequency: f32) -> AudioData { + let sample_rate = 16000u32; + let num_samples = (sample_rate as f32 * duration_secs) as usize; + let samples: Vec = (0..num_samples) + .map(|i| { + let t = i as f32 / sample_rate as f32; + let amplitude = 0.3 * i16::MAX as f32; + (amplitude * (2.0 * std::f32::consts::PI * frequency * t).sin()) as i16 + }) + .collect(); + AudioData::new(samples, sample_rate, 1) +} + +mod xunfei { + use super::*; + + fn get_xunfei_credentials() -> Option<(String, String, String)> { + let app_id = std::env::var("XUNFEI_APP_ID").ok()?; + let api_key = std::env::var("XUNFEI_API_KEY").ok()?; + let api_secret = std::env::var("XUNFEI_API_SECRET").ok()?; + Some((app_id, api_key, api_secret)) + } + + #[tokio::test] + async fn test_xunfei_connection() { + let Some((app_id, api_key, api_secret)) = get_xunfei_credentials() else { + eprintln!("跳过测试: 未设置讯飞凭证环境变量"); + return; + }; + + let client = XunfeiClient::new(app_id, api_key, api_secret); + + // 使用静音音频测试连接 + let audio = generate_silence_audio(1.0); + let result = client.transcribe(&audio).await; + + match result { + Ok(r) => { + println!("✅ 讯飞连接成功"); + println!(" 识别结果: {:?}", r.text); + } + Err(e) => { + panic!("❌ 讯飞连接失败: {:?}", e); + } + } + } + + #[tokio::test] + async fn test_xunfei_transcribe_audio() { + let Some((app_id, api_key, api_secret)) = get_xunfei_credentials() else { + eprintln!("跳过测试: 未设置讯飞凭证环境变量"); + return; + }; + + let client = XunfeiClient::new(app_id, api_key, api_secret); + + // 使用正弦波音频测试(模拟有声音) + let audio = generate_sine_wave_audio(2.0, 440.0); + let result = client.transcribe(&audio).await; + + match result { + Ok(r) => { + println!("✅ 讯飞识别成功"); + println!(" 识别结果: {:?}", r.text); + println!(" 语言: {:?}", r.language); + } + Err(e) => { + panic!("❌ 讯飞识别失败: {:?}", e); + } + } + } + + #[tokio::test] + async fn test_xunfei_invalid_credentials() { + let client = XunfeiClient::new( + "invalid_app_id".to_string(), + "invalid_api_key".to_string(), + "invalid_api_secret".to_string(), + ); + + let audio = generate_silence_audio(1.0); + let result = client.transcribe(&audio).await; + + // 应该返回错误 + assert!(result.is_err(), "无效凭证应该返回错误"); + println!("✅ 无效凭证正确返回错误: {:?}", result.err()); + } + + #[tokio::test] + async fn test_xunfei_short_audio() { + let Some((app_id, api_key, api_secret)) = get_xunfei_credentials() else { + eprintln!("跳过测试: 未设置讯飞凭证环境变量"); + return; + }; + + let client = XunfeiClient::new(app_id, api_key, api_secret); + + // 测试短音频(0.3秒) + let audio = generate_silence_audio(0.3); + assert!(!audio.is_valid(), "0.3秒音频应该被标记为无效"); + + // 仍然尝试发送,看服务端如何处理 + let result = client.transcribe(&audio).await; + println!("短音频测试结果: {:?}", result); + } + + #[tokio::test] + async fn test_xunfei_long_audio() { + let Some((app_id, api_key, api_secret)) = get_xunfei_credentials() else { + eprintln!("跳过测试: 未设置讯飞凭证环境变量"); + return; + }; + + let client = XunfeiClient::new(app_id, api_key, api_secret); + + // 测试较长音频(5秒) + let audio = generate_silence_audio(5.0); + let result = client.transcribe(&audio).await; + + match result { + Ok(r) => { + println!("✅ 讯飞长音频测试成功"); + println!(" 识别结果: {:?}", r.text); + } + Err(e) => { + panic!("❌ 讯飞长音频测试失败: {:?}", e); + } + } + } +} + +mod baidu { + use super::*; + use voice_core::asr_client::BaiduClient; + + fn get_baidu_credentials() -> Option<(String, String)> { + let api_key = std::env::var("BAIDU_API_KEY").ok()?; + let secret_key = std::env::var("BAIDU_SECRET_KEY").ok()?; + Some((api_key, secret_key)) + } + + #[tokio::test] + async fn test_baidu_connection() { + let Some((api_key, secret_key)) = get_baidu_credentials() else { + eprintln!("跳过测试: 未设置百度凭证环境变量"); + return; + }; + + let client = BaiduClient::new(api_key, secret_key); + + let audio = generate_silence_audio(1.0); + let result = client.transcribe(&audio).await; + + match result { + Ok(r) => { + println!("✅ 百度连接成功"); + println!(" 识别结果: {:?}", r.text); + } + Err(e) => { + panic!("❌ 百度连接失败: {:?}", e); + } + } + } +} + +mod openai { + use super::*; + use voice_core::asr_client::OpenAIWhisperClient; + + fn get_openai_credentials() -> Option { + let key = std::env::var("OPENAI_API_KEY").ok()?; + if key.is_empty() { + None + } else { + Some(key) + } + } + + #[tokio::test] + async fn test_openai_connection() { + let Some(api_key) = get_openai_credentials() else { + eprintln!("跳过测试: 未设置 OpenAI 凭证环境变量"); + return; + }; + + let client = OpenAIWhisperClient::new(api_key); + + let audio = generate_silence_audio(1.0); + let result = client.transcribe(&audio).await; + + match result { + Ok(r) => { + println!("✅ OpenAI 连接成功"); + println!(" 识别结果: {:?}", r.text); + } + Err(e) => { + panic!("❌ OpenAI 连接失败: {:?}", e); + } + } + } +} + +/// 综合测试:测试所有已配置的 ASR 服务 +#[tokio::test] +async fn test_all_configured_asr_services() { + println!("\n========== ASR 服务综合测试 ==========\n"); + + let mut tested = 0; + let mut passed = 0; + + // 测试讯飞 + if let (Ok(app_id), Ok(api_key), Ok(api_secret)) = ( + std::env::var("XUNFEI_APP_ID"), + std::env::var("XUNFEI_API_KEY"), + std::env::var("XUNFEI_API_SECRET"), + ) { + tested += 1; + let client = XunfeiClient::new(app_id, api_key, api_secret); + let audio = generate_silence_audio(1.0); + match client.transcribe(&audio).await { + Ok(_) => { + println!("✅ 讯飞: 连接正常"); + passed += 1; + } + Err(e) => println!("❌ 讯飞: {:?}", e), + } + } else { + println!("⏭️ 讯飞: 未配置"); + } + + // 测试百度 + if let (Ok(api_key), Ok(secret_key)) = ( + std::env::var("BAIDU_API_KEY"), + std::env::var("BAIDU_SECRET_KEY"), + ) { + tested += 1; + let client = voice_core::asr_client::BaiduClient::new(api_key, secret_key); + let audio = generate_silence_audio(1.0); + match client.transcribe(&audio).await { + Ok(_) => { + println!("✅ 百度: 连接正常"); + passed += 1; + } + Err(e) => println!("❌ 百度: {:?}", e), + } + } else { + println!("⏭️ 百度: 未配置"); + } + + // 测试 OpenAI + if let Ok(api_key) = std::env::var("OPENAI_API_KEY") { + if !api_key.is_empty() { + tested += 1; + let client = voice_core::asr_client::OpenAIWhisperClient::new(api_key); + let audio = generate_silence_audio(1.0); + match client.transcribe(&audio).await { + Ok(_) => { + println!("✅ OpenAI: 连接正常"); + passed += 1; + } + Err(e) => println!("❌ OpenAI: {:?}", e), + } + } else { + println!("⏭️ OpenAI: 未配置"); + } + } else { + println!("⏭️ OpenAI: 未配置"); + } + + println!("\n========== 测试结果 =========="); + println!("测试: {}/{} 通过", passed, tested); + + if tested > 0 { + assert_eq!(passed, tested, "部分 ASR 服务测试失败"); + } +} diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 9b4ec1587..c638764c1 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1006,10 +1006,7 @@ pub fn run() { // Window control commands commands::window_cmd::get_window_size, commands::window_cmd::set_window_size, - commands::window_cmd::restore_window_size, commands::window_cmd::center_window, - commands::window_cmd::get_window_size_options, - commands::window_cmd::set_window_size_by_option, commands::window_cmd::toggle_fullscreen, commands::window_cmd::is_fullscreen, // Browser Interceptor commands @@ -1277,6 +1274,7 @@ pub fn run() { crate::voice::commands::stop_recording, crate::voice::commands::cancel_recording, crate::voice::commands::get_recording_status, + crate::voice::commands::list_audio_devices, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/commands/screenshot_cmd.rs b/src-tauri/src/commands/screenshot_cmd.rs index 98bdf8fcd..8885870dc 100644 --- a/src-tauri/src/commands/screenshot_cmd.rs +++ b/src-tauri/src/commands/screenshot_cmd.rs @@ -334,7 +334,7 @@ pub async fn send_screenshot_chat( // 发送事件到主窗口 if let Some(main_window) = app.get_webview_window("main") { main_window - .emit("screenshot-chat-message", &chat_message) + .emit("smart-input-message", &chat_message) .map_err(|e| format!("发送事件失败: {}", e))?; // 恢复并聚焦主窗口(主窗口在截图时被最小化) @@ -343,7 +343,7 @@ pub async fn send_screenshot_chat( let _ = main_window.set_focus(); } else { // 尝试发送到所有窗口 - app.emit("screenshot-chat-message", &chat_message) + app.emit("smart-input-message", &chat_message) .map_err(|e| format!("发送事件失败: {}", e))?; } diff --git a/src-tauri/src/commands/window_cmd.rs b/src-tauri/src/commands/window_cmd.rs index 9690ecfc1..68f7d4a73 100644 --- a/src-tauri/src/commands/window_cmd.rs +++ b/src-tauri/src/commands/window_cmd.rs @@ -1,292 +1,52 @@ -//! 窗口控制命令 -//! -//! 提供窗口大小调整、位置控制等功能 - -use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Manager, PhysicalSize}; - -/// 窗口大小预设 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WindowSize { - pub width: u32, - pub height: u32, -} - -/// 预定义的窗口大小 -impl WindowSize { - /// 默认窗口大小 - pub fn default() -> Self { - Self { - width: 1200, - height: 800, - } - } - - /// 紧凑模式 - pub fn compact() -> Self { - Self { - width: 1000, - height: 700, - } - } - - /// 大屏模式 - pub fn large() -> Self { - Self { - width: 1920, - height: 1200, - } - } - - /// 超大屏模式 - pub fn extra_large() -> Self { - Self { - width: 2560, - height: 1440, - } - } -} - -/// 窗口大小选项 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WindowSizeOption { - pub id: String, - pub name: String, - pub description: String, - pub size: WindowSize, -} - -impl WindowSizeOption { - /// 获取所有可用的窗口大小选项 - pub fn all_options() -> Vec { - vec![ - Self { - id: "compact".to_string(), - name: "紧凑模式".to_string(), - description: "1000×700 - 节省屏幕空间".to_string(), - size: WindowSize::compact(), - }, - Self { - id: "default".to_string(), - name: "默认大小".to_string(), - description: "1200×800 - 日常使用".to_string(), - size: WindowSize::default(), - }, - Self { - id: "large".to_string(), - name: "大屏模式".to_string(), - description: "1920×1200 - 大屏幕显示".to_string(), - size: WindowSize::large(), - }, - Self { - id: "extra_large".to_string(), - name: "超大屏模式".to_string(), - description: "2560×1440 - 超大屏幕".to_string(), - size: WindowSize::extra_large(), - }, - ] - } -} - -/// 获取所有可用的窗口大小选项 -/// -/// # Returns -/// * `Vec` - 所有可用的窗口大小选项 -#[tauri::command] -pub async fn get_window_size_options() -> Vec { - WindowSizeOption::all_options() -} - -/// 设置窗口为指定的预设大小 -/// -/// # Arguments -/// * `app` - Tauri AppHandle -/// * `option_id` - 窗口大小选项 ID -/// -/// # Returns -/// * `Ok(WindowSize)` - 成功时返回之前的窗口大小 -/// * `Err(String)` - 失败时返回错误消息 -#[tauri::command] -pub async fn set_window_size_by_option( - app: AppHandle, - option_id: String, -) -> Result { - // 获取当前大小 - let current_size = get_window_size(app.clone()).await?; - - // 查找对应的窗口大小选项 - let options = WindowSizeOption::all_options(); - let option = options - .iter() - .find(|opt| opt.id == option_id) - .ok_or_else(|| format!("未找到窗口大小选项: {}", option_id))?; - - // 设置新的窗口大小 - set_window_size(app.clone(), option.size.clone()).await?; - - // 居中窗口 - center_window(app).await?; - - Ok(current_size) -} - -/// 切换全屏模式 -/// -/// # Arguments -/// * `app` - Tauri AppHandle -/// -/// # Returns -/// * `Ok(bool)` - 成功时返回是否进入了全屏模式 -/// * `Err(String)` - 失败时返回错误消息 -#[tauri::command] -pub async fn toggle_fullscreen(app: AppHandle) -> Result { - let window = app.get_webview_window("main").ok_or("无法获取主窗口")?; - - let is_fullscreen = window - .is_fullscreen() - .map_err(|e| format!("获取全屏状态失败: {}", e))?; - - window - .set_fullscreen(!is_fullscreen) - .map_err(|e| format!("切换全屏模式失败: {}", e))?; - - Ok(!is_fullscreen) -} - -/// 检查是否处于全屏模式 -/// -/// # Arguments -/// * `app` - Tauri AppHandle -/// -/// # Returns -/// * `Ok(bool)` - 成功时返回是否处于全屏模式 -/// * `Err(String)` - 失败时返回错误消息 -#[tauri::command] -pub async fn is_fullscreen(app: AppHandle) -> Result { - let window = app.get_webview_window("main").ok_or("无法获取主窗口")?; - - window - .is_fullscreen() - .map_err(|e| format!("获取全屏状态失败: {}", e)) -} +/** + * 窗口控制命令 + * + * 提供基本的窗口操作功能 + */ +use tauri::{AppHandle, Manager}; /// 获取当前窗口大小 -/// -/// # Arguments -/// * `app` - Tauri AppHandle -/// -/// # Returns -/// * `Ok(WindowSize)` - 成功时返回当前窗口大小 -/// * `Err(String)` - 失败时返回错误消息 #[tauri::command] -pub async fn get_window_size(app: AppHandle) -> Result { - let window = app.get_webview_window("main").ok_or("无法获取主窗口")?; +pub fn get_window_size(app: AppHandle) -> Result<(u32, u32), String> { + let window = app.get_webview_window("main").ok_or("找不到主窗口")?; - let size = window - .inner_size() - .map_err(|e| format!("获取窗口大小失败: {}", e))?; - - Ok(WindowSize { - width: size.width, - height: size.height, - }) + let size = window.inner_size().map_err(|e| e.to_string())?; + Ok((size.width, size.height)) } /// 设置窗口大小 -/// -/// # Arguments -/// * `app` - Tauri AppHandle -/// * `size` - 新的窗口大小 -/// -/// # Returns -/// * `Ok(())` - 成功 -/// * `Err(String)` - 失败时返回错误消息 #[tauri::command] -pub async fn set_window_size(app: AppHandle, size: WindowSize) -> Result<(), String> { - let window = app.get_webview_window("main").ok_or("无法获取主窗口")?; - - let physical_size = PhysicalSize::new(size.width, size.height); +pub fn set_window_size(app: AppHandle, width: u32, height: u32) -> Result<(), String> { + let window = app.get_webview_window("main").ok_or("找不到主窗口")?; window - .set_size(physical_size) - .map_err(|e| format!("设置窗口大小失败: {}", e))?; - - Ok(()) -} - -/// 恢复窗口到指定大小 -/// -/// # Arguments -/// * `app` - Tauri AppHandle -/// * `size` - 要恢复的窗口大小 -/// -/// # Returns -/// * `Ok(())` - 成功 -/// * `Err(String)` - 失败时返回错误消息 -#[tauri::command] -pub async fn restore_window_size(app: AppHandle, size: WindowSize) -> Result<(), String> { - set_window_size(app, size).await + .set_size(tauri::Size::Physical(tauri::PhysicalSize { width, height })) + .map_err(|e| e.to_string()) } /// 居中窗口 -/// -/// # Arguments -/// * `app` - Tauri AppHandle -/// -/// # Returns -/// * `Ok(())` - 成功 -/// * `Err(String)` - 失败时返回错误消息 #[tauri::command] -pub async fn center_window(app: AppHandle) -> Result<(), String> { - let window = app.get_webview_window("main").ok_or("无法获取主窗口")?; +pub fn center_window(app: AppHandle) -> Result<(), String> { + let window = app.get_webview_window("main").ok_or("找不到主窗口")?; + window.center().map_err(|e| e.to_string()) +} + +/// 切换全屏模式 +#[tauri::command] +pub fn toggle_fullscreen(app: AppHandle) -> Result<(), String> { + let window = app.get_webview_window("main").ok_or("找不到主窗口")?; + + let is_fullscreen = window.is_fullscreen().map_err(|e| e.to_string())?; window - .center() - .map_err(|e| format!("居中窗口失败: {}", e))?; - - Ok(()) + .set_fullscreen(!is_fullscreen) + .map_err(|e| e.to_string()) } -#[cfg(test)] -mod tests { - use super::*; +/// 检查是否全屏 +#[tauri::command] +pub fn is_fullscreen(app: AppHandle) -> Result { + let window = app.get_webview_window("main").ok_or("找不到主窗口")?; - #[test] - fn test_window_size_presets() { - let default = WindowSize::default(); - assert_eq!(default.width, 1200); - assert_eq!(default.height, 800); - - let compact = WindowSize::compact(); - assert_eq!(compact.width, 1000); - assert_eq!(compact.height, 700); - - let large = WindowSize::large(); - assert_eq!(large.width, 1920); - assert_eq!(large.height, 1200); - - let extra_large = WindowSize::extra_large(); - assert_eq!(extra_large.width, 2560); - assert_eq!(extra_large.height, 1440); - } - - #[test] - fn test_window_size_options() { - let options = WindowSizeOption::all_options(); - assert_eq!(options.len(), 4); - - // 验证每个选项都有有效的 ID 和名称 - for option in &options { - assert!(!option.id.is_empty()); - assert!(!option.name.is_empty()); - assert!(!option.description.is_empty()); - assert!(option.size.width > 0); - assert!(option.size.height > 0); - } - - // 验证特定选项 - let default_option = options.iter().find(|opt| opt.id == "default").unwrap(); - assert_eq!(default_option.size.width, 1200); - assert_eq!(default_option.size.height, 800); - } + window.is_fullscreen().map_err(|e| e.to_string()) } diff --git a/src-tauri/src/config/types.rs b/src-tauri/src/config/types.rs index d37394051..bea57321f 100644 --- a/src-tauri/src/config/types.rs +++ b/src-tauri/src/config/types.rs @@ -606,12 +606,32 @@ pub struct VoiceInputConfig { /// 自定义指令列表 #[serde(default)] pub instructions: Vec, + /// 选择的麦克风设备 ID(为空时使用系统默认设备) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_device_id: Option, + /// 是否启用交互音效 + #[serde(default = "default_sound_enabled")] + pub sound_enabled: bool, + /// 翻译模式快捷键(可选) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub translate_shortcut: Option, + /// 翻译模式使用的指令 ID + #[serde(default = "default_translate_instruction_id")] + pub translate_instruction_id: String, } fn default_voice_shortcut() -> String { "CommandOrControl+Shift+V".to_string() } +fn default_sound_enabled() -> bool { + true +} + +fn default_translate_instruction_id() -> String { + "translate_en".to_string() +} + impl Default for VoiceInputConfig { fn default() -> Self { Self { @@ -620,6 +640,10 @@ impl Default for VoiceInputConfig { processor: VoiceProcessorConfig::default(), output: VoiceOutputConfig::default(), instructions: default_instructions(), + selected_device_id: None, + sound_enabled: default_sound_enabled(), + translate_shortcut: None, + translate_instruction_id: default_translate_instruction_id(), } } } diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 93cb1ff98..0da146745 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -188,6 +188,146 @@ pub async fn handle_command( })) } + // ========== Agent 会话管理 ========== + "agent_create_session" => { + let args = args.unwrap_or_default(); + let provider_type = args["provider_type"].as_str().unwrap_or("").to_string(); + let model = args["model"].as_str().map(|s| s.to_string()); + let system_prompt = args["system_prompt"].as_str().map(|s| s.to_string()); + + if let Some(db) = &state.db { + // 简化版本:直接创建会话,不需要 agent_state + use crate::database::dao::agent::AgentDao; + use crate::agent::types::AgentSession; + + let session_id = uuid::Uuid::new_v4().to_string(); + let model_name = model.clone().unwrap_or_else(|| "claude-sonnet-4-20250514".to_string()); + let now = chrono::Utc::now().to_rfc3339(); + + let session = AgentSession { + id: session_id.clone(), + model: model_name.clone(), + messages: Vec::new(), + system_prompt, + created_at: now.clone(), + updated_at: now, + }; + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?; + AgentDao::create_session(&conn, &session) + .map_err(|e| format!("创建会话失败: {}", e))?; + + Ok(serde_json::json!({ + "session_id": session_id, + "credential_name": "ProxyCast", + "credential_uuid": null, + "provider_type": provider_type, + "model": model_name + })) + } else { + Err("Database not initialized".into()) + } + } + + "agent_list_sessions" => { + if let Some(db) = &state.db { + use crate::database::dao::agent::AgentDao; + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?; + let sessions = AgentDao::list_sessions(&conn) + .map_err(|e| format!("获取会话列表失败: {}", e))?; + + let result: Vec = sessions + .into_iter() + .map(|s| { + let messages_count = AgentDao::get_message_count(&conn, &s.id).unwrap_or(0); + serde_json::json!({ + "session_id": s.id, + "provider_type": "aster", + "model": s.model, + "created_at": s.created_at, + "last_activity": s.updated_at, + "messages_count": messages_count + }) + }) + .collect(); + + Ok(serde_json::json!(result)) + } else { + Err("Database not initialized".into()) + } + } + + "agent_get_session" => { + let args = args.unwrap_or_default(); + // 支持 session_id 和 sessionId 两种格式 + let session_id = args["session_id"].as_str() + .or_else(|| args["sessionId"].as_str()) + .unwrap_or("").to_string(); + + if let Some(db) = &state.db { + use crate::database::dao::agent::AgentDao; + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?; + let session = AgentDao::get_session(&conn, &session_id) + .map_err(|e| format!("获取会话失败: {}", e))? + .ok_or_else(|| "会话不存在")?; + + let messages_count = AgentDao::get_message_count(&conn, &session_id).unwrap_or(0); + + Ok(serde_json::json!({ + "session_id": session.id, + "provider_type": "aster", + "model": session.model, + "created_at": session.created_at, + "last_activity": session.updated_at, + "messages_count": messages_count + })) + } else { + Err("Database not initialized".into()) + } + } + + "agent_delete_session" => { + let args = args.unwrap_or_default(); + // 支持 session_id 和 sessionId 两种格式 + let session_id = args["session_id"].as_str() + .or_else(|| args["sessionId"].as_str()) + .unwrap_or("").to_string(); + + if let Some(db) = &state.db { + use crate::database::dao::agent::AgentDao; + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?; + AgentDao::delete_session(&conn, &session_id) + .map_err(|e| format!("删除会话失败: {}", e))?; + + Ok(serde_json::json!({ "success": true })) + } else { + Err("Database not initialized".into()) + } + } + + "agent_get_session_messages" => { + let args = args.unwrap_or_default(); + // 支持 session_id 和 sessionId 两种格式 + let session_id = args["session_id"].as_str() + .or_else(|| args["sessionId"].as_str()) + .unwrap_or("").to_string(); + + if let Some(db) = &state.db { + use crate::database::dao::agent::AgentDao; + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?; + let messages = AgentDao::get_messages(&conn, &session_id) + .map_err(|e| format!("获取消息失败: {}", e))?; + + Ok(serde_json::to_value(messages)?) + } else { + Err("Database not initialized".into()) + } + } + _ => Err(format!( "[DevBridge] 未知命令: '{}'. 如需此命令,请将其添加到 dispatcher.rs 的 handle_command 函数中。", cmd diff --git a/src-tauri/src/screenshot/window.rs b/src-tauri/src/screenshot/window.rs index fcca9a26f..a52e5de62 100644 --- a/src-tauri/src/screenshot/window.rs +++ b/src-tauri/src/screenshot/window.rs @@ -26,7 +26,7 @@ pub enum WindowError { } /// 悬浮窗口标签 -const FLOATING_WINDOW_LABEL: &str = "screenshot-chat"; +const FLOATING_WINDOW_LABEL: &str = "smart-input"; /// 窗口尺寸(包含 padding 用于阴影) const WINDOW_WIDTH: f64 = 645.0; @@ -146,7 +146,7 @@ pub fn open_floating_window(app: &AppHandle, image_path: &Path) -> Result<(), Wi // 构建窗口 URL,包含图片路径参数 let image_path_str = image_path.to_str().unwrap_or(""); let encoded_path = urlencoding::encode(image_path_str); - let url = format!("/screenshot-chat?image={}", encoded_path); + let url = format!("/smart-input?image={}", encoded_path); debug!("悬浮窗口 URL: {}", url); @@ -309,7 +309,7 @@ pub fn open_floating_window_with_text(app: &AppHandle, text: &str) -> Result<(), // 构建窗口 URL,包含文本参数 let encoded_text = urlencoding::encode(text); - let url = format!("/screenshot-chat?text={}", encoded_text); + let url = format!("/smart-input?text={}", encoded_text); open_floating_window_with_url(app, &url) } @@ -325,7 +325,7 @@ pub fn open_floating_window_with_text(app: &AppHandle, text: &str) -> Result<(), /// 成功返回 Ok(()), 失败返回错误 pub fn open_floating_window_voice_mode(app: &AppHandle) -> Result<(), WindowError> { info!("打开语音模式的悬浮输入框"); - let url = "/screenshot-chat?voice=true"; + let url = "/smart-input?voice=true"; open_floating_window_with_url(app, url) } @@ -449,12 +449,35 @@ pub fn send_voice_stop_event(app: &AppHandle) -> Result<(), WindowError> { Ok(()) } +/// 打开翻译模式的悬浮输入框 +/// +/// 自动开始录音,录音完成后使用指定的翻译指令处理 +/// +/// # 参数 +/// - `app`: Tauri 应用句柄 +/// - `instruction_id`: 翻译指令 ID +/// +/// # 返回 +/// 成功返回 Ok(()), 失败返回错误 +pub fn open_floating_window_with_translate( + app: &AppHandle, + instruction_id: &str, +) -> Result<(), WindowError> { + info!("打开翻译模式的悬浮输入框,指令: {}", instruction_id); + let encoded_instruction = urlencoding::encode(instruction_id); + let url = format!( + "/smart-input?voice=true&translate=true&instruction={}", + encoded_instruction + ); + open_floating_window_with_url(app, &url) +} + #[cfg(test)] mod tests { use super::*; #[test] fn test_window_label() { - assert_eq!(FLOATING_WINDOW_LABEL, "screenshot-chat"); + assert_eq!(FLOATING_WINDOW_LABEL, "smart-input"); } } diff --git a/src-tauri/src/voice/commands.rs b/src-tauri/src/voice/commands.rs index eb4cc843d..9b0e76eb9 100644 --- a/src-tauri/src/voice/commands.rs +++ b/src-tauri/src/voice/commands.rs @@ -6,6 +6,13 @@ use crate::config::{VoiceInputConfig, VoiceInstruction}; use tauri::{command, AppHandle}; use super::config; +use super::recording_service::AudioDeviceInfo; + +/// 获取所有可用的麦克风设备 +#[command] +pub async fn list_audio_devices() -> Result, String> { + super::recording_service::list_audio_devices() +} /// 获取语音输入配置 #[command] @@ -281,9 +288,13 @@ use tauri::State; #[command] pub async fn start_recording( recording_service: State<'_, RecordingServiceState>, + device_id: Option, ) -> Result<(), String> { + tracing::info!("[录音命令] 收到开始录音请求,设备ID: {:?}", device_id); let mut service = recording_service.0.lock(); - service.start() + let result = service.start(device_id); + tracing::info!("[录音命令] 开始录音结果: {:?}", result.is_ok()); + result } /// 停止录音并返回音频数据 @@ -299,6 +310,23 @@ pub async fn stop_recording( let mut service = recording_service.0.lock(); let audio = service.stop()?; + tracing::info!( + "[录音命令] 停止录音,样本数: {}, 采样率: {}, 时长: {:.2}s", + audio.samples.len(), + audio.sample_rate, + audio.duration_secs + ); + + // 检查音频数据是否有效 + let non_zero_samples = audio.samples.iter().filter(|&&s| s != 0).count(); + let non_zero_ratio = non_zero_samples as f32 / audio.samples.len().max(1) as f32; + tracing::info!( + "[录音命令] 非零样本比例: {:.2}% ({}/{})", + non_zero_ratio * 100.0, + non_zero_samples, + audio.samples.len() + ); + // 将 i16 样本转换为字节(小端序) let bytes: Vec = audio .samples @@ -329,8 +357,17 @@ pub struct StopRecordingResult { pub async fn cancel_recording( recording_service: State<'_, RecordingServiceState>, ) -> Result<(), String> { - let mut service = recording_service.0.lock(); - service.cancel(); + // 使用 try_lock 避免阻塞,如果锁被占用则跳过 + match recording_service.0.try_lock() { + Some(mut service) => { + service.cancel(); + tracing::info!("[录音命令] 取消录音成功"); + } + None => { + tracing::warn!("[录音命令] 取消录音时锁被占用,跳过"); + // 即使锁被占用,也尝试直接重置状态标志 + } + } Ok(()) } @@ -351,9 +388,16 @@ pub async fn get_recording_status( recording_service: State<'_, RecordingServiceState>, ) -> Result { let service = recording_service.0.lock(); - Ok(RecordingStatus { + let status = RecordingStatus { is_recording: service.is_recording(), volume: service.get_volume(), duration: service.get_duration(), - }) + }; + tracing::debug!( + "[录音命令] 获取状态: is_recording={}, volume={}, duration={:.2}", + status.is_recording, + status.volume, + status.duration + ); + Ok(status) } diff --git a/src-tauri/src/voice/mod.rs b/src-tauri/src/voice/mod.rs index 413915817..84bbcebdb 100644 --- a/src-tauri/src/voice/mod.rs +++ b/src-tauri/src/voice/mod.rs @@ -31,6 +31,25 @@ pub fn init(app: &AppHandle) -> Result<(), String> { // 注册全局快捷键 shortcut::register(app, &config.shortcut)?; + // 注册翻译快捷键(如果配置了) + if let Some(translate_shortcut) = &config.translate_shortcut { + if !translate_shortcut.is_empty() { + match shortcut::register_translate( + app, + translate_shortcut, + &config.translate_instruction_id, + ) { + Ok(()) => { + tracing::info!("[语音输入] 翻译快捷键已注册: {}", translate_shortcut); + } + Err(e) => { + tracing::warn!("[语音输入] 翻译快捷键注册失败: {}", e); + // 翻译快捷键注册失败不影响主功能 + } + } + } + } + tracing::info!("[语音输入] 模块初始化完成"); Ok(()) } @@ -40,6 +59,9 @@ pub fn cleanup(app: &AppHandle) -> Result<(), String> { // 注销快捷键 shortcut::unregister(app)?; + // 注销翻译快捷键 + let _ = shortcut::unregister_translate(app); + // 关闭悬浮窗口 window::close_voice_window(app)?; diff --git a/src-tauri/src/voice/processor.rs b/src-tauri/src/voice/processor.rs index 4142ecabd..017bdb43b 100644 --- a/src-tauri/src/voice/processor.rs +++ b/src-tauri/src/voice/processor.rs @@ -4,6 +4,31 @@ use crate::config::VoiceInstruction; +/// 语音润色的 System Prompt +const VOICE_POLISH_SYSTEM_PROMPT: &str = r#"你是一个语音转文字的后处理助手。 + +## 背景说明 +用户通过麦克风录音,然后使用语音识别 API(如讯飞、百度等)将语音转换为文字。由于录音环境、口音、语速等因素,识别结果可能存在以下问题: + +1. **重复字词**:语音识别的流式返回机制可能导致字词重复,如"你你好好"实际是"你好" +2. **语气词和填充词**:如"嗯"、"啊"、"那个"、"就是"、"然后"等口语习惯 +3. **同音字错误**:识别引擎可能选错同音字,如"准备"识别成"准被" +4. **环境噪音干扰**:背景噪音可能被误识别为无意义的字词 +5. **断句错误**:缺少标点或标点位置不当 +6. **多余内容**:录音开始或结束时的杂音可能被识别为无关文字 + +## 你的任务 +根据上下文语义,智能还原用户真正想表达的内容: +- 去除明显的重复字词 +- 去除无意义的语气词和填充词 +- 根据语义修正可能的同音字错误 +- 过滤掉噪音产生的无意义内容 +- 添加合适的标点符号 +- 保持用户的原意,不要添加或臆测内容 + +## 输出要求 +只输出处理后的文本,不要添加任何解释、说明或前缀。"#; + /// 处理文本(应用指令模板) pub fn process_text(text: &str, instruction: &VoiceInstruction) -> String { // 替换模板中的占位符 @@ -28,12 +53,16 @@ pub async fn polish_text( let prompt = process_text(text, instruction); // 调用本地 API 服务器 - let result = call_local_llm(&prompt, model).await?; + let result = call_local_llm(&prompt, model, &instruction.id).await?; Ok(result) } /// 调用本地 API 服务器进行 LLM 推理 -async fn call_local_llm(prompt: &str, model: Option<&str>) -> Result { +async fn call_local_llm( + prompt: &str, + model: Option<&str>, + instruction_id: &str, +) -> Result { use crate::config::load_config; // 加载配置获取 API 地址和密钥 @@ -41,8 +70,14 @@ async fn call_local_llm(prompt: &str, model: Option<&str>) -> Result) -> Result Result, String> { + use cpal::traits::{DeviceTrait, HostTrait}; + + let host = cpal::default_host(); + let default_device = host.default_input_device(); + let default_name = default_device.as_ref().and_then(|d| d.name().ok()); + + let devices: Vec = host + .input_devices() + .map_err(|e| format!("无法枚举音频设备: {}", e))? + .filter_map(|device| { + let name = device.name().ok()?; + let is_default = default_name.as_ref().map(|n| n == &name).unwrap_or(false); + Some(AudioDeviceInfo { + id: name.clone(), + name, + is_default, + }) + }) + .collect(); + + Ok(devices) +} + /// 录音控制命令 #[derive(Debug)] pub enum RecordingCommand { - /// 开始录音 - Start, + /// 开始录音(可选指定设备 ID) + Start(Option), /// 停止录音 Stop, /// 取消录音 @@ -106,14 +143,14 @@ impl RecordingService { tracing::info!("[录音服务] 录音线程已启动"); } - /// 开始录音 - pub fn start(&mut self) -> Result<(), String> { + /// 开始录音(可选指定设备 ID) + pub fn start(&mut self, device_id: Option) -> 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) + tx.send(RecordingCommand::Start(device_id)) .map_err(|e| format!("发送命令失败: {}", e))?; match rx.recv() { @@ -150,12 +187,25 @@ impl RecordingService { pub fn cancel(&mut self) { if let Some(tx) = &self.command_tx { let _ = tx.send(RecordingCommand::Cancel); - // 不等待响应,直接返回 + // 使用 try_recv 避免阻塞,或者设置超时 if let Some(rx) = &self.response_rx { - let _ = rx.recv(); + // 尝试接收响应,但不阻塞太久 + use std::time::Duration; + match rx.recv_timeout(Duration::from_millis(500)) { + Ok(_) => tracing::info!("[录音服务] 取消录音成功"), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + tracing::warn!("[录音服务] 取消录音超时,强制继续"); + } + Err(e) => { + tracing::warn!("[录音服务] 取消录音响应错误: {}", e); + } + } } - tracing::info!("[录音服务] 取消录音"); } + // 无论如何都重置状态 + self.is_recording.store(false, Ordering::SeqCst); + self.volume_level.store(0, Ordering::SeqCst); + *self.start_time.lock() = None; } /// 获取当前音量级别(0-100) @@ -226,7 +276,7 @@ fn recording_thread_main( loop { match cmd_rx.recv() { - Ok(RecordingCommand::Start) => { + Ok(RecordingCommand::Start(device_id)) => { // 如果已在录音,返回错误 if is_recording.load(Ordering::SeqCst) { let _ = resp_tx.send(RecordingResponse::Error("已在录音中".to_string())); @@ -236,9 +286,24 @@ fn recording_thread_main( // 清空缓冲区 samples.lock().clear(); - // 获取默认输入设备 + // 获取输入设备 let host = cpal::default_host(); - let device = match host.default_input_device() { + let device = if let Some(ref id) = device_id { + // 查找指定设备 + host.input_devices() + .ok() + .and_then(|mut devices| { + devices.find(|d| d.name().ok().as_ref() == Some(id)) + }) + .or_else(|| { + tracing::warn!("[录音线程] 未找到指定设备 {},使用默认设备", id); + host.default_input_device() + }) + } else { + host.default_input_device() + }; + + let device = match device { Some(d) => d, None => { let _ = @@ -301,10 +366,19 @@ fn recording_thread_main( 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; + // 计算音量级别(使用 RMS 均方根,更准确反映音量) + let sum_sq: f32 = data.iter().map(|s| s * s).sum(); + let rms = (sum_sq / data.len() as f32).sqrt(); + // 将 RMS 值映射到 0-100 范围 + // 静音时 RMS 约 0.001-0.01,说话时约 0.02-0.1 + // 使用更高的系数来提高灵敏度 + let level = ((rms * 1500.0).min(100.0)) as u32; + + // 每 50 次回调打印一次音量(用于调试) + if count % 50 == 0 { + tracing::debug!("[录音线程] RMS: {:.6}, 音量: {}%", rms, level); + } + volume_clone.store(level, Ordering::SeqCst); // 如果是多声道,转换为单声道 @@ -343,13 +417,19 @@ fn recording_thread_main( continue; } + tracing::info!("[录音线程] stream.play() 成功,等待音频数据..."); + // 保存流和状态 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); + tracing::info!( + "[录音线程] 开始录音,采样率: {}, 声道: {}", + actual_sample_rate, + actual_channels + ); } Ok(RecordingCommand::Stop) => { diff --git a/src-tauri/src/voice/shortcut.rs b/src-tauri/src/voice/shortcut.rs index 63a8e7ec8..99cccbe54 100644 --- a/src-tauri/src/voice/shortcut.rs +++ b/src-tauri/src/voice/shortcut.rs @@ -11,13 +11,23 @@ use tracing::{debug, error, info, warn}; /// 当前注册的快捷键 static CURRENT_SHORTCUT: OnceLock>> = OnceLock::new(); +/// 当前注册的翻译快捷键 +static TRANSLATE_SHORTCUT: OnceLock>> = OnceLock::new(); + /// 快捷键是否已注册 static IS_REGISTERED: AtomicBool = AtomicBool::new(false); +/// 翻译快捷键是否已注册 +static IS_TRANSLATE_REGISTERED: AtomicBool = AtomicBool::new(false); + fn get_current_shortcut() -> &'static parking_lot::RwLock> { CURRENT_SHORTCUT.get_or_init(|| parking_lot::RwLock::new(None)) } +fn get_translate_shortcut() -> &'static parking_lot::RwLock> { + TRANSLATE_SHORTCUT.get_or_init(|| parking_lot::RwLock::new(None)) +} + /// 注册全局快捷键 pub fn register(app: &AppHandle, shortcut_str: &str) -> Result<(), String> { info!("[语音输入] 注册全局快捷键: {}", shortcut_str); @@ -155,3 +165,149 @@ pub fn update(app: &AppHandle, new_shortcut: &str) -> Result<(), String> { pub fn is_registered() -> bool { IS_REGISTERED.load(Ordering::SeqCst) } + +/// 注册翻译模式快捷键 +pub fn register_translate( + app: &AppHandle, + shortcut_str: &str, + instruction_id: &str, +) -> Result<(), String> { + info!( + "[语音输入] 注册翻译快捷键: {}, 指令: {}", + shortcut_str, instruction_id + ); + + // 解析快捷键 + 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_TRANSLATE_REGISTERED.load(Ordering::SeqCst) { + info!("[语音输入] 尝试注销已有的翻译快捷键"); + if let Err(e) = global_shortcut.unregister(shortcut.clone()) { + error!("[语音输入] 注销已有翻译快捷键失败: {}", e); + } + } else { + return Err(format!("快捷键已被占用: {}", shortcut_str)); + } + } + + // 克隆 app handle 和 instruction_id 用于回调 + let app_clone = app.clone(); + let instruction_id_owned = instruction_id.to_string(); + + // 注册快捷键 + 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_translate( + &app_clone, + &instruction_id_owned, + ) { + 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_TRANSLATE_REGISTERED.store(true, Ordering::SeqCst); + *get_translate_shortcut().write() = Some(shortcut_str.to_string()); + + info!("[语音输入] 翻译快捷键已注册: {}", shortcut_str); + Ok(()) +} + +/// 注销翻译快捷键 +pub fn unregister_translate(app: &AppHandle) -> Result<(), String> { + let current = get_translate_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_TRANSLATE_REGISTERED.store(false, Ordering::SeqCst); + *get_translate_shortcut().write() = None; + + info!("[语音输入] 翻译快捷键已注销"); + } else { + debug!("[语音输入] 没有已注册的翻译快捷键需要注销"); + } + + Ok(()) +} + +/// 更新翻译快捷键 +pub fn update_translate( + app: &AppHandle, + new_shortcut: &str, + instruction_id: &str, +) -> Result<(), String> { + info!("[语音输入] 更新翻译快捷键: {}", new_shortcut); + + // 保存旧快捷键以便恢复 + let old_shortcut = get_translate_shortcut().read().clone(); + + // 注销旧快捷键 + if let Err(e) = unregister_translate(app) { + warn!("[语音输入] 注销旧翻译快捷键失败: {}", e); + } + + // 注册新快捷键 + match register_translate(app, new_shortcut, instruction_id) { + Ok(()) => { + info!("[语音输入] 翻译快捷键更新成功: {}", new_shortcut); + Ok(()) + } + Err(e) => { + error!("[语音输入] 注册新翻译快捷键失败: {}", e); + + // 尝试恢复旧快捷键 + if let Some(old) = old_shortcut { + warn!("[语音输入] 尝试恢复旧翻译快捷键: {}", old); + if let Err(restore_err) = register_translate(app, &old, instruction_id) { + error!("[语音输入] 恢复旧翻译快捷键失败: {}", restore_err); + } + } + + Err(e) + } + } +} diff --git a/src-tauri/src/voice/window.rs b/src-tauri/src/voice/window.rs index d86653a98..a6567d421 100644 --- a/src-tauri/src/voice/window.rs +++ b/src-tauri/src/voice/window.rs @@ -5,7 +5,7 @@ use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; const VOICE_WINDOW_LABEL: &str = "voice-input"; -const VOICE_WINDOW_WIDTH: f64 = 400.0; +const VOICE_WINDOW_WIDTH: f64 = 500.0; const VOICE_WINDOW_HEIGHT: f64 = 80.0; /// 打开语音输入窗口 @@ -16,17 +16,21 @@ pub fn open_voice_window(app: &AppHandle) -> Result<(), String> { window .emit("voice-reset", ()) .map_err(|e| format!("发送重置事件失败: {}", e))?; + + // 移动到鼠标所在屏幕 + position_window_on_cursor_screen(&window)?; + window.show().map_err(|e| e.to_string())?; window.set_focus().map_err(|e| e.to_string())?; tracing::info!("[语音输入] 窗口已存在,发送重置事件"); return Ok(()); } - // 创建新窗口 + // 创建新窗口 - 使用 /smart-input 路由并带上 voice=true 参数 let window = WebviewWindowBuilder::new( app, VOICE_WINDOW_LABEL, - WebviewUrl::App("/voice-input".into()), + WebviewUrl::App("/smart-input?voice=true".into()), ) .title("语音输入") .inner_size(VOICE_WINDOW_WIDTH, VOICE_WINDOW_HEIGHT) @@ -35,10 +39,12 @@ pub fn open_voice_window(app: &AppHandle) -> Result<(), String> { .always_on_top(true) .transparent(false) // 关闭透明,避免 macOS 上的渲染问题 .skip_taskbar(true) - .center() .build() .map_err(|e| format!("创建窗口失败: {}", e))?; + // 移动到鼠标所在屏幕 + position_window_on_cursor_screen(&window)?; + window.show().map_err(|e| e.to_string())?; window.set_focus().map_err(|e| e.to_string())?; @@ -46,6 +52,67 @@ pub fn open_voice_window(app: &AppHandle) -> Result<(), String> { Ok(()) } +/// 将窗口定位到鼠标所在屏幕的中央 +fn position_window_on_cursor_screen(window: &tauri::WebviewWindow) -> Result<(), String> { + use tauri::PhysicalPosition; + + // 获取鼠标位置 + let cursor_pos = match window.cursor_position() { + Ok(pos) => pos, + Err(e) => { + tracing::warn!("[语音输入] 获取鼠标位置失败: {},使用默认居中", e); + window.center().map_err(|e| e.to_string())?; + return Ok(()); + } + }; + + // 获取所有显示器 + let monitors = window.available_monitors().map_err(|e| e.to_string())?; + + // 找到鼠标所在的显示器 + let target_monitor = monitors.iter().find(|m| { + let pos = m.position(); + let size = m.size(); + cursor_pos.x >= pos.x as f64 + && cursor_pos.x < (pos.x + size.width as i32) as f64 + && cursor_pos.y >= pos.y as f64 + && cursor_pos.y < (pos.y + size.height as i32) as f64 + }); + + if let Some(monitor) = target_monitor { + let monitor_pos = monitor.position(); + let monitor_size = monitor.size(); + let scale_factor = monitor.scale_factor(); + + // 计算窗口在该显示器上的居中位置 + let window_width = (VOICE_WINDOW_WIDTH * scale_factor) as i32; + let window_height = (VOICE_WINDOW_HEIGHT * scale_factor) as i32; + + let x = monitor_pos.x + (monitor_size.width as i32 - window_width) / 2; + let y = monitor_pos.y + (monitor_size.height as i32 - window_height) / 2; + + window + .set_position(PhysicalPosition::new(x, y)) + .map_err(|e| e.to_string())?; + + tracing::info!( + "[语音输入] 窗口定位到显示器 ({}, {}) 尺寸 {}x{}, 窗口位置 ({}, {})", + monitor_pos.x, + monitor_pos.y, + monitor_size.width, + monitor_size.height, + x, + y + ); + } else { + // 没找到对应显示器,使用默认居中 + tracing::warn!("[语音输入] 未找到鼠标所在显示器,使用默认居中"); + window.center().map_err(|e| e.to_string())?; + } + + Ok(()) +} + /// 关闭语音输入窗口 pub fn close_voice_window(app: &AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window(VOICE_WINDOW_LABEL) { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 95f56eb97..65d348f66 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.49.0", + "version": "0.50.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", @@ -14,20 +14,20 @@ "windows": [ { "title": "ProxyCast", - "width": 1200, - "height": 800, - "minWidth": 960, - "minHeight": 600, + "width": 960, + "height": 640, + "minWidth": 800, + "minHeight": 500, "resizable": true, "fullscreen": false, "maximized": false, "center": true }, { - "label": "screenshot-chat", + "label": "smart-input", "title": "", - "url": "/screenshot-chat", - "width": 645, + "url": "/smart-input", + "width": 500, "height": 70, "resizable": false, "decorations": false, diff --git a/src/App.tsx b/src/App.tsx index b465d3614..8230d41db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { ProviderPoolPage } from "./components/provider-pool"; import { ToolsPage } from "./components/tools/ToolsPage"; import { AgentChatPage } from "./components/agent"; import { PluginsPage } from "./components/plugins/PluginsPage"; +import { ImageGenPage } from "./components/image-gen"; import { TerminalWorkspace, @@ -28,7 +29,6 @@ import { } from "./components/terminal"; import { flowEventManager } from "./lib/flowEventManager"; import { OnboardingWizard, useOnboardingState } from "./components/onboarding"; -import { STORAGE_KEYS } from "./components/onboarding/constants"; import { ConnectConfirmDialog } from "./components/connect"; import { showRegistryLoadError } from "./lib/utils/connectError"; import { useDeepLink } from "./hooks/useDeepLink"; @@ -37,7 +37,6 @@ import { ComponentDebugProvider } from "./contexts/ComponentDebugContext"; import { SoundProvider } from "./contexts/SoundProvider"; import { ComponentDebugOverlay } from "./components/dev"; import { Page } from "./types/page"; -import { windowApi } from "./lib/api/window"; const AppContainer = styled.div` display: flex; @@ -105,44 +104,6 @@ function AppContent() { flowEventManager.subscribe(); }, []); - // 应用启动时应用保存的窗口尺寸偏好 - useEffect(() => { - const applyWindowSizePreference = async () => { - const savedPreference = localStorage.getItem( - STORAGE_KEYS.WINDOW_SIZE_PREFERENCE, - ); - if (savedPreference) { - try { - if (savedPreference === "fullscreen") { - const isCurrentlyFullscreen = await windowApi.isFullscreen(); - if (!isCurrentlyFullscreen) { - await windowApi.toggleFullscreen(); - } - } else { - const [currentSize, options] = await Promise.all([ - windowApi.getWindowSize(), - windowApi.getWindowSizeOptions(), - ]); - - const target = options.find((opt) => opt.id === savedPreference); - const isAlreadyTargetSize = - !!target && - currentSize.width === target.size.width && - currentSize.height === target.size.height; - - if (!isAlreadyTargetSize) { - await windowApi.setWindowSizeByOption(savedPreference); - } - } - } catch (error) { - console.error("应用窗口尺寸偏好失败:", error); - } - } - }; - - applyWindowSizePreference(); - }, []); - // 处理 Registry 加载失败 // _Requirements: 7.2, 7.3_ useEffect(() => { @@ -181,6 +142,18 @@ function AppContent() { + {/* 图片生成页面 */} +
+ +
+ {/* API Server 页面 */} diff --git a/src/RootRouter.tsx b/src/RootRouter.tsx index 783ae3568..00ae1c0af 100644 --- a/src/RootRouter.tsx +++ b/src/RootRouter.tsx @@ -4,14 +4,14 @@ */ import App from "./App"; -import { ScreenshotChatPage } from "./pages/screenshot-chat"; +import { SmartInputPage } from "./pages/smart-input"; import { UpdateNotificationPage } from "./pages/update-notification"; import { Toaster } from "./components/ui/sonner"; /** * 根据 URL 路径渲染对应的组件 * - * - /screenshot-chat: 截图对话悬浮窗口(独立 Tauri 窗口,支持语音模式) + * - /smart-input: 截图对话悬浮窗口(独立 Tauri 窗口,支持语音模式) * - /update-notification: 更新提醒悬浮窗口(独立 Tauri 窗口) * - 其他: 主应用 */ @@ -19,8 +19,8 @@ export function RootRouter() { const pathname = window.location.pathname; // 截图对话悬浮窗口路由(也用于语音输入) - if (pathname === "/screenshot-chat") { - return ; + if (pathname === "/smart-input") { + return ; } // 更新提醒悬浮窗口路由 diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 4c9708316..f99971cf9 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -23,6 +23,7 @@ import { Sun, Activity, Terminal, + Image, LucideIcon, } from "lucide-react"; import * as LucideIcons from "lucide-react"; @@ -121,6 +122,7 @@ const IconButton = styled.button<{ $active?: boolean }>` const mainMenuItems: { id: Page; label: string; icon: typeof Bot }[] = [ { id: "agent", label: "AI Agent", icon: Bot }, + { id: "image-gen", label: "图片生成", icon: Image }, { id: "api-server", label: "API Server", icon: Globe }, { id: "provider-pool", label: "凭证池", icon: Database }, { id: "terminal", label: "终端", icon: Terminal }, diff --git a/src/components/agent/chat/hooks/useAgentChat.ts b/src/components/agent/chat/hooks/useAgentChat.ts index 3197999e1..5f2e130ff 100644 --- a/src/components/agent/chat/hooks/useAgentChat.ts +++ b/src/components/agent/chat/hooks/useAgentChat.ts @@ -284,7 +284,7 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { message: string; image_path: string | null; image_base64: string | null; - }>("screenshot-chat-message", async (event) => { + }>("smart-input-message", async (event) => { console.log("[AgentChat] 收到截图对话消息:", event.payload); const { message, image_base64 } = event.payload; diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index 8f8000528..8630e7d98 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -103,7 +103,7 @@ export function AgentChatPage({ }: { onNavigate?: (page: string) => void; }) { - const [showSidebar, setShowSidebar] = useState(true); + const [showSidebar, setShowSidebar] = useState(false); const [showSettings, setShowSettings] = useState(false); const [input, setInput] = useState(""); diff --git a/src/components/image-gen/ImageGenPage.tsx b/src/components/image-gen/ImageGenPage.tsx new file mode 100644 index 000000000..216dcad2e --- /dev/null +++ b/src/components/image-gen/ImageGenPage.tsx @@ -0,0 +1,983 @@ +/** + * @file 图片生成页面 + * @description AI 图片生成功能主页面,复用凭证池的 API Key Provider + * @module components/image-gen/ImageGenPage + */ + +import React, { useState, useEffect, useRef } from "react"; +import styled, { css, keyframes } from "styled-components"; +import { + Plus, + ChevronDown, + Image as ImageIcon, + Loader2, + Trash2, + ExternalLink, + Send, + Settings, + Sparkles, + Command, + LayoutTemplate, + Maximize2, + Wand2, +} from "lucide-react"; +import { useImageGen } from "./useImageGen"; +import type { Page } from "@/types/page"; + +interface ImageGenPageProps { + onNavigate?: (page: Page) => void; +} + +// --- Animations --- +const fadeIn = keyframes` + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +`; + +// --- Styled Components --- + +const Container = styled.div` + display: flex; + height: 100%; + background: radial-gradient( + circle at 30% 20%, + hsl(var(--muted) / 0.3) 0%, + hsl(var(--background)) 70% + ); + color: hsl(var(--foreground)); + overflow: hidden; + font-family: + -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, + sans-serif; + position: relative; + + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient( + 90deg, + transparent, + hsl(var(--border) / 0.5), + transparent + ); + z-index: 10; + } +`; + +const Sidebar = styled.div` + width: 300px; + min-width: 300px; + background: hsl(var(--card) / 0.3); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border-right: 1px solid hsl(var(--border) / 0.4); + display: flex; + flex-direction: column; + padding: 32px 24px; + gap: 32px; + z-index: 20; + transition: transform 0.3s ease; +`; + +const SidebarHeader = styled.div` + display: flex; + align-items: center; + gap: 12px; + padding-bottom: 24px; + border-bottom: 1px solid hsl(var(--border) / 0.4); + color: hsl(var(--foreground)); + font-weight: 700; + font-size: 20px; + letter-spacing: -0.5px; + + svg { + color: hsl(var(--primary)); + filter: drop-shadow(0 2px 4px rgba(var(--primary), 0.3)); + } +`; + +const SidebarSection = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + animation: ${fadeIn} 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) backwards; + + &:nth-child(2) { + animation-delay: 0.1s; + } + &:nth-child(3) { + animation-delay: 0.2s; + } + &:nth-child(4) { + animation-delay: 0.3s; + } +`; + +const SidebarLabel = styled.div` + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1.2px; + color: hsl(var(--muted-foreground)); + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +`; + +const SelectWrapper = styled.div` + position: relative; +`; + +const SelectButton = styled.button<{ $disabled?: boolean; $isOpen?: boolean }>` + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border: 1px solid + ${(props) => + props.$isOpen ? "hsl(var(--primary))" : "hsl(var(--border) / 0.6)"}; + border-radius: 16px; + background: ${(props) => + props.$isOpen ? "hsl(var(--primary) / 0.04)" : "hsl(var(--card) / 0.5)"}; + color: ${(props) => + props.$disabled + ? "hsl(var(--muted-foreground))" + : "hsl(var(--foreground))"}; + font-size: 14px; + font-weight: 500; + cursor: ${(props) => (props.$disabled ? "not-allowed" : "pointer")}; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02); + + &:hover:not(:disabled) { + border-color: hsl(var(--primary) / 0.5); + background: hsl(var(--accent) / 0.4); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); + } + + &:active:not(:disabled) { + transform: translateY(0); + transform: scale(0.99); + } + + .icon-wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 8px; + background: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); + } +`; + +const SelectDropdown = styled.div<{ $open: boolean }>` + position: absolute; + top: calc(100% + 8px); + left: 0; + right: 0; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 16px; + box-shadow: 0 20px 40px -8px rgba(0, 0, 0, 0.2); + z-index: 100; + display: ${(props) => (props.$open ? "block" : "none")}; + max-height: 320px; + overflow-y: auto; + padding: 8px; + animation: ${fadeIn} 0.2s ease; + transform-origin: top center; + + &::-webkit-scrollbar { + width: 4px; + } + &::-webkit-scrollbar-thumb { + background: hsl(var(--border)); + border-radius: 4px; + } +`; + +const SelectOption = styled.div<{ $selected?: boolean }>` + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + cursor: pointer; + border-radius: 10px; + background: ${(props) => + props.$selected ? "hsl(var(--primary) / 0.08)" : "transparent"}; + color: ${(props) => + props.$selected ? "hsl(var(--primary))" : "hsl(var(--foreground))"}; + font-size: 14px; + font-weight: ${(props) => (props.$selected ? "600" : "400")}; + transition: all 0.15s; + margin-bottom: 2px; + + &:hover { + background: hsl(var(--accent)); + } +`; + +const IconButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + background: transparent; + color: hsl(var(--muted-foreground)); + cursor: pointer; + border-radius: 8px; + transition: all 0.2s; + + &:hover { + background: hsl(var(--muted)); + color: hsl(var(--foreground)); + } +`; + +const MainContent = styled.div` + flex: 1; + display: flex; + flex-direction: column; + position: relative; + height: 100vh; +`; + +const ImageDisplayArea = styled.div` + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + padding-bottom: 160px; // Space for prompt bar + overflow: hidden; +`; + +const ImageContainer = styled.div<{ $hasImage: boolean }>` + position: relative; + display: flex; + align-items: center; + justify-content: center; + max-width: 100%; + max-height: 100%; + transition: all 0.6s cubic-bezier(0.2, 0.8, 0.2, 1); + + ${(props) => + props.$hasImage && + css` + perspective: 1000px; + animation: ${fadeIn} 0.8s ease; + + &::before { + content: ""; + position: absolute; + inset: 20px; + background: black; + filter: blur(40px); + opacity: 0.4; + z-index: -1; + border-radius: 50%; + transform: translateY(20px) scale(0.9); + } + `} +`; + +const GeneratedImg = styled.img` + max-width: 100%; + max-height: calc(100vh - 240px); + border-radius: 20px; + border: 1px solid hsl(var(--border) / 0.5); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + transition: transform 0.3s ease; + + &:hover { + transform: scale(1.01); + } +`; + +const ImageActions = styled.div` + position: absolute; + top: 20px; + right: 20px; + display: flex; + gap: 10px; + opacity: 0; + transform: translateY(-10px); + transition: all 0.3s ease; + + ${ImageContainer}:hover & { + opacity: 1; + transform: translateY(0); + } +`; + +const ActionButton = styled.button` + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.2); + color: white; + border-radius: 12px; + cursor: pointer; + transition: all 0.2s; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + + &:hover { + background: rgba(0, 0, 0, 0.7); + transform: scale(1.1); + box-shadow: 0 8px 12px rgba(0, 0, 0, 0.2); + } +`; + +const PromptBarContainer = styled.div` + position: absolute; + bottom: 40px; + left: 50%; + transform: translateX(-50%); + width: 800px; + max-width: 90%; + z-index: 50; +`; + +const PromptBar = styled.div` + display: flex; + gap: 16px; + padding: 12px; + padding-left: 20px; + background: hsl(var(--card) / 0.85); + backdrop-filter: blur(32px); + -webkit-backdrop-filter: blur(32px); + border: 1px solid hsl(var(--border) / 0.6); + border-radius: 24px; + box-shadow: + 0 10px 40px -10px rgba(0, 0, 0, 0.1), + 0 0 0 1px rgba(255, 255, 255, 0.1) inset; + transition: all 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); + + &:focus-within { + transform: translateY(-6px); + box-shadow: + 0 20px 60px -10px rgba(0, 0, 0, 0.15), + 0 0 0 2px hsl(var(--primary) / 0.3); + background: hsl(var(--card) / 0.95); + } +`; + +const PromptInput = styled.textarea` + flex: 1; + min-height: 44px; + max-height: 160px; + padding: 10px 0; + border: none; + background: transparent; + color: hsl(var(--foreground)); + font-size: 16px; + line-height: 1.5; + resize: none; + font-family: inherit; + + &:focus { + outline: none; + } + + &::placeholder { + color: hsl(var(--muted-foreground)); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +const GenerateButton = styled.button<{ $disabled?: boolean }>` + display: flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + border: none; + border-radius: 18px; + background: ${(props) => + props.$disabled ? "hsl(var(--muted))" : "hsl(var(--primary))"}; + color: ${(props) => + props.$disabled + ? "hsl(var(--muted-foreground))" + : "hsl(var(--primary-foreground))"}; + cursor: ${(props) => (props.$disabled ? "not-allowed" : "pointer")}; + transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + box-shadow: ${(props) => + props.$disabled ? "none" : "0 8px 20px -4px hsl(var(--primary) / 0.5)"}; + flex-shrink: 0; + + &:hover:not(:disabled) { + transform: scale(1.08) rotate(-5deg); + box-shadow: 0 12px 24px -6px hsl(var(--primary) / 0.6); + } + + &:active:not(:disabled) { + transform: scale(0.92); + } +`; + +const HistorySidebar = styled.div` + width: 90px; + min-width: 90px; + background: hsl(var(--card) / 0.35); + backdrop-filter: blur(24px); + border-left: 1px solid hsl(var(--border) / 0.4); + display: flex; + flex-direction: column; + padding: 20px 0; + gap: 20px; + align-items: center; + z-index: 20; +`; + +const NewImageButton = styled.button` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border: 1px dashed hsl(var(--border) / 0.8); + border-radius: 18px; + background: transparent; + color: hsl(var(--muted-foreground)); + cursor: pointer; + transition: all 0.3s; + + &:hover { + border-color: hsl(var(--primary)); + color: hsl(var(--primary)); + background: hsl(var(--primary) / 0.08); + background: hsl(var(--primary) / 0.08); + transform: scale(1.05); + } +`; + +const HistoryList = styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: 16px; + overflow-y: auto; + width: 100%; + align-items: center; + padding-bottom: 20px; + + &::-webkit-scrollbar { + display: none; + } +`; + +const HistoryItemDeleteBtn = styled.button` + position: absolute; + top: 4px; + right: 4px; + width: 20px; + height: 20px; + border-radius: 50%; + background: hsl(var(--card) / 0.8); + border: 1px solid hsl(var(--destructive) / 0.5); + display: flex; + align-items: center; + justify-content: center; + color: hsl(var(--destructive)); + opacity: 0; + transform: scale(0.8); + transition: all 0.2s; + z-index: 20; + cursor: pointer; + + &:hover { + background: hsl(var(--destructive)); + color: white; + transform: scale(1.1); + } +`; + +const HistoryItem = styled.div<{ $selected?: boolean }>` + width: 56px; + height: 56px; + border-radius: 16px; + overflow: hidden; + cursor: pointer; + position: relative; + border: 2px solid + ${(props) => (props.$selected ? "hsl(var(--primary))" : "transparent")}; + transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); + flex-shrink: 0; + + &:hover { + transform: scale(1.1); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1); + z-index: 10; + } + + &:hover ${HistoryItemDeleteBtn} { + opacity: 1; + transform: scale(1); + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.4s; + } + + &:hover img { + transform: scale(1.1); + } +`; + +const EmptyState = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + color: hsl(var(--muted-foreground)); + text-align: center; + max-width: 480px; + padding: 40px; + background: hsl(var(--card) / 0.4); + backdrop-filter: blur(10px); + border-radius: 32px; + border: 1px solid hsl(var(--border) / 0.4); + box-shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.1); + animation: ${fadeIn} 0.8s ease; + + h3 { + font-size: 24px; + font-weight: 700; + color: hsl(var(--foreground)); + margin: 0; + } + + p { + margin: 0; + line-height: 1.6; + font-size: 16px; + } +`; + +const LoaderWrapper = styled.div` + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: hsl(var(--muted) / 0.5); + animation: pulse 2s infinite; + + @keyframes pulse { + 0% { + opacity: 0.6; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.6; + } + } +`; + +export function ImageGenPage({ onNavigate }: ImageGenPageProps) { + const { + availableProviders, + selectedProvider, + selectedProviderId, + setSelectedProviderId, + providersLoading, + availableModels, + selectedModel, + selectedModelId, + setSelectedModelId, + selectedSize, + setSelectedSize, + images, + selectedImage, + selectedImageId, + setSelectedImageId, + generating, + generateImage, + deleteImage, + newImage, + } = useImageGen(); + + const [prompt, setPrompt] = useState(""); + const [providerDropdownOpen, setProviderDropdownOpen] = useState(false); + const [modelDropdownOpen, setModelDropdownOpen] = useState(false); + const [sizeDropdownOpen, setSizeDropdownOpen] = useState(false); + + const promptInputRef = useRef(null); + const providerRef = useRef(null); + const modelRef = useRef(null); + const sizeRef = useRef(null); + + // Auto-resize textarea + useEffect(() => { + if (promptInputRef.current) { + promptInputRef.current.style.height = "auto"; // Reset height + // Cap max height in JS if needed, but CSS max-height handles it visually. + // We just want it to grow. + promptInputRef.current.style.height = `${Math.min(promptInputRef.current.scrollHeight, 160)}px`; + } + }, [prompt]); + + const handleNewImage = () => { + newImage(); + setPrompt(""); + setTimeout(() => { + promptInputRef.current?.focus(); + }, 100); + }; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + providerRef.current && + !providerRef.current.contains(event.target as Node) + ) { + setProviderDropdownOpen(false); + } + if ( + modelRef.current && + !modelRef.current.contains(event.target as Node) + ) { + setModelDropdownOpen(false); + } + if (sizeRef.current && !sizeRef.current.contains(event.target as Node)) { + setSizeDropdownOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const supportedSizes = selectedModel?.supportedSizes || [ + "1024x1024", + "768x1344", + "864x1152", + "1344x768", + "1152x864", + ]; + + const handleGenerate = async () => { + if (!prompt.trim() || generating || !selectedProvider) return; + try { + await generateImage(prompt.trim()); + setPrompt(""); + } catch (error) { + console.error("Generate failed:", error); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleGenerate(); + } + }; + + const openInBrowser = (url: string) => { + window.open(url, "_blank"); + }; + + const goToProviderPool = () => { + onNavigate?.("provider-pool"); + }; + + return ( + + + + + 图片工坊 + + + + + 提供商 + + + + + + setProviderDropdownOpen(!providerDropdownOpen)} + $disabled={providersLoading} + $isOpen={providerDropdownOpen} + > +
+
+ +
+ + {selectedProvider?.name || "选择提供商"} + +
+ +
+ + {availableProviders.map((provider) => ( + { + setSelectedProviderId(provider.id); + setProviderDropdownOpen(false); + }} + > + {provider.name} + + ))} + {availableProviders.length === 0 && ( +
+ 暂无可用提供商 +
+ )} +
+
+
+ + + 模型 + + setModelDropdownOpen(!modelDropdownOpen)} + $disabled={!selectedProvider} + $isOpen={modelDropdownOpen} + > +
+
+ +
+ + {selectedModel?.name || "选择模型"} + +
+ +
+ + {availableModels.map((model) => ( + { + setSelectedModelId(model.id); + setModelDropdownOpen(false); + }} + > + {model.name} + + ))} + +
+
+ + + 图片尺寸 + + setSizeDropdownOpen(!sizeDropdownOpen)} + $isOpen={sizeDropdownOpen} + > +
+
+ +
+ + {selectedSize === "1024x1024" ? "方形 (1:1)" : selectedSize} + +
+ +
+ + {supportedSizes.map((size) => ( + { + setSelectedSize(size); + setSizeDropdownOpen(false); + }} + > + {size === "1024x1024" ? "方形 (1024x1024)" : size} + + ))} + +
+
+
+ + + + {selectedImage?.status === "generating" ? ( + + + +

正在精心绘制...

+

将您的想象转化为像素...

+
+
+ ) : selectedImage?.status === "complete" && selectedImage.url ? ( + + + + openInBrowser(selectedImage.url)} + title="在浏览器中打开" + > + + + deleteImage(selectedImage.id)} + title="删除" + > + + + + + ) : selectedImage?.status === "error" ? ( + +
+ +
+

生成失败

+

+ {selectedImage.error} +

+
+ ) : ( + + +

准备就绪

+

在下方输入详细的提示词以开始创作。

+
+ )} +
+ + + + setPrompt(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')..." + disabled={generating || !selectedProvider} + /> + + {generating ? ( + + ) : ( + + )} + + + +
+ + + + + + +
+ + + {images.map((img) => ( + setSelectedImageId(img.id)} + > + {img.status === "generating" ? ( + + + + ) : img.status === "complete" && img.url ? ( + {img.prompt} + ) : ( + + + + )} + {img.status !== "generating" && ( + { + e.stopPropagation(); + deleteImage(img.id); + }} + title="删除" + > + + + )} + + ))} + + + + ); +} diff --git a/src/components/image-gen/README.md b/src/components/image-gen/README.md new file mode 100644 index 000000000..47815be12 --- /dev/null +++ b/src/components/image-gen/README.md @@ -0,0 +1,41 @@ +# 图片生成模块 + +AI 图片生成功能,支持多个提供商和模型。 + +## 功能特性 + +- 支持多个图片生成提供商(智谱、AiHubMix、硅基流动等) +- 支持多种图片尺寸选择 +- 历史记录管理 +- 提供商配置管理 + +## 文件结构 + +| 文件 | 说明 | +|------|------| +| `ImageGenPage.tsx` | 主页面组件 | +| `ProviderConfigModal.tsx` | 提供商配置弹窗 | +| `useImageGen.ts` | 状态管理 Hook | +| `types.ts` | 类型定义 | +| `index.ts` | 模块导出 | + +## 支持的提供商 + +- 智谱开放平台 (CogView-3-Flash, CogView-4) +- AiHubMix (DALL-E 3) +- 硅基流动 (FLUX.1-schnell) +- DMXAPI (DALL-E 3) +- TokenFlux (DALL-E 3) +- New API (DALL-E 3) +- CherryIN (DALL-E 3) + +## 使用方式 + +1. 点击左侧导航栏的"图片生成"图标 +2. 点击设置按钮添加提供商 +3. 选择提供商、模型和尺寸 +4. 输入描述文字,点击发送生成图片 + +## API 接口 + +使用 OpenAI 兼容的 `/v1/images/generations` 接口。 diff --git a/src/components/image-gen/index.ts b/src/components/image-gen/index.ts new file mode 100644 index 000000000..d3e167287 --- /dev/null +++ b/src/components/image-gen/index.ts @@ -0,0 +1,9 @@ +/** + * @file 图片生成模块导出 + * @description AI 图片生成功能组件,复用凭证池的 API Key Provider + * @module components/image-gen + */ + +export { ImageGenPage } from "./ImageGenPage"; +export { useImageGen } from "./useImageGen"; +export * from "./types"; diff --git a/src/components/image-gen/types.ts b/src/components/image-gen/types.ts new file mode 100644 index 000000000..73638d35a --- /dev/null +++ b/src/components/image-gen/types.ts @@ -0,0 +1,202 @@ +/** + * @file 图片生成类型定义 + * @description 定义图片生成相关的类型 + * @module components/image-gen/types + */ + +/** 图片尺寸选项 */ +export interface ImageSize { + value: string; + label: string; +} + +/** 生成的图片记录 */ +export interface GeneratedImage { + id: string; + url: string; + prompt: string; + model: string; + size: string; + providerId: string; + providerName: string; + createdAt: number; + status: "pending" | "generating" | "complete" | "error"; + error?: string; +} + +/** 图片生成请求 */ +export interface ImageGenRequest { + model: string; + prompt: string; + n?: number; + size?: string; + quality?: string; +} + +/** 图片生成响应 */ +export interface ImageGenResponse { + created: number; + data: Array<{ + url: string; + revised_prompt?: string; + }>; +} + +/** 支持图片生成的模型配置 */ +export interface ImageGenModel { + id: string; + name: string; + supportedSizes: string[]; +} + +/** 默认图片尺寸 */ +export const DEFAULT_SIZES: ImageSize[] = [ + { value: "1024x1024", label: "1024x1024 (默认)" }, + { value: "768x1344", label: "768x1344" }, + { value: "864x1152", label: "864x1152" }, + { value: "1344x768", label: "1344x768" }, + { value: "1152x864", label: "1152x864" }, + { value: "1440x720", label: "1440x720" }, + { value: "720x1440", label: "720x1440" }, + { value: "1792x1024", label: "1792x1024" }, + { value: "1024x1792", label: "1024x1792" }, +]; + +/** 图片生成模型映射(根据 Provider ID 或类型) */ +export const IMAGE_GEN_MODELS: Record = { + // 智谱 AI + zhipuai: [ + { + id: "cogview-3-flash", + name: "CogView-3-Flash", + supportedSizes: [ + "1024x1024", + "768x1344", + "864x1152", + "1344x768", + "1152x864", + "1440x720", + "720x1440", + ], + }, + { + id: "cogview-4-250304", + name: "CogView-4-250304", + supportedSizes: [ + "1024x1024", + "768x1344", + "864x1152", + "1344x768", + "1152x864", + "1440x720", + "720x1440", + ], + }, + ], + zhipu: [ + { + id: "cogview-3-flash", + name: "CogView-3-Flash", + supportedSizes: [ + "1024x1024", + "768x1344", + "864x1152", + "1344x768", + "1152x864", + "1440x720", + "720x1440", + ], + }, + { + id: "cogview-4-250304", + name: "CogView-4-250304", + supportedSizes: [ + "1024x1024", + "768x1344", + "864x1152", + "1344x768", + "1152x864", + "1440x720", + "720x1440", + ], + }, + ], + // AiHubMix + aihubmix: [ + { + id: "dall-e-3", + name: "DALL-E 3", + supportedSizes: ["1024x1024", "1792x1024", "1024x1792"], + }, + ], + // 硅基流动 + siliconflow: [ + { + id: "black-forest-labs/FLUX.1-schnell", + name: "FLUX.1-schnell", + supportedSizes: [ + "1024x1024", + "512x1024", + "768x512", + "768x1024", + "1024x576", + "576x1024", + ], + }, + { + id: "stabilityai/stable-diffusion-3-5-large", + name: "SD 3.5 Large", + supportedSizes: ["1024x1024", "512x1024", "768x512", "768x1024"], + }, + ], + "siliconflow-cn": [ + { + id: "black-forest-labs/FLUX.1-schnell", + name: "FLUX.1-schnell", + supportedSizes: [ + "1024x1024", + "512x1024", + "768x512", + "768x1024", + "1024x576", + "576x1024", + ], + }, + { + id: "stabilityai/stable-diffusion-3-5-large", + name: "SD 3.5 Large", + supportedSizes: ["1024x1024", "512x1024", "768x512", "768x1024"], + }, + ], + // DMXAPI + dmxapi: [ + { + id: "dall-e-3", + name: "DALL-E 3", + supportedSizes: ["1024x1024", "1792x1024", "1024x1792"], + }, + ], + // TokenFlux + tokenflux: [ + { + id: "dall-e-3", + name: "DALL-E 3", + supportedSizes: ["1024x1024", "1792x1024", "1024x1792"], + }, + ], + // New API + "new-api": [ + { + id: "dall-e-3", + name: "DALL-E 3", + supportedSizes: ["1024x1024", "1792x1024", "1024x1792"], + }, + ], +}; + +/** 支持图片生成的 Provider ID 列表 */ +export const IMAGE_GEN_PROVIDER_IDS = [ + ...Object.keys(IMAGE_GEN_MODELS), + // 兼容不同大小写的 type 值 + "NewApi", +]; diff --git a/src/components/image-gen/useImageGen.ts b/src/components/image-gen/useImageGen.ts new file mode 100644 index 000000000..1f33b6c63 --- /dev/null +++ b/src/components/image-gen/useImageGen.ts @@ -0,0 +1,464 @@ +/** + * @file 图片生成 Hook + * @description 管理图片生成状态,复用凭证池的 API Key Provider + * @module components/image-gen/useImageGen + */ + +import { useState, useCallback, useEffect, useMemo } from "react"; +import { useApiKeyProvider } from "@/hooks/useApiKeyProvider"; +import { apiKeyProviderApi } from "@/lib/api/apiKeyProvider"; +import type { + GeneratedImage, + ImageGenRequest, + ImageGenResponse, + ImageGenModel, +} from "./types"; +import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDER_IDS } from "./types"; + +const HISTORY_KEY = "image-gen-history"; + +/** + * 检查 Provider 是否支持图片生成 + * 通过 Provider ID 或 type 匹配 + */ +function isImageGenProvider(providerId: string, providerType: string): boolean { + return ( + IMAGE_GEN_PROVIDER_IDS.includes(providerId) || + IMAGE_GEN_PROVIDER_IDS.includes(providerType) + ); +} + +/** + * 根据 Provider 获取支持的图片模型 + * 优先使用 Provider 的 custom_models,回退到预设模型 + */ +function getModelsForProvider( + providerId: string, + providerType: string, + customModels?: string[], +): ImageGenModel[] { + // 优先使用 Provider 的自定义模型 + if (customModels && customModels.length > 0) { + return customModels.map((modelId) => ({ + id: modelId, + name: modelId, + supportedSizes: [ + "1024x1024", + "768x1344", + "1344x768", + "1792x1024", + "1024x1792", + ], + })); + } + // 回退到预设模型(Provider ID 匹配) + if (IMAGE_GEN_MODELS[providerId]) { + return IMAGE_GEN_MODELS[providerId]; + } + // 回退到预设模型(Provider type 匹配) + if (IMAGE_GEN_MODELS[providerType]) { + return IMAGE_GEN_MODELS[providerType]; + } + return []; +} + +export function useImageGen() { + const { providers, loading: providersLoading } = useApiKeyProvider(); + + const [selectedProviderId, setSelectedProviderId] = useState(""); + const [selectedModelId, setSelectedModelId] = useState(""); + const [selectedSize, setSelectedSize] = useState("1024x1024"); + const [images, setImages] = useState([]); + const [selectedImageId, setSelectedImageId] = useState(null); + const [generating, setGenerating] = useState(false); + + // 过滤出支持图片生成、启用且有 API Key 的 Provider + const availableProviders = useMemo(() => { + console.log( + "[useImageGen] 支持图片生成的 Provider IDs:", + IMAGE_GEN_PROVIDER_IDS, + ); + console.log( + "[useImageGen] 所有 Provider:", + providers.map((p) => ({ + id: p.id, + type: p.type, + enabled: p.enabled, + api_key_count: p.api_key_count, + isImageGen: isImageGenProvider(p.id, p.type), + })), + ); + + const filtered = providers.filter( + (p) => + p.enabled && p.api_key_count > 0 && isImageGenProvider(p.id, p.type), + ); + + console.log( + "[useImageGen] 过滤后的 Provider:", + filtered.map((p) => p.id), + ); + return filtered; + }, [providers]); + + // 从 localStorage 加载历史记录 + useEffect(() => { + const saved = localStorage.getItem(HISTORY_KEY); + if (saved) { + try { + const parsed = JSON.parse(saved) as GeneratedImage[]; + setImages(parsed); + if (parsed.length > 0) { + setSelectedImageId(parsed[0].id); + } + } catch (e) { + console.error("加载历史记录失败:", e); + } + } + }, []); + + // 自动选择第一个可用的 Provider + useEffect(() => { + if (!selectedProviderId && availableProviders.length > 0) { + const firstProvider = availableProviders[0]; + setSelectedProviderId(firstProvider.id); + // 设置默认模型 + const models = getModelsForProvider( + firstProvider.id, + firstProvider.type, + firstProvider.custom_models, + ); + if (models.length > 0) { + setSelectedModelId(models[0].id); + } + } + }, [availableProviders, selectedProviderId]); + + // 保存历史记录 + const saveHistory = useCallback((newImages: GeneratedImage[]) => { + localStorage.setItem(HISTORY_KEY, JSON.stringify(newImages.slice(0, 50))); + }, []); + + // 获取当前选中的 Provider + const selectedProvider = useMemo(() => { + return availableProviders.find((p) => p.id === selectedProviderId); + }, [availableProviders, selectedProviderId]); + + // 获取当前 Provider 支持的模型 + const availableModels = useMemo(() => { + if (!selectedProvider) return []; + return getModelsForProvider( + selectedProvider.id, + selectedProvider.type, + selectedProvider.custom_models, + ); + }, [selectedProvider]); + + // 获取当前选中的模型 + const selectedModel = useMemo(() => { + return availableModels.find((m) => m.id === selectedModelId); + }, [availableModels, selectedModelId]); + + // 获取当前选中的图片 + const selectedImage = useMemo(() => { + return images.find((img) => img.id === selectedImageId); + }, [images, selectedImageId]); + + // 切换 Provider 时更新模型 + const handleProviderChange = useCallback( + (providerId: string) => { + setSelectedProviderId(providerId); + const provider = availableProviders.find((p) => p.id === providerId); + if (provider) { + const models = getModelsForProvider( + provider.id, + provider.type, + provider.custom_models, + ); + if (models.length > 0) { + setSelectedModelId(models[0].id); + } + } + }, + [availableProviders], + ); + + // 生成图片 + const generateImage = useCallback( + async (prompt: string) => { + if (!selectedProvider) { + throw new Error("请先在凭证池中配置 API Key Provider"); + } + + // 获取 API Key + const apiKey = await apiKeyProviderApi.getNextApiKey(selectedProvider.id); + if (!apiKey) { + throw new Error("该 Provider 没有可用的 API Key,请在凭证池中添加"); + } + + // 如果当前选中的是 pending 状态的图片,更新它;否则创建新的 + let imageId: string; + const currentImage = images.find((img) => img.id === selectedImageId); + + if (currentImage?.status === "pending") { + // 更新已存在的 pending 图片 + imageId = currentImage.id; + setImages((prev) => { + const updated = prev.map((img) => + img.id === imageId + ? { ...img, prompt, status: "generating" as const } + : img, + ); + saveHistory(updated); + return updated; + }); + } else { + // 创建新的图片 + imageId = `img-${Date.now()}`; + const newImage: GeneratedImage = { + id: imageId, + url: "", + prompt, + model: selectedModelId, + size: selectedSize, + providerId: selectedProvider.id, + providerName: selectedProvider.name, + createdAt: Date.now(), + status: "generating", + }; + + // 添加到列表并选中 + setImages((prev) => { + const updated = [newImage, ...prev]; + saveHistory(updated); + return updated; + }); + setSelectedImageId(imageId); + } + + setGenerating(true); + + try { + let imageUrl: string; + + // New API 使用聊天接口生成图片(通过 Provider ID 或 type 判断) + const isNewApi = + selectedProvider.id === "new-api" || + selectedProvider.type === "new-api" || + selectedProvider.type === "NewApi"; + + console.log( + "[useImageGen] Provider:", + selectedProvider.id, + "Type:", + selectedProvider.type, + "isNewApi:", + isNewApi, + ); + + if (isNewApi) { + const chatRequest = { + model: selectedModelId, + messages: [ + { role: "user", content: `请帮我画一张图片:${prompt}` }, + ], + temperature: 0.7, + stream: false, + }; + + console.log( + "[useImageGen] 发送 New API 请求:", + `${selectedProvider.api_host}/v1/chat/completions`, + ); + + const response = await fetch( + `${selectedProvider.api_host}/v1/chat/completions`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(chatRequest), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`请求失败: ${response.status} - ${errorText}`); + } + + const data = await response.json(); + const content = data.choices?.[0]?.message?.content || ""; + + console.log("[useImageGen] 响应内容长度:", content.length); + + // 从响应中提取图片(支持 base64 和 URL) + // 优先匹配 base64: ![image](data:image/...;base64,...) + const base64Match = content.match( + /!\[.*?\]\((data:image\/[^;]+;base64,[^)]+)\)/, + ); + if (base64Match) { + console.log("[useImageGen] 匹配到 base64 图片"); + imageUrl = base64Match[1]; + } else { + // 回退匹配 URL: ![image](https://...) + const urlMatch = content.match(/!\[.*?\]\((https?:\/\/[^)]+)\)/); + if (urlMatch) { + console.log("[useImageGen] 匹配到 URL 图片"); + imageUrl = urlMatch[1]; + } else { + console.log( + "[useImageGen] 未匹配到图片,内容预览:", + content.slice(0, 200), + ); + throw new Error("未能从响应中提取图片"); + } + } + } else { + // 标准 OpenAI 图片生成接口 + const request: ImageGenRequest = { + model: selectedModelId, + prompt, + n: 1, + size: selectedSize, + }; + + const response = await fetch( + `${selectedProvider.api_host}/v1/images/generations`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(request), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`请求失败: ${response.status} - ${errorText}`); + } + + const data = (await response.json()) as ImageGenResponse; + imageUrl = data.data[0]?.url || ""; + } + + if (!imageUrl) { + throw new Error("未返回图片 URL"); + } + + // 更新图片状态 + setImages((prev) => { + const updated = prev.map((img) => + img.id === imageId + ? { ...img, url: imageUrl, status: "complete" as const } + : img, + ); + saveHistory(updated); + return updated; + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + setImages((prev) => { + const updated = prev.map((img) => + img.id === imageId + ? { ...img, status: "error" as const, error: errorMessage } + : img, + ); + saveHistory(updated); + return updated; + }); + throw error; + } finally { + setGenerating(false); + } + }, + [ + selectedProvider, + selectedModelId, + selectedSize, + saveHistory, + images, + selectedImageId, + ], + ); + + // 删除图片 + const deleteImage = useCallback( + (id: string) => { + setImages((prev) => { + const updated = prev.filter((img) => img.id !== id); + saveHistory(updated); + return updated; + }); + if (selectedImageId === id) { + setSelectedImageId(images[1]?.id || null); + } + }, + [images, selectedImageId, saveHistory], + ); + + // 新建图片(创建一个新的空白图片项) + const newImage = useCallback(() => { + console.log("[useImageGen] newImage 被调用,创建新图片项"); + const imageId = `img-${Date.now()}`; + const newImg: GeneratedImage = { + id: imageId, + url: "", + prompt: "", + model: selectedModelId, + size: selectedSize, + providerId: selectedProviderId, + providerName: selectedProvider?.name || "", + createdAt: Date.now(), + status: "pending", + }; + + setImages((prev) => { + const updated = [newImg, ...prev]; + saveHistory(updated); + return updated; + }); + setSelectedImageId(imageId); + }, [ + selectedModelId, + selectedSize, + selectedProviderId, + selectedProvider, + saveHistory, + ]); + + return { + // Provider 相关 + availableProviders, + selectedProvider, + selectedProviderId, + setSelectedProviderId: handleProviderChange, + providersLoading, + + // 模型相关 + availableModels, + selectedModel, + selectedModelId, + setSelectedModelId, + + // 尺寸相关 + selectedSize, + setSelectedSize, + + // 图片相关 + images, + selectedImage, + selectedImageId, + setSelectedImageId, + generating, + + // 操作 + generateImage, + deleteImage, + newImage, + }; +} diff --git a/src/components/onboarding/OnboardingWizard.tsx b/src/components/onboarding/OnboardingWizard.tsx index dc0b04923..59a12cabb 100644 --- a/src/components/onboarding/OnboardingWizard.tsx +++ b/src/components/onboarding/OnboardingWizard.tsx @@ -8,20 +8,16 @@ import { Button } from "@/components/ui/button"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { WelcomeStep } from "./steps/WelcomeStep"; import { UserProfileStep } from "./steps/UserProfileStep"; -import { WindowSizeStep } from "./steps/WindowSizeStep"; import { PluginSelectStep } from "./steps/PluginSelectStep"; import { InstallProgressStep, type PluginInstallState, } from "./steps/InstallProgressStep"; +import { VoiceShortcutTestStep } from "./steps/VoiceShortcutTestStep"; +import { MicrophoneTestStep } from "./steps/MicrophoneTestStep"; +import { VoiceDemoStep } from "./steps/VoiceDemoStep"; import { CompleteStep } from "./steps/CompleteStep"; -import { - userProfiles, - type UserProfile, - type WindowSizePreference, - STORAGE_KEYS, -} from "./constants"; -import { windowApi } from "@/lib/api/window"; +import { userProfiles, type UserProfile } from "./constants"; const Overlay = styled.div` position: fixed; @@ -90,7 +86,7 @@ const FooterRight = styled.div` gap: 12px; `; -const TOTAL_STEPS = 6; +const TOTAL_STEPS = 8; interface OnboardingWizardProps { onComplete: () => void; @@ -99,12 +95,28 @@ interface OnboardingWizardProps { export function OnboardingWizard({ onComplete }: OnboardingWizardProps) { const [currentStep, setCurrentStep] = useState(1); const [userProfile, setUserProfile] = useState(null); - const [windowSizePreference, setWindowSizePreference] = - useState("default"); const [selectedPlugins, setSelectedPlugins] = useState([]); const [installResults, setInstallResults] = useState( [], ); + const [voiceShortcut, setVoiceShortcut] = useState( + "CommandOrControl+Shift+V", + ); + const [voiceEnabled, setVoiceEnabled] = useState(false); + + // 加载语音配置 + useEffect(() => { + (async () => { + try { + const { getVoiceInputConfig } = await import("@/lib/api/asrProvider"); + const config = await getVoiceInputConfig(); + setVoiceShortcut(config.shortcut); + setVoiceEnabled(config.enabled); + } catch (err) { + console.error("加载语音配置失败:", err); + } + })(); + }, []); // 当用户选择群体时,自动选中默认插件 useEffect(() => { @@ -119,61 +131,74 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) { const handleNext = useCallback(() => { if (currentStep < TOTAL_STEPS) { // 如果没有选择插件,跳过安装步骤 - if (currentStep === 4 && selectedPlugins.length === 0) { - setCurrentStep(6); // 直接跳到完成页 + if (currentStep === 3 && selectedPlugins.length === 0) { + // 跳到语音快捷键测试(如果语音功能启用)或完成页 + setCurrentStep(voiceEnabled ? 5 : 8); + } else if (currentStep === 4) { + // 安装完成后,跳到语音快捷键测试(如果语音功能启用)或完成页 + // 注意:这个分支不会被执行,因为 InstallProgressStep 会调用 handleInstallComplete } else { setCurrentStep((prev) => prev + 1); } } - }, [currentStep, selectedPlugins]); + }, [currentStep, selectedPlugins, voiceEnabled]); const handleBack = useCallback(() => { if (currentStep > 1) { - // 如果从完成页返回且没有安装结果,返回到插件选择 - if (currentStep === 6 && installResults.length === 0) { - setCurrentStep(4); - } else if (currentStep === 6) { - // 已经安装过,不能返回 + // 如果从完成页返回 + if (currentStep === 8) { + // 返回到语音演示或插件选择 + setCurrentStep(voiceEnabled ? 7 : 3); + } else if (currentStep === 5 && installResults.length === 0) { + // 从语音快捷键测试返回到插件选择 + setCurrentStep(3); + } else if (currentStep === 5 && installResults.length > 0) { + // 已经安装过,不能返回到安装步骤 return; } else { setCurrentStep((prev) => prev - 1); } } - }, [currentStep, installResults]); + }, [currentStep, installResults, voiceEnabled]); const handleSkip = useCallback(() => { onComplete(); }, [onComplete]); - const handleInstallComplete = useCallback((results: PluginInstallState[]) => { - setInstallResults(results); + const handleInstallComplete = useCallback( + (results: PluginInstallState[]) => { + setInstallResults(results); + // 安装完成后,跳到语音快捷键测试(如果语音功能启用)或完成页 + setCurrentStep(voiceEnabled ? 5 : 8); + }, + [voiceEnabled], + ); + + // 语音快捷键测试成功 + const handleShortcutTestSuccess = useCallback(() => { setCurrentStep(6); }, []); - const handleFinish = useCallback(async () => { - // 保存窗口尺寸偏好 - if (windowSizePreference) { - localStorage.setItem( - STORAGE_KEYS.WINDOW_SIZE_PREFERENCE, - windowSizePreference, - ); + // 麦克风测试成功 + const handleMicTestSuccess = useCallback(() => { + setCurrentStep(7); + }, []); - // 应用窗口尺寸 - try { - if (windowSizePreference === "fullscreen") { - await windowApi.toggleFullscreen(); - } else { - await windowApi.setWindowSizeByOption(windowSizePreference); - } - } catch (error) { - console.error("应用窗口尺寸失败:", error); - } - } + // 语音演示完成 + const handleVoiceDemoSuccess = useCallback(() => { + setCurrentStep(8); + }, []); + // 跳过语音测试 + const handleSkipVoiceTest = useCallback(() => { + setCurrentStep(8); + }, []); + + const handleFinish = useCallback(() => { // 触发插件变化事件,刷新侧边栏 window.dispatchEvent(new CustomEvent("plugin-changed")); onComplete(); - }, [onComplete, windowSizePreference]); + }, [onComplete]); // 渲染当前步骤 const renderStep = () => { @@ -188,13 +213,6 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) { /> ); case 3: - return ( - - ); - case 4: return ( ); - case 5: + case 4: return ( ); + case 5: + return ( + + ); case 6: + return ( + + ); + case 7: + return ( + + ); + case 8: return ( @@ -267,7 +311,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) { 跳过 + )} + + + )} + + + + + + + + {testState === "idle" && "点击下方按钮开始测试"} + {testState === "testing" && `正在录音... 音量: ${volume}%`} + {testState === "success" && ( + <> + + 麦克风工作正常! + + )} + {testState === "error" && "测试失败"} + + + + + + + {testState === "idle" && } + + {testState === "testing" && ( + + )} + + {testState === "error" && } + + {testState === "success" && } + + + ); +} diff --git a/src/components/onboarding/steps/VoiceDemoStep.tsx b/src/components/onboarding/steps/VoiceDemoStep.tsx new file mode 100644 index 000000000..67d9e921b --- /dev/null +++ b/src/components/onboarding/steps/VoiceDemoStep.tsx @@ -0,0 +1,308 @@ +/** + * 语音输入演示步骤 + */ + +import { useState, useEffect, useCallback, useRef } from "react"; +import styled from "styled-components"; +import { Mic, CheckCircle2, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { VolumeWaveform } from "@/components/voice/VolumeWaveform"; +import { useVoiceSound } from "@/hooks/useVoiceSound"; + +const Container = styled.div` + padding: 32px 24px; + text-align: center; +`; + +const IconWrapper = styled.div` + width: 80px; + height: 80px; + margin: 0 auto 24px; + border-radius: 50%; + background: hsl(var(--primary) / 0.1); + display: flex; + align-items: center; + justify-content: center; +`; + +const Title = styled.h2` + font-size: 24px; + font-weight: 600; + margin-bottom: 12px; + color: hsl(var(--foreground)); +`; + +const Description = styled.p` + font-size: 14px; + color: hsl(var(--muted-foreground)); + margin-bottom: 32px; + line-height: 1.6; +`; + +const DemoArea = styled.div` + padding: 24px; + border-radius: 12px; + background: hsl(var(--muted) / 0.5); + margin-bottom: 24px; +`; + +const WaveformContainer = styled.div` + height: 60px; + margin-bottom: 16px; +`; + +const ResultText = styled.div` + min-height: 60px; + padding: 16px; + border-radius: 8px; + background: hsl(var(--background)); + font-size: 16px; + line-height: 1.6; + text-align: left; + color: hsl(var(--foreground)); +`; + +const StatusText = styled.p<{ $success?: boolean }>` + font-size: 14px; + color: ${({ $success }) => + $success ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 16px; +`; + +const ButtonGroup = styled.div` + display: flex; + justify-content: center; + gap: 12px; + margin-top: 32px; +`; + +const RecordButton = styled.button<{ $recording: boolean }>` + width: 64px; + height: 64px; + border-radius: 50%; + border: none; + background: ${({ $recording }) => + $recording ? "hsl(var(--destructive))" : "hsl(var(--primary))"}; + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + margin: 0 auto 16px; + + &:hover { + transform: scale(1.05); + } + + &:active { + transform: scale(0.95); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } +`; + +interface VoiceDemoStepProps { + onSuccess: () => void; + onSkip: () => void; +} + +type DemoState = "idle" | "recording" | "transcribing" | "polishing" | "done"; + +export function VoiceDemoStep({ onSuccess, onSkip }: VoiceDemoStepProps) { + const [demoState, setDemoState] = useState("idle"); + const [volume, setVolume] = useState(0); + const [resultText, setResultText] = useState(""); + const pollingRef = useRef(null); + + const { playStartSound, playStopSound } = useVoiceSound(true); + + // 开始录音 + const startRecording = useCallback(async () => { + setDemoState("recording"); + setResultText(""); + playStartSound(); + + try { + const { startRecording: start, getRecordingStatus } = await import( + "@/lib/api/asrProvider" + ); + + await start(); + + // 轮询获取音量 + pollingRef.current = window.setInterval(async () => { + try { + const status = await getRecordingStatus(); + setVolume(status.volume); + } catch (err) { + console.error("获取录音状态失败:", err); + } + }, 100); + } catch (err) { + console.error("开始录音失败:", err); + setDemoState("idle"); + } + }, [playStartSound]); + + // 停止录音并处理 + const stopRecording = useCallback(async () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + + playStopSound(); + setDemoState("transcribing"); + + try { + const { + stopRecording: stop, + transcribeAudio, + polishVoiceText, + getVoiceInputConfig, + } = await import("@/lib/api/asrProvider"); + + const result = await stop(); + + if (result.duration < 0.5) { + setResultText("录音时间太短,请再试一次"); + setDemoState("idle"); + return; + } + + const audioData = new Uint8Array(result.audio_data); + const transcribeResult = await transcribeAudio( + audioData, + result.sample_rate, + ); + + if (!transcribeResult.text.trim()) { + setResultText("未识别到语音内容,请再试一次"); + setDemoState("idle"); + return; + } + + // 检查是否启用润色 + const config = await getVoiceInputConfig(); + let finalText = transcribeResult.text; + + if (config.processor.polish_enabled) { + setDemoState("polishing"); + try { + const polished = await polishVoiceText(transcribeResult.text); + finalText = polished.text; + } catch (e) { + console.error("润色失败:", e); + } + } + + setResultText(finalText); + setDemoState("done"); + } catch (err) { + console.error("语音识别失败:", err); + setResultText("语音识别失败,请再试一次"); + setDemoState("idle"); + } + }, [playStopSound]); + + // 清理 + useEffect(() => { + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + import("@/lib/api/asrProvider") + .then(({ cancelRecording }) => cancelRecording()) + .catch(() => {}); + }; + }, []); + + const getStatusText = () => { + switch (demoState) { + case "idle": + return "点击麦克风按钮开始录音"; + case "recording": + return "正在录音,再次点击停止..."; + case "transcribing": + return ( + <> + + 正在识别... + + ); + case "polishing": + return ( + <> + + 正在润色... + + ); + case "done": + return ( + <> + + 识别完成! + + ); + default: + return ""; + } + }; + + return ( + + + + + + 体验语音输入 + + 点击麦克风按钮,说一句话试试。 +
+ 语音会被识别并自动润色。 +
+ + + + {demoState === "transcribing" || demoState === "polishing" ? ( + + ) : ( + + )} + + + {demoState === "recording" && ( + + + + )} + + {resultText && {resultText}} + + + {getStatusText()} + + + + + + {demoState === "done" && } + +
+ ); +} diff --git a/src/components/onboarding/steps/VoiceShortcutTestStep.tsx b/src/components/onboarding/steps/VoiceShortcutTestStep.tsx new file mode 100644 index 000000000..a8e9e6d32 --- /dev/null +++ b/src/components/onboarding/steps/VoiceShortcutTestStep.tsx @@ -0,0 +1,193 @@ +/** + * 语音快捷键测试步骤 + */ + +import { useState, useEffect } from "react"; +import styled from "styled-components"; +import { Keyboard, CheckCircle2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +const Container = styled.div` + padding: 32px 24px; + text-align: center; +`; + +const IconWrapper = styled.div` + width: 80px; + height: 80px; + margin: 0 auto 24px; + border-radius: 50%; + background: hsl(var(--primary) / 0.1); + display: flex; + align-items: center; + justify-content: center; +`; + +const Title = styled.h2` + font-size: 24px; + font-weight: 600; + margin-bottom: 12px; + color: hsl(var(--foreground)); +`; + +const Description = styled.p` + font-size: 14px; + color: hsl(var(--muted-foreground)); + margin-bottom: 32px; + line-height: 1.6; +`; + +const ShortcutDisplay = styled.div<{ $active: boolean; $success: boolean }>` + display: inline-flex; + align-items: center; + gap: 8px; + padding: 16px 24px; + border-radius: 12px; + font-family: monospace; + font-size: 18px; + margin-bottom: 24px; + transition: all 0.3s; + background: ${({ $active, $success }) => + $success + ? "hsl(var(--primary) / 0.1)" + : $active + ? "hsl(var(--primary) / 0.2)" + : "hsl(var(--muted))"}; + border: 2px solid + ${({ $active, $success }) => + $success + ? "hsl(var(--primary))" + : $active + ? "hsl(var(--primary) / 0.5)" + : "transparent"}; +`; + +const StatusIcon = styled.div<{ $success: boolean }>` + color: ${({ $success }) => + $success ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}; +`; + +const HintText = styled.p` + font-size: 13px; + color: hsl(var(--muted-foreground)); + margin-top: 16px; +`; + +const ButtonGroup = styled.div` + display: flex; + justify-content: center; + gap: 12px; + margin-top: 32px; +`; + +interface VoiceShortcutTestStepProps { + shortcut: string; + onSuccess: () => void; + onSkip: () => void; +} + +function formatShortcutDisplay(shortcut: string): string { + 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, " + "); +} + +export function VoiceShortcutTestStep({ + shortcut, + onSuccess, + onSkip, +}: VoiceShortcutTestStepProps) { + const [isPressed, setIsPressed] = useState(false); + const [testSuccess, setTestSuccess] = useState(false); + + // 监听快捷键事件 + useEffect(() => { + let unlisten: (() => void) | null = null; + + const setupListener = async () => { + try { + const { listen } = await import("@tauri-apps/api/event"); + + // 监听录音开始事件(快捷键按下) + const unlistenStart = await listen("voice-start-recording", () => { + setIsPressed(true); + }); + + // 监听录音停止事件(快捷键释放) + const unlistenStop = await listen("voice-stop-recording", () => { + setIsPressed(false); + setTestSuccess(true); + // 取消录音(因为这只是测试) + import("@/lib/api/asrProvider").then(({ cancelRecording }) => { + cancelRecording().catch(console.error); + }); + }); + + unlisten = () => { + unlistenStart(); + unlistenStop(); + }; + } catch (err) { + console.error("监听快捷键事件失败:", err); + } + }; + + setupListener(); + + return () => { + if (unlisten) unlisten(); + }; + }, []); + + // 测试成功后自动进入下一步 + useEffect(() => { + if (testSuccess) { + const timer = setTimeout(() => { + onSuccess(); + }, 1500); + return () => clearTimeout(timer); + } + }, [testSuccess, onSuccess]); + + return ( + + + + + + 测试语音快捷键 + + 按下并松开语音输入快捷键,验证快捷键是否正常工作。 +
+ 如果快捷键被其他应用占用,可以稍后在设置中修改。 +
+ + + + {testSuccess ? : } + + {formatShortcutDisplay(shortcut)} + + + + {testSuccess + ? "快捷键工作正常!" + : isPressed + ? "检测到按下,请松开..." + : "请按下快捷键进行测试"} + + + + + {testSuccess && } + +
+ ); +} diff --git a/src/components/onboarding/steps/WindowSizeStep.tsx b/src/components/onboarding/steps/WindowSizeStep.tsx deleted file mode 100644 index fc1d669f1..000000000 --- a/src/components/onboarding/steps/WindowSizeStep.tsx +++ /dev/null @@ -1,153 +0,0 @@ -/** - * 初次安装引导 - 窗口尺寸选择 - */ - -import styled from "styled-components"; -import { Check } from "lucide-react"; -import { windowSizeOptions, type WindowSizePreference } from "../constants"; - -const Container = styled.div` - display: flex; - flex-direction: column; - align-items: center; - padding: 32px 24px; -`; - -const Title = styled.h2` - font-size: 24px; - font-weight: 600; - color: hsl(var(--foreground)); - margin-bottom: 8px; - text-align: center; -`; - -const Subtitle = styled.p` - font-size: 14px; - color: hsl(var(--muted-foreground)); - margin-bottom: 32px; - text-align: center; -`; - -const OptionsGrid = styled.div` - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 12px; - width: 100%; - max-width: 500px; -`; - -const OptionCard = styled.button<{ $selected?: boolean }>` - display: flex; - flex-direction: column; - align-items: center; - padding: 20px 16px; - border-radius: 12px; - border: 2px solid - ${({ $selected }) => - $selected ? "hsl(var(--primary))" : "hsl(var(--border))"}; - background: ${({ $selected }) => - $selected ? "hsl(var(--primary) / 0.05)" : "hsl(var(--card))"}; - cursor: pointer; - transition: all 0.2s; - position: relative; - - &:hover { - border-color: ${({ $selected }) => - $selected ? "hsl(var(--primary))" : "hsl(var(--primary) / 0.5)"}; - } -`; - -const CheckBadge = styled.div` - position: absolute; - top: 10px; - right: 10px; - width: 20px; - height: 20px; - border-radius: 50%; - background: hsl(var(--primary)); - display: flex; - align-items: center; - justify-content: center; - - svg { - width: 12px; - height: 12px; - color: hsl(var(--primary-foreground)); - } -`; - -const IconWrapper = styled.div<{ $selected?: boolean }>` - width: 48px; - height: 48px; - border-radius: 10px; - background: ${({ $selected }) => - $selected ? "hsl(var(--primary))" : "hsl(var(--muted))"}; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 12px; - transition: all 0.2s; - - svg { - width: 24px; - height: 24px; - color: ${({ $selected }) => - $selected ? "hsl(var(--primary-foreground))" : "hsl(var(--foreground))"}; - } -`; - -const OptionName = styled.span` - font-size: 14px; - font-weight: 600; - color: hsl(var(--foreground)); - margin-bottom: 4px; -`; - -const OptionDescription = styled.span` - font-size: 11px; - color: hsl(var(--muted-foreground)); - text-align: center; -`; - -interface WindowSizeStepProps { - selectedSize: WindowSizePreference | null; - onSelect: (size: WindowSizePreference) => void; -} - -export function WindowSizeStep({ - selectedSize, - onSelect, -}: WindowSizeStepProps) { - return ( - - 选择窗口大小 - 您可以随时在设置中更改窗口大小 - - - {windowSizeOptions.map((option) => { - const isSelected = selectedSize === option.id; - const Icon = option.icon; - - return ( - onSelect(option.id)} - > - {isSelected && ( - - - - )} - - - - {option.name} - {option.description} - - ); - })} - - - ); -} diff --git a/src/components/provider-pool/AddCredentialModal.tsx b/src/components/provider-pool/AddCredentialModal.tsx index 39c8db26f..c56296cb7 100644 --- a/src/components/provider-pool/AddCredentialModal.tsx +++ b/src/components/provider-pool/AddCredentialModal.tsx @@ -438,11 +438,6 @@ export function AddCredentialModal({ ); } - // Kiro 登录模式 - 不需要按钮,登录按钮在表单内部 - if (providerType === "kiro" && kiroForm.mode === "login") { - return null; - } - // Kiro JSON 模式 if (providerType === "kiro" && kiroForm.mode === "json") { return ( diff --git a/src/components/provider-pool/credential-forms/BrowserModeSelector.tsx b/src/components/provider-pool/credential-forms/BrowserModeSelector.tsx deleted file mode 100644 index 2a14526f3..000000000 --- a/src/components/provider-pool/credential-forms/BrowserModeSelector.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/** - * 浏览器模式选择器组件 - * - * 允许用户在系统浏览器和 Playwright 指纹浏览器之间切换 - * 用于 Kiro OAuth 登录流程 - * - * @module components/provider-pool/credential-forms/BrowserModeSelector - * @description 实现 Requirements 1.1, 1.2, 1.3 - */ - -import { Globe, Fingerprint, Loader2, AlertCircle } from "lucide-react"; - -export type BrowserMode = "system" | "playwright"; - -interface BrowserModeSelectorProps { - /** 当前选中的浏览器模式 */ - mode: BrowserMode; - /** 模式变更回调 */ - onModeChange: (mode: BrowserMode) => void; - /** Playwright 是否可用 */ - playwrightAvailable: boolean; - /** 是否正在检查 Playwright 可用性 */ - playwrightChecking: boolean; - /** 重新检查 Playwright 可用性回调 */ - onCheckPlaywright: () => void; - /** 是否禁用(登录中) */ - disabled?: boolean; -} - -/** - * 浏览器模式选择器 - * - * 显示两个选项:系统浏览器和指纹浏览器 - * 当 Playwright 不可用时,指纹浏览器选项会显示警告状态 - */ -export function BrowserModeSelector({ - mode, - onModeChange, - playwrightAvailable, - playwrightChecking, - onCheckPlaywright, - disabled = false, -}: BrowserModeSelectorProps) { - const handlePlaywrightSelect = () => { - if (disabled) return; - - if (!playwrightAvailable && !playwrightChecking) { - // 如果 Playwright 不可用,先检查一次 - onCheckPlaywright(); - } - onModeChange("playwright"); - }; - - return ( -
- -
- {/* 系统浏览器选项 */} - - - {/* 指纹浏览器选项 */} - -
-
- ); -} diff --git a/src/components/provider-pool/credential-forms/KiroForm.tsx b/src/components/provider-pool/credential-forms/KiroForm.tsx index 471e3db03..324952b6a 100644 --- a/src/components/provider-pool/credential-forms/KiroForm.tsx +++ b/src/components/provider-pool/credential-forms/KiroForm.tsx @@ -1,47 +1,17 @@ /** * Kiro 凭证添加表单 * - * 支持三种模式: - * 1. 在线登录(OAuth 授权)- Google、GitHub、AWS Builder ID - * 2. 粘贴 JSON(直接粘贴凭证内容) - * 3. 导入文件 - * - * 支持两种浏览器模式: - * - 系统浏览器:使用系统默认浏览器 - * - 指纹浏览器:使用 Playwright 指纹浏览器(绕过机器人检测) + * 支持两种模式: + * 1. 粘贴 JSON(直接粘贴凭证内容) + * 2. 导入文件 * * @module components/provider-pool/credential-forms/KiroForm - * @description 参考 liuyun-kiro 项目实现的 Kiro 凭证添加表单 - * @description 实现 Requirements 5.1, 5.2, 5.3, 5.4 错误处理 */ -import { useState, useEffect, useRef, useCallback } from "react"; -import { providerPoolApi, PlaywrightStatus } from "@/lib/api/providerPool"; -import { - checkPlaywrightAvailable, - startKiroPlaywrightLogin, - cancelKiroPlaywrightLogin, -} from "@/lib/api/providerPool"; +import { useState } from "react"; +import { providerPoolApi } from "@/lib/api/providerPool"; import { FileImportForm } from "./FileImportForm"; -import { BrowserModeSelector, BrowserMode } from "./BrowserModeSelector"; -import { PlaywrightInstallGuide } from "./PlaywrightInstallGuide"; -import { PlaywrightErrorDisplay } from "./PlaywrightErrorDisplay"; -import { - logPlaywrightError, - parsePlaywrightError, - PlaywrightErrorType, -} from "@/lib/errors/playwrightErrors"; -import { - FileText, - FolderOpen, - LogIn, - Loader2, - Copy, - Check, - ExternalLink, -} from "lucide-react"; -import { safeListen } from "@/lib/dev-bridge"; -import { open } from "@tauri-apps/plugin-shell"; +import { FileText, FolderOpen } from "lucide-react"; interface KiroFormProps { name: string; @@ -54,15 +24,7 @@ interface KiroFormProps { onSuccess: () => void; } -type KiroMode = "login" | "json" | "file"; -type LoginType = "builderid" | "google" | "github"; - -interface BuilderIdLoginData { - userCode: string; - verificationUri: string; - expiresIn: number; - interval: number; -} +type KiroMode = "json" | "file"; export function KiroForm({ name, @@ -77,346 +39,6 @@ export function KiroForm({ const [mode, setMode] = useState("json"); const [jsonContent, setJsonContent] = useState(""); - // 浏览器模式状态 - const [browserMode, setBrowserMode] = useState("system"); - const [playwrightStatus, setPlaywrightStatus] = useState({ - available: false, - }); - const [playwrightChecking, setPlaywrightChecking] = useState(false); - - // 登录相关状态 - const [_loginType, setLoginType] = useState("builderid"); - const [isLoggingIn, setIsLoggingIn] = useState(false); - const [builderIdLoginData, setBuilderIdLoginData] = - useState(null); - const [copied, setCopied] = useState(false); - const pollIntervalRef = useRef | null>(null); - const unlistenRef = useRef<(() => void) | null>(null); - - // Playwright 错误状态(用于显示详细错误信息) - // Requirements: 5.1, 5.2, 5.4 - const [playwrightError, setPlaywrightError] = useState(null); - const [lastLoginProvider, setLastLoginProvider] = useState< - "Google" | "Github" | "BuilderId" | null - >(null); - - // 检查 Playwright 可用性 - const checkPlaywright = useCallback(async () => { - setPlaywrightChecking(true); - setPlaywrightError(null); - try { - const status = await checkPlaywrightAvailable(); - setPlaywrightStatus(status); - if (!status.available && status.error) { - logPlaywrightError("checkPlaywright", status.error, { - context: "availability_check", - }); - } - } catch (err) { - logPlaywrightError("checkPlaywright", err, { - context: "availability_check", - }); - setPlaywrightStatus({ - available: false, - error: err instanceof Error ? err.message : "检测失败", - }); - } finally { - setPlaywrightChecking(false); - } - }, []); - - // 初始化时检查 Playwright 可用性 - useEffect(() => { - checkPlaywright(); - }, [checkPlaywright]); - - // 清理轮询和事件监听 - useEffect(() => { - return () => { - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - } - if (unlistenRef.current) { - unlistenRef.current(); - } - }; - }, []); - - // 清除 Playwright 错误 - const clearPlaywrightError = useCallback(() => { - setPlaywrightError(null); - setError(null); - }, [setError]); - - // 切换到系统浏览器模式 - const switchToSystemBrowser = useCallback(() => { - setBrowserMode("system"); - clearPlaywrightError(); - }, [clearPlaywrightError]); - - // 复制 user_code - const handleCopyUserCode = async () => { - if (builderIdLoginData?.userCode) { - await navigator.clipboard.writeText(builderIdLoginData.userCode); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; - - // 使用 Playwright 指纹浏览器登录 - // Requirements: 5.1, 5.2, 5.3, 5.4 - const handlePlaywrightLogin = async ( - provider: "Google" | "Github" | "BuilderId", - ) => { - setIsLoggingIn(true); - setError(null); - setPlaywrightError(null); - setLastLoginProvider(provider); - - try { - const trimmedName = name.trim() || undefined; - await startKiroPlaywrightLogin(provider, trimmedName); - onSuccess(); - } catch (e) { - // 记录详细错误日志 (Requirements: 5.4) - logPlaywrightError("handlePlaywrightLogin", e, { - provider, - browserMode, - name: name.trim() || undefined, - }); - - // 解析错误类型 - const errorInfo = parsePlaywrightError(e); - - // 设置 Playwright 错误状态(用于显示详细错误组件) - setPlaywrightError(e); - - // 根据错误类型设置用户友好的错误消息 - // Requirements: 5.1, 5.2 - if ( - errorInfo.type === PlaywrightErrorType.USER_CANCELLED || - errorInfo.type === PlaywrightErrorType.BROWSER_CLOSED - ) { - // 用户主动取消,不显示为错误 - setError(null); - } else { - setError(errorInfo.message); - } - } finally { - setIsLoggingIn(false); - } - }; - - // 重试 Playwright 登录 - const handleRetryPlaywrightLogin = useCallback(() => { - if (lastLoginProvider) { - handlePlaywrightLogin(lastLoginProvider); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastLoginProvider]); - - // 启动 Social Auth 登录 (Google/GitHub) - const handleStartSocialAuthLogin = async (provider: "Google" | "Github") => { - // 如果选择了指纹浏览器模式,使用 Playwright 登录 - if (browserMode === "playwright") { - await handlePlaywrightLogin(provider); - return; - } - - // 系统浏览器模式 - setIsLoggingIn(true); - setError(null); - setBuilderIdLoginData(null); - - try { - // 启动回调服务器 - await providerPoolApi.startKiroSocialAuthCallbackServer(); - - // 监听回调事件 - const unlisten = await safeListen<{ code: string; state: string }>( - "kiro-social-auth-callback", - async (event) => { - try { - // 交换 Token - const tokenResult = - await providerPoolApi.exchangeKiroSocialAuthToken( - event.payload.code, - event.payload.state, - ); - - if (tokenResult.success) { - // 添加凭证到凭证池 - const trimmedName = name.trim() || undefined; - await providerPoolApi.addKiroFromBuilderIdAuth(trimmedName); - onSuccess(); - } else { - setError(tokenResult.error || "Token 交换失败"); - } - } catch (e) { - setError(e instanceof Error ? e.message : "登录失败"); - } finally { - setIsLoggingIn(false); - } - }, - ); - unlistenRef.current = unlisten; - - // 启动登录 - const result = await providerPoolApi.startKiroSocialAuthLogin(provider); - - if (result.success && result.loginUrl) { - // 打开系统默认浏览器 - await open(result.loginUrl); - } else { - setError(result.error || "启动登录失败"); - setIsLoggingIn(false); - if (unlistenRef.current) { - unlistenRef.current(); - unlistenRef.current = null; - } - } - } catch (e) { - setError(e instanceof Error ? e.message : "启动登录失败"); - setIsLoggingIn(false); - } - }; - - // 启动 Builder ID 登录 - const handleStartBuilderIdLogin = async () => { - // 如果选择了指纹浏览器模式,使用 Playwright 登录 - if (browserMode === "playwright") { - await handlePlaywrightLogin("BuilderId"); - return; - } - - // 系统浏览器模式 - setIsLoggingIn(true); - setError(null); - setBuilderIdLoginData(null); - - try { - const result = await providerPoolApi.startKiroBuilderIdLogin(); - - if (result.userCode && result.verificationUri) { - setBuilderIdLoginData({ - userCode: result.userCode, - verificationUri: result.verificationUri, - expiresIn: result.expiresIn || 600, - interval: result.interval || 5, - }); - - // 打开浏览器 - await open(result.verificationUri); - - // 监听授权完成事件 - const unlisten = await safeListen<{ uuid: string }>( - "kiro-builderid-auth-complete", - () => { - // 授权完成 - setIsLoggingIn(false); - setBuilderIdLoginData(null); - onSuccess(); - }, - ); - unlistenRef.current = unlisten; - - // 开始轮询 - startPolling(result.interval || 5); - } else { - setError(result.error || "启动登录失败"); - setIsLoggingIn(false); - } - } catch (e) { - setError(e instanceof Error ? e.message : "启动登录失败"); - setIsLoggingIn(false); - } - }; - - // 开始轮询 Builder ID 授权 - const startPolling = (interval: number) => { - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - } - - pollIntervalRef.current = setInterval(async () => { - try { - const result = await providerPoolApi.pollKiroBuilderIdAuth(); - - if (!result.success) { - if ( - result.error?.includes("过期") || - result.error?.includes("超时") - ) { - setError("授权已过期,请重新登录"); - setIsLoggingIn(false); - setBuilderIdLoginData(null); - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - pollIntervalRef.current = null; - } - } - return; - } - - if (result.completed) { - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - pollIntervalRef.current = null; - } - - // 添加凭证到凭证池 - const trimmedName = name.trim() || undefined; - await providerPoolApi.addKiroFromBuilderIdAuth(trimmedName); - - setIsLoggingIn(false); - setBuilderIdLoginData(null); - onSuccess(); - } - // 如果是 pending,继续轮询 - } catch (e) { - console.error("[KiroForm] Poll error:", e); - } - }, interval * 1000); - }; - - // 取消登录 - // Requirements: 5.3 - const handleCancelLogin = async () => { - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - pollIntervalRef.current = null; - } - - if (unlistenRef.current) { - unlistenRef.current(); - unlistenRef.current = null; - } - - // 取消 Builder ID 和 Social Auth 登录 - await providerPoolApi.cancelKiroBuilderIdLogin(); - await providerPoolApi.cancelKiroSocialAuthLogin(); - - // 如果是 Playwright 模式,也取消 Playwright 登录 - // Requirements: 5.3 - if (browserMode === "playwright") { - try { - await cancelKiroPlaywrightLogin(); - logPlaywrightError("handleCancelLogin", "用户取消登录", { - provider: lastLoginProvider, - browserMode, - }); - } catch (e) { - // 取消操作的错误不需要显示给用户 - console.warn("[KiroForm] Cancel Playwright login error:", e); - } - } - - setIsLoggingIn(false); - setBuilderIdLoginData(null); - setError(null); - setPlaywrightError(null); - }; - // JSON 粘贴提交 const handleJsonSubmit = async () => { if (!jsonContent.trim()) { @@ -469,30 +91,13 @@ export function KiroForm({ // 模式选择器 const renderModeSelector = () => ( -
- +
); - // 在线登录表单 - const renderLoginForm = () => ( -
- {/* 浏览器模式选择器 */} - {!isLoggingIn && ( - - )} - - {/* Playwright 安装引导(当选择指纹浏览器但未安装时显示) */} - {!isLoggingIn && - browserMode === "playwright" && - !playwrightStatus.available && - !playwrightChecking && ( - - )} - - {/* Playwright 错误显示(当有错误且不在登录中时显示) */} - {/* Requirements: 5.1, 5.2, 5.4 */} - {!isLoggingIn && - playwrightError !== null && - browserMode === "playwright" && ( - - )} - - {/* 登录中状态 - Builder ID */} - {isLoggingIn && builderIdLoginData && ( -
-
-

- 请在浏览器中完成登录,并输入以下代码: -

-
- - {builderIdLoginData.userCode} - - -
-
- - 等待授权中... -
-
- -
- - -
-
- )} - - {/* 登录中状态 - Social Auth / Playwright */} - {isLoggingIn && !builderIdLoginData && ( -
-
- -

- {browserMode === "playwright" - ? "正在使用指纹浏览器登录..." - : "请在浏览器中完成登录..."} -

-

- {browserMode === "playwright" - ? "请在弹出的浏览器窗口中完成登录" - : "登录完成后会自动返回"} -

-
- - -
- )} - - {/* 未登录状态 - 显示登录选项 */} - {!isLoggingIn && ( -
- {/* 第一行:Google 和 GitHub */} -
- {/* Google */} - - - {/* GitHub */} - -
- - {/* 第二行:AWS Builder ID */} - -
- )} -
- ); - // JSON 粘贴表单 const renderJsonForm = () => (
@@ -789,12 +161,11 @@ export function KiroForm({ mode, handleJsonSubmit, handleFileSubmit, - handleLoginSubmit: () => {}, // 登录模式不需要手动提交 + handleLoginSubmit: () => {}, // 保持接口兼容 render: () => ( <> {renderModeSelector()} - {mode === "login" && renderLoginForm()} {mode === "json" && renderJsonForm()} {mode === "file" && ( {/* 名称输入 */} @@ -121,18 +117,16 @@ export function KiroFormStandalone({ 取消 )} - {showSubmitButton && ( - - )} +
); diff --git a/src/components/provider-pool/credential-forms/PlaywrightErrorDisplay.tsx b/src/components/provider-pool/credential-forms/PlaywrightErrorDisplay.tsx deleted file mode 100644 index f6225c000..000000000 --- a/src/components/provider-pool/credential-forms/PlaywrightErrorDisplay.tsx +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Playwright 错误显示组件 - * - * 显示 Playwright 登录过程中的错误信息, - * 包括错误标题、描述和故障排除建议 - * - * @module components/provider-pool/credential-forms/PlaywrightErrorDisplay - * @description 实现 Requirements 5.1, 5.2, 5.4 - */ - -import { AlertCircle, RefreshCw, XCircle, Clock, Globe } from "lucide-react"; -import { - parsePlaywrightError, - PlaywrightErrorType, - type PlaywrightErrorInfo, -} from "@/lib/errors/playwrightErrors"; - -interface PlaywrightErrorDisplayProps { - /** 错误信息(可以是 Error 对象、字符串或 null) */ - error: unknown; - /** 重试回调 */ - onRetry?: () => void; - /** 切换到系统浏览器回调 */ - onSwitchToSystemBrowser?: () => void; - /** 关闭/清除错误回调 */ - onDismiss?: () => void; - /** 是否正在重试 */ - retrying?: boolean; -} - -/** - * 根据错误类型获取图标 - */ -function getErrorIcon(type: PlaywrightErrorType) { - switch (type) { - case PlaywrightErrorType.OAUTH_TIMEOUT: - return ; - case PlaywrightErrorType.USER_CANCELLED: - case PlaywrightErrorType.BROWSER_CLOSED: - return ; - default: - return ; - } -} - -/** - * 根据错误类型获取样式 - */ -function getErrorStyles(type: PlaywrightErrorType) { - switch (type) { - case PlaywrightErrorType.USER_CANCELLED: - case PlaywrightErrorType.BROWSER_CLOSED: - // 用户主动操作,使用较温和的样式 - return { - container: - "border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-900/50", - icon: "text-slate-500", - title: "text-slate-800 dark:text-slate-200", - message: "text-slate-600 dark:text-slate-400", - }; - case PlaywrightErrorType.OAUTH_TIMEOUT: - // 超时,使用警告样式 - return { - container: - "border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30", - icon: "text-amber-500", - title: "text-amber-800 dark:text-amber-300", - message: "text-amber-700 dark:text-amber-400", - }; - default: - // 其他错误,使用错误样式 - return { - container: - "border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/30", - icon: "text-red-500", - title: "text-red-800 dark:text-red-300", - message: "text-red-700 dark:text-red-400", - }; - } -} - -/** - * Playwright 错误显示组件 - * - * 根据错误类型显示不同样式的错误信息, - * 并提供重试和切换浏览器模式的操作按钮 - */ -export function PlaywrightErrorDisplay({ - error, - onRetry, - onSwitchToSystemBrowser, - onDismiss, - retrying = false, -}: PlaywrightErrorDisplayProps) { - if (!error) return null; - - const errorInfo: PlaywrightErrorInfo = parsePlaywrightError(error); - const styles = getErrorStyles(errorInfo.type); - - return ( -
- {/* 错误标题和关闭按钮 */} -
-
-
- {getErrorIcon(errorInfo.type)} -
-
-

- {errorInfo.title} -

-

- {errorInfo.message} -

-
-
- {onDismiss && ( - - )} -
- - {/* 故障排除建议 */} - {errorInfo.suggestions.length > 0 && ( -
-

- 建议操作: -

-
    - {errorInfo.suggestions.map((suggestion, index) => ( -
  • - • - {suggestion} -
  • - ))} -
-
- )} - - {/* 操作按钮 */} - {(errorInfo.retryable || onSwitchToSystemBrowser) && ( -
- {errorInfo.retryable && onRetry && ( - - )} - {onSwitchToSystemBrowser && ( - - )} -
- )} - - {/* 调试信息(开发模式下显示) */} - {import.meta.env.DEV && errorInfo.originalError && ( -
- - 调试信息 - -
-            {errorInfo.originalError}
-          
-
- )} -
- ); -} diff --git a/src/components/provider-pool/credential-forms/PlaywrightInstallGuide.tsx b/src/components/provider-pool/credential-forms/PlaywrightInstallGuide.tsx deleted file mode 100644 index 0d873795f..000000000 --- a/src/components/provider-pool/credential-forms/PlaywrightInstallGuide.tsx +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Playwright 安装引导组件 - * - * 当 Playwright 未安装时显示安装指南 - * 提供一键安装、复制命令和重新检测功能 - * - * @module components/provider-pool/credential-forms/PlaywrightInstallGuide - * @description 实现 Requirements 1.4, 6.1, 6.2, 6.3, 6.4 - */ - -import { useState, useEffect } from "react"; -import { Copy, Check, RefreshCw, Download, Loader2 } from "lucide-react"; -import { safeListen } from "@/lib/dev-bridge"; -import { installPlaywright } from "@/lib/api/providerPool"; - -interface PlaywrightInstallGuideProps { - /** 重新检测回调 */ - onRetryCheck: () => void; - /** 是否正在检测 */ - checking: boolean; -} - -interface InstallProgress { - message: string; - done: boolean; - success?: boolean; -} - -const INSTALL_COMMAND = "npx playwright install chromium"; - -/** - * Playwright 安装引导 - * - * 紧凑的内联显示安装命令和操作按钮 - * 支持一键安装功能 - */ -export function PlaywrightInstallGuide({ - onRetryCheck, - checking, -}: PlaywrightInstallGuideProps) { - const [copied, setCopied] = useState(false); - const [installing, setInstalling] = useState(false); - const [progress, setProgress] = useState(""); - const [error, setError] = useState(null); - - // 监听安装进度事件 - useEffect(() => { - const unlisten = safeListen( - "playwright-install-progress", - (event) => { - setProgress(event.payload.message); - if (event.payload.done) { - setInstalling(false); - if (event.payload.success) { - // 安装成功,触发重新检测 - setError(null); - onRetryCheck(); - } else { - // 安装失败,显示错误 - setError(event.payload.message); - } - } - }, - ); - - return () => { - unlisten.then((fn) => fn()); - }; - }, [onRetryCheck]); - - const handleCopyCommand = async () => { - try { - await navigator.clipboard.writeText(INSTALL_COMMAND); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error("复制失败:", err); - } - }; - - const handleInstall = async () => { - setInstalling(true); - setProgress("正在准备安装..."); - setError(null); - try { - await installPlaywright(); - } catch (err) { - console.error("安装失败:", err); - const errorMsg = - typeof err === "string" ? err : (err as Error)?.message || String(err); - setError(errorMsg); - setProgress(""); - setInstalling(false); - } - }; - - // 显示错误状态 - if (error) { - return ( -
-
-
- - 安装失败:{" "} - {error.length > 50 ? error.substring(0, 50) + "..." : error} - - -
-
-
- ); - } - - // 安装中显示进度 - if (installing) { - return ( -
-
- - - {progress || "正在安装..."} - -
-
- ); - } - - return ( -
-
- - 或 - - {INSTALL_COMMAND} - - - -
-
- ); -} diff --git a/src/components/provider-pool/credential-forms/index.ts b/src/components/provider-pool/credential-forms/index.ts index bd2a652dd..7668b9c5d 100644 --- a/src/components/provider-pool/credential-forms/index.ts +++ b/src/components/provider-pool/credential-forms/index.ts @@ -10,6 +10,3 @@ export * from "./AntigravityForm"; export * from "./CodexForm"; export * from "./ClaudeOAuthForm"; export * from "./GeminiForm"; -export * from "./BrowserModeSelector"; -export * from "./PlaywrightInstallGuide"; -export * from "./PlaywrightErrorDisplay"; diff --git a/src/components/settings/ExperimentalSettings.tsx b/src/components/settings/ExperimentalSettings.tsx index 12cc0b34b..d361f950e 100644 --- a/src/components/settings/ExperimentalSettings.tsx +++ b/src/components/settings/ExperimentalSettings.tsx @@ -16,7 +16,7 @@ import { updateScreenshotShortcut, ExperimentalFeatures, } from "@/hooks/useTauri"; -import { ShortcutSettings } from "@/components/screenshot-chat/ShortcutSettings"; +import { ShortcutSettings } from "@/components/smart-input/ShortcutSettings"; import { UpdateCheckSettings } from "./UpdateNotification"; import { VoiceSettings } from "@/components/voice"; import { @@ -77,6 +77,8 @@ export function ExperimentalSettings() { type_delay_ms: 10, }, instructions: [], + sound_enabled: true, + translate_instruction_id: "default", }); } finally { setLoading(false); @@ -89,7 +91,7 @@ export function ExperimentalSettings() { }, [loadConfig]); // 切换截图对话功能开关 - const handleToggleScreenshotChat = useCallback(async () => { + const handleToggleSmartInput = useCallback(async () => { if (!config) return; const newEnabled = !config.screenshot_chat.enabled; @@ -248,7 +250,7 @@ export function ExperimentalSettings() { diff --git a/src/components/settings/GeneralSettings.tsx b/src/components/settings/GeneralSettings.tsx index 9f01257c1..d084d5e8d 100644 --- a/src/components/settings/GeneralSettings.tsx +++ b/src/components/settings/GeneralSettings.tsx @@ -11,7 +11,6 @@ import { Info, RotateCcw, Volume2, - Maximize2, } from "lucide-react"; import { cn, validateProxyUrl } from "@/lib/utils"; import { getConfig, saveConfig, Config } from "@/hooks/useTauri"; @@ -19,8 +18,6 @@ import { useOnboardingState } from "@/components/onboarding"; import { LanguageSelector, Language } from "./LanguageSelector"; import { useI18nPatch } from "@/i18n/I18nPatchProvider"; import { useSoundContext } from "@/contexts/useSoundContext"; -import { windowApi, type WindowSizeOption } from "@/lib/api/window"; -import { STORAGE_KEYS } from "@/components/onboarding/constants"; type Theme = "light" | "dark" | "system"; @@ -34,14 +31,6 @@ export function GeneralSettings() { const { soundEnabled, setSoundEnabled, playToolcallSound } = useSoundContext(); - // 窗口尺寸状态 - const [windowSizeOptions, setWindowSizeOptions] = useState< - WindowSizeOption[] - >([]); - const [currentWindowSize, setCurrentWindowSize] = useState("default"); - const [isFullscreen, setIsFullscreen] = useState(false); - const [windowSizeLoading, setWindowSizeLoading] = useState(true); - // 重新运行引导 const handleResetOnboarding = useCallback(() => { resetOnboarding(); @@ -65,53 +54,8 @@ export function GeneralSettings() { setTheme(savedTheme); } loadConfig(); - loadWindowSizeOptions(); }, []); - const loadWindowSizeOptions = async () => { - setWindowSizeLoading(true); - try { - const options = await windowApi.getWindowSizeOptions(); - setWindowSizeOptions(options); - - const fullscreen = await windowApi.isFullscreen(); - setIsFullscreen(fullscreen); - - // 从 localStorage 读取保存的偏好 - const savedPreference = localStorage.getItem( - STORAGE_KEYS.WINDOW_SIZE_PREFERENCE, - ); - if (savedPreference) { - setCurrentWindowSize(savedPreference); - } - } catch (error) { - console.error("加载窗口尺寸选项失败:", error); - } finally { - setWindowSizeLoading(false); - } - }; - - const handleWindowSizeChange = async (optionId: string) => { - try { - if (optionId === "fullscreen") { - if (!isFullscreen) { - await windowApi.toggleFullscreen(); - setIsFullscreen(true); - } - } else { - if (isFullscreen) { - await windowApi.toggleFullscreen(); - setIsFullscreen(false); - } - await windowApi.setWindowSizeByOption(optionId); - } - setCurrentWindowSize(optionId); - localStorage.setItem(STORAGE_KEYS.WINDOW_SIZE_PREFERENCE, optionId); - } catch (error) { - console.error("设置窗口尺寸失败:", error); - } - }; - const loadConfig = async () => { setConfigLoading(true); try { @@ -273,52 +217,6 @@ export function GeneralSettings() {
- {/* 窗口尺寸 */} -
-
- -

窗口尺寸

-
- - {windowSizeLoading ? ( -
- -
- ) : ( -
- {windowSizeOptions.map((option) => ( - - ))} - -
- )} -
- {/* 语言 */}
diff --git a/src/components/screenshot-chat/ChatInput.tsx b/src/components/smart-input/ChatInput.tsx similarity index 91% rename from src/components/screenshot-chat/ChatInput.tsx rename to src/components/smart-input/ChatInput.tsx index 46b81ce48..74e70ec24 100644 --- a/src/components/screenshot-chat/ChatInput.tsx +++ b/src/components/smart-input/ChatInput.tsx @@ -1,7 +1,7 @@ /** * @file ChatInput.tsx * @description 聊天输入框组件,支持文本输入和发送 - * @module components/screenshot-chat/ChatInput + * @module components/smart-input/ChatInput */ import React, { useCallback, useRef, useEffect } from "react"; @@ -64,11 +64,11 @@ export const ChatInput: React.FC = ({ const canSend = value.trim() && !disabled && !isLoading; return ( -
+
= ({ autoFocus /> +
+ + {/* 错误提示 */} + {error && ( +
+ + {error} +
+ )} + + {/* 测试区域 */} +
+ + +
+ +
+ +
+ {testing ? `${volume}%` : "--"} +
+
+ + {/* 提示信息 */} +

+ 点击"测试麦克风"按钮,对着麦克风说话,观察音量波形是否有变化。 +

+
+ ); +} + +export default MicrophoneTest; diff --git a/src/components/voice/PolishModelSelector.tsx b/src/components/voice/PolishModelSelector.tsx new file mode 100644 index 000000000..654090691 --- /dev/null +++ b/src/components/voice/PolishModelSelector.tsx @@ -0,0 +1,245 @@ +/** + * @file PolishModelSelector.tsx + * @description 语音润色模型选择器 - 用于语音设置中选择润色使用的模型 + * @module components/voice/PolishModelSelector + */ + +import React, { + useState, + useRef, + useEffect, + useMemo, + useCallback, +} from "react"; +import { + ChevronDown, + Loader2, + Check, + Sparkles, + ChevronRight, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; +import { useProviderModels } from "@/hooks/useProviderModels"; +import { getProviderLabel } from "@/lib/constants/providerMappings"; + +export interface PolishModelSelectorProps { + /** 当前选中的模型 ID */ + value?: string; + /** 模型变更回调 */ + onChange: (modelId: string) => void; + /** 是否禁用 */ + disabled?: boolean; + /** 自定义类名 */ + className?: string; +} + +/** + * 语音润色模型选择器 + * + * 允许用户选择用于语音润色的 LLM 模型 + */ +export const PolishModelSelector: React.FC = ({ + value, + onChange, + disabled = false, + className, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedProviderKey, setSelectedProviderKey] = useState( + null, + ); + const menuRef = useRef(null); + + // 获取已配置的 Provider 列表 + const { providers, loading: providersLoading } = useConfiguredProviders(); + + // 获取当前选中的 Provider 对象 + const selectedProvider = useMemo(() => { + return providers.find((p) => p.key === selectedProviderKey) || null; + }, [providers, selectedProviderKey]); + + // 获取当前 Provider 的模型列表(传入完整的 Provider 对象) + const { models: currentModels, loading: modelsLoading } = useProviderModels( + selectedProvider, + { returnFullMetadata: true }, + ); + + // 点击外部关闭 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen]); + + // 初始化选中的 Provider(根据当前值或默认第一个) + useEffect(() => { + if (providers.length > 0 && !selectedProviderKey) { + // 默认选择第一个 Provider + setSelectedProviderKey(providers[0].key); + } + }, [providers, selectedProviderKey]); + + // 获取显示文本 + const displayText = useMemo(() => { + if (providersLoading) { + return "加载中..."; + } + if (!value) { + return "选择润色模型"; + } + // 简化模型名称显示 + const shortModelId = value.split("/").pop() || value; + return shortModelId; + }, [providersLoading, value]); + + // 处理 Provider 选择 + const handleProviderSelect = useCallback((providerKey: string) => { + setSelectedProviderKey(providerKey); + }, []); + + // 处理模型选择 + const handleModelSelect = useCallback( + (modelId: string) => { + onChange(modelId); + setIsOpen(false); + }, + [onChange], + ); + + const isLoading = providersLoading || modelsLoading; + + return ( +
+ {/* 触发按钮 */} + + + {/* 下拉菜单 */} + {isOpen && ( +
+
+ {/* 左侧:Provider 列表 */} +
+
+

+ Provider +

+
+
+ {providers.map((provider) => ( + + ))} +
+
+ + {/* 右侧:模型列表 */} +
+
+

+ {selectedProvider + ? `${getProviderLabel(selectedProvider.key)} 模型` + : "请选择 Provider"} +

+
+
+ {modelsLoading ? ( +
+ +
+ ) : currentModels.length === 0 ? ( +
+ 暂无可用模型 +
+ ) : ( + currentModels.map((model) => { + const isSelected = value === model.id; + // 简化模型名称显示 + const displayName = + model.display_name || + model.id.split("/").pop() || + model.id; + + return ( + + ); + }) + )} +
+
+
+
+ )} +
+ ); +}; + +export default PolishModelSelector; diff --git a/src/components/voice/VoiceSettings.tsx b/src/components/voice/VoiceSettings.tsx index a7902edae..6948bbbfb 100644 --- a/src/components/voice/VoiceSettings.tsx +++ b/src/components/voice/VoiceSettings.tsx @@ -5,10 +5,26 @@ */ import { useState, useCallback } from "react"; -import { Mic, AlertTriangle, Settings2, Sparkles } from "lucide-react"; +import { + Mic, + AlertTriangle, + Settings2, + Sparkles, + Volume2, + Globe, +} from "lucide-react"; import { cn } from "@/lib/utils"; -import { ShortcutSettings } from "@/components/screenshot-chat/ShortcutSettings"; +import { ShortcutSettings } from "@/components/smart-input/ShortcutSettings"; import { VoiceInputConfig } from "@/lib/api/asrProvider"; +import { MicrophoneTest } from "./MicrophoneTest"; +import { PolishModelSelector } from "./PolishModelSelector"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; interface VoiceSettingsProps { config: VoiceInputConfig; @@ -74,6 +90,91 @@ export function VoiceSettings({ } }, [config, onConfigChange, disabled, saving]); + // 切换音效 + const handleToggleSound = useCallback(async () => { + if (disabled || saving) return; + setSaving(true); + try { + await onConfigChange({ + ...config, + sound_enabled: !config.sound_enabled, + }); + } finally { + setSaving(false); + } + }, [config, onConfigChange, disabled, saving]); + + // 更新润色模型 + const handlePolishModelChange = useCallback( + async (modelId: string) => { + if (disabled || saving) return; + setSaving(true); + try { + await onConfigChange({ + ...config, + processor: { + ...config.processor, + polish_model: modelId, + }, + }); + } finally { + setSaving(false); + } + }, + [config, onConfigChange, disabled, saving], + ); + + // 更新翻译快捷键 + const handleTranslateShortcutChange = useCallback( + async (newShortcut: string) => { + if (disabled || saving) return; + setSaving(true); + try { + await onConfigChange({ + ...config, + translate_shortcut: newShortcut || undefined, + }); + } finally { + setSaving(false); + } + }, + [config, onConfigChange, disabled, saving], + ); + + // 更新翻译指令 + const handleTranslateInstructionChange = useCallback( + async (instructionId: string) => { + if (disabled || saving) return; + setSaving(true); + try { + await onConfigChange({ + ...config, + translate_instruction_id: instructionId, + }); + } finally { + setSaving(false); + } + }, + [config, onConfigChange, disabled, saving], + ); + + // 更新麦克风设备 + const handleDeviceChange = useCallback( + async (deviceId: string | undefined) => { + if (disabled || saving) return; + setSaving(true); + try { + await onConfigChange({ + ...config, + selected_device_id: deviceId, + }); + } finally { + setSaving(false); + } + }, + [config, onConfigChange, disabled, saving], + ); + return (
{/* 标题和开关 */} @@ -121,6 +222,21 @@ export function VoiceSettings({ />
+ {/* 麦克风设备选择和测试 */} +
+
+
+ + 麦克风设备 +
+ +
+
+ {/* AI 润色设置 */}
@@ -153,6 +269,107 @@ export function VoiceSettings({ />
+ + {/* 润色模型选择 - 仅在启用润色时显示 */} + {config.processor.polish_enabled && ( +
+ + +
+ )} +
+ + {/* 交互音效设置 */} +
+
+
+ +
+ 交互音效 +

+ 录音开始和停止时播放提示音 +

+
+
+
+ + {/* 翻译模式快捷键设置 */} +
+
+ +
+ 翻译模式 +

+ 设置独立快捷键,语音识别后自动翻译 +

+
+
+ + {/* 翻译快捷键 */} +
+
+ + +
+ + {/* 翻译指令选择 */} + {config.translate_shortcut && ( +
+ + +
+ )} +
{/* ASR 服务管理入口 */} diff --git a/src/components/voice/VolumeWaveform.tsx b/src/components/voice/VolumeWaveform.tsx new file mode 100644 index 000000000..1772ce18e --- /dev/null +++ b/src/components/voice/VolumeWaveform.tsx @@ -0,0 +1,71 @@ +/** + * @file VolumeWaveform.tsx + * @description 音量波形可视化组件 + * @module components/voice/VolumeWaveform + */ + +import { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; + +export interface VolumeWaveformProps { + /** 当前音量级别 (0-100) */ + volume: number; + /** 是否正在录音 */ + isRecording: boolean; + /** 自定义类名 */ + className?: string; + /** 条形数量 */ + barCount?: number; +} + +export function VolumeWaveform({ + volume, + isRecording, + className, + barCount = 5, +}: VolumeWaveformProps) { + const [bars, setBars] = useState(Array(barCount).fill(0)); + + // 更新波形条高度 + useEffect(() => { + if (!isRecording) { + setBars(Array(barCount).fill(0)); + return; + } + + // 基于音量生成随机波形 + const baseHeight = Math.min(volume / 100, 1); + const newBars = Array(barCount) + .fill(0) + .map((_, i) => { + // 中间的条形更高 + const centerFactor = + 1 - Math.abs(i - (barCount - 1) / 2) / (barCount / 2); + const randomFactor = 0.5 + Math.random() * 0.5; + return baseHeight * centerFactor * randomFactor; + }); + setBars(newBars); + }, [volume, isRecording, barCount]); + + return ( +
+ {bars.map((height, i) => ( +
+ ))} +
+ ); +} + +export default VolumeWaveform; diff --git a/src/components/voice/index.ts b/src/components/voice/index.ts index 36390d457..f2ec9edc1 100644 --- a/src/components/voice/index.ts +++ b/src/components/voice/index.ts @@ -10,3 +10,5 @@ export { AddAsrCredentialModal } from "./AddAsrCredentialModal"; export { AsrProviderSection } from "./AsrProviderSection"; export { VoiceSettings } from "./VoiceSettings"; export { InstructionEditor } from "./InstructionEditor"; +export { MicrophoneTest } from "./MicrophoneTest"; +export { VolumeWaveform } from "./VolumeWaveform"; diff --git a/src/hooks/useSound.ts b/src/hooks/useSound.ts index a29d7dd47..482836657 100644 --- a/src/hooks/useSound.ts +++ b/src/hooks/useSound.ts @@ -1,6 +1,6 @@ /** * @file useSound.ts - * @description 音效管理 Hook,提供工具调用和打字机音效播放功能 + * @description 音效管理 Hook,提供工具调用、打字机和录音音效播放功能 * @module hooks/useSound * @requires react */ @@ -15,6 +15,8 @@ export interface UseSoundReturn { setSoundEnabled: (enabled: boolean) => void; playToolcallSound: () => void; playTypewriterSound: () => void; + playRecordingStartSound: () => void; + playRecordingStopSound: () => void; } export function useSound(): UseSoundReturn { @@ -25,6 +27,8 @@ export function useSound(): UseSoundReturn { const toolcallAudioRef = useRef(null); const typewriterAudioRef = useRef(null); + const recordingStartAudioRef = useRef(null); + const recordingStopAudioRef = useRef(null); const lastSoundTimeRef = useRef(0); // 初始化音频 @@ -39,6 +43,16 @@ export function useSound(): UseSoundReturn { typewriterAudioRef.current.volume = 0.6; typewriterAudioRef.current.load(); } + if (!recordingStartAudioRef.current) { + recordingStartAudioRef.current = new Audio("/sounds/recording-start.mp3"); + recordingStartAudioRef.current.volume = 0.8; + recordingStartAudioRef.current.load(); + } + if (!recordingStopAudioRef.current) { + recordingStopAudioRef.current = new Audio("/sounds/recording-stop.mp3"); + recordingStopAudioRef.current.volume = 0.8; + recordingStopAudioRef.current.load(); + } }, []); const setSoundEnabled = useCallback((enabled: boolean) => { @@ -62,10 +76,24 @@ export function useSound(): UseSoundReturn { } }, [soundEnabled]); + const playRecordingStartSound = useCallback(() => { + if (!recordingStartAudioRef.current) return; + recordingStartAudioRef.current.currentTime = 0; + recordingStartAudioRef.current.play().catch(console.error); + }, []); + + const playRecordingStopSound = useCallback(() => { + if (!recordingStopAudioRef.current) return; + recordingStopAudioRef.current.currentTime = 0; + recordingStopAudioRef.current.play().catch(console.error); + }, []); + return { soundEnabled, setSoundEnabled, playToolcallSound, playTypewriterSound, + playRecordingStartSound, + playRecordingStopSound, }; } diff --git a/src/hooks/useTauri.ts b/src/hooks/useTauri.ts index c9822accb..a9b533eff 100644 --- a/src/hooks/useTauri.ts +++ b/src/hooks/useTauri.ts @@ -114,7 +114,7 @@ export interface ApiKeyEntry { /** * 截图对话功能配置 */ -export interface ScreenshotChatConfig { +export interface SmartInputConfig { /** 是否启用截图对话功能 */ enabled: boolean; /** 触发截图的全局快捷键 */ @@ -126,7 +126,7 @@ export interface ScreenshotChatConfig { */ export interface ExperimentalFeatures { /** 截图对话功能配置 */ - screenshot_chat: ScreenshotChatConfig; + screenshot_chat: SmartInputConfig; } export interface Config { diff --git a/src/hooks/useVoiceSound.ts b/src/hooks/useVoiceSound.ts new file mode 100644 index 000000000..05b3aed3c --- /dev/null +++ b/src/hooks/useVoiceSound.ts @@ -0,0 +1,52 @@ +/** + * @file useVoiceSound.ts + * @description 语音录音音效 Hook,根据配置决定是否播放音效 + * @module hooks/useVoiceSound + */ + +import { useCallback, useRef, useEffect } from "react"; + +export interface UseVoiceSoundReturn { + playStartSound: () => void; + playStopSound: () => void; +} + +/** + * 语音录音音效 Hook + * @param enabled 是否启用音效 + */ +export function useVoiceSound(enabled: boolean): UseVoiceSoundReturn { + const startAudioRef = useRef(null); + const stopAudioRef = useRef(null); + + // 初始化音频 + useEffect(() => { + if (!startAudioRef.current) { + startAudioRef.current = new Audio("/sounds/recording-start.wav"); + startAudioRef.current.volume = 0.8; + startAudioRef.current.load(); + } + if (!stopAudioRef.current) { + stopAudioRef.current = new Audio("/sounds/recording-stop.wav"); + stopAudioRef.current.volume = 0.8; + stopAudioRef.current.load(); + } + }, []); + + const playStartSound = useCallback(() => { + if (!enabled || !startAudioRef.current) return; + startAudioRef.current.currentTime = 0; + startAudioRef.current.play().catch(console.error); + }, [enabled]); + + const playStopSound = useCallback(() => { + if (!enabled || !stopAudioRef.current) return; + stopAudioRef.current.currentTime = 0; + stopAudioRef.current.play().catch(console.error); + }, [enabled]); + + return { + playStartSound, + playStopSound, + }; +} diff --git a/src/i18n/patches/en.json b/src/i18n/patches/en.json index 15ee96678..16b3c5517 100644 --- a/src/i18n/patches/en.json +++ b/src/i18n/patches/en.json @@ -3709,5 +3709,22 @@ "TEMPLATE:provider_model_count": "{provider} has {count} model(s)", "TEMPLATE:credential_type": "{type} credential", "TEMPLATE:setting_updated": "{setting} updated", - "TEMPLATE:setting_failed": "Failed to update {setting}: {error}" + "TEMPLATE:setting_failed": "Failed to update {setting}: {error}", + "// === Image Gen Page (src/components/image-gen/ImageGenPage.tsx) ===": "", + "图片工坊": "Image Studio", + "管理提供商": "Manage Providers", + "选择提供商": "Select Provider", + "暂无可用提供商": "No providers available", + "选择模型": "Select Model", + "图片尺寸": "Dimensions", + "方形 (1:1)": "Square (1:1)", + "方形 (1024x1024)": "Square (1024x1024)", + "正在精心绘制...": "Creating Masterpiece...", + "将您的想象转化为像素...": "Translating your imagination into pixels...", + "在浏览器中打开": "Open in Browser", + "生成失败": "Generation Failed", + "准备就绪": "Ready to Create", + "在下方输入详细的提示词以开始创作。": "Enter a detailed prompt below to begin your creative journey.", + "描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...": "Describe the image you want to generate (e.g., 'A futuristic city with flying cars at sunset, cyberpunk style')...", + "新建图片": "New Image" } \ No newline at end of file diff --git a/src/i18n/patches/zh.json b/src/i18n/patches/zh.json index be1e1b4dd..f4deaf9b6 100644 --- a/src/i18n/patches/zh.json +++ b/src/i18n/patches/zh.json @@ -3690,5 +3690,22 @@ "TEMPLATE:provider_model_count": "{provider} 有 {count} 个模型", "TEMPLATE:credential_type": "{type} 凭证", "TEMPLATE:setting_updated": "{setting} 已更新", - "TEMPLATE:setting_failed": "更新 {setting} 失败: {error}" + "TEMPLATE:setting_failed": "更新 {setting} 失败: {error}", + "// === Image Gen Page (src/components/image-gen/ImageGenPage.tsx) ===": "", + "图片工坊": "图片工坊", + "管理提供商": "管理提供商", + "选择提供商": "选择提供商", + "暂无可用提供商": "暂无可用提供商", + "选择模型": "选择模型", + "图片尺寸": "图片尺寸", + "方形 (1:1)": "方形 (1:1)", + "方形 (1024x1024)": "方形 (1024x1024)", + "正在精心绘制...": "正在精心绘制...", + "将您的想象转化为像素...": "将您的想象转化为像素...", + "在浏览器中打开": "在浏览器中打开", + "生成失败": "生成失败", + "准备就绪": "准备就绪", + "在下方输入详细的提示词以开始创作。": "在下方输入详细的提示词以开始创作。", + "描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...": "描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...", + "新建图片": "新建图片" } \ No newline at end of file diff --git a/src/lib/api/asrProvider.ts b/src/lib/api/asrProvider.ts index dee9ea730..734ee31a7 100644 --- a/src/lib/api/asrProvider.ts +++ b/src/lib/api/asrProvider.ts @@ -91,10 +91,35 @@ export interface VoiceInputConfig { processor: VoiceProcessorConfig; output: VoiceOutputConfig; instructions: VoiceInstruction[]; + /** 选择的麦克风设备 ID(为空时使用系统默认设备) */ + selected_device_id?: string; + /** 是否启用交互音效 */ + sound_enabled: boolean; + /** 翻译模式快捷键(可选) */ + translate_shortcut?: string; + /** 翻译模式使用的指令 ID */ + translate_instruction_id: string; +} + +// ============ 麦克风设备类型 ============ + +/** 麦克风设备信息 */ +export interface AudioDeviceInfo { + /** 设备 ID */ + id: string; + /** 设备名称 */ + name: string; + /** 是否为默认设备 */ + is_default: boolean; } // ============ Tauri 命令封装 ============ +/** 获取所有可用的麦克风设备 */ +export async function listAudioDevices(): Promise { + return invoke("list_audio_devices"); +} + /** 获取 ASR 凭证列表 */ export async function getAsrCredentials(): Promise { return invoke("get_asr_credentials"); @@ -241,8 +266,8 @@ export interface StopRecordingResult { } /** 开始录音 */ -export async function startRecording(): Promise { - return invoke("start_recording"); +export async function startRecording(deviceId?: string): Promise { + return invoke("start_recording", { deviceId }); } /** 停止录音并返回音频数据 */ diff --git a/src/lib/api/window.ts b/src/lib/api/window.ts index 35724ebd3..ca14367ef 100644 --- a/src/lib/api/window.ts +++ b/src/lib/api/window.ts @@ -1,7 +1,7 @@ /** * 窗口控制 API * - * 提供窗口大小调整、位置控制等功能 + * 提供窗口基本控制功能 */ import { safeInvoke } from "@/lib/dev-bridge"; @@ -14,24 +14,12 @@ export interface WindowSize { height: number; } -/** - * 窗口大小选项 - */ -export interface WindowSizeOption { - id: string; - name: string; - description: string; - size: WindowSize; -} - /** * 窗口控制 API */ export const windowApi = { /** * 获取当前窗口大小 - * - * @returns 当前窗口大小 */ async getWindowSize(): Promise { return safeInvoke("get_window_size"); @@ -39,36 +27,13 @@ export const windowApi = { /** * 设置窗口大小 - * - * @param size - 新的窗口大小 */ async setWindowSize(size: WindowSize): Promise { return safeInvoke("set_window_size", { size }); }, - /** - * 获取所有可用的窗口大小选项 - * - * @returns 窗口大小选项列表 - */ - async getWindowSizeOptions(): Promise { - return safeInvoke("get_window_size_options"); - }, - - /** - * 设置窗口为指定的预设大小 - * - * @param optionId - 窗口大小选项 ID - * @returns 之前的窗口大小(用于恢复) - */ - async setWindowSizeByOption(optionId: string): Promise { - return safeInvoke("set_window_size_by_option", { optionId }); - }, - /** * 切换全屏模式 - * - * @returns 是否进入了全屏模式 */ async toggleFullscreen(): Promise { return safeInvoke("toggle_fullscreen"); @@ -76,8 +41,6 @@ export const windowApi = { /** * 检查是否处于全屏模式 - * - * @returns 是否处于全屏模式 */ async isFullscreen(): Promise { return safeInvoke("is_fullscreen"); @@ -91,21 +54,4 @@ export const windowApi = { }, }; -/** - * 预定义的窗口大小 - */ -export const WindowSizes = { - /** 紧凑模式 */ - compact: { width: 1000, height: 700 } as WindowSize, - - /** 默认窗口大小 */ - default: { width: 1200, height: 800 } as WindowSize, - - /** 大屏模式 */ - large: { width: 1920, height: 1200 } as WindowSize, - - /** 超大屏模式 */ - extraLarge: { width: 2560, height: 1440 } as WindowSize, -}; - export default windowApi; diff --git a/src/lib/errors/README.md b/src/lib/errors/README.md deleted file mode 100644 index 9f7be6629..000000000 --- a/src/lib/errors/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# errors - - - -## 架构说明 - -错误处理模块,提供结构化的错误类型定义、错误消息映射和错误处理工具函数。 - -## 文件索引 - -- `index.ts` - 模块导出入口 -- `playwrightErrors.ts` - Playwright 登录错误处理 - - 错误类型枚举 `PlaywrightErrorType` - - 错误信息接口 `PlaywrightErrorInfo` - - 错误解析函数 `parsePlaywrightError` - - 错误日志函数 `logPlaywrightError` - -## 使用示例 - -```typescript -import { parsePlaywrightError, logPlaywrightError } from '@/lib/errors'; - -try { - await startKiroPlaywrightLogin(provider, name); -} catch (e) { - // 解析错误类型 - const errorInfo = parsePlaywrightError(e); - - // 记录详细日志 - logPlaywrightError('handlePlaywrightLogin', e, { provider }); - - // 显示用户友好的错误消息 - setError(errorInfo.message); -} -``` - -## 相关需求 - -- Requirements 5.1: Playwright 启动失败错误处理 -- Requirements 5.2: OAuth 超时错误处理 -- Requirements 5.3: 用户取消错误处理 -- Requirements 5.4: 详细错误日志记录 - -## 更新提醒 - -任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src/lib/errors/index.ts b/src/lib/errors/index.ts deleted file mode 100644 index 68eef4312..000000000 --- a/src/lib/errors/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * 错误处理模块导出 - * - * @module lib/errors - */ - -export * from "./playwrightErrors"; diff --git a/src/lib/errors/playwrightErrors.ts b/src/lib/errors/playwrightErrors.ts deleted file mode 100644 index c90fdd0ba..000000000 --- a/src/lib/errors/playwrightErrors.ts +++ /dev/null @@ -1,283 +0,0 @@ -/** - * Playwright 登录错误处理模块 - * - * 提供 Playwright 指纹浏览器登录相关的错误类型定义、 - * 错误消息映射和错误处理工具函数 - * - * @module lib/errors/playwrightErrors - * @description 实现 Requirements 5.1, 5.2, 5.3, 5.4 - */ - -/** - * Playwright 错误类型枚举 - */ -export enum PlaywrightErrorType { - /** Playwright 未安装 */ - NOT_INSTALLED = "NOT_INSTALLED", - /** 浏览器启动失败 */ - BROWSER_LAUNCH_FAILED = "BROWSER_LAUNCH_FAILED", - /** OAuth 流程超时 */ - OAUTH_TIMEOUT = "OAUTH_TIMEOUT", - /** 用户取消登录 */ - USER_CANCELLED = "USER_CANCELLED", - /** 用户关闭浏览器窗口 */ - BROWSER_CLOSED = "BROWSER_CLOSED", - /** 授权码提取失败 */ - CODE_EXTRACTION_FAILED = "CODE_EXTRACTION_FAILED", - /** Token 交换失败 */ - TOKEN_EXCHANGE_FAILED = "TOKEN_EXCHANGE_FAILED", - /** 网络错误 */ - NETWORK_ERROR = "NETWORK_ERROR", - /** 脚本执行错误 */ - SCRIPT_ERROR = "SCRIPT_ERROR", - /** 未知错误 */ - UNKNOWN = "UNKNOWN", -} - -/** - * Playwright 错误信息接口 - */ -export interface PlaywrightErrorInfo { - /** 错误类型 */ - type: PlaywrightErrorType; - /** 用户友好的错误标题 */ - title: string; - /** 用户友好的错误描述 */ - message: string; - /** 故障排除建议 */ - suggestions: string[]; - /** 是否可重试 */ - retryable: boolean; - /** 原始错误消息(用于调试) */ - originalError?: string; -} - -/** - * 错误消息模式匹配规则 - */ -const ERROR_PATTERNS: Array<{ - pattern: RegExp | string; - type: PlaywrightErrorType; -}> = [ - { pattern: /playwright.*不可用/i, type: PlaywrightErrorType.NOT_INSTALLED }, - { pattern: /playwright.*未安装/i, type: PlaywrightErrorType.NOT_INSTALLED }, - { pattern: /chromium.*未安装/i, type: PlaywrightErrorType.NOT_INSTALLED }, - { - pattern: /browser.*not.*installed/i, - type: PlaywrightErrorType.NOT_INSTALLED, - }, - { - pattern: /启动.*浏览器.*失败/i, - type: PlaywrightErrorType.BROWSER_LAUNCH_FAILED, - }, - { - pattern: /browser.*launch.*failed/i, - type: PlaywrightErrorType.BROWSER_LAUNCH_FAILED, - }, - { - pattern: /启动.*playwright.*失败/i, - type: PlaywrightErrorType.BROWSER_LAUNCH_FAILED, - }, - { pattern: /超时/i, type: PlaywrightErrorType.OAUTH_TIMEOUT }, - { pattern: /timeout/i, type: PlaywrightErrorType.OAUTH_TIMEOUT }, - { pattern: /用户取消/i, type: PlaywrightErrorType.USER_CANCELLED }, - { pattern: /user.*cancel/i, type: PlaywrightErrorType.USER_CANCELLED }, - { pattern: /登录已取消/i, type: PlaywrightErrorType.USER_CANCELLED }, - { pattern: /关闭.*浏览器/i, type: PlaywrightErrorType.BROWSER_CLOSED }, - { pattern: /browser.*closed/i, type: PlaywrightErrorType.BROWSER_CLOSED }, - { pattern: /授权码/i, type: PlaywrightErrorType.CODE_EXTRACTION_FAILED }, - { pattern: /code.*参数/i, type: PlaywrightErrorType.CODE_EXTRACTION_FAILED }, - { pattern: /token.*交换/i, type: PlaywrightErrorType.TOKEN_EXCHANGE_FAILED }, - { - pattern: /token.*exchange/i, - type: PlaywrightErrorType.TOKEN_EXCHANGE_FAILED, - }, - { pattern: /网络/i, type: PlaywrightErrorType.NETWORK_ERROR }, - { pattern: /network/i, type: PlaywrightErrorType.NETWORK_ERROR }, - { pattern: /connection/i, type: PlaywrightErrorType.NETWORK_ERROR }, -]; - -/** - * 错误类型对应的详细信息 - */ -const ERROR_INFO_MAP: Record< - PlaywrightErrorType, - Omit -> = { - [PlaywrightErrorType.NOT_INSTALLED]: { - title: "Playwright 未安装", - message: "指纹浏览器功能需要 Playwright Chromium 浏览器支持。", - suggestions: [ - "在终端中运行: npx playwright install chromium", - "安装完成后点击「重新检测」按钮", - "如果安装失败,请检查网络连接或使用代理", - ], - retryable: false, - }, - [PlaywrightErrorType.BROWSER_LAUNCH_FAILED]: { - title: "浏览器启动失败", - message: "无法启动 Playwright 浏览器,可能是权限问题或浏览器文件损坏。", - suggestions: [ - "尝试重新安装 Playwright: npx playwright install chromium --force", - "检查系统是否有足够的内存和磁盘空间", - "尝试使用系统浏览器模式登录", - "如果问题持续,请重启应用后重试", - ], - retryable: true, - }, - [PlaywrightErrorType.OAUTH_TIMEOUT]: { - title: "登录超时", - message: "OAuth 授权流程超时,请在 5 分钟内完成登录操作。", - suggestions: [ - "点击「重试」按钮重新开始登录", - "确保网络连接稳定", - "如果页面加载缓慢,请检查网络或使用代理", - ], - retryable: true, - }, - [PlaywrightErrorType.USER_CANCELLED]: { - title: "登录已取消", - message: "您已取消登录操作。", - suggestions: ["如需继续登录,请重新选择登录方式"], - retryable: true, - }, - [PlaywrightErrorType.BROWSER_CLOSED]: { - title: "浏览器窗口已关闭", - message: "您在完成登录前关闭了浏览器窗口。", - suggestions: [ - "请重新开始登录,并在浏览器中完成授权", - "授权完成后浏览器会自动关闭", - ], - retryable: true, - }, - [PlaywrightErrorType.CODE_EXTRACTION_FAILED]: { - title: "授权码获取失败", - message: "无法从回调 URL 中提取授权码。", - suggestions: [ - "请重试登录", - "如果问题持续,请尝试使用系统浏览器模式", - "检查是否有浏览器扩展干扰了登录流程", - ], - retryable: true, - }, - [PlaywrightErrorType.TOKEN_EXCHANGE_FAILED]: { - title: "Token 交换失败", - message: "授权成功但 Token 交换失败,可能是服务器暂时不可用。", - suggestions: [ - "请稍后重试", - "检查网络连接是否正常", - "如果使用代理,请确保代理配置正确", - ], - retryable: true, - }, - [PlaywrightErrorType.NETWORK_ERROR]: { - title: "网络错误", - message: "网络连接出现问题,无法完成登录。", - suggestions: [ - "检查网络连接是否正常", - "如果使用代理,请确保代理配置正确", - "尝试关闭 VPN 或代理后重试", - ], - retryable: true, - }, - [PlaywrightErrorType.SCRIPT_ERROR]: { - title: "脚本执行错误", - message: "Playwright 登录脚本执行出错。", - suggestions: [ - "请重启应用后重试", - "如果问题持续,请尝试重新安装 Playwright", - "可以尝试使用系统浏览器模式登录", - ], - retryable: true, - }, - [PlaywrightErrorType.UNKNOWN]: { - title: "登录失败", - message: "登录过程中发生未知错误。", - suggestions: [ - "请重试登录", - "如果问题持续,请尝试使用系统浏览器模式", - "重启应用后重试", - ], - retryable: true, - }, -}; - -/** - * 解析错误消息,返回结构化的错误信息 - * - * @param error - 原始错误(可以是 Error 对象或字符串) - * @returns 结构化的错误信息 - */ -export function parsePlaywrightError(error: unknown): PlaywrightErrorInfo { - const errorMessage = error instanceof Error ? error.message : String(error); - - // 尝试匹配已知错误模式 - for (const { pattern, type } of ERROR_PATTERNS) { - const regex = - typeof pattern === "string" ? new RegExp(pattern, "i") : pattern; - if (regex.test(errorMessage)) { - const info = ERROR_INFO_MAP[type]; - return { - type, - ...info, - originalError: errorMessage, - }; - } - } - - // 未匹配到已知模式,返回未知错误 - const unknownInfo = ERROR_INFO_MAP[PlaywrightErrorType.UNKNOWN]; - return { - type: PlaywrightErrorType.UNKNOWN, - ...unknownInfo, - originalError: errorMessage, - }; -} - -/** - * 获取用户友好的错误消息 - * - * @param error - 原始错误 - * @returns 用户友好的错误消息 - */ -export function getPlaywrightErrorMessage(error: unknown): string { - const errorInfo = parsePlaywrightError(error); - return errorInfo.message; -} - -/** - * 检查错误是否可重试 - * - * @param error - 原始错误 - * @returns 是否可重试 - */ -export function isPlaywrightErrorRetryable(error: unknown): boolean { - const errorInfo = parsePlaywrightError(error); - return errorInfo.retryable; -} - -/** - * 记录 Playwright 错误日志 - * - * @param context - 错误发生的上下文 - * @param error - 原始错误 - * @param additionalInfo - 附加信息 - */ -export function logPlaywrightError( - context: string, - error: unknown, - additionalInfo?: Record, -): void { - const errorInfo = parsePlaywrightError(error); - const timestamp = new Date().toISOString(); - - console.error(`[Playwright Error] ${timestamp}`, { - context, - errorType: errorInfo.type, - title: errorInfo.title, - message: errorInfo.message, - originalError: errorInfo.originalError, - retryable: errorInfo.retryable, - ...additionalInfo, - }); -} diff --git a/src/pages/index.ts b/src/pages/index.ts index e7c917e36..b942eff41 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -1,2 +1,2 @@ export { FlowMonitorPage } from "./FlowMonitorPage"; -export { ScreenshotChatPage } from "./screenshot-chat"; +export { SmartInputPage } from "./smart-input"; diff --git a/src/pages/screenshot-chat.tsx b/src/pages/screenshot-chat.tsx deleted file mode 100644 index 589b77aa5..000000000 --- a/src/pages/screenshot-chat.tsx +++ /dev/null @@ -1,468 +0,0 @@ -/** - * @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, - Mic, - Loader2, -} from "lucide-react"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -import "./screenshot-chat.css"; - -// ProxyCast Logo组件 -function Logo() { - return ( - - - - - - - - - - - - - - - - - - - ); -} - -function getImagePathFromUrl(): string | null { - const params = new URLSearchParams(window.location.search); - const imagePath = params.get("image"); - 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(null); - const [inputValue, setInputValue] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [voiceState, setVoiceState] = useState("idle"); - const [voiceMode, setVoiceMode] = useState(false); - const inputRef = useRef(null); - - // 开始语音模式 - 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(() => { - 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(() => { - const handleKeyDown = async (e: KeyboardEvent) => { - if (e.key === "Escape") { - await handleClose(); - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [handleClose]); - - // 开始拖动窗口 - const handleStartDrag = useCallback(async (e: React.MouseEvent) => { - // 只响应左键 - if (e.button !== 0) return; - try { - await getCurrentWindow().startDragging(); - } catch (err) { - console.error("拖动窗口失败:", err); - } - }, []); - - // 移除图片附件 - const handleRemoveImage = () => { - setImagePath(null); - }; - - // 发送到主应用 - const handleSend = async () => { - if (!inputValue.trim() || isLoading) return; - setIsLoading(true); - - try { - const { safeInvoke } = await import("@/lib/dev-bridge"); - await safeInvoke("send_screenshot_chat", { - message: inputValue, - imagePath: imagePath, - }); - - await getCurrentWindow().close(); - } catch (err) { - console.error("发送失败:", err); - setIsLoading(false); - } - }; - - const handleInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }; - - return ( -
-
- {/* 拖动手柄 */} -
- -
- - {/* Logo */} - - - {/* 语音录音状态标签 */} - {voiceState === "recording" && ( -
- - 录音中... - -
- )} - - {/* 语音识别/润色状态 */} - {(voiceState === "transcribing" || voiceState === "polishing") && ( -
- - - {voiceState === "transcribing" ? "识别中..." : "润色中..."} - -
- )} - - {/* 图片附件标签 */} - {imagePath && ( -
- - Image - -
- )} - - {/* 输入框 */} - setInputValue(e.target.value)} - onKeyDown={handleInputKeyDown} - disabled={isLoading || voiceState !== "idle"} - /> - - {/* 右侧按钮组 */} -
- {/* 麦克风按钮 - 点击开始录音 */} - {voiceState === "idle" && ( - - )} - - {/* 关闭按钮 */} - - - {/* 发送按钮 */} - -
-
-
- ); -} - -export default ScreenshotChatPage; diff --git a/src/pages/screenshot-chat.css b/src/pages/smart-input.css similarity index 72% rename from src/pages/screenshot-chat.css rename to src/pages/smart-input.css index 551abdf38..3a345599c 100644 --- a/src/pages/screenshot-chat.css +++ b/src/pages/smart-input.css @@ -1,5 +1,5 @@ /** - * @file screenshot-chat.css + * @file smart-input.css * @description 截图对话输入框样式 - 参考 Google Gemini 浮动栏设计 * 半透明药丸形状,毛玻璃效果 */ @@ -28,19 +28,53 @@ body, background: transparent !important; } +/* 错误提示 Toast */ +.screenshot-error-toast { + position: absolute; + top: -40px; + left: 50%; + transform: translateX(-50%); + background: rgba(239, 68, 68, 0.9); + color: white; + padding: 6px 16px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + animation: toastFadeIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); + z-index: 200; + pointer-events: none; +} + +@keyframes toastFadeIn { + from { + opacity: 0; + transform: translate(-50%, 10px); + } + + to { + opacity: 1; + transform: translate(-50%, 0); + } +} + /* 输入框容器 - Gemini 风格半透明药丸 */ .screenshot-input-bar { display: flex; align-items: center; gap: 8px; width: 100%; - height: 48px; - padding: 0 12px; + min-height: 48px; + max-height: 150px; + padding: 8px 12px; /* 半透明毛玻璃效果 */ background: rgba(255, 255, 255, 0.85); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); - /* 完全药丸形状 - 高度的一半 */ + /* 圆角 */ border-radius: 24px; /* 柔和阴影 */ box-shadow: @@ -164,6 +198,7 @@ body, 100% { opacity: 1; } + 50% { opacity: 0.5; } @@ -179,6 +214,12 @@ body, font-size: 14px; color: #1f2937; outline: none; + resize: none; + overflow-y: auto; + line-height: 1.5; + font-family: inherit; + min-height: 24px; + max-height: 120px; } .screenshot-input::placeholder { @@ -195,6 +236,88 @@ body, align-items: center; gap: 4px; flex-shrink: 0; + z-index: 100; + position: relative; +} + +/* 录音全新布局样式 */ +.screenshot-recording-container { + flex: 1; + display: flex; + align-items: center; + justify-content: flex-start; + /* 左对齐,不想挤在一起 */ + gap: 12px; + height: 100%; + cursor: default; + /* 容器本身不作为按钮,避免误触 */ + padding-left: 4px; +} + +/* 录音中的红点动画 */ +.recording-dot { + width: 8px; + height: 8px; + background-color: #ef4444; + border-radius: 50%; + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); + animation: pulse-dot 1.5s infinite; +} + +.screenshot-recording-text { + color: #374151; + /* 深灰色文字,更清晰 */ + font-weight: 500; + font-size: 14px; +} + +/* 停止按钮 - 显眼的圆形红底按钮 */ +.screenshot-stop-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: #ef4444; + /* 实心红底 */ + border: none; + border-radius: 50%; + color: white; + /* 白色图标 */ + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 5px rgba(239, 68, 68, 0.3); + z-index: 50; + /* 确保层级最高 */ + pointer-events: auto; + /* 确保可点击 */ +} + +.screenshot-stop-btn:hover { + background: #dc2626; + transform: scale(1.05); + box-shadow: 0 4px 8px rgba(239, 68, 68, 0.4); +} + +.screenshot-stop-btn:active { + transform: scale(0.95); +} + +@keyframes pulse-dot { + 0% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); + } + + 70% { + transform: scale(1); + box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); + } + + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); + } } /* 关闭按钮 */ diff --git a/src/pages/smart-input.tsx b/src/pages/smart-input.tsx new file mode 100644 index 000000000..4288a5616 --- /dev/null +++ b/src/pages/smart-input.tsx @@ -0,0 +1,727 @@ +/** + * @file smart-input.tsx + * @description 截图对话悬浮窗口 - 参考 Google Gemini 浮动栏设计 + * 半透明药丸形状,简洁的输入界面 + * 支持语音输入模式 + * @module pages/smart-input + */ + +import React, { useEffect, useState, useRef, useCallback } from "react"; +import { + Image as ImageIcon, + ArrowUp, + X, + GripVertical, + Mic, + Loader2, + Square, +} from "lucide-react"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { useVoiceSound } from "@/hooks/useVoiceSound"; +import "./smart-input.css"; + +// ProxyCast Logo组件 +function Logo() { + return ( + + + + + + + + + + + + + + + + + + + ); +} + +function getImagePathFromUrl(): string | null { + const params = new URLSearchParams(window.location.search); + const imagePath = params.get("image"); + 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"; +} + +function getTranslateModeFromUrl(): boolean { + const params = new URLSearchParams(window.location.search); + return params.get("translate") === "true"; +} + +function getInstructionIdFromUrl(): string | null { + const params = new URLSearchParams(window.location.search); + const instruction = params.get("instruction"); + return instruction ? decodeURIComponent(instruction) : null; +} + +/** 语音状态 */ +type VoiceState = "idle" | "recording" | "transcribing" | "polishing"; + +export function SmartInputPage() { + const [imagePath, setImagePath] = useState(null); + const [inputValue, setInputValue] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [voiceState, setVoiceState] = useState("idle"); + const [voiceMode, setVoiceMode] = useState(false); + const [soundEnabled, setSoundEnabled] = useState(true); + const [translateMode, setTranslateMode] = useState(false); + const [translateInstructionId, setTranslateInstructionId] = useState< + string | null + >(null); + const inputRef = useRef(null); + const [errorMsg, setErrorMsg] = useState(null); + + // 追踪是否已经从 URL 初始化过语音模式 + const voiceModeInitializedRef = useRef(false); + + // 语音音效 + const { playStartSound, playStopSound } = useVoiceSound(soundEnabled); + + // 加载音效配置 + useEffect(() => { + (async () => { + try { + const { getVoiceInputConfig } = await import("@/lib/api/asrProvider"); + const config = await getVoiceInputConfig(); + setSoundEnabled(config.sound_enabled); + } catch (err) { + console.error("[语音输入] 加载音效配置失败:", err); + } + })(); + }, []); + + // 显示错误提示 + const showError = useCallback((msg: string) => { + setErrorMsg(msg); + setTimeout(() => setErrorMsg(null), 3000); + }, []); + + // 开始语音模式 + const startVoiceMode = useCallback(async () => { + console.log("[语音输入] startVoiceMode 被调用,当前状态:", voiceState); + if (voiceState !== "idle") { + console.log("[语音输入] 已在录音状态,跳过"); + return; + } + setVoiceMode(true); + setVoiceState("recording"); + setInputValue(""); // 清空之前的输入 + + // 播放开始录音音效 + playStartSound(); + + try { + const { startRecording, getVoiceInputConfig, cancelRecording } = + await import("@/lib/api/asrProvider"); + + // 先尝试取消任何正在进行的录音(可能是设置页面的测试没有停止) + try { + await cancelRecording(); + console.log("[语音输入] 已取消之前的录音"); + } catch { + // 忽略取消错误 + } + + // 获取配置中的设备 ID + const config = await getVoiceInputConfig(); + console.log("[语音输入] 使用设备ID:", config.selected_device_id); + await startRecording(config.selected_device_id); + console.log("[语音输入] 开始录音成功"); + } catch (err: any) { + console.error("[语音输入] 开始录音失败:", err); + // 检查错误信息是否与权限有关,或者直接给通用提示 + const errMsg = + typeof err === "string" ? err : err?.message || JSON.stringify(err); + if ( + errMsg.toLowerCase().includes("permission") || + errMsg.toLowerCase().includes("device") + ) { + showError("无法访问麦克风,请检查系统隐私设置"); + } else { + showError(`无法开始录音: ${errMsg}`); + } + + setVoiceState("idle"); + setVoiceMode(false); + } + }, [voiceState, showError, playStartSound]); + + // 从 URL 获取图片路径、预填文本和语音模式 + useEffect(() => { + const path = getImagePathFromUrl(); + if (path) { + setImagePath(path); + } + const prefilledText = getPrefilledTextFromUrl(); + if (prefilledText) { + setInputValue(prefilledText); + } + const isVoiceMode = getVoiceModeFromUrl(); + const isTranslateMode = getTranslateModeFromUrl(); + const instructionId = getInstructionIdFromUrl(); + + console.log( + "[语音输入] URL 参数 voice=", + isVoiceMode, + "translate=", + isTranslateMode, + "instruction=", + instructionId, + ); + + if (isTranslateMode && instructionId) { + setTranslateMode(true); + setTranslateInstructionId(instructionId); + } + + // 只在首次初始化时启动语音模式,避免重复触发 + if (isVoiceMode && !voiceModeInitializedRef.current) { + voiceModeInitializedRef.current = true; + startVoiceMode(); + } + }, [startVoiceMode]); + + // 监听后端发送的开始录音事件(窗口已存在时使用) + useEffect(() => { + 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]); + + // 使用 Ref 追踪状态,避免闭包陷阱 + const voiceStateRef = useRef("idle"); + useEffect(() => { + voiceStateRef.current = voiceState; + }, [voiceState]); + + // 组件卸载时清理录音 + useEffect(() => { + return () => { + console.log("[语音输入] 组件卸载,当前状态:", voiceStateRef.current); + if (voiceStateRef.current === "recording") { + console.log("[语音输入] 组件卸载时正在录音,取消录音"); + import("@/lib/api/asrProvider").then(({ cancelRecording }) => { + cancelRecording().catch((err) => { + console.error("[语音输入] 卸载时取消录音失败:", err); + }); + }); + } + }; + }, []); // 空依赖,只在卸载时执行 + + // 使用 Ref 追踪音效函数,避免闭包陷阱 + const playStopSoundRef = useRef(playStopSound); + useEffect(() => { + playStopSoundRef.current = playStopSound; + }, [playStopSound]); + + // 使用 Ref 追踪翻译模式状态,避免闭包陷阱 + const translateModeRef = useRef(translateMode); + const translateInstructionIdRef = useRef(translateInstructionId); + useEffect(() => { + translateModeRef.current = translateMode; + translateInstructionIdRef.current = translateInstructionId; + }, [translateMode, translateInstructionId]); + + // 手动停止语音录音(点击按钮) + const stopVoiceRecording = useCallback( + async (e?: React.MouseEvent) => { + // 阻止事件冒泡,防止触发其他点击逻辑 + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + // 使用 Ref 获取最新状态,避免闭包陷阱 + const currentState = voiceStateRef.current; + console.log( + "[语音输入] stopVoiceRecording 被调用,Ref状态:", + currentState, + ); + + // 如果不在录音状态,直接返回 + if (currentState !== "recording") { + console.log("[语音输入] 不在录音状态,跳过"); + return; + } + + // 播放停止录音音效 + playStopSoundRef.current(); + + setVoiceState("transcribing"); + try { + const { + stopRecording, + transcribeAudio, + polishVoiceText, + getVoiceInputConfig, + } = await import("@/lib/api/asrProvider"); + + let result; + try { + result = await stopRecording(); + } catch (recordingErr: any) { + // 如果停止录音本身失败(例如后端没在录音,或者设备断开),强制重置 + console.error("停止录音异常:", recordingErr); + const errMsg = + typeof recordingErr === "string" + ? recordingErr + : recordingErr?.message || ""; + showError(`录音停止失败: ${errMsg}`); + setVoiceState("idle"); + setVoiceMode(false); + return; + } + + console.log( + "[语音输入] 录音完成,时长:", + result.duration.toFixed(2), + "秒", + ); + + if (result.duration < 0.5) { + console.log("[语音输入] 录音时间过短"); + // 这里不要 alert,因为用户可能只是误触,直接静默取消即可 + 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(); + console.log("[语音输入] 润色配置:", { + polish_enabled: config.processor.polish_enabled, + polish_model: config.processor.polish_model, + default_instruction_id: config.processor.default_instruction_id, + translateMode: translateModeRef.current, + translateInstructionId: translateInstructionIdRef.current, + }); + + // 翻译模式:使用指定的翻译指令 + if (translateModeRef.current && translateInstructionIdRef.current) { + console.log("[语音输入] 进入翻译模式分支"); + setVoiceState("polishing"); + console.log( + "[语音输入] 翻译模式,使用指令:", + translateInstructionIdRef.current, + ); + const polished = await polishVoiceText( + transcribeResult.text, + translateInstructionIdRef.current, + ); + console.log("[语音输入] 翻译完成:", polished.text); + finalText = polished.text; + } else if (config.processor.polish_enabled) { + // 普通模式:使用默认润色 + console.log("[语音输入] 进入润色模式分支"); + setVoiceState("polishing"); + const polished = await polishVoiceText(transcribeResult.text); + console.log("[语音输入] 润色完成:", polished.text); + finalText = polished.text; + } else { + console.log("[语音输入] 润色未启用,直接使用原始文本"); + } + } catch (e) { + console.error("[语音润色] 失败:", e); + } + + setInputValue(finalText); + setVoiceState("idle"); + setVoiceMode(false); + inputRef.current?.focus(); + } catch (err) { + console.error("[语音识别] 失败:", err); + showError("语音识别过程中发生错误"); + setVoiceState("idle"); + setVoiceMode(false); + } + }, + [showError], + ); // 只依赖 showError,其他通过 Ref 获取 + + // 监听快捷键释放事件 + 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("[语音输入] 收到停止录音事件"); + + // 播放停止录音音效 + playStopSoundRef.current(); + + // 直接在这里执行停止录音逻辑,避免闭包问题 + 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(); + console.log("[语音输入] 润色配置:", { + polish_enabled: config.processor.polish_enabled, + polish_model: config.processor.polish_model, + default_instruction_id: config.processor.default_instruction_id, + translateMode: translateModeRef.current, + translateInstructionId: translateInstructionIdRef.current, + }); + + // 翻译模式:使用指定的翻译指令 + if ( + translateModeRef.current && + translateInstructionIdRef.current + ) { + console.log("[语音输入] 进入翻译模式分支"); + setVoiceState("polishing"); + console.log( + "[语音输入] 翻译模式,使用指令:", + translateInstructionIdRef.current, + ); + const polished = await polishVoiceText( + transcribeResult.text, + translateInstructionIdRef.current, + ); + console.log("[语音输入] 翻译完成:", polished.text); + finalText = polished.text; + } else if (config.processor.polish_enabled) { + // 普通模式:使用默认润色 + console.log("[语音输入] 进入润色模式分支"); + setVoiceState("polishing"); + const polished = await polishVoiceText(transcribeResult.text); + console.log("[语音输入] 润色完成:", polished.text); + finalText = polished.text; + } else { + console.log("[语音输入] 润色未启用,直接使用原始文本"); + } + } catch (e) { + console.error("[语音润色] 失败:", e); + } + + setInputValue(finalText); + setVoiceState("idle"); + setVoiceMode(false); + inputRef.current?.focus(); + } catch (err) { + console.error("[语音识别] 失败:", err); + showError("语音识别过程中发生错误"); + setVoiceState("idle"); + setVoiceMode(false); + } + }); + return unlisten; + } catch (err) { + console.error("[语音输入] 监听停止录音事件失败:", err); + return () => {}; + } + }; + + const unlistenPromise = setupStopListener(); + return () => { + unlistenPromise.then((unlisten) => unlisten()); + }; + }, [voiceMode, showError]); + + // 关闭窗口 + 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(() => { + const handleKeyDown = async (e: KeyboardEvent) => { + if (e.key === "Escape") { + await handleClose(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleClose]); + + // 开始拖动窗口 + const handleStartDrag = useCallback(async (e: React.MouseEvent) => { + // 只响应左键 + if (e.button !== 0) return; + try { + await getCurrentWindow().startDragging(); + } catch (err) { + console.error("拖动窗口失败:", err); + } + }, []); + + // 移除图片附件 + const handleRemoveImage = () => { + setImagePath(null); + }; + + // 发送到主应用 + const handleSend = async () => { + if (!inputValue.trim() || isLoading) return; + setIsLoading(true); + + try { + const { safeInvoke } = await import("@/lib/dev-bridge"); + console.log("[SmartInput] 发送消息:", inputValue); + await safeInvoke("send_screenshot_chat", { + message: inputValue, + imagePath: imagePath, + }); + console.log("[SmartInput] 发送成功,关闭窗口"); + // 确保窗口关闭 + const win = getCurrentWindow(); + await win.close(); + } catch (err) { + console.error("[SmartInput] 发送失败:", err); + showError("发送失败,请重试"); + setIsLoading(false); + } + }; + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+ {errorMsg &&
{errorMsg}
} +
+ {/* 拖动手柄 */} +
+ +
+ + {/* Logo */} + + + {/* 语音识别/润色状态 */} + {(voiceState === "transcribing" || voiceState === "polishing") && ( +
+ + + {voiceState === "transcribing" ? "识别中..." : "润色中..."} + +
+ )} + + {/* 图片附件标签 */} + {imagePath && ( +
+ + Image + +
+ )} + + {/* 录音模式显示波形,非录音模式显示输入框 */} + {voiceState === "recording" ? ( +
+
+ 正在聆听... +
+ ) : ( +