From ef43d3e74d1b128d3bb40956f9c597659f9d8e71 Mon Sep 17 00:00:00 2001 From: coso Date: Sun, 1 Feb 2026 00:05:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.50.0=20-=20=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=94=AF=E6=8C=81=20base64=20=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E3=80=81=E8=AF=AD=E9=9F=B3=E5=8A=9F=E8=83=BD=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E3=80=81Smart=20Input=20=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- public/sounds/recording-start.wav | Bin 0 -> 99050 bytes public/sounds/recording-stop.wav | Bin 0 -> 113198 bytes scripts/test-newapi-image.ts | 137 +++ src-tauri/Cargo.lock | 32 +- src-tauri/Cargo.toml | 4 +- src-tauri/capabilities/default.json | 2 +- .../voice-core/src/asr_client/xunfei.rs | 130 ++- .../crates/voice-core/tests/asr_e2e_tests.rs | 315 ++++++ src-tauri/src/app/runner.rs | 4 +- src-tauri/src/commands/screenshot_cmd.rs | 4 +- src-tauri/src/commands/window_cmd.rs | 304 +----- src-tauri/src/config/types.rs | 24 + src-tauri/src/dev_bridge/dispatcher.rs | 140 +++ src-tauri/src/screenshot/window.rs | 33 +- src-tauri/src/voice/commands.rs | 54 +- src-tauri/src/voice/mod.rs | 22 + src-tauri/src/voice/processor.rs | 69 +- src-tauri/src/voice/recording_service.rs | 112 +- src-tauri/src/voice/shortcut.rs | 156 +++ src-tauri/src/voice/window.rs | 75 +- src-tauri/tauri.conf.json | 16 +- src/App.tsx | 53 +- src/RootRouter.tsx | 8 +- src/components/AppSidebar.tsx | 2 + .../agent/chat/hooks/useAgentChat.ts | 2 +- src/components/agent/chat/index.tsx | 2 +- src/components/image-gen/ImageGenPage.tsx | 983 ++++++++++++++++++ src/components/image-gen/README.md | 41 + src/components/image-gen/index.ts | 9 + src/components/image-gen/types.ts | 202 ++++ src/components/image-gen/useImageGen.ts | 464 +++++++++ .../onboarding/OnboardingWizard.tsx | 150 ++- src/components/onboarding/constants.ts | 64 +- .../onboarding/steps/MicrophoneTestStep.tsx | 277 +++++ .../onboarding/steps/VoiceDemoStep.tsx | 308 ++++++ .../steps/VoiceShortcutTestStep.tsx | 193 ++++ .../onboarding/steps/WindowSizeStep.tsx | 153 --- .../provider-pool/AddCredentialModal.tsx | 5 - .../credential-forms/BrowserModeSelector.tsx | 111 -- .../credential-forms/KiroForm.tsx | 647 +----------- .../credential-forms/KiroFormStandalone.tsx | 26 +- .../PlaywrightErrorDisplay.tsx | 193 ---- .../PlaywrightInstallGuide.tsx | 181 ---- .../provider-pool/credential-forms/index.ts | 3 - .../settings/ExperimentalSettings.tsx | 8 +- src/components/settings/GeneralSettings.tsx | 102 -- .../ChatInput.tsx | 10 +- .../ChatMessages.tsx | 26 +- .../README.md | 0 .../ShortcutSettings.tsx | 2 +- .../SmartInputPreview.tsx} | 10 +- .../SmartInputWindow.tsx} | 42 +- .../{screenshot-chat => smart-input}/index.ts | 20 +- .../smart-input.css} | 102 +- .../{screenshot-chat => smart-input}/types.ts | 12 +- .../useSmartInput.ts} | 14 +- src/components/voice/MicrophoneTest.tsx | 246 +++++ src/components/voice/PolishModelSelector.tsx | 245 +++++ src/components/voice/VoiceSettings.tsx | 221 +++- src/components/voice/VolumeWaveform.tsx | 71 ++ src/components/voice/index.ts | 2 + src/hooks/useSound.ts | 30 +- src/hooks/useTauri.ts | 4 +- src/hooks/useVoiceSound.ts | 52 + src/i18n/patches/en.json | 19 +- src/i18n/patches/zh.json | 19 +- src/lib/api/asrProvider.ts | 29 +- src/lib/api/window.ts | 56 +- src/lib/errors/README.md | 46 - src/lib/errors/index.ts | 7 - src/lib/errors/playwrightErrors.ts | 283 ----- src/pages/index.ts | 2 +- src/pages/screenshot-chat.tsx | 468 --------- .../{screenshot-chat.css => smart-input.css} | 131 ++- src/pages/smart-input.tsx | 727 +++++++++++++ src/types/page.ts | 1 + 77 files changed, 5811 insertions(+), 2908 deletions(-) create mode 100644 public/sounds/recording-start.wav create mode 100644 public/sounds/recording-stop.wav create mode 100644 scripts/test-newapi-image.ts create mode 100644 src-tauri/crates/voice-core/tests/asr_e2e_tests.rs create mode 100644 src/components/image-gen/ImageGenPage.tsx create mode 100644 src/components/image-gen/README.md create mode 100644 src/components/image-gen/index.ts create mode 100644 src/components/image-gen/types.ts create mode 100644 src/components/image-gen/useImageGen.ts create mode 100644 src/components/onboarding/steps/MicrophoneTestStep.tsx create mode 100644 src/components/onboarding/steps/VoiceDemoStep.tsx create mode 100644 src/components/onboarding/steps/VoiceShortcutTestStep.tsx delete mode 100644 src/components/onboarding/steps/WindowSizeStep.tsx delete mode 100644 src/components/provider-pool/credential-forms/BrowserModeSelector.tsx delete mode 100644 src/components/provider-pool/credential-forms/PlaywrightErrorDisplay.tsx delete mode 100644 src/components/provider-pool/credential-forms/PlaywrightInstallGuide.tsx rename src/components/{screenshot-chat => smart-input}/ChatInput.tsx (91%) rename src/components/{screenshot-chat => smart-input}/ChatMessages.tsx (73%) rename src/components/{screenshot-chat => smart-input}/README.md (100%) rename src/components/{screenshot-chat => smart-input}/ShortcutSettings.tsx (99%) rename src/components/{screenshot-chat/ScreenshotPreview.tsx => smart-input/SmartInputPreview.tsx} (95%) rename src/components/{screenshot-chat/ScreenshotChatWindow.tsx => smart-input/SmartInputWindow.tsx} (77%) rename src/components/{screenshot-chat => smart-input}/index.ts (51%) rename src/components/{screenshot-chat/screenshot-chat.css => smart-input/smart-input.css} (87%) rename src/components/{screenshot-chat => smart-input}/types.ts (92%) rename src/components/{screenshot-chat/useScreenshotChat.ts => smart-input/useSmartInput.ts} (96%) create mode 100644 src/components/voice/MicrophoneTest.tsx create mode 100644 src/components/voice/PolishModelSelector.tsx create mode 100644 src/components/voice/VolumeWaveform.tsx create mode 100644 src/hooks/useVoiceSound.ts delete mode 100644 src/lib/errors/README.md delete mode 100644 src/lib/errors/index.ts delete mode 100644 src/lib/errors/playwrightErrors.ts delete mode 100644 src/pages/screenshot-chat.tsx rename src/pages/{screenshot-chat.css => smart-input.css} (72%) create mode 100644 src/pages/smart-input.tsx 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 0000000000000000000000000000000000000000..b73400b738bf4439d137f5d192ea929f903de038 GIT binary patch literal 99050 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

