mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: v0.50.0 - 图片生成支持 base64 格式、语音功能优化、Smart Input 重构
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.49.0",
|
||||
"version": "0.50.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 测试 New API 图片生成
|
||||
* 运行: npx tsx scripts/test-newapi-image.ts
|
||||
*
|
||||
* 生成的图片保存到 scripts/test-output/ 目录
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const API_HOST = "http://127.0.0.1:8045";
|
||||
const API_KEY = "sk-1b2d91c47f4d455485fdd8f4fe52883e";
|
||||
const MODEL = "gemini-3-pro-image";
|
||||
const OUTPUT_DIR = path.join(__dirname, "test-output");
|
||||
|
||||
// 确保输出目录存在
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 base64 数据保存图片
|
||||
*/
|
||||
function saveBase64Image(base64Data: string, filename: string): string {
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const filepath = path.join(OUTPUT_DIR, filename);
|
||||
fs.writeFileSync(filepath, buffer);
|
||||
return filepath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 下载并保存图片
|
||||
*/
|
||||
async function downloadImage(url: string, filename: string): Promise<string> {
|
||||
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 图片格式 
|
||||
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();
|
||||
Generated
+16
-16
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<i16> {
|
||||
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<i32, String> = 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<i32> = 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<u8> = audio.samples.iter().flat_map(|s| s.to_le_bytes()).collect();
|
||||
let audio_bytes: Vec<u8> = 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<i32>,
|
||||
/// 词列表
|
||||
ws: Vec<XunfeiWord>,
|
||||
/// 是否是最终结果
|
||||
#[allow(dead_code)]
|
||||
ls: Option<bool>,
|
||||
/// 动态修正标识:apd=追加,rpl=替换
|
||||
pgs: Option<String>,
|
||||
/// 替换范围 [start, end],当 pgs=rpl 时有效
|
||||
rg: Option<Vec<i32>>,
|
||||
}
|
||||
|
||||
/// 词
|
||||
|
||||
@@ -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<i16> = (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<String> {
|
||||
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 服务测试失败");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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))?;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Self> {
|
||||
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<WindowSizeOption>` - 所有可用的窗口大小选项
|
||||
#[tauri::command]
|
||||
pub async fn get_window_size_options() -> Vec<WindowSizeOption> {
|
||||
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<WindowSize, String> {
|
||||
// 获取当前大小
|
||||
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<bool, String> {
|
||||
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<bool, String> {
|
||||
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<WindowSize, String> {
|
||||
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<bool, String> {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -606,12 +606,32 @@ pub struct VoiceInputConfig {
|
||||
/// 自定义指令列表
|
||||
#[serde(default)]
|
||||
pub instructions: Vec<VoiceInstruction>,
|
||||
/// 选择的麦克风设备 ID(为空时使用系统默认设备)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selected_device_id: Option<String>,
|
||||
/// 是否启用交互音效
|
||||
#[serde(default = "default_sound_enabled")]
|
||||
pub sound_enabled: bool,
|
||||
/// 翻译模式快捷键(可选)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub translate_shortcut: Option<String>,
|
||||
/// 翻译模式使用的指令 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<serde_json::Value> = 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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vec<AudioDeviceInfo>, 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<String>,
|
||||
) -> 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<u8> = 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<RecordingStatus, String> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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<String, String> {
|
||||
async fn call_local_llm(
|
||||
prompt: &str,
|
||||
model: Option<&str>,
|
||||
instruction_id: &str,
|
||||
) -> Result<String, String> {
|
||||
use crate::config::load_config;
|
||||
|
||||
// 加载配置获取 API 地址和密钥
|
||||
@@ -41,8 +70,14 @@ async fn call_local_llm(prompt: &str, model: Option<&str>) -> Result<String, Str
|
||||
let base_url = format!("http://{}:{}", config.server.host, config.server.port);
|
||||
let api_key = &config.server.api_key;
|
||||
|
||||
// 使用配置的模型或默认模型
|
||||
let model_name = model.unwrap_or("claude-sonnet-4-20250514");
|
||||
// 使用配置的模型,如果没有配置则使用 deepseek-chat
|
||||
let model_name = model.filter(|m| !m.is_empty()).unwrap_or("deepseek-chat");
|
||||
|
||||
tracing::info!(
|
||||
"[语音润色] 使用模型: {}, 指令: {}",
|
||||
model_name,
|
||||
instruction_id
|
||||
);
|
||||
|
||||
// 构建请求
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -59,12 +94,30 @@ async fn call_local_llm(prompt: &str, model: Option<&str>) -> Result<String, Str
|
||||
temperature: f32,
|
||||
}
|
||||
|
||||
let request = ChatRequest {
|
||||
model: model_name.to_string(),
|
||||
messages: vec![Message {
|
||||
// 根据指令类型决定是否使用 system prompt
|
||||
let messages = if instruction_id == "default" {
|
||||
// 默认润色使用专门的 system prompt
|
||||
vec![
|
||||
Message {
|
||||
role: "system".to_string(),
|
||||
content: VOICE_POLISH_SYSTEM_PROMPT.to_string(),
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: prompt.to_string(),
|
||||
},
|
||||
]
|
||||
} else {
|
||||
// 其他指令(翻译、邮件等)直接使用 user message
|
||||
vec![Message {
|
||||
role: "user".to_string(),
|
||||
content: prompt.to_string(),
|
||||
}],
|
||||
}]
|
||||
};
|
||||
|
||||
let request = ChatRequest {
|
||||
model: model_name.to_string(),
|
||||
messages,
|
||||
max_tokens: 2048,
|
||||
temperature: 0.3,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
//! - 录音线程通过 channel 返回结果
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::Arc;
|
||||
@@ -27,11 +28,47 @@ use std::thread::{self, JoinHandle};
|
||||
use std::time::Instant;
|
||||
use voice_core::types::AudioData;
|
||||
|
||||
/// 麦克风设备信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioDeviceInfo {
|
||||
/// 设备 ID(用于选择设备)
|
||||
pub id: String,
|
||||
/// 设备名称
|
||||
pub name: String,
|
||||
/// 是否为默认设备
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
/// 获取所有可用的麦克风设备
|
||||
pub fn list_audio_devices() -> Result<Vec<AudioDeviceInfo>, 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<AudioDeviceInfo> = 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<String>),
|
||||
/// 停止录音
|
||||
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<String>) -> 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) => {
|
||||
|
||||
@@ -11,13 +11,23 @@ use tracing::{debug, error, info, warn};
|
||||
/// 当前注册的快捷键
|
||||
static CURRENT_SHORTCUT: OnceLock<parking_lot::RwLock<Option<String>>> = OnceLock::new();
|
||||
|
||||
/// 当前注册的翻译快捷键
|
||||
static TRANSLATE_SHORTCUT: OnceLock<parking_lot::RwLock<Option<String>>> = 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<Option<String>> {
|
||||
CURRENT_SHORTCUT.get_or_init(|| parking_lot::RwLock::new(None))
|
||||
}
|
||||
|
||||
fn get_translate_shortcut() -> &'static parking_lot::RwLock<Option<String>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+13
-40
@@ -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() {
|
||||
<ProviderPoolPage />
|
||||
</PageWrapper>
|
||||
|
||||
{/* 图片生成页面 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: currentPage === "image-gen" ? "flex" : "none",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<ImageGenPage onNavigate={setCurrentPage} />
|
||||
</div>
|
||||
|
||||
{/* API Server 页面 */}
|
||||
<PageWrapper $isActive={currentPage === "api-server"}>
|
||||
<ApiServerPage />
|
||||
|
||||
+4
-4
@@ -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 <ScreenshotChatPage />;
|
||||
if (pathname === "/smart-input") {
|
||||
return <SmartInputPage />;
|
||||
}
|
||||
|
||||
// 更新提醒悬浮窗口路由
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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("");
|
||||
|
||||
|
||||
@@ -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<HTMLTextAreaElement>(null);
|
||||
const providerRef = useRef<HTMLDivElement>(null);
|
||||
const modelRef = useRef<HTMLDivElement>(null);
|
||||
const sizeRef = useRef<HTMLDivElement>(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 (
|
||||
<Container>
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<Sparkles size={24} fill="currentColor" />
|
||||
<span>图片工坊</span>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarSection>
|
||||
<SidebarLabel>
|
||||
提供商
|
||||
<IconButton onClick={goToProviderPool} title="管理提供商">
|
||||
<Settings size={14} />
|
||||
</IconButton>
|
||||
</SidebarLabel>
|
||||
<SelectWrapper ref={providerRef}>
|
||||
<SelectButton
|
||||
onClick={() => setProviderDropdownOpen(!providerDropdownOpen)}
|
||||
$disabled={providersLoading}
|
||||
$isOpen={providerDropdownOpen}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div className="icon-wrapper">
|
||||
<Command size={14} />
|
||||
</div>
|
||||
<span className="truncate">
|
||||
{selectedProvider?.name || "选择提供商"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown size={14} className="opacity-50" />
|
||||
</SelectButton>
|
||||
<SelectDropdown $open={providerDropdownOpen}>
|
||||
{availableProviders.map((provider) => (
|
||||
<SelectOption
|
||||
key={provider.id}
|
||||
$selected={provider.id === selectedProviderId}
|
||||
onClick={() => {
|
||||
setSelectedProviderId(provider.id);
|
||||
setProviderDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{provider.name}
|
||||
</SelectOption>
|
||||
))}
|
||||
{availableProviders.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px",
|
||||
textAlign: "center",
|
||||
fontSize: "12px",
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
}}
|
||||
>
|
||||
暂无可用提供商
|
||||
</div>
|
||||
)}
|
||||
</SelectDropdown>
|
||||
</SelectWrapper>
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarSection>
|
||||
<SidebarLabel>模型</SidebarLabel>
|
||||
<SelectWrapper ref={modelRef}>
|
||||
<SelectButton
|
||||
onClick={() => setModelDropdownOpen(!modelDropdownOpen)}
|
||||
$disabled={!selectedProvider}
|
||||
$isOpen={modelDropdownOpen}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div className="icon-wrapper">
|
||||
<LayoutTemplate size={14} />
|
||||
</div>
|
||||
<span className="truncate">
|
||||
{selectedModel?.name || "选择模型"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown size={14} className="opacity-50" />
|
||||
</SelectButton>
|
||||
<SelectDropdown $open={modelDropdownOpen}>
|
||||
{availableModels.map((model) => (
|
||||
<SelectOption
|
||||
key={model.id}
|
||||
$selected={model.id === selectedModelId}
|
||||
onClick={() => {
|
||||
setSelectedModelId(model.id);
|
||||
setModelDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{model.name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</SelectDropdown>
|
||||
</SelectWrapper>
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarSection>
|
||||
<SidebarLabel>图片尺寸</SidebarLabel>
|
||||
<SelectWrapper ref={sizeRef}>
|
||||
<SelectButton
|
||||
onClick={() => setSizeDropdownOpen(!sizeDropdownOpen)}
|
||||
$isOpen={sizeDropdownOpen}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: "8px" }}
|
||||
>
|
||||
<div className="icon-wrapper">
|
||||
<Maximize2 size={14} />
|
||||
</div>
|
||||
<span>
|
||||
{selectedSize === "1024x1024" ? "方形 (1:1)" : selectedSize}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown size={14} className="opacity-50" />
|
||||
</SelectButton>
|
||||
<SelectDropdown $open={sizeDropdownOpen}>
|
||||
{supportedSizes.map((size) => (
|
||||
<SelectOption
|
||||
key={size}
|
||||
$selected={size === selectedSize}
|
||||
onClick={() => {
|
||||
setSelectedSize(size);
|
||||
setSizeDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{size === "1024x1024" ? "方形 (1024x1024)" : size}
|
||||
</SelectOption>
|
||||
))}
|
||||
</SelectDropdown>
|
||||
</SelectWrapper>
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
|
||||
<MainContent>
|
||||
<ImageDisplayArea>
|
||||
{selectedImage?.status === "generating" ? (
|
||||
<ImageContainer $hasImage={false}>
|
||||
<EmptyState>
|
||||
<Loader2 size={48} className="animate-spin text-primary" />
|
||||
<h3>正在精心绘制...</h3>
|
||||
<p>将您的想象转化为像素...</p>
|
||||
</EmptyState>
|
||||
</ImageContainer>
|
||||
) : selectedImage?.status === "complete" && selectedImage.url ? (
|
||||
<ImageContainer $hasImage={true}>
|
||||
<GeneratedImg
|
||||
src={selectedImage.url}
|
||||
alt={selectedImage.prompt}
|
||||
/>
|
||||
<ImageActions>
|
||||
<ActionButton
|
||||
onClick={() => openInBrowser(selectedImage.url)}
|
||||
title="在浏览器中打开"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={() => deleteImage(selectedImage.id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</ActionButton>
|
||||
</ImageActions>
|
||||
</ImageContainer>
|
||||
) : selectedImage?.status === "error" ? (
|
||||
<EmptyState>
|
||||
<div
|
||||
style={{ color: "hsl(var(--destructive))", marginBottom: 8 }}
|
||||
>
|
||||
<Wand2 size={40} />
|
||||
</div>
|
||||
<h3>生成失败</h3>
|
||||
<p style={{ color: "hsl(var(--destructive))" }}>
|
||||
{selectedImage.error}
|
||||
</p>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<EmptyState>
|
||||
<Wand2 size={56} className="text-primary opacity-80" />
|
||||
<h3>准备就绪</h3>
|
||||
<p>在下方输入详细的提示词以开始创作。</p>
|
||||
</EmptyState>
|
||||
)}
|
||||
</ImageDisplayArea>
|
||||
|
||||
<PromptBarContainer>
|
||||
<PromptBar>
|
||||
<PromptInput
|
||||
ref={promptInputRef}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')..."
|
||||
disabled={generating || !selectedProvider}
|
||||
/>
|
||||
<GenerateButton
|
||||
onClick={handleGenerate}
|
||||
$disabled={generating || !prompt.trim() || !selectedProvider}
|
||||
disabled={generating || !prompt.trim() || !selectedProvider}
|
||||
>
|
||||
{generating ? (
|
||||
<Loader2 size={24} className="animate-spin" />
|
||||
) : (
|
||||
<Send size={24} />
|
||||
)}
|
||||
</GenerateButton>
|
||||
</PromptBar>
|
||||
</PromptBarContainer>
|
||||
</MainContent>
|
||||
|
||||
<HistorySidebar>
|
||||
<NewImageButton onClick={handleNewImage} title="新建图片">
|
||||
<Plus size={24} />
|
||||
</NewImageButton>
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "1px",
|
||||
background: "hsl(var(--border))",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
|
||||
<HistoryList>
|
||||
{images.map((img) => (
|
||||
<HistoryItem
|
||||
key={img.id}
|
||||
$selected={img.id === selectedImageId}
|
||||
onClick={() => setSelectedImageId(img.id)}
|
||||
>
|
||||
{img.status === "generating" ? (
|
||||
<LoaderWrapper>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
</LoaderWrapper>
|
||||
) : img.status === "complete" && img.url ? (
|
||||
<img src={img.url} alt={img.prompt} />
|
||||
) : (
|
||||
<LoaderWrapper>
|
||||
<ImageIcon size={16} />
|
||||
</LoaderWrapper>
|
||||
)}
|
||||
{img.status !== "generating" && (
|
||||
<HistoryItemDeleteBtn
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteImage(img.id);
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</HistoryItemDeleteBtn>
|
||||
)}
|
||||
</HistoryItem>
|
||||
))}
|
||||
</HistoryList>
|
||||
</HistorySidebar>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -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` 接口。
|
||||
@@ -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";
|
||||
@@ -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<string, ImageGenModel[]> = {
|
||||
// 智谱 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",
|
||||
];
|
||||
@@ -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<string>("");
|
||||
const [selectedModelId, setSelectedModelId] = useState<string>("");
|
||||
const [selectedSize, setSelectedSize] = useState<string>("1024x1024");
|
||||
const [images, setImages] = useState<GeneratedImage[]>([]);
|
||||
const [selectedImageId, setSelectedImageId] = useState<string | null>(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: 
|
||||
const base64Match = content.match(
|
||||
/!\[.*?\]\((data:image\/[^;]+;base64,[^)]+)\)/,
|
||||
);
|
||||
if (base64Match) {
|
||||
console.log("[useImageGen] 匹配到 base64 图片");
|
||||
imageUrl = base64Match[1];
|
||||
} else {
|
||||
// 回退匹配 URL: 
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<UserProfile | null>(null);
|
||||
const [windowSizePreference, setWindowSizePreference] =
|
||||
useState<WindowSizePreference | null>("default");
|
||||
const [selectedPlugins, setSelectedPlugins] = useState<string[]>([]);
|
||||
const [installResults, setInstallResults] = useState<PluginInstallState[]>(
|
||||
[],
|
||||
);
|
||||
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 (
|
||||
<WindowSizeStep
|
||||
selectedSize={windowSizePreference}
|
||||
onSelect={setWindowSizePreference}
|
||||
/>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<PluginSelectStep
|
||||
userProfile={userProfile}
|
||||
@@ -202,14 +220,36 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
||||
onSelectionChange={setSelectedPlugins}
|
||||
/>
|
||||
);
|
||||
case 5:
|
||||
case 4:
|
||||
return (
|
||||
<InstallProgressStep
|
||||
selectedPlugins={selectedPlugins}
|
||||
onComplete={handleInstallComplete}
|
||||
/>
|
||||
);
|
||||
case 5:
|
||||
return (
|
||||
<VoiceShortcutTestStep
|
||||
shortcut={voiceShortcut}
|
||||
onSuccess={handleShortcutTestSuccess}
|
||||
onSkip={handleSkipVoiceTest}
|
||||
/>
|
||||
);
|
||||
case 6:
|
||||
return (
|
||||
<MicrophoneTestStep
|
||||
onSuccess={handleMicTestSuccess}
|
||||
onSkip={handleSkipVoiceTest}
|
||||
/>
|
||||
);
|
||||
case 7:
|
||||
return (
|
||||
<VoiceDemoStep
|
||||
onSuccess={handleVoiceDemoSuccess}
|
||||
onSkip={handleSkipVoiceTest}
|
||||
/>
|
||||
);
|
||||
case 8:
|
||||
return (
|
||||
<CompleteStep
|
||||
installResults={installResults}
|
||||
@@ -226,16 +266,20 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
||||
switch (currentStep) {
|
||||
case 2:
|
||||
return userProfile !== null;
|
||||
case 3:
|
||||
return windowSizePreference !== null;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// 判断是否显示底部导航
|
||||
// 不显示底部导航的步骤:欢迎页、安装进度、语音测试步骤、完成页
|
||||
const showFooter =
|
||||
currentStep !== 1 && currentStep !== 5 && currentStep !== 6;
|
||||
currentStep !== 1 &&
|
||||
currentStep !== 4 &&
|
||||
currentStep !== 5 &&
|
||||
currentStep !== 6 &&
|
||||
currentStep !== 7 &&
|
||||
currentStep !== 8;
|
||||
|
||||
return (
|
||||
<Overlay>
|
||||
@@ -267,7 +311,7 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
||||
跳过
|
||||
</Button>
|
||||
<Button onClick={handleNext} disabled={!canProceed()}>
|
||||
{currentStep === 4
|
||||
{currentStep === 3
|
||||
? selectedPlugins.length > 0
|
||||
? "开始安装"
|
||||
: "跳过安装"
|
||||
|
||||
@@ -2,16 +2,7 @@
|
||||
* 初次安装引导 - 常量配置
|
||||
*/
|
||||
|
||||
import {
|
||||
Code,
|
||||
User,
|
||||
FileCode,
|
||||
Activity,
|
||||
Minimize2,
|
||||
Monitor,
|
||||
Maximize2,
|
||||
Fullscreen,
|
||||
} from "lucide-react";
|
||||
import { Code, User, FileCode, Activity } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
/**
|
||||
@@ -85,8 +76,9 @@ export const onboardingPlugins: OnboardingPlugin[] = [
|
||||
|
||||
/**
|
||||
* 引导版本号 - 用于控制是否重新显示引导
|
||||
* 更新此版本号会触发已完成引导的用户重新看到引导
|
||||
*/
|
||||
export const ONBOARDING_VERSION = "1.0.0";
|
||||
export const ONBOARDING_VERSION = "1.1.0";
|
||||
|
||||
/**
|
||||
* localStorage 键名
|
||||
@@ -95,54 +87,4 @@ export const STORAGE_KEYS = {
|
||||
ONBOARDING_COMPLETE: "proxycast_onboarding_complete",
|
||||
ONBOARDING_VERSION: "proxycast_onboarding_version",
|
||||
USER_PROFILE: "proxycast_user_profile",
|
||||
WINDOW_SIZE_PREFERENCE: "proxycast_window_size_preference",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 窗口尺寸偏好类型
|
||||
*/
|
||||
export type WindowSizePreference =
|
||||
| "compact"
|
||||
| "default"
|
||||
| "large"
|
||||
| "fullscreen";
|
||||
|
||||
/**
|
||||
* 窗口尺寸选项配置
|
||||
*/
|
||||
export interface WindowSizeOptionConfig {
|
||||
id: WindowSizePreference;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* 窗口尺寸选项列表
|
||||
*/
|
||||
export const windowSizeOptions: WindowSizeOptionConfig[] = [
|
||||
{
|
||||
id: "compact",
|
||||
name: "紧凑模式",
|
||||
description: "1000×700 - 节省屏幕空间",
|
||||
icon: Minimize2,
|
||||
},
|
||||
{
|
||||
id: "default",
|
||||
name: "默认大小",
|
||||
description: "1200×800 - 日常使用",
|
||||
icon: Monitor,
|
||||
},
|
||||
{
|
||||
id: "large",
|
||||
name: "大屏模式",
|
||||
description: "1920×1200 - 大屏幕显示",
|
||||
icon: Maximize2,
|
||||
},
|
||||
{
|
||||
id: "fullscreen",
|
||||
name: "全屏模式",
|
||||
description: "占满整个屏幕",
|
||||
icon: Fullscreen,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* 麦克风测试步骤
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Mic, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { VolumeWaveform } from "@/components/voice/VolumeWaveform";
|
||||
|
||||
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 TestArea = 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 StatusText = styled.p<{ $success?: boolean; $error?: boolean }>`
|
||||
font-size: 14px;
|
||||
color: ${({ $success, $error }) =>
|
||||
$success
|
||||
? "hsl(var(--primary))"
|
||||
: $error
|
||||
? "hsl(var(--destructive))"
|
||||
: "hsl(var(--muted-foreground))"};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const ButtonGroup = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
`;
|
||||
|
||||
const PermissionWarning = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
border: 1px solid hsl(var(--destructive) / 0.3);
|
||||
text-align: left;
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const WarningText = styled.div`
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--destructive));
|
||||
`;
|
||||
|
||||
interface MicrophoneTestStepProps {
|
||||
onSuccess: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
type TestState = "idle" | "testing" | "success" | "error";
|
||||
|
||||
export function MicrophoneTestStep({
|
||||
onSuccess,
|
||||
onSkip,
|
||||
}: MicrophoneTestStepProps) {
|
||||
const [testState, setTestState] = useState<TestState>("idle");
|
||||
const [volume, setVolume] = useState(0);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [maxVolume, setMaxVolume] = useState(0);
|
||||
const pollingRef = useRef<number | null>(null);
|
||||
|
||||
// 开始测试
|
||||
const startTest = useCallback(async () => {
|
||||
setTestState("testing");
|
||||
setErrorMessage(null);
|
||||
setMaxVolume(0);
|
||||
|
||||
try {
|
||||
const { startRecording, getRecordingStatus } = await import(
|
||||
"@/lib/api/asrProvider"
|
||||
);
|
||||
|
||||
await startRecording();
|
||||
|
||||
// 轮询获取音量
|
||||
pollingRef.current = window.setInterval(async () => {
|
||||
try {
|
||||
const status = await getRecordingStatus();
|
||||
setVolume(status.volume);
|
||||
setMaxVolume((prev) => Math.max(prev, status.volume));
|
||||
} catch (err) {
|
||||
console.error("获取录音状态失败:", err);
|
||||
}
|
||||
}, 100);
|
||||
} catch (err: any) {
|
||||
console.error("开始录音失败:", err);
|
||||
setTestState("error");
|
||||
const errMsg = typeof err === "string" ? err : err?.message || "未知错误";
|
||||
if (
|
||||
errMsg.toLowerCase().includes("permission") ||
|
||||
errMsg.toLowerCase().includes("device")
|
||||
) {
|
||||
setErrorMessage("无法访问麦克风,请检查系统隐私设置");
|
||||
} else {
|
||||
setErrorMessage(`麦克风测试失败: ${errMsg}`);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 停止测试
|
||||
const stopTest = useCallback(async () => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { cancelRecording } = await import("@/lib/api/asrProvider");
|
||||
await cancelRecording();
|
||||
} catch (err) {
|
||||
console.error("取消录音失败:", err);
|
||||
}
|
||||
|
||||
// 判断测试结果
|
||||
if (maxVolume > 10) {
|
||||
setTestState("success");
|
||||
} else {
|
||||
setTestState("error");
|
||||
setErrorMessage("未检测到声音,请检查麦克风是否正常工作");
|
||||
}
|
||||
}, [maxVolume]);
|
||||
|
||||
// 清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
}
|
||||
// 确保停止录音
|
||||
import("@/lib/api/asrProvider")
|
||||
.then(({ cancelRecording }) => cancelRecording())
|
||||
.catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 测试成功后自动进入下一步
|
||||
useEffect(() => {
|
||||
if (testState === "success") {
|
||||
const timer = setTimeout(() => {
|
||||
onSuccess();
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [testState, onSuccess]);
|
||||
|
||||
// 打开系统设置
|
||||
const openSystemSettings = async () => {
|
||||
try {
|
||||
const { Command } = await import("@tauri-apps/plugin-shell");
|
||||
const cmd = Command.create("open", [
|
||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone",
|
||||
]);
|
||||
await cmd.execute();
|
||||
} catch (err) {
|
||||
console.error("打开系统设置失败:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<IconWrapper>
|
||||
<Mic size={40} className="text-primary" />
|
||||
</IconWrapper>
|
||||
|
||||
<Title>测试麦克风</Title>
|
||||
<Description>
|
||||
点击开始测试,然后对着麦克风说话,检查麦克风是否正常工作。
|
||||
</Description>
|
||||
|
||||
{testState === "error" && errorMessage && (
|
||||
<PermissionWarning>
|
||||
<AlertCircle size={20} />
|
||||
<WarningText>
|
||||
<p style={{ fontWeight: 500, marginBottom: 4 }}>{errorMessage}</p>
|
||||
{navigator.platform.includes("Mac") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-0 h-auto text-destructive"
|
||||
onClick={openSystemSettings}
|
||||
>
|
||||
打开系统设置
|
||||
</Button>
|
||||
)}
|
||||
</WarningText>
|
||||
</PermissionWarning>
|
||||
)}
|
||||
|
||||
<TestArea>
|
||||
<WaveformContainer>
|
||||
<VolumeWaveform
|
||||
volume={volume}
|
||||
isRecording={testState === "testing"}
|
||||
/>
|
||||
</WaveformContainer>
|
||||
|
||||
<StatusText
|
||||
$success={testState === "success"}
|
||||
$error={testState === "error"}
|
||||
>
|
||||
{testState === "idle" && "点击下方按钮开始测试"}
|
||||
{testState === "testing" && `正在录音... 音量: ${volume}%`}
|
||||
{testState === "success" && (
|
||||
<>
|
||||
<CheckCircle2 size={16} />
|
||||
麦克风工作正常!
|
||||
</>
|
||||
)}
|
||||
{testState === "error" && "测试失败"}
|
||||
</StatusText>
|
||||
</TestArea>
|
||||
|
||||
<ButtonGroup>
|
||||
<Button variant="outline" onClick={onSkip}>
|
||||
跳过测试
|
||||
</Button>
|
||||
|
||||
{testState === "idle" && <Button onClick={startTest}>开始测试</Button>}
|
||||
|
||||
{testState === "testing" && (
|
||||
<Button onClick={stopTest}>停止测试</Button>
|
||||
)}
|
||||
|
||||
{testState === "error" && <Button onClick={startTest}>重新测试</Button>}
|
||||
|
||||
{testState === "success" && <Button onClick={onSuccess}>继续</Button>}
|
||||
</ButtonGroup>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -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<DemoState>("idle");
|
||||
const [volume, setVolume] = useState(0);
|
||||
const [resultText, setResultText] = useState("");
|
||||
const pollingRef = useRef<number | null>(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 (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
正在识别...
|
||||
</>
|
||||
);
|
||||
case "polishing":
|
||||
return (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
正在润色...
|
||||
</>
|
||||
);
|
||||
case "done":
|
||||
return (
|
||||
<>
|
||||
<CheckCircle2 size={16} />
|
||||
识别完成!
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<IconWrapper>
|
||||
<Mic size={40} className="text-primary" />
|
||||
</IconWrapper>
|
||||
|
||||
<Title>体验语音输入</Title>
|
||||
<Description>
|
||||
点击麦克风按钮,说一句话试试。
|
||||
<br />
|
||||
语音会被识别并自动润色。
|
||||
</Description>
|
||||
|
||||
<DemoArea>
|
||||
<RecordButton
|
||||
$recording={demoState === "recording"}
|
||||
onClick={demoState === "recording" ? stopRecording : startRecording}
|
||||
disabled={demoState === "transcribing" || demoState === "polishing"}
|
||||
>
|
||||
{demoState === "transcribing" || demoState === "polishing" ? (
|
||||
<Loader2 size={28} className="animate-spin" />
|
||||
) : (
|
||||
<Mic size={28} />
|
||||
)}
|
||||
</RecordButton>
|
||||
|
||||
{demoState === "recording" && (
|
||||
<WaveformContainer>
|
||||
<VolumeWaveform volume={volume} isRecording={true} />
|
||||
</WaveformContainer>
|
||||
)}
|
||||
|
||||
{resultText && <ResultText>{resultText}</ResultText>}
|
||||
|
||||
<StatusText $success={demoState === "done"}>
|
||||
{getStatusText()}
|
||||
</StatusText>
|
||||
</DemoArea>
|
||||
|
||||
<ButtonGroup>
|
||||
<Button variant="outline" onClick={onSkip}>
|
||||
跳过
|
||||
</Button>
|
||||
{demoState === "done" && <Button onClick={onSuccess}>完成设置</Button>}
|
||||
</ButtonGroup>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Container>
|
||||
<IconWrapper>
|
||||
<Keyboard size={40} className="text-primary" />
|
||||
</IconWrapper>
|
||||
|
||||
<Title>测试语音快捷键</Title>
|
||||
<Description>
|
||||
按下并松开语音输入快捷键,验证快捷键是否正常工作。
|
||||
<br />
|
||||
如果快捷键被其他应用占用,可以稍后在设置中修改。
|
||||
</Description>
|
||||
|
||||
<ShortcutDisplay $active={isPressed} $success={testSuccess}>
|
||||
<StatusIcon $success={testSuccess}>
|
||||
{testSuccess ? <CheckCircle2 size={20} /> : <Keyboard size={20} />}
|
||||
</StatusIcon>
|
||||
{formatShortcutDisplay(shortcut)}
|
||||
</ShortcutDisplay>
|
||||
|
||||
<HintText>
|
||||
{testSuccess
|
||||
? "快捷键工作正常!"
|
||||
: isPressed
|
||||
? "检测到按下,请松开..."
|
||||
: "请按下快捷键进行测试"}
|
||||
</HintText>
|
||||
|
||||
<ButtonGroup>
|
||||
<Button variant="outline" onClick={onSkip}>
|
||||
跳过测试
|
||||
</Button>
|
||||
{testSuccess && <Button onClick={onSuccess}>继续</Button>}
|
||||
</ButtonGroup>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Container>
|
||||
<Title>选择窗口大小</Title>
|
||||
<Subtitle>您可以随时在设置中更改窗口大小</Subtitle>
|
||||
|
||||
<OptionsGrid>
|
||||
{windowSizeOptions.map((option) => {
|
||||
const isSelected = selectedSize === option.id;
|
||||
const Icon = option.icon;
|
||||
|
||||
return (
|
||||
<OptionCard
|
||||
key={option.id}
|
||||
$selected={isSelected}
|
||||
onClick={() => onSelect(option.id)}
|
||||
>
|
||||
{isSelected && (
|
||||
<CheckBadge>
|
||||
<Check />
|
||||
</CheckBadge>
|
||||
)}
|
||||
<IconWrapper $selected={isSelected}>
|
||||
<Icon />
|
||||
</IconWrapper>
|
||||
<OptionName>{option.name}</OptionName>
|
||||
<OptionDescription>{option.description}</OptionDescription>
|
||||
</OptionCard>
|
||||
);
|
||||
})}
|
||||
</OptionsGrid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
浏览器模式
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* 系统浏览器选项 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && onModeChange("system")}
|
||||
disabled={disabled}
|
||||
className={`relative flex items-center justify-center gap-1.5 px-2 py-1.5 rounded border text-xs transition-all duration-200 ${
|
||||
mode === "system"
|
||||
? "border-primary bg-primary/5 text-primary font-medium"
|
||||
: "border-muted hover:border-muted-foreground/30 hover:bg-muted/50"
|
||||
} ${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
<Globe className="h-3 w-3 flex-shrink-0" />
|
||||
<span>系统浏览器</span>
|
||||
</button>
|
||||
|
||||
{/* 指纹浏览器选项 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlaywrightSelect}
|
||||
disabled={disabled}
|
||||
className={`relative flex items-center justify-center gap-1.5 px-2 py-1.5 rounded border text-xs transition-all duration-200 ${
|
||||
mode === "playwright"
|
||||
? playwrightAvailable
|
||||
? "border-primary bg-primary/5 text-primary font-medium"
|
||||
: "border-amber-500 bg-amber-50 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400 font-medium"
|
||||
: "border-muted hover:border-muted-foreground/30 hover:bg-muted/50"
|
||||
} ${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{playwrightChecking ? (
|
||||
<Loader2 className="h-3 w-3 flex-shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Fingerprint className="h-3 w-3 flex-shrink-0" />
|
||||
)}
|
||||
<span>
|
||||
{playwrightChecking
|
||||
? "检测中"
|
||||
: playwrightAvailable
|
||||
? "指纹浏览器"
|
||||
: "指纹(需安装)"}
|
||||
</span>
|
||||
|
||||
{/* 不可用警告图标 */}
|
||||
{mode === "playwright" &&
|
||||
!playwrightAvailable &&
|
||||
!playwrightChecking && (
|
||||
<AlertCircle className="h-2.5 w-2.5 text-amber-500 absolute -top-0.5 -right-0.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<KiroMode>("json");
|
||||
const [jsonContent, setJsonContent] = useState("");
|
||||
|
||||
// 浏览器模式状态
|
||||
const [browserMode, setBrowserMode] = useState<BrowserMode>("system");
|
||||
const [playwrightStatus, setPlaywrightStatus] = useState<PlaywrightStatus>({
|
||||
available: false,
|
||||
});
|
||||
const [playwrightChecking, setPlaywrightChecking] = useState(false);
|
||||
|
||||
// 登录相关状态
|
||||
const [_loginType, setLoginType] = useState<LoginType>("builderid");
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
const [builderIdLoginData, setBuilderIdLoginData] =
|
||||
useState<BuilderIdLoginData | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const unlistenRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Playwright 错误状态(用于显示详细错误信息)
|
||||
// Requirements: 5.1, 5.2, 5.4
|
||||
const [playwrightError, setPlaywrightError] = useState<unknown>(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 = () => (
|
||||
<div className="grid grid-cols-3 gap-1 p-1 bg-muted/50 rounded-xl border mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isLoggingIn}
|
||||
className={`py-2 px-3 text-sm rounded-lg transition-all duration-200 font-medium ${
|
||||
mode === "login"
|
||||
? "bg-background text-foreground shadow-sm ring-1 ring-black/5"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||
}`}
|
||||
>
|
||||
<LogIn className="inline h-4 w-4 mr-1" />
|
||||
在线登录
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-1 p-1 bg-muted/50 rounded-xl border mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("json");
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isLoggingIn}
|
||||
className={`py-2 px-3 text-sm rounded-lg transition-all duration-200 font-medium ${
|
||||
mode === "json"
|
||||
? "bg-background text-foreground shadow-sm ring-1 ring-black/5"
|
||||
@@ -508,7 +113,6 @@ export function KiroForm({
|
||||
setMode("file");
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isLoggingIn}
|
||||
className={`py-2 px-3 text-sm rounded-lg transition-all duration-200 font-medium ${
|
||||
mode === "file"
|
||||
? "bg-background text-foreground shadow-sm ring-1 ring-black/5"
|
||||
@@ -521,238 +125,6 @@ export function KiroForm({
|
||||
</div>
|
||||
);
|
||||
|
||||
// 在线登录表单
|
||||
const renderLoginForm = () => (
|
||||
<div className="space-y-4">
|
||||
{/* 浏览器模式选择器 */}
|
||||
{!isLoggingIn && (
|
||||
<BrowserModeSelector
|
||||
mode={browserMode}
|
||||
onModeChange={setBrowserMode}
|
||||
playwrightAvailable={playwrightStatus.available}
|
||||
playwrightChecking={playwrightChecking}
|
||||
onCheckPlaywright={checkPlaywright}
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Playwright 安装引导(当选择指纹浏览器但未安装时显示) */}
|
||||
{!isLoggingIn &&
|
||||
browserMode === "playwright" &&
|
||||
!playwrightStatus.available &&
|
||||
!playwrightChecking && (
|
||||
<PlaywrightInstallGuide
|
||||
onRetryCheck={checkPlaywright}
|
||||
checking={playwrightChecking}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Playwright 错误显示(当有错误且不在登录中时显示) */}
|
||||
{/* Requirements: 5.1, 5.2, 5.4 */}
|
||||
{!isLoggingIn &&
|
||||
playwrightError !== null &&
|
||||
browserMode === "playwright" && (
|
||||
<PlaywrightErrorDisplay
|
||||
error={playwrightError}
|
||||
onRetry={handleRetryPlaywrightLogin}
|
||||
onSwitchToSystemBrowser={switchToSystemBrowser}
|
||||
onDismiss={clearPlaywrightError}
|
||||
retrying={isLoggingIn}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 登录中状态 - Builder ID */}
|
||||
{isLoggingIn && builderIdLoginData && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-center">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 mb-2">
|
||||
请在浏览器中完成登录,并输入以下代码:
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<code className="text-2xl font-bold tracking-widest bg-white dark:bg-gray-800 px-4 py-2 rounded border">
|
||||
{builderIdLoginData.userCode}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyUserCode}
|
||||
className="p-2 rounded-lg border hover:bg-muted"
|
||||
title="复制代码"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
等待授权中...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => open(builderIdLoginData.verificationUri)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg border hover:bg-muted"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
重新打开浏览器
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelLogin}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
取消登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 登录中状态 - Social Auth / Playwright */}
|
||||
{isLoggingIn && !builderIdLoginData && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-2 text-blue-500" />
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
{browserMode === "playwright"
|
||||
? "正在使用指纹浏览器登录..."
|
||||
: "请在浏览器中完成登录..."}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{browserMode === "playwright"
|
||||
? "请在弹出的浏览器窗口中完成登录"
|
||||
: "登录完成后会自动返回"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelLogin}
|
||||
className="w-full px-4 py-2 rounded-lg bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
取消登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 未登录状态 - 显示登录选项 */}
|
||||
{!isLoggingIn && (
|
||||
<div className="space-y-2">
|
||||
{/* 第一行:Google 和 GitHub */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* Google */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoginType("google");
|
||||
handleStartSocialAuthLogin("Google");
|
||||
}}
|
||||
disabled={
|
||||
browserMode === "playwright" && !playwrightStatus.available
|
||||
}
|
||||
className={`group flex items-center px-3 py-2 gap-2 bg-white dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg transition-all duration-200 hover:shadow-sm hover:border-primary/30 ${
|
||||
browserMode === "playwright" && !playwrightStatus.available
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="w-5 h-5 flex items-center justify-center bg-white rounded-full shadow-sm border p-0.5 group-hover:scale-110 transition-transform flex-shrink-0">
|
||||
<svg viewBox="0 0 24 24" className="w-full h-full">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
Google
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* GitHub */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoginType("github");
|
||||
handleStartSocialAuthLogin("Github");
|
||||
}}
|
||||
disabled={
|
||||
browserMode === "playwright" && !playwrightStatus.available
|
||||
}
|
||||
className={`group flex items-center px-3 py-2 gap-2 bg-white dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg transition-all duration-200 hover:shadow-sm hover:border-primary/30 ${
|
||||
browserMode === "playwright" && !playwrightStatus.available
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="w-5 h-5 flex items-center justify-center bg-white rounded-full shadow-sm border p-0.5 group-hover:scale-110 transition-transform flex-shrink-0">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="#24292f"
|
||||
className="w-full h-full"
|
||||
>
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
GitHub
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 第二行:AWS Builder ID */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoginType("builderid");
|
||||
handleStartBuilderIdLogin();
|
||||
}}
|
||||
disabled={
|
||||
browserMode === "playwright" && !playwrightStatus.available
|
||||
}
|
||||
className={`group w-full flex items-center justify-center px-3 py-2 gap-2 bg-white dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg transition-all duration-200 hover:shadow-sm hover:border-primary/30 ${
|
||||
browserMode === "playwright" && !playwrightStatus.available
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="w-5 h-5 flex items-center justify-center bg-[#232f3e] rounded-full shadow-sm border p-0.5 group-hover:scale-110 transition-transform flex-shrink-0">
|
||||
<svg viewBox="0 0 24 24" fill="#ff9900" className="w-full h-full">
|
||||
<text
|
||||
x="2"
|
||||
y="16"
|
||||
fontSize="10"
|
||||
fontWeight="bold"
|
||||
fontFamily="Arial"
|
||||
>
|
||||
aws
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
AWS Builder ID
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// JSON 粘贴表单
|
||||
const renderJsonForm = () => (
|
||||
<div className="space-y-4">
|
||||
@@ -789,12 +161,11 @@ export function KiroForm({
|
||||
mode,
|
||||
handleJsonSubmit,
|
||||
handleFileSubmit,
|
||||
handleLoginSubmit: () => {}, // 登录模式不需要手动提交
|
||||
handleLoginSubmit: () => {}, // 保持接口兼容
|
||||
render: () => (
|
||||
<>
|
||||
{renderModeSelector()}
|
||||
|
||||
{mode === "login" && renderLoginForm()}
|
||||
{mode === "json" && renderJsonForm()}
|
||||
{mode === "file" && (
|
||||
<FileImportForm
|
||||
|
||||
@@ -78,12 +78,8 @@ export function KiroFormStandalone({
|
||||
} else if (kiroForm.mode === "file") {
|
||||
kiroForm.handleFileSubmit();
|
||||
}
|
||||
// login 模式不需要手动提交,登录按钮会直接触发
|
||||
}, [kiroForm]);
|
||||
|
||||
// 是否显示提交按钮(login 模式不需要)
|
||||
const showSubmitButton = kiroForm.mode !== "login";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 名称输入 */}
|
||||
@@ -121,18 +117,16 @@ export function KiroFormStandalone({
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
{showSubmitButton && (
|
||||
<Button type="button" onClick={handleSubmit} disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
添加中...
|
||||
</>
|
||||
) : (
|
||||
"添加凭证"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" onClick={handleSubmit} disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
添加中...
|
||||
</>
|
||||
) : (
|
||||
"添加凭证"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 <Clock className="h-5 w-5" />;
|
||||
case PlaywrightErrorType.USER_CANCELLED:
|
||||
case PlaywrightErrorType.BROWSER_CLOSED:
|
||||
return <XCircle className="h-5 w-5" />;
|
||||
default:
|
||||
return <AlertCircle className="h-5 w-5" />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据错误类型获取样式
|
||||
*/
|
||||
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 (
|
||||
<div className={`rounded-lg border p-4 space-y-3 ${styles.container}`}>
|
||||
{/* 错误标题和关闭按钮 */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex-shrink-0 mt-0.5 ${styles.icon}`}>
|
||||
{getErrorIcon(errorInfo.type)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className={`text-sm font-semibold ${styles.title}`}>
|
||||
{errorInfo.title}
|
||||
</h4>
|
||||
<p className={`text-sm mt-1 ${styles.message}`}>
|
||||
{errorInfo.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
title="关闭"
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 故障排除建议 */}
|
||||
{errorInfo.suggestions.length > 0 && (
|
||||
<div className="pl-8">
|
||||
<p className={`text-xs font-medium mb-1.5 ${styles.message}`}>
|
||||
建议操作:
|
||||
</p>
|
||||
<ul className={`text-xs space-y-1 ${styles.message}`}>
|
||||
{errorInfo.suggestions.map((suggestion, index) => (
|
||||
<li key={index} className="flex items-start gap-1.5">
|
||||
<span className="flex-shrink-0">•</span>
|
||||
<span>{suggestion}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{(errorInfo.retryable || onSwitchToSystemBrowser) && (
|
||||
<div className="flex items-center gap-2 pl-8 pt-1">
|
||||
{errorInfo.retryable && onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
disabled={retrying}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
||||
retrying
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
}`}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${retrying ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{retrying ? "重试中..." : "重试"}
|
||||
</button>
|
||||
)}
|
||||
{onSwitchToSystemBrowser && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToSystemBrowser}
|
||||
disabled={retrying}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg border hover:bg-muted transition-colors"
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
使用系统浏览器
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 调试信息(开发模式下显示) */}
|
||||
{import.meta.env.DEV && errorInfo.originalError && (
|
||||
<details className="pl-8 pt-2">
|
||||
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
|
||||
调试信息
|
||||
</summary>
|
||||
<pre className="mt-2 p-2 text-xs bg-slate-100 dark:bg-slate-800 rounded overflow-x-auto">
|
||||
{errorInfo.originalError}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string>("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 监听安装进度事件
|
||||
useEffect(() => {
|
||||
const unlisten = safeListen<InstallProgress>(
|
||||
"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 (
|
||||
<div className="rounded border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-950/30 px-2 py-1.5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="text-red-700 dark:text-red-400 flex-1 truncate"
|
||||
title={error}
|
||||
>
|
||||
安装失败:{" "}
|
||||
{error.length > 50 ? error.substring(0, 50) + "..." : error}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-200 dark:bg-red-800 hover:bg-red-300 dark:hover:bg-red-700 transition-colors text-red-800 dark:text-red-200 whitespace-nowrap"
|
||||
title="重试安装"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
<span>重试</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 安装中显示进度
|
||||
if (installing) {
|
||||
return (
|
||||
<div className="rounded border border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-950/30 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Loader2 className="h-3 w-3 text-blue-600 dark:text-blue-400 animate-spin" />
|
||||
<span className="flex-1 text-blue-700 dark:text-blue-300 truncate">
|
||||
{progress || "正在安装..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-200 dark:bg-amber-800 hover:bg-amber-300 dark:hover:bg-amber-700 transition-colors text-amber-800 dark:text-amber-200"
|
||||
title="一键安装"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
<span>安装</span>
|
||||
</button>
|
||||
<span className="text-amber-600 dark:text-amber-500">或</span>
|
||||
<code className="flex-1 font-mono text-amber-800 dark:text-amber-300 truncate select-all text-[10px]">
|
||||
{INSTALL_COMMAND}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyCommand}
|
||||
className="p-0.5 rounded hover:bg-amber-200 dark:hover:bg-amber-800 transition-colors"
|
||||
title="复制"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3 text-amber-600 dark:text-amber-400" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetryCheck}
|
||||
disabled={checking}
|
||||
className="p-0.5 rounded hover:bg-amber-200 dark:hover:bg-amber-800 transition-colors disabled:opacity-50"
|
||||
title="重新检测"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 text-amber-600 dark:text-amber-400 ${checking ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config?.screenshot_chat.enabled ?? false}
|
||||
onChange={handleToggleScreenshotChat}
|
||||
onChange={handleToggleSmartInput}
|
||||
disabled={saving}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
|
||||
@@ -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<string>("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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 窗口尺寸 */}
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Maximize2 className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">窗口尺寸</h3>
|
||||
</div>
|
||||
|
||||
{windowSizeLoading ? (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<RefreshCw className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{windowSizeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => handleWindowSizeChange(option.id)}
|
||||
className={cn(
|
||||
"flex flex-col items-start p-2 rounded border text-left transition-colors",
|
||||
currentWindowSize === option.id && !isFullscreen
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium">{option.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => handleWindowSizeChange("fullscreen")}
|
||||
className={cn(
|
||||
"flex flex-col items-start p-2 rounded border text-left transition-colors",
|
||||
isFullscreen ? "border-primary bg-primary/5" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium">全屏模式</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
占满整个屏幕
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 语言 */}
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
+5
-5
@@ -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<ChatInputProps> = ({
|
||||
const canSend = value.trim() && !disabled && !isLoading;
|
||||
|
||||
return (
|
||||
<div className="screenshot-chat-input-area">
|
||||
<div className="smart-input-input-area">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="screenshot-chat-input"
|
||||
className="smart-input-input"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -77,14 +77,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
className="screenshot-chat-send-btn"
|
||||
className="smart-input-send-btn"
|
||||
onClick={handleSendClick}
|
||||
disabled={!canSend}
|
||||
title="发送 (Enter)"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span
|
||||
className="screenshot-chat-loading-spinner"
|
||||
className="smart-input-loading-spinner"
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>
|
||||
) : (
|
||||
+13
-13
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* @file ChatMessages.tsx
|
||||
* @description 消息列表组件,显示用户和 AI 的对话消息
|
||||
* @module components/screenshot-chat/ChatMessages
|
||||
* @module components/smart-input/ChatMessages
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { ChatMessagesProps, ChatMessage } from "./types";
|
||||
import "./screenshot-chat.css";
|
||||
import "./smart-input.css";
|
||||
|
||||
/**
|
||||
* 单条消息组件
|
||||
@@ -18,30 +18,30 @@ const MessageItem: React.FC<{ message: ChatMessage }> = ({ message }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`screenshot-chat-message ${isUser ? "screenshot-chat-message-user" : "screenshot-chat-message-assistant"}`}
|
||||
className={`smart-input-message ${isUser ? "smart-input-message-user" : "smart-input-message-assistant"}`}
|
||||
>
|
||||
{/* 用户消息显示图片 */}
|
||||
{isUser && message.image && (
|
||||
<div className="screenshot-chat-message-image">
|
||||
<div className="smart-input-message-image">
|
||||
<img
|
||||
src={`data:${message.image.mediaType};base64,${message.image.data}`}
|
||||
alt="截图"
|
||||
className="screenshot-chat-message-thumbnail"
|
||||
className="smart-input-message-thumbnail"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息内容 */}
|
||||
<div className="screenshot-chat-message-content">
|
||||
<div className="smart-input-message-content">
|
||||
{message.isThinking ? (
|
||||
<div className="screenshot-chat-thinking">
|
||||
<span className="screenshot-chat-loading-spinner" />
|
||||
<div className="smart-input-thinking">
|
||||
<span className="smart-input-loading-spinner" />
|
||||
<span>{message.thinkingContent || "思考中..."}</span>
|
||||
</div>
|
||||
) : isUser ? (
|
||||
<p>{message.content}</p>
|
||||
) : (
|
||||
<div className="screenshot-chat-markdown">
|
||||
<div className="smart-input-markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
@@ -50,7 +50,7 @@ const MessageItem: React.FC<{ message: ChatMessage }> = ({ message }) => {
|
||||
</div>
|
||||
|
||||
{/* 时间戳 */}
|
||||
<div className="screenshot-chat-message-time">
|
||||
<div className="smart-input-message-time">
|
||||
{new Date(message.timestamp).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
@@ -84,8 +84,8 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className={`screenshot-chat-messages ${className}`}>
|
||||
<div className="screenshot-chat-placeholder">
|
||||
<div className={`smart-input-messages ${className}`}>
|
||||
<div className="smart-input-placeholder">
|
||||
输入问题,开始与 AI 讨论截图内容
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,7 +93,7 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`screenshot-chat-messages ${className}`}>
|
||||
<div ref={containerRef} className={`smart-input-messages ${className}`}>
|
||||
{messages.map((message) => (
|
||||
<MessageItem key={message.id} message={message} />
|
||||
))}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file ShortcutSettings.tsx
|
||||
* @description 快捷键设置组件 - 显示当前快捷键、支持录制模式和保存/取消
|
||||
* @module components/screenshot-chat/ShortcutSettings
|
||||
* @module components/smart-input/ShortcutSettings
|
||||
*
|
||||
* 需求: 6.3, 6.4 - 显示当前快捷键和修改按钮,支持快捷键录制模式
|
||||
*/
|
||||
+5
-5
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* @file ScreenshotPreview.tsx
|
||||
* @file SmartInputPreview.tsx
|
||||
* @description 截图预览组件,用于悬浮对话窗口中显示截图
|
||||
* @module components/screenshot-chat/ScreenshotPreview
|
||||
* @module components/smart-input/SmartInputPreview
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
|
||||
/** 截图预览属性 */
|
||||
export interface ScreenshotPreviewProps {
|
||||
export interface SmartInputPreviewProps {
|
||||
/** 图片路径或 Base64 编码 */
|
||||
src: string;
|
||||
/** 图片 alt 文本 */
|
||||
@@ -22,7 +22,7 @@ export interface ScreenshotPreviewProps {
|
||||
* 截图预览组件
|
||||
* 支持缩放和拖拽查看截图
|
||||
*/
|
||||
export const ScreenshotPreview: React.FC<ScreenshotPreviewProps> = ({
|
||||
export const SmartInputPreview: React.FC<SmartInputPreviewProps> = ({
|
||||
src,
|
||||
alt = "截图预览",
|
||||
className = "",
|
||||
@@ -171,4 +171,4 @@ export const ScreenshotPreview: React.FC<ScreenshotPreviewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ScreenshotPreview;
|
||||
export default SmartInputPreview;
|
||||
+21
-21
@@ -1,17 +1,17 @@
|
||||
/**
|
||||
* @file ScreenshotChatWindow.tsx
|
||||
* @file SmartInputWindow.tsx
|
||||
* @description 截图对话悬浮窗主组件
|
||||
* @module components/screenshot-chat/ScreenshotChatWindow
|
||||
* @module components/smart-input/SmartInputWindow
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import { ScreenshotPreview } from "./ScreenshotPreview";
|
||||
import { SmartInputPreview } from "./SmartInputPreview";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import { useScreenshotChat } from "./useScreenshotChat";
|
||||
import type { ScreenshotChatWindowProps } from "./types";
|
||||
import "./screenshot-chat.css";
|
||||
import { useSmartInput } from "./useSmartInput";
|
||||
import type { SmartInputWindowProps } from "./types";
|
||||
import "./smart-input.css";
|
||||
|
||||
/**
|
||||
* 截图对话悬浮窗主组件
|
||||
@@ -23,7 +23,7 @@ import "./screenshot-chat.css";
|
||||
* - 4.6: 当用户按下 ESC 或点击窗口外部时,悬浮窗口应关闭
|
||||
* - 4.7: 悬浮窗口应支持用户拖动
|
||||
*/
|
||||
export const ScreenshotChatWindow: React.FC<ScreenshotChatWindowProps> = ({
|
||||
export const SmartInputWindow: React.FC<SmartInputWindowProps> = ({
|
||||
imagePath,
|
||||
onClose,
|
||||
}) => {
|
||||
@@ -37,7 +37,7 @@ export const ScreenshotChatWindow: React.FC<ScreenshotChatWindowProps> = ({
|
||||
setImagePath,
|
||||
clearError,
|
||||
retry,
|
||||
} = useScreenshotChat();
|
||||
} = useSmartInput();
|
||||
|
||||
// 加载图片
|
||||
useEffect(() => {
|
||||
@@ -82,12 +82,12 @@ export const ScreenshotChatWindow: React.FC<ScreenshotChatWindowProps> = ({
|
||||
: imagePath;
|
||||
|
||||
return (
|
||||
<div className="screenshot-chat-page">
|
||||
<div className="smart-input-page">
|
||||
{/* 窗口头部 - 可拖动区域 */}
|
||||
<div className="screenshot-chat-header">
|
||||
<span className="screenshot-chat-title">截图对话</span>
|
||||
<div className="smart-input-header">
|
||||
<span className="smart-input-title">截图对话</span>
|
||||
<button
|
||||
className="screenshot-chat-close-btn"
|
||||
className="smart-input-close-btn"
|
||||
onClick={handleClose}
|
||||
title="关闭 (ESC)"
|
||||
>
|
||||
@@ -106,19 +106,19 @@ export const ScreenshotChatWindow: React.FC<ScreenshotChatWindowProps> = ({
|
||||
|
||||
{/* 截图预览区域 */}
|
||||
{imageSrc && (
|
||||
<div className="screenshot-chat-preview">
|
||||
<ScreenshotPreview src={imageSrc} maxHeight={200} />
|
||||
<div className="smart-input-preview">
|
||||
<SmartInputPreview src={imageSrc} maxHeight={200} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 对话区域 */}
|
||||
<div className="screenshot-chat-conversation">
|
||||
<div className="smart-input-conversation">
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="screenshot-chat-error">
|
||||
<div className="screenshot-chat-error-content">
|
||||
<div className="smart-input-error">
|
||||
<div className="smart-input-error-content">
|
||||
<p style={{ color: "#f43f5e", marginBottom: 8 }}>{error}</p>
|
||||
<button className="screenshot-chat-retry-btn" onClick={retry}>
|
||||
<button className="smart-input-retry-btn" onClick={retry}>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -132,7 +132,7 @@ export const ScreenshotChatWindow: React.FC<ScreenshotChatWindowProps> = ({
|
||||
重试
|
||||
</button>
|
||||
<button
|
||||
className="screenshot-chat-retry-btn"
|
||||
className="smart-input-retry-btn"
|
||||
onClick={clearError}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
@@ -158,7 +158,7 @@ export const ScreenshotChatWindow: React.FC<ScreenshotChatWindowProps> = ({
|
||||
|
||||
{/* 调试信息(开发模式) */}
|
||||
{import.meta.env.DEV && (
|
||||
<div className="screenshot-chat-debug">
|
||||
<div className="smart-input-debug">
|
||||
路径: {imagePath} | Base64: {imageBase64 ? "已加载" : "未加载"} |
|
||||
消息数: {messages.length}
|
||||
</div>
|
||||
@@ -167,4 +167,4 @@ export const ScreenshotChatWindow: React.FC<ScreenshotChatWindowProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ScreenshotChatWindow;
|
||||
export default SmartInputWindow;
|
||||
@@ -1,33 +1,33 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description 截图对话模块导出入口
|
||||
* @module components/screenshot-chat
|
||||
* @module components/smart-input
|
||||
*/
|
||||
|
||||
// 类型导出
|
||||
export type {
|
||||
ScreenshotChatConfig,
|
||||
SmartInputConfig,
|
||||
MessageImage,
|
||||
ChatMessage,
|
||||
ScreenshotChatState,
|
||||
UseScreenshotChatReturn,
|
||||
ScreenshotPreviewProps,
|
||||
SmartInputState,
|
||||
UseSmartInputReturn,
|
||||
SmartInputPreviewProps,
|
||||
ChatInputProps,
|
||||
ChatMessagesProps,
|
||||
ScreenshotChatWindowProps,
|
||||
SmartInputWindowProps,
|
||||
} from "./types";
|
||||
|
||||
export type { ShortcutSettingsProps } from "./ShortcutSettings";
|
||||
|
||||
// 组件导出
|
||||
export { ScreenshotPreview } from "./ScreenshotPreview";
|
||||
export { SmartInputPreview } from "./SmartInputPreview";
|
||||
export { ChatInput } from "./ChatInput";
|
||||
export { ChatMessages } from "./ChatMessages";
|
||||
export { ScreenshotChatWindow } from "./ScreenshotChatWindow";
|
||||
export { SmartInputWindow } from "./SmartInputWindow";
|
||||
export { ShortcutSettings } from "./ShortcutSettings";
|
||||
|
||||
// Hook 导出
|
||||
export { useScreenshotChat, readImageAsBase64 } from "./useScreenshotChat";
|
||||
export { useSmartInput, readImageAsBase64 } from "./useSmartInput";
|
||||
|
||||
// 默认导出主组件
|
||||
export { ScreenshotChatWindow as default } from "./ScreenshotChatWindow";
|
||||
export { SmartInputWindow as default } from "./SmartInputWindow";
|
||||
+51
-51
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file screenshot-chat.css
|
||||
* @file smart-input.css
|
||||
* @description 截图对话组件样式 - 参考 Claude 的简洁设计
|
||||
* @module components/screenshot-chat
|
||||
* @module components/smart-input
|
||||
*/
|
||||
|
||||
/* ============================================================================
|
||||
@@ -306,7 +306,7 @@
|
||||
* 截图对话页面
|
||||
* ============================================================================ */
|
||||
|
||||
.screenshot-chat-page {
|
||||
.smart-input-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
@@ -317,7 +317,7 @@
|
||||
}
|
||||
|
||||
/* 窗口头部 - 拖动区域 */
|
||||
.screenshot-chat-header {
|
||||
.smart-input-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -328,13 +328,13 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.screenshot-chat-title {
|
||||
.smart-input-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--terminal-fg, #c0caf5);
|
||||
}
|
||||
|
||||
.screenshot-chat-close-btn {
|
||||
.smart-input-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -349,32 +349,32 @@
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.screenshot-chat-close-btn:hover {
|
||||
.smart-input-close-btn:hover {
|
||||
background-color: #f43f5e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 截图预览区域 */
|
||||
.screenshot-chat-preview {
|
||||
.smart-input-preview {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--terminal-border, #3b4261);
|
||||
}
|
||||
|
||||
/* 对话区域 */
|
||||
.screenshot-chat-conversation {
|
||||
.smart-input-conversation {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.screenshot-chat-messages {
|
||||
.smart-input-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.screenshot-chat-placeholder {
|
||||
.smart-input-placeholder {
|
||||
text-align: center;
|
||||
color: var(--terminal-muted, #565f89);
|
||||
font-size: 13px;
|
||||
@@ -382,7 +382,7 @@
|
||||
}
|
||||
|
||||
/* 输入区域 */
|
||||
.screenshot-chat-input-area {
|
||||
.smart-input-input-area {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
@@ -390,7 +390,7 @@
|
||||
background-color: var(--terminal-tab-bg, #24283b);
|
||||
}
|
||||
|
||||
.screenshot-chat-input {
|
||||
.smart-input-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
@@ -402,16 +402,16 @@
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.screenshot-chat-input:focus {
|
||||
.smart-input-input:focus {
|
||||
border-color: var(--terminal-accent, #7aa2f7);
|
||||
}
|
||||
|
||||
.screenshot-chat-input:disabled {
|
||||
.smart-input-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.screenshot-chat-send-btn {
|
||||
.smart-input-send-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
@@ -423,23 +423,23 @@
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.screenshot-chat-send-btn:hover:not(:disabled) {
|
||||
.smart-input-send-btn:hover:not(:disabled) {
|
||||
background-color: var(--terminal-accent-hover, #5d8bea);
|
||||
}
|
||||
|
||||
.screenshot-chat-send-btn:disabled {
|
||||
.smart-input-send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 错误状态 */
|
||||
.screenshot-chat-error {
|
||||
.smart-input-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.screenshot-chat-error-content {
|
||||
.smart-input-error-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -448,7 +448,7 @@
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.screenshot-chat-loading {
|
||||
.smart-input-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -457,23 +457,23 @@
|
||||
color: var(--terminal-muted, #565f89);
|
||||
}
|
||||
|
||||
.screenshot-chat-loading-spinner {
|
||||
.smart-input-loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--terminal-border, #3b4261);
|
||||
border-top-color: var(--terminal-accent, #7aa2f7);
|
||||
border-radius: 50%;
|
||||
animation: screenshot-chat-spin 0.8s linear infinite;
|
||||
animation: smart-input-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes screenshot-chat-spin {
|
||||
@keyframes smart-input-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 调试信息 */
|
||||
.screenshot-chat-debug {
|
||||
.smart-input-debug {
|
||||
padding: 4px 12px;
|
||||
background-color: var(--terminal-tab-bg, #24283b);
|
||||
border-top: 1px solid var(--terminal-border, #3b4261);
|
||||
@@ -488,7 +488,7 @@
|
||||
* 消息列表组件
|
||||
* ============================================================================ */
|
||||
|
||||
.screenshot-chat-message {
|
||||
.smart-input-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
@@ -498,23 +498,23 @@
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.screenshot-chat-message-user {
|
||||
.smart-input-message-user {
|
||||
align-self: flex-end;
|
||||
background-color: var(--terminal-accent, #7aa2f7);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.screenshot-chat-message-assistant {
|
||||
.smart-input-message-assistant {
|
||||
align-self: flex-start;
|
||||
background-color: var(--terminal-tab-bg, #24283b);
|
||||
border: 1px solid var(--terminal-border, #3b4261);
|
||||
}
|
||||
|
||||
.screenshot-chat-message-image {
|
||||
.smart-input-message-image {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.screenshot-chat-message-thumbnail {
|
||||
.smart-input-message-thumbnail {
|
||||
max-width: 120px;
|
||||
max-height: 80px;
|
||||
border-radius: 4px;
|
||||
@@ -522,23 +522,23 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.screenshot-chat-message-content {
|
||||
.smart-input-message-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.screenshot-chat-message-content p {
|
||||
.smart-input-message-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.screenshot-chat-message-time {
|
||||
.smart-input-message-time {
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
/* 思考中状态 */
|
||||
.screenshot-chat-thinking {
|
||||
.smart-input-thinking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -546,27 +546,27 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.screenshot-chat-thinking .screenshot-chat-loading-spinner {
|
||||
.smart-input-thinking .smart-input-loading-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
/* Markdown 渲染样式 */
|
||||
.screenshot-chat-markdown {
|
||||
.smart-input-markdown {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.screenshot-chat-markdown p {
|
||||
.smart-input-markdown p {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.screenshot-chat-markdown p:last-child {
|
||||
.smart-input-markdown p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.screenshot-chat-markdown code {
|
||||
.smart-input-markdown code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
@@ -574,7 +574,7 @@
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.screenshot-chat-markdown pre {
|
||||
.smart-input-markdown pre {
|
||||
margin: 8px 0;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
@@ -582,39 +582,39 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.screenshot-chat-markdown pre code {
|
||||
.smart-input-markdown pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.screenshot-chat-markdown ul,
|
||||
.screenshot-chat-markdown ol {
|
||||
.smart-input-markdown ul,
|
||||
.smart-input-markdown ol {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.screenshot-chat-markdown li {
|
||||
.smart-input-markdown li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.screenshot-chat-markdown strong {
|
||||
.smart-input-markdown strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.screenshot-chat-markdown em {
|
||||
.smart-input-markdown em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.screenshot-chat-markdown a {
|
||||
.smart-input-markdown a {
|
||||
color: var(--terminal-accent, #7aa2f7);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.screenshot-chat-markdown a:hover {
|
||||
.smart-input-markdown a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.screenshot-chat-markdown blockquote {
|
||||
.smart-input-markdown blockquote {
|
||||
margin: 8px 0;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid var(--terminal-accent, #7aa2f7);
|
||||
@@ -622,7 +622,7 @@
|
||||
}
|
||||
|
||||
/* 重试按钮 */
|
||||
.screenshot-chat-retry-btn {
|
||||
.smart-input-retry-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -637,7 +637,7 @@
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.screenshot-chat-retry-btn:hover {
|
||||
.smart-input-retry-btn:hover {
|
||||
background-color: var(--terminal-tab-hover-bg, #414868);
|
||||
color: var(--terminal-fg, #c0caf5);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file types.ts
|
||||
* @description 截图对话模块类型定义
|
||||
* @module components/screenshot-chat/types
|
||||
* @module components/smart-input/types
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
@@ -12,7 +12,7 @@
|
||||
* 截图对话功能配置
|
||||
* 需求: 1.1 - 实验室功能应提供 screenshot_chat.enabled 布尔开关
|
||||
*/
|
||||
export interface ScreenshotChatConfig {
|
||||
export interface SmartInputConfig {
|
||||
/** 是否启用截图对话功能 */
|
||||
enabled: boolean;
|
||||
/** 触发截图的全局快捷键 */
|
||||
@@ -62,7 +62,7 @@ export interface ChatMessage {
|
||||
/**
|
||||
* 截图对话 Hook 状态
|
||||
*/
|
||||
export interface ScreenshotChatState {
|
||||
export interface SmartInputState {
|
||||
/** 消息列表 */
|
||||
messages: ChatMessage[];
|
||||
/** 是否正在加载 */
|
||||
@@ -78,7 +78,7 @@ export interface ScreenshotChatState {
|
||||
/**
|
||||
* 截图对话 Hook 返回值
|
||||
*/
|
||||
export interface UseScreenshotChatReturn extends ScreenshotChatState {
|
||||
export interface UseSmartInputReturn extends SmartInputState {
|
||||
/** 发送消息到 AI */
|
||||
sendMessage: (message: string) => Promise<void>;
|
||||
/** 设置截图路径 */
|
||||
@@ -99,7 +99,7 @@ export interface UseScreenshotChatReturn extends ScreenshotChatState {
|
||||
* 截图预览组件属性
|
||||
* 需求: 4.2 - 悬浮窗口应显示截图预览
|
||||
*/
|
||||
export interface ScreenshotPreviewProps {
|
||||
export interface SmartInputPreviewProps {
|
||||
/** 图片路径或 Base64 编码 */
|
||||
src: string;
|
||||
/** 图片 alt 文本 */
|
||||
@@ -144,7 +144,7 @@ export interface ChatMessagesProps {
|
||||
* 悬浮窗主组件属性
|
||||
* 需求: 4.1, 4.6, 4.7 - 无边框置顶窗口,支持 ESC 关闭和拖动
|
||||
*/
|
||||
export interface ScreenshotChatWindowProps {
|
||||
export interface SmartInputWindowProps {
|
||||
/** 截图路径 */
|
||||
imagePath: string;
|
||||
/** 关闭窗口回调 */
|
||||
+5
-9
@@ -1,18 +1,14 @@
|
||||
/**
|
||||
* @file useScreenshotChat.ts
|
||||
* @file useSmartInput.ts
|
||||
* @description 截图对话核心 Hook,管理消息、图片和 AI 通信
|
||||
* @module components/screenshot-chat/useScreenshotChat
|
||||
* @module components/smart-input/useSmartInput
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { safeInvoke, safeListen } from "@/lib/dev-bridge";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
ChatMessage,
|
||||
MessageImage,
|
||||
UseScreenshotChatReturn,
|
||||
} from "./types";
|
||||
import type { ChatMessage, MessageImage, UseSmartInputReturn } from "./types";
|
||||
import { parseStreamEvent, type StreamEvent } from "@/lib/api/agent";
|
||||
|
||||
/**
|
||||
@@ -45,7 +41,7 @@ export async function readImageAsBase64(imagePath: string): Promise<string> {
|
||||
* - 5.3: 显示加载指示器
|
||||
* - 5.5: 显示错误信息并提供重试选项
|
||||
*/
|
||||
export function useScreenshotChat(): UseScreenshotChatReturn {
|
||||
export function useSmartInput(): UseSmartInputReturn {
|
||||
// 状态
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -314,4 +310,4 @@ export function useScreenshotChat(): UseScreenshotChatReturn {
|
||||
};
|
||||
}
|
||||
|
||||
export default useScreenshotChat;
|
||||
export default useSmartInput;
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* @file MicrophoneTest.tsx
|
||||
* @description 麦克风测试组件 - 设备选择和音量测试
|
||||
* @module components/voice/MicrophoneTest
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Mic, MicOff, RefreshCw, Check, AlertCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
listAudioDevices,
|
||||
startRecording,
|
||||
cancelRecording,
|
||||
getRecordingStatus,
|
||||
type AudioDeviceInfo,
|
||||
} from "@/lib/api/asrProvider";
|
||||
import { VolumeWaveform } from "./VolumeWaveform";
|
||||
|
||||
interface MicrophoneTestProps {
|
||||
/** 当前选择的设备 ID */
|
||||
selectedDeviceId?: string;
|
||||
/** 设备选择变化回调 */
|
||||
onDeviceChange: (deviceId: string | undefined) => void;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function MicrophoneTest({
|
||||
selectedDeviceId,
|
||||
onDeviceChange,
|
||||
disabled = false,
|
||||
}: MicrophoneTestProps) {
|
||||
const [devices, setDevices] = useState<AudioDeviceInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [volume, setVolume] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pollIntervalRef = useRef<number | null>(null);
|
||||
// 使用 ref 跟踪录音状态,避免闭包捕获过时的 state
|
||||
const isTestingRef = useRef(false);
|
||||
|
||||
// 加载设备列表
|
||||
const loadDevices = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const deviceList = await listAudioDevices();
|
||||
setDevices(deviceList);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "无法获取麦克风设备列表");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadDevices();
|
||||
}, [loadDevices]);
|
||||
|
||||
// 开始测试
|
||||
const startTest = useCallback(async () => {
|
||||
if (testing) return;
|
||||
setTesting(true);
|
||||
isTestingRef.current = true;
|
||||
setError(null);
|
||||
setVolume(0);
|
||||
|
||||
console.log("[麦克风测试] 开始测试,设备ID:", selectedDeviceId);
|
||||
|
||||
try {
|
||||
await startRecording(selectedDeviceId);
|
||||
console.log("[麦克风测试] 录音已开始");
|
||||
|
||||
// 轮询获取音量
|
||||
pollIntervalRef.current = window.setInterval(async () => {
|
||||
try {
|
||||
const status = await getRecordingStatus();
|
||||
console.log("[麦克风测试] 状态:", status);
|
||||
setVolume(status.volume);
|
||||
} catch (err) {
|
||||
console.error("[麦克风测试] 获取状态失败:", err);
|
||||
}
|
||||
}, 100);
|
||||
} catch (err: any) {
|
||||
console.error("[麦克风测试] 开始录音失败:", err);
|
||||
setError(err?.message || "无法开始录音测试");
|
||||
setTesting(false);
|
||||
isTestingRef.current = false;
|
||||
}
|
||||
}, [testing, selectedDeviceId]);
|
||||
|
||||
// 停止测试
|
||||
const stopTest = useCallback(async () => {
|
||||
if (!testing) return;
|
||||
|
||||
console.log("[麦克风测试] 停止测试");
|
||||
|
||||
// 清除轮询
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// 先更新状态,避免重复调用
|
||||
setTesting(false);
|
||||
isTestingRef.current = false;
|
||||
|
||||
try {
|
||||
await cancelRecording();
|
||||
console.log("[麦克风测试] 录音已取消");
|
||||
} catch (err) {
|
||||
console.error("[麦克风测试] 取消录音失败:", err);
|
||||
// 忽略取消错误
|
||||
}
|
||||
|
||||
setVolume(0);
|
||||
}, [testing]);
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
console.log(
|
||||
"[麦克风测试] 组件卸载,清理资源,isTestingRef:",
|
||||
isTestingRef.current,
|
||||
);
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
// 使用 ref 而不是 state,确保能获取到最新值
|
||||
if (isTestingRef.current) {
|
||||
console.log("[麦克风测试] 组件卸载时正在录音,取消录音");
|
||||
isTestingRef.current = false;
|
||||
cancelRecording().catch((err) => {
|
||||
console.error("[麦克风测试] 卸载时取消录音失败:", err);
|
||||
});
|
||||
}
|
||||
};
|
||||
}, []); // 空依赖,只在卸载时执行
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 设备选择 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={selectedDeviceId || "__default__"}
|
||||
onValueChange={(value) =>
|
||||
onDeviceChange(value === "__default__" ? undefined : value)
|
||||
}
|
||||
disabled={disabled || loading || testing}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="选择麦克风设备" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>系统默认</span>
|
||||
{devices.find((d) => d.is_default) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({devices.find((d) => d.is_default)?.name})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
{devices.map((device) => (
|
||||
<SelectItem key={device.id} value={device.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{device.name}</span>
|
||||
{device.is_default && (
|
||||
<Check className="h-3 w-3 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={loadDevices}
|
||||
disabled={loading || testing}
|
||||
title="刷新设备列表"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 测试区域 */}
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
|
||||
<Button
|
||||
variant={testing ? "destructive" : "default"}
|
||||
size="sm"
|
||||
onClick={testing ? stopTest : startTest}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<MicOff className="h-4 w-4 mr-2" />
|
||||
停止测试
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mic className="h-4 w-4 mr-2" />
|
||||
测试麦克风
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex-1">
|
||||
<VolumeWaveform volume={volume} isRecording={testing} barCount={12} />
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground w-12 text-right">
|
||||
{testing ? `${volume}%` : "--"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
点击"测试麦克风"按钮,对着麦克风说话,观察音量波形是否有变化。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MicrophoneTest;
|
||||
@@ -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<PolishModelSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
className,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedProviderKey, setSelectedProviderKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const menuRef = useRef<HTMLDivElement>(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 (
|
||||
<div className={cn("relative", className)} ref={menuRef}>
|
||||
{/* 触发按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between gap-2 px-3 py-2 text-sm rounded-md transition-colors",
|
||||
"border border-border hover:border-border hover:bg-muted",
|
||||
"focus:outline-none focus:ring-2 focus:ring-primary/50",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
isOpen && "border-primary bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{displayText}</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform flex-shrink-0",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* 下拉菜单 */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 w-full min-w-[400px] max-h-80 overflow-hidden rounded-lg border border-border shadow-lg z-50 bg-white dark:bg-zinc-900">
|
||||
<div className="flex h-72">
|
||||
{/* 左侧:Provider 列表 */}
|
||||
<div className="w-36 border-r border-border flex flex-col bg-zinc-50 dark:bg-zinc-800">
|
||||
<div className="px-3 py-2 border-b border-border bg-zinc-100 dark:bg-zinc-700">
|
||||
<h4 className="text-xs font-medium text-foreground">
|
||||
Provider
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-1.5 space-y-0.5">
|
||||
{providers.map((provider) => (
|
||||
<button
|
||||
key={provider.key}
|
||||
type="button"
|
||||
onClick={() => handleProviderSelect(provider.key)}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-2 py-1.5 text-xs rounded transition-colors",
|
||||
selectedProviderKey === provider.key
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{provider.label}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3 w-3 flex-shrink-0 transition-transform",
|
||||
selectedProviderKey === provider.key && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:模型列表 */}
|
||||
<div className="flex-1 flex flex-col min-w-0 bg-white dark:bg-zinc-900">
|
||||
<div className="px-3 py-2 border-b border-border bg-zinc-100 dark:bg-zinc-700">
|
||||
<h4 className="text-xs font-medium text-foreground">
|
||||
{selectedProvider
|
||||
? `${getProviderLabel(selectedProvider.key)} 模型`
|
||||
: "请选择 Provider"}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-1.5 space-y-0.5">
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : currentModels.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-xs text-muted-foreground">
|
||||
暂无可用模型
|
||||
</div>
|
||||
) : (
|
||||
currentModels.map((model) => {
|
||||
const isSelected = value === model.id;
|
||||
// 简化模型名称显示
|
||||
const displayName =
|
||||
model.display_name ||
|
||||
model.id.split("/").pop() ||
|
||||
model.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
onClick={() => handleModelSelect(model.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-2 py-1.5 text-xs rounded transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary border border-primary/30"
|
||||
: "hover:bg-muted text-foreground border border-transparent",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{displayName}</span>
|
||||
{isSelected && (
|
||||
<Check className="h-3 w-3 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PolishModelSelector;
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
{/* 标题和开关 */}
|
||||
@@ -121,6 +222,21 @@ export function VoiceSettings({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 麦克风设备选择和测试 */}
|
||||
<div className="pt-3 border-t">
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Mic className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">麦克风设备</span>
|
||||
</div>
|
||||
<MicrophoneTest
|
||||
selectedDeviceId={config.selected_device_id}
|
||||
onDeviceChange={handleDeviceChange}
|
||||
disabled={disabled || saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI 润色设置 */}
|
||||
<div className="pt-3 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -153,6 +269,107 @@ export function VoiceSettings({
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 润色模型选择 - 仅在启用润色时显示 */}
|
||||
{config.processor.polish_enabled && (
|
||||
<div className="mt-3">
|
||||
<label className="text-xs text-muted-foreground mb-1.5 block">
|
||||
润色模型
|
||||
</label>
|
||||
<PolishModelSelector
|
||||
value={config.processor.polish_model}
|
||||
onChange={handlePolishModelChange}
|
||||
disabled={disabled || saving}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 交互音效设置 */}
|
||||
<div className="pt-3 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Volume2 className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<span className="text-sm">交互音效</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
录音开始和停止时播放提示音
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.sound_enabled}
|
||||
onChange={handleToggleSound}
|
||||
disabled={disabled || saving}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"w-9 h-5 rounded-full transition-colors",
|
||||
"bg-muted peer-checked:bg-primary",
|
||||
"after:content-[''] after:absolute after:top-0.5 after:left-0.5",
|
||||
"after:bg-white after:rounded-full after:h-4 after:w-4",
|
||||
"after:transition-transform peer-checked:after:translate-x-4",
|
||||
(disabled || saving) && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 翻译模式快捷键设置 */}
|
||||
<div className="pt-3 border-t">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<span className="text-sm font-medium">翻译模式</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
设置独立快捷键,语音识别后自动翻译
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 翻译快捷键 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
翻译快捷键(可选)
|
||||
</label>
|
||||
<ShortcutSettings
|
||||
currentShortcut={config.translate_shortcut || ""}
|
||||
onShortcutChange={handleTranslateShortcutChange}
|
||||
onValidate={onValidateShortcut}
|
||||
disabled={disabled || saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 翻译指令选择 */}
|
||||
{config.translate_shortcut && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
翻译指令
|
||||
</label>
|
||||
<Select
|
||||
value={config.translate_instruction_id}
|
||||
onValueChange={handleTranslateInstructionChange}
|
||||
disabled={disabled || saving}
|
||||
>
|
||||
<SelectTrigger className="w-full h-8 text-sm">
|
||||
<SelectValue placeholder="选择翻译指令" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.instructions.map((instruction) => (
|
||||
<SelectItem key={instruction.id} value={instruction.id}>
|
||||
{instruction.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ASR 服务管理入口 */}
|
||||
|
||||
@@ -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<number[]>(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 (
|
||||
<div
|
||||
className={cn("flex items-center justify-center gap-0.5 h-6", className)}
|
||||
>
|
||||
{bars.map((height, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"w-1 rounded-full transition-all duration-75",
|
||||
isRecording ? "bg-primary" : "bg-muted",
|
||||
)}
|
||||
style={{
|
||||
height: `${Math.max(4, height * 24)}px`,
|
||||
opacity: isRecording ? 0.6 + height * 0.4 : 0.3,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VolumeWaveform;
|
||||
@@ -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";
|
||||
|
||||
+29
-1
@@ -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<HTMLAudioElement | null>(null);
|
||||
const typewriterAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const recordingStartAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const recordingStopAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const lastSoundTimeRef = useRef<number>(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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<HTMLAudioElement | null>(null);
|
||||
const stopAudioRef = useRef<HTMLAudioElement | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)",
|
||||
"正在精心绘制...": "正在精心绘制...",
|
||||
"将您的想象转化为像素...": "将您的想象转化为像素...",
|
||||
"在浏览器中打开": "在浏览器中打开",
|
||||
"生成失败": "生成失败",
|
||||
"准备就绪": "准备就绪",
|
||||
"在下方输入详细的提示词以开始创作。": "在下方输入详细的提示词以开始创作。",
|
||||
"描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...": "描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...",
|
||||
"新建图片": "新建图片"
|
||||
}
|
||||
@@ -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<AudioDeviceInfo[]> {
|
||||
return invoke<AudioDeviceInfo[]>("list_audio_devices");
|
||||
}
|
||||
|
||||
/** 获取 ASR 凭证列表 */
|
||||
export async function getAsrCredentials(): Promise<AsrCredentialEntry[]> {
|
||||
return invoke<AsrCredentialEntry[]>("get_asr_credentials");
|
||||
@@ -241,8 +266,8 @@ export interface StopRecordingResult {
|
||||
}
|
||||
|
||||
/** 开始录音 */
|
||||
export async function startRecording(): Promise<void> {
|
||||
return invoke("start_recording");
|
||||
export async function startRecording(deviceId?: string): Promise<void> {
|
||||
return invoke("start_recording", { deviceId });
|
||||
}
|
||||
|
||||
/** 停止录音并返回音频数据 */
|
||||
|
||||
+1
-55
@@ -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<WindowSize> {
|
||||
return safeInvoke("get_window_size");
|
||||
@@ -39,36 +27,13 @@ export const windowApi = {
|
||||
|
||||
/**
|
||||
* 设置窗口大小
|
||||
*
|
||||
* @param size - 新的窗口大小
|
||||
*/
|
||||
async setWindowSize(size: WindowSize): Promise<void> {
|
||||
return safeInvoke("set_window_size", { size });
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取所有可用的窗口大小选项
|
||||
*
|
||||
* @returns 窗口大小选项列表
|
||||
*/
|
||||
async getWindowSizeOptions(): Promise<WindowSizeOption[]> {
|
||||
return safeInvoke("get_window_size_options");
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置窗口为指定的预设大小
|
||||
*
|
||||
* @param optionId - 窗口大小选项 ID
|
||||
* @returns 之前的窗口大小(用于恢复)
|
||||
*/
|
||||
async setWindowSizeByOption(optionId: string): Promise<WindowSize> {
|
||||
return safeInvoke("set_window_size_by_option", { optionId });
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换全屏模式
|
||||
*
|
||||
* @returns 是否进入了全屏模式
|
||||
*/
|
||||
async toggleFullscreen(): Promise<boolean> {
|
||||
return safeInvoke("toggle_fullscreen");
|
||||
@@ -76,8 +41,6 @@ export const windowApi = {
|
||||
|
||||
/**
|
||||
* 检查是否处于全屏模式
|
||||
*
|
||||
* @returns 是否处于全屏模式
|
||||
*/
|
||||
async isFullscreen(): Promise<boolean> {
|
||||
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;
|
||||
|
||||
@@ -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: 详细错误日志记录
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* 错误处理模块导出
|
||||
*
|
||||
* @module lib/errors
|
||||
*/
|
||||
|
||||
export * from "./playwrightErrors";
|
||||
@@ -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<PlaywrightErrorInfo, "type" | "originalError">
|
||||
> = {
|
||||
[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<string, unknown>,
|
||||
): 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,
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export { FlowMonitorPage } from "./FlowMonitorPage";
|
||||
export { ScreenshotChatPage } from "./screenshot-chat";
|
||||
export { SmartInputPage } from "./smart-input";
|
||||
|
||||
@@ -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 (
|
||||
<svg
|
||||
viewBox="0 0 128 128"
|
||||
width="20"
|
||||
height="20"
|
||||
className="screenshot-logo"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="leftP" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: "#4fc3f7" }} />
|
||||
<stop offset="100%" style={{ stopColor: "#1a237e" }} />
|
||||
</linearGradient>
|
||||
<linearGradient id="rightP" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: "#7c4dff" }} />
|
||||
<stop offset="100%" style={{ stopColor: "#e91e63" }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g>
|
||||
<rect x="36" y="32" width="10" height="64" rx="3" fill="url(#leftP)" />
|
||||
<rect x="46" y="32" width="28" height="9" rx="3" fill="url(#rightP)" />
|
||||
<rect x="46" y="60" width="24" height="8" rx="2" fill="url(#rightP)" />
|
||||
<rect x="70" y="41" width="8" height="27" rx="3" fill="url(#rightP)" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [voiceState, setVoiceState] = useState<VoiceState>("idle");
|
||||
const [voiceMode, setVoiceMode] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 开始语音模式
|
||||
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 (
|
||||
<div className="screenshot-container">
|
||||
<div className="screenshot-input-bar">
|
||||
{/* 拖动手柄 */}
|
||||
<div
|
||||
className="screenshot-drag-handle"
|
||||
onMouseDown={handleStartDrag}
|
||||
title="拖动移动窗口"
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<Logo />
|
||||
|
||||
{/* 语音录音状态标签 */}
|
||||
{voiceState === "recording" && (
|
||||
<div className="screenshot-attachment recording">
|
||||
<Mic size={12} />
|
||||
<span>录音中...</span>
|
||||
<button
|
||||
className="screenshot-attachment-remove"
|
||||
onClick={stopVoiceRecording}
|
||||
title="停止录音"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 语音识别/润色状态 */}
|
||||
{(voiceState === "transcribing" || voiceState === "polishing") && (
|
||||
<div className="screenshot-attachment processing">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
<span>
|
||||
{voiceState === "transcribing" ? "识别中..." : "润色中..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片附件标签 */}
|
||||
{imagePath && (
|
||||
<div className="screenshot-attachment">
|
||||
<ImageIcon size={12} />
|
||||
<span>Image</span>
|
||||
<button
|
||||
className="screenshot-attachment-remove"
|
||||
onClick={handleRemoveImage}
|
||||
title="移除图片"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入框 */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="screenshot-input"
|
||||
placeholder={
|
||||
voiceState === "recording"
|
||||
? "点击 × 或松开快捷键停止录音"
|
||||
: "Ask anything..."
|
||||
}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
disabled={isLoading || voiceState !== "idle"}
|
||||
/>
|
||||
|
||||
{/* 右侧按钮组 */}
|
||||
<div className="screenshot-actions">
|
||||
{/* 麦克风按钮 - 点击开始录音 */}
|
||||
{voiceState === "idle" && (
|
||||
<button
|
||||
className="screenshot-mic-btn"
|
||||
onClick={startVoiceMode}
|
||||
title="语音输入"
|
||||
>
|
||||
<Mic size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
className="screenshot-close-btn"
|
||||
onClick={handleClose}
|
||||
title="关闭 (ESC)"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
|
||||
{/* 发送按钮 */}
|
||||
<button
|
||||
className={`screenshot-send-btn ${inputValue.trim() ? "active" : ""}`}
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim() || isLoading}
|
||||
title="发送 (Enter)"
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScreenshotChatPage;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/* 关闭按钮 */
|
||||
@@ -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 (
|
||||
<svg
|
||||
viewBox="0 0 128 128"
|
||||
width="20"
|
||||
height="20"
|
||||
className="screenshot-logo"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="leftP" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: "#4fc3f7" }} />
|
||||
<stop offset="100%" style={{ stopColor: "#1a237e" }} />
|
||||
</linearGradient>
|
||||
<linearGradient id="rightP" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: "#7c4dff" }} />
|
||||
<stop offset="100%" style={{ stopColor: "#e91e63" }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g>
|
||||
<rect x="36" y="32" width="10" height="64" rx="3" fill="url(#leftP)" />
|
||||
<rect x="46" y="32" width="28" height="9" rx="3" fill="url(#rightP)" />
|
||||
<rect x="46" y="60" width="24" height="8" rx="2" fill="url(#rightP)" />
|
||||
<rect x="70" y="41" width="8" height="27" rx="3" fill="url(#rightP)" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [voiceState, setVoiceState] = useState<VoiceState>("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<HTMLTextAreaElement>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(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<VoiceState>("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 (
|
||||
<div className="screenshot-container">
|
||||
{errorMsg && <div className="screenshot-error-toast">{errorMsg}</div>}
|
||||
<div className="screenshot-input-bar">
|
||||
{/* 拖动手柄 */}
|
||||
<div
|
||||
className="screenshot-drag-handle"
|
||||
onMouseDown={handleStartDrag}
|
||||
title="拖动移动窗口"
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<Logo />
|
||||
|
||||
{/* 语音识别/润色状态 */}
|
||||
{(voiceState === "transcribing" || voiceState === "polishing") && (
|
||||
<div className="screenshot-attachment processing">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
<span>
|
||||
{voiceState === "transcribing" ? "识别中..." : "润色中..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片附件标签 */}
|
||||
{imagePath && (
|
||||
<div className="screenshot-attachment">
|
||||
<ImageIcon size={12} />
|
||||
<span>Image</span>
|
||||
<button
|
||||
className="screenshot-attachment-remove"
|
||||
onClick={handleRemoveImage}
|
||||
title="移除图片"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 录音模式显示波形,非录音模式显示输入框 */}
|
||||
{voiceState === "recording" ? (
|
||||
<div className="screenshot-recording-container">
|
||||
<div className="recording-dot" />
|
||||
<span className="screenshot-recording-text">正在聆听...</span>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="screenshot-input"
|
||||
placeholder="Ask anything..."
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
// 自动调整高度
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height =
|
||||
Math.min(e.target.scrollHeight, 120) + "px";
|
||||
}}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
disabled={isLoading || voiceState !== "idle"}
|
||||
rows={1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 右侧按钮组 */}
|
||||
<div className="screenshot-actions">
|
||||
{/* 麦克风按钮 - 点击开始录音 */}
|
||||
{voiceState === "idle" && (
|
||||
<button
|
||||
className="screenshot-mic-btn"
|
||||
onClick={startVoiceMode}
|
||||
title="语音输入"
|
||||
>
|
||||
<Mic size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 停止录音按钮 */}
|
||||
{voiceState === "recording" && (
|
||||
<button
|
||||
className="screenshot-stop-btn"
|
||||
onClick={(e) => {
|
||||
console.log("[语音输入] 停止按钮被点击!");
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
stopVoiceRecording();
|
||||
}}
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
position: "relative",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
title="完成录音"
|
||||
>
|
||||
<Square size={12} fill="#ffffff" color="#ffffff" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
className="screenshot-close-btn"
|
||||
onClick={(e) => {
|
||||
console.log("[语音输入] 关闭按钮被点击!");
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleClose();
|
||||
}}
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
position: "relative",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
title="关闭 (ESC)"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
|
||||
{/* 发送按钮 */}
|
||||
<button
|
||||
className={`screenshot-send-btn ${inputValue.trim() ? "active" : ""}`}
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim() || isLoading}
|
||||
title="发送 (Enter)"
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SmartInputPage;
|
||||
@@ -12,6 +12,7 @@ export type Page =
|
||||
| "provider-pool"
|
||||
| "api-server"
|
||||
| "agent"
|
||||
| "image-gen"
|
||||
| "tools"
|
||||
| "plugins"
|
||||
| "settings"
|
||||
|
||||
Reference in New Issue
Block a user