p@{bwR-5H(o z`}ghEt!tpY-ar;MCvG@75X#rFKpCw1-FXXr6K2Mq1nt?z&y zol>*%Q#Rk0KcHu?Zv6*nuBy%cL;Lsa-KE>mUOl_^?s;sF0Ygvd(@E>wa@Y^I)uZP& zwdJtJ9DNYw({_lKEnEtZ+o4qHRH9Sh0-p@gq;PYg-)e*lti} zuV}B)T@q&&_n26g=s&UdfWnC*3UlMh^f5kf{HW2R5*4ww(oIQu-uRB~Vj-*Nc^@{X zv}>C@q+&$Jc9pt1xL4n{T}sOmZFAd4BZbjuYTNAG_Jz^3XhuQWksJ!N-ARGBPHbrR5nuGPS-?vVOjC$3MsN}Xg5|+I;NsyyLRD18~>{s*r?IH zdTqQ^NqPI^FxwZFj!GFH8=WG*ri>buQrDT~72U>aw5pSJo>@L%!stXwzeIWIn6kn| zx3P(miUaCys$ZgJyxY)}!|QB_)4sn)bzMDPxbd9PW6DM*fsPlZBt|Ai>2Ns=QtJf3 z#ky_{x>1$Tb)8TW8&zDGECo|G`iKkio!qNeUeA*9idacuqG#7mHncA;j_2hh3bPAy zaxx-C@px7wBfBsj$;pVOMxtp2v7+?c^n&zwL4L{>r`Nc)s_Cw!g=0ehHJylOB;uI` zxslAmtn5fMo}CdX$S%x|6lD|@se=5MnOfast<$z>vQJsD5Q>egbxi1WT(7gD1*t{x zjGRbLG&dH>h!QP)?QCc=385$cIn~ahE ziL*A{8qf?RA6cB_`si3$c_Q$xW4mhN)ds$%u_S-`qe(l0DVL)n%`zPns4S&D;{)4{w$)Q#4iQHH$yC_w3<;Js%A{nXaxslwgjDkosJuQ|<%TCQH zD2P>$@+Wwra_J){FFotO!V^KxDrKlTZc++MODe>1^%$$S|6h!!f7yr8SXMz{Q8Xix zl~zy~qNylSkS0GA#S-bM>DgI@+3DHU8ht-4opXj`JYGzI}Gc6~QogFKP(7%jG zZmdw6FDl5$P0LD4jm7_U=Y_R_e>njEH|4m0IVQKQ(yY{?pB$6_Gu;}dc=1@p#B%xHb9Sg7RYz>SiBYlg5xrt%BP`i>>RnoroDBsO zYH#2C?7GX*%B`C^quRSYOX_l9m3RBbN-FA{Om5m%6@WiwGRcpdPUo|V;swzhNirup zo)am^OsHHFS*gm`>_pYPFsG*P8&?4~2O@Z)vh7-vZZpk-cy^8!vtpT%w1Tt@Z6|Ug zS-DvSm2GEiTq3A-+Sc2yV@6&b?3rot?9|j~VI(U(P49ElanhtdQWVW9$~4E#$t)x# zm0hf(nXO-QOCj9Iy)fwDgiRH+_U`HSXs+!@?YTG)yg=2a9|j7)5ivZ`-6q*kH7nysckJ7Ze{hR>MF_ITe(br z%J=m#-_D}avQe>$PO;IWM-~^x!V+D|*phhF;&d!9Hc|O7Li>@i35l{~gH`8j4sIAw zB{_Q5EZfUqAu^l?t%k+;~nRJ2O(4 zn`xmwHy(@RrlzMwa#CX%sfnCuK~Z?-moNceUH;$lASJlzj)@$$0oBB_B zs#5&65_Q$JVcqq4O*gly(AnEGGuqcwNaT1=&MkKGW6j~!J+AIgtyyDrGa*e4u*w)7bpCPO4N}&g#>a*!m~2O0x!Ag9{`D`O21wGANE9P%?q6JPSGdT2jU;z|8+9?AbdT^kDw literal 0 HcmV?d00001 diff --git a/public/sounds/recording-stop.wav b/public/sounds/recording-stop.wav new file mode 100644 index 0000000000000000000000000000000000000000..775d046489aab05639deb3670c852d5f0ff16e37 GIT binary patch literal 113198 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 { + 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" ? ( +
+
+ 正在聆听... +
+ ) : ( +