release: v0.67.0

This commit is contained in:
coso
2026-02-16 02:54:58 +08:00
parent afbadd49b4
commit 2e35df927d
97 changed files with 4516 additions and 595 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.66.0",
"version": "0.67.0",
"type": "module",
"repository": {
"type": "git",
+15 -15
View File
@@ -6621,7 +6621,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.66.0"
version = "0.67.0"
dependencies = [
"anyhow",
"arboard",
@@ -6719,7 +6719,7 @@ dependencies = [
[[package]]
name = "proxycast-agent"
version = "0.66.0"
version = "0.67.0"
dependencies = [
"aster",
"async-trait",
@@ -6742,7 +6742,7 @@ dependencies = [
[[package]]
name = "proxycast-config"
version = "0.66.0"
version = "0.67.0"
dependencies = [
"async-trait",
"parking_lot",
@@ -6758,7 +6758,7 @@ dependencies = [
[[package]]
name = "proxycast-core"
version = "0.66.0"
version = "0.67.0"
dependencies = [
"async-trait",
"axum 0.7.9",
@@ -6797,7 +6797,7 @@ dependencies = [
[[package]]
name = "proxycast-credential"
version = "0.66.0"
version = "0.67.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -6828,7 +6828,7 @@ dependencies = [
[[package]]
name = "proxycast-infra"
version = "0.66.0"
version = "0.67.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -6848,7 +6848,7 @@ dependencies = [
[[package]]
name = "proxycast-mcp"
version = "0.66.0"
version = "0.67.0"
dependencies = [
"async-trait",
"glob",
@@ -6879,7 +6879,7 @@ dependencies = [
[[package]]
name = "proxycast-processor"
version = "0.66.0"
version = "0.67.0"
dependencies = [
"async-trait",
"parking_lot",
@@ -6898,7 +6898,7 @@ dependencies = [
[[package]]
name = "proxycast-providers"
version = "0.66.0"
version = "0.67.0"
dependencies = [
"anyhow",
"async-stream",
@@ -6950,7 +6950,7 @@ dependencies = [
[[package]]
name = "proxycast-server"
version = "0.66.0"
version = "0.67.0"
dependencies = [
"async-stream",
"axum 0.7.9",
@@ -6989,7 +6989,7 @@ dependencies = [
[[package]]
name = "proxycast-server-utils"
version = "0.66.0"
version = "0.67.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -7004,7 +7004,7 @@ dependencies = [
[[package]]
name = "proxycast-services"
version = "0.66.0"
version = "0.67.0"
dependencies = [
"anyhow",
"aster",
@@ -7045,7 +7045,7 @@ dependencies = [
[[package]]
name = "proxycast-skills"
version = "0.66.0"
version = "0.67.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -7061,7 +7061,7 @@ dependencies = [
[[package]]
name = "proxycast-terminal"
version = "0.66.0"
version = "0.67.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7088,7 +7088,7 @@ dependencies = [
[[package]]
name = "proxycast-websocket"
version = "0.66.0"
version = "0.67.0"
dependencies = [
"axum 0.7.9",
"chrono",
+2 -2
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.66.0"
version = "0.67.0"
edition = "2021"
authors = ["you"]
repository = "https://github.com/aiclientproxy/proxycast"
@@ -183,7 +183,7 @@ version = "2.4"
[package]
name = "proxycast"
version = "0.66.0"
version = "0.67.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
@@ -143,6 +143,31 @@ pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result<SessionDe
})
}
/// 重命名会话
pub fn rename_session_sync(db: &DbConnection, session_id: &str, name: &str) -> Result<(), String> {
let trimmed_name = name.trim();
if trimmed_name.is_empty() {
return Err("会话名称不能为空".to_string());
}
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
AgentDao::update_title(&conn, session_id, trimmed_name)
.map_err(|e| format!("更新会话标题失败: {e}"))?;
let now = Utc::now().to_rfc3339();
AgentDao::update_session_time(&conn, session_id, &now)
.map_err(|e| format!("更新会话时间失败: {e}"))?;
Ok(())
}
/// 删除会话
pub fn delete_session_sync(db: &DbConnection, session_id: &str) -> Result<(), String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
AgentDao::delete_session(&conn, session_id).map_err(|e| format!("删除会话失败: {e}"))?;
Ok(())
}
/// 将 AgentMessage 转换为 TauriMessage
fn convert_agent_message(message: &AgentMessage) -> TauriMessage {
let content = match &message.content {
@@ -140,7 +140,6 @@ where
#[cfg(test)]
mod tests {
use super::*;
use crate::observer::events::{ConfigChangeSource, FullReloadEvent};
struct TestObserver {
name: String,
@@ -161,7 +161,7 @@ impl A2UIFormDao {
)?;
let forms: Vec<A2UIForm> = stmt
.query_map([message_id], |row| Self::map_row(row))?
.query_map([message_id], Self::map_row)?
.filter_map(|r| r.ok())
.collect();
@@ -180,7 +180,7 @@ impl A2UIFormDao {
)?;
let forms: Vec<A2UIForm> = stmt
.query_map([session_id], |row| Self::map_row(row))?
.query_map([session_id], Self::map_row)?
.filter_map(|r| r.ok())
.collect();
@@ -179,7 +179,7 @@ impl MaterialDao {
// 按搜索关键词筛选(名称或描述)
if let Some(ref query) = f.search_query {
sql.push_str(" AND (name LIKE ? OR description LIKE ?)");
let pattern = format!("%{}%", query);
let pattern = format!("%{query}%");
params_vec.push(Box::new(pattern.clone()));
params_vec.push(Box::new(pattern));
}
@@ -188,7 +188,7 @@ impl MaterialDao {
if let Some(ref tags) = f.tags {
for tag in tags {
sql.push_str(" AND tags_json LIKE ?");
params_vec.push(Box::new(format!("%\"{}%", tag)));
params_vec.push(Box::new(format!("%\"{tag}%")));
}
}
}
@@ -202,7 +202,7 @@ impl MaterialDao {
params_vec.iter().map(|p| p.as_ref()).collect();
let materials: Vec<Material> = stmt
.query_map(params_refs.as_slice(), |row| Self::map_row(row))?
.query_map(params_refs.as_slice(), Self::map_row)?
.filter_map(|r| r.ok())
.collect();
@@ -480,7 +480,7 @@ mod tests {
for i in 1..=3 {
let req = UploadMaterialRequest {
project_id: "project-1".to_string(),
name: format!("素材{}", i),
name: format!("素材{i}"),
material_type: "document".to_string(),
file_path: None,
content: None,
@@ -517,11 +517,11 @@ mod tests {
create_test_project(&conn, "project-1");
// 创建不同类型的素材
let types = vec!["document", "image", "document", "text"];
let types = ["document", "image", "document", "text"];
for (i, t) in types.iter().enumerate() {
let req = UploadMaterialRequest {
project_id: "project-1".to_string(),
name: format!("素材{}", i),
name: format!("素材{i}"),
material_type: t.to_string(),
file_path: None,
content: None,
@@ -743,7 +743,7 @@ mod tests {
for i in 1..=3 {
let req = UploadMaterialRequest {
project_id: "project-1".to_string(),
name: format!("素材{}", i),
name: format!("素材{i}"),
material_type: "document".to_string(),
file_path: None,
content: None,
@@ -768,9 +768,9 @@ mod tests {
for i in 1..=2 {
let req = UploadMaterialRequest {
project_id: "project-1".to_string(),
name: format!("素材{}", i),
name: format!("素材{i}"),
material_type: "document".to_string(),
file_path: Some(format!("/path/to/file{}.pdf", i)),
file_path: Some(format!("/path/to/file{i}.pdf")),
content: None,
tags: None,
description: None,
@@ -817,7 +817,7 @@ mod tests {
for i in 1..=3 {
let req = UploadMaterialRequest {
project_id: "project-a".to_string(),
name: format!("A素材{}", i),
name: format!("A素材{i}"),
material_type: "document".to_string(),
file_path: None,
content: None,
@@ -830,7 +830,7 @@ mod tests {
for i in 1..=2 {
let req = UploadMaterialRequest {
project_id: "project-b".to_string(),
name: format!("B素材{}", i),
name: format!("B素材{i}"),
material_type: "image".to_string(),
file_path: None,
content: None,
@@ -150,7 +150,7 @@ impl PersonaDao {
)?;
let personas: Vec<Persona> = stmt
.query_map([project_id], |row| Self::map_row(row))?
.query_map([project_id], Self::map_row)?
.filter_map(|r| r.ok())
.collect();
@@ -471,7 +471,7 @@ mod tests {
for i in 1..=2 {
let req = CreatePersonaRequest {
project_id: "project-1".to_string(),
name: format!("人设{}", i),
name: format!("人设{i}"),
description: None,
style: "测试".to_string(),
tone: None,
@@ -196,11 +196,11 @@ impl PosterMaterialDao {
let mut stmt = conn.prepare(sql)?;
let results: Vec<PosterMaterial> = if let Some(cat) = category {
stmt.query_map(params![project_id, cat], |row| Self::map_joined_row(row))?
stmt.query_map(params![project_id, cat], Self::map_joined_row)?
.filter_map(|r| r.ok())
.collect()
} else {
stmt.query_map([project_id], |row| Self::map_joined_row(row))?
stmt.query_map([project_id], Self::map_joined_row)?
.filter_map(|r| r.ok())
.collect()
};
@@ -241,11 +241,11 @@ impl PosterMaterialDao {
let mut stmt = conn.prepare(sql)?;
let results: Vec<PosterMaterial> = if let Some(cat) = category {
stmt.query_map(params![project_id, cat], |row| Self::map_joined_row(row))?
stmt.query_map(params![project_id, cat], Self::map_joined_row)?
.filter_map(|r| r.ok())
.collect()
} else {
stmt.query_map([project_id], |row| Self::map_joined_row(row))?
stmt.query_map([project_id], Self::map_joined_row)?
.filter_map(|r| r.ok())
.collect()
};
@@ -286,11 +286,11 @@ impl PosterMaterialDao {
let mut stmt = conn.prepare(sql)?;
let results: Vec<PosterMaterial> = if let Some(m) = mood {
stmt.query_map(params![project_id, m], |row| Self::map_joined_row(row))?
stmt.query_map(params![project_id, m], Self::map_joined_row)?
.filter_map(|r| r.ok())
.collect()
} else {
stmt.query_map([project_id], |row| Self::map_joined_row(row))?
stmt.query_map([project_id], Self::map_joined_row)?
.filter_map(|r| r.ok())
.collect()
};
@@ -163,7 +163,7 @@ impl PublishConfigDao {
)?;
let configs: Vec<PublishConfig> = stmt
.query_map([project_id], |row| Self::map_row(row))?
.query_map([project_id], Self::map_row)?
.filter_map(|r| r.ok())
.collect();
@@ -141,7 +141,7 @@ impl TemplateDao {
)?;
let templates: Vec<Template> = stmt
.query_map([project_id], |row| Self::map_row(row))?
.query_map([project_id], Self::map_row)?
.filter_map(|r| r.ok())
.collect();
@@ -496,7 +496,7 @@ mod tests {
for i in 1..=2 {
let req = CreateTemplateRequest {
project_id: "project-1".to_string(),
name: format!("模板{}", i),
name: format!("模板{i}"),
platform: "xiaohongshu".to_string(),
title_style: None,
paragraph_style: None,
@@ -771,7 +771,7 @@ mod tests {
for i in 1..=3 {
let req = CreateTemplateRequest {
project_id: "project-1".to_string(),
name: format!("模板{}", i),
name: format!("模板{i}"),
platform: "markdown".to_string(),
title_style: None,
paragraph_style: None,
@@ -798,7 +798,7 @@ mod tests {
for i in 1..=2 {
let req = CreateTemplateRequest {
project_id: "project-1".to_string(),
name: format!("模板{}", i),
name: format!("模板{i}"),
platform: "xiaohongshu".to_string(),
title_style: None,
paragraph_style: None,
@@ -851,7 +851,7 @@ mod tests {
for i in 1..=3 {
let req = CreateTemplateRequest {
project_id: "project-a".to_string(),
name: format!("A模板{}", i),
name: format!("A模板{i}"),
platform: "xiaohongshu".to_string(),
title_style: None,
paragraph_style: None,
@@ -866,7 +866,7 @@ mod tests {
for i in 1..=2 {
let req = CreateTemplateRequest {
project_id: "project-b".to_string(),
name: format!("B模板{}", i),
name: format!("B模板{i}"),
platform: "wechat".to_string(),
title_style: None,
paragraph_style: None,
@@ -905,7 +905,7 @@ mod tests {
for i in 1..=3 {
let req = CreateTemplateRequest {
project_id: "project-1".to_string(),
name: format!("模板{}", i),
name: format!("模板{i}"),
platform: "markdown".to_string(),
title_style: None,
paragraph_style: None,
@@ -214,8 +214,7 @@ fn verify_migration(conn: &Connection) -> Result<(), String> {
if null_count > 0 {
return Err(format!(
"迁移验证失败: 仍有 {} 条内容的 project_id 为空",
null_count
"迁移验证失败: 仍有 {null_count} 条内容的 project_id 为空"
));
}
+1 -1
View File
@@ -517,7 +517,7 @@ impl MemoryManager {
.map_err(|e| format!("准备查询失败: {e}"))?;
let nodes = stmt
.query_map(params![project_id], |row| Self::row_to_outline_node(row))
.query_map(params![project_id], Self::row_to_outline_node)
.map_err(|e| format!("查询失败: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("解析结果失败: {e}"))?;
@@ -158,6 +158,10 @@ pub enum MaterialType {
Document,
/// 图片
Image,
/// 音频
Audio,
/// 视频
Video,
/// 纯文本
Text,
/// 数据文件(CSV、JSON 等)
@@ -184,6 +188,8 @@ impl MaterialType {
match self {
MaterialType::Document => "document",
MaterialType::Image => "image",
MaterialType::Audio => "audio",
MaterialType::Video => "video",
MaterialType::Text => "text",
MaterialType::Data => "data",
MaterialType::Link => "link",
@@ -197,6 +203,8 @@ impl MaterialType {
match s.to_lowercase().as_str() {
"document" => MaterialType::Document,
"image" => MaterialType::Image,
"audio" => MaterialType::Audio,
"video" => MaterialType::Video,
"text" => MaterialType::Text,
"data" => MaterialType::Data,
"link" => MaterialType::Link,
@@ -1139,6 +1147,7 @@ impl Default for DesignConfig {
/// 视觉规范配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Default)]
pub struct VisualConfig {
/// Logo 图片 URL
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1154,18 +1163,6 @@ pub struct VisualConfig {
pub decorations: Vec<String>,
}
impl Default for VisualConfig {
fn default() -> Self {
Self {
logo_url: None,
logo_placement: LogoPlacement::default(),
image_style: ImageStyle::default(),
icon_style: IconStyle::default(),
decorations: vec![],
}
}
}
/// 品牌人设扩展
///
/// 存储品牌人设的海报设计专用字段,与基础 Persona 关联。
@@ -1269,6 +1266,8 @@ mod tests {
fn test_material_type_conversion() {
assert_eq!(MaterialType::Document.as_str(), "document");
assert_eq!(MaterialType::Image.as_str(), "image");
assert_eq!(MaterialType::Audio.as_str(), "audio");
assert_eq!(MaterialType::Video.as_str(), "video");
assert_eq!(MaterialType::Text.as_str(), "text");
assert_eq!(MaterialType::Data.as_str(), "data");
assert_eq!(MaterialType::Link.as_str(), "link");
@@ -1278,6 +1277,8 @@ mod tests {
assert_eq!(MaterialType::from_str("document"), MaterialType::Document);
assert_eq!(MaterialType::from_str("IMAGE"), MaterialType::Image);
assert_eq!(MaterialType::from_str("audio"), MaterialType::Audio);
assert_eq!(MaterialType::from_str("VIDEO"), MaterialType::Video);
assert_eq!(MaterialType::from_str("icon"), MaterialType::Icon);
assert_eq!(MaterialType::from_str("color"), MaterialType::Color);
assert_eq!(MaterialType::from_str("layout"), MaterialType::Layout);
@@ -1291,6 +1292,8 @@ mod tests {
assert!(MaterialType::Color.is_poster_material());
assert!(MaterialType::Layout.is_poster_material());
assert!(!MaterialType::Document.is_poster_material());
assert!(!MaterialType::Audio.is_poster_material());
assert!(!MaterialType::Video.is_poster_material());
assert!(!MaterialType::Text.is_poster_material());
assert!(!MaterialType::Data.is_poster_material());
assert!(!MaterialType::Link.is_poster_material());
+9 -9
View File
@@ -85,7 +85,7 @@ pub async fn get_embedding(
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?;
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
let model = model.unwrap_or("text-embedding-3-small");
@@ -100,11 +100,11 @@ pub async fn get_embedding(
let resp = client
.post(url)
.header("Authorization", format!("Bearer {}", api_key))
.header("Authorization", format!("Bearer {api_key}"))
.json(&req)
.send()
.await
.map_err(|e| format!("请求失败: {}", e))?;
.map_err(|e| format!("请求失败: {e}"))?;
tracing::debug!("[嵌入服务] 响应状态: {}", resp.status());
@@ -113,22 +113,22 @@ pub async fn get_embedding(
let error_text = resp
.text()
.await
.unwrap_or_else(|e| format!("读取错误响应失败: {}", e));
.unwrap_or_else(|e| format!("读取错误响应失败: {e}"));
tracing::error!("[嵌入服务] API 错误: {} - {}", status, error_text);
return Err(format!("API 错误: {} - {}", status, error_text));
return Err(format!("API 错误: {status} - {error_text}"));
}
let body = resp
.text()
.await
.map_err(|e| format!("读取响应体失败: {}", e))?;
.map_err(|e| format!("读取响应体失败: {e}"))?;
tracing::debug!("[嵌入服务] 响应体长度: {} bytes", body.len());
let response: EmbeddingResponse =
serde_json::from_str(&body).map_err(|e| format!("JSON 解析失败: {}", e))?;
serde_json::from_str(&body).map_err(|e| format!("JSON 解析失败: {e}"))?;
if response.data.is_empty() {
return Err("API 返回数据为空".to_string());
@@ -182,7 +182,7 @@ pub async fn get_embeddings_batch(
let mut errors = Vec::new();
for task in tasks {
match task.await.map_err(|e| format!("任务失败: {}", e))? {
match task.await.map_err(|e| format!("任务失败: {e}"))? {
Ok(embedding) => results.push(embedding),
Err(e) => {
tracing::warn!("[嵌入服务] 批量中单个失败: {}", e);
@@ -221,7 +221,7 @@ mod tests {
println!("向量前 5 维: {:?}", &embedding[..5]);
}
Err(e) => {
eprintln!("测试失败: {}", e);
eprintln!("测试失败: {e}");
}
}
}
+16 -19
View File
@@ -453,7 +453,7 @@ impl McpClientManager {
let (transport, mut stderr_opt) = match spawn_result {
Ok(result) => result,
Err(e) => {
let error_msg = format!("无法启动服务器进程: {}", e);
let error_msg = format!("无法启动服务器进程: {e}");
error!(server_name = %name, error = %e, "启动 MCP 服务器进程失败");
self.emit_server_error(name, &error_msg);
return Err(McpError::ProcessSpawnFailed(error_msg));
@@ -461,15 +461,13 @@ impl McpClientManager {
};
// 启动 stderr 读取任务(用于错误诊断)
let stderr_task = if let Some(mut stderr) = stderr_opt.take() {
Some(tokio::spawn(async move {
let stderr_task = stderr_opt.take().map(|mut stderr| {
tokio::spawn(async move {
let mut all_stderr = Vec::new();
let _ = stderr.read_to_end(&mut all_stderr).await;
String::from_utf8_lossy(&all_stderr).into_owned()
}))
} else {
None
};
})
});
// 4. 初始化 MCP 客户端
let client_handler =
@@ -491,9 +489,9 @@ impl McpClientManager {
};
let error_msg = if stderr_content.is_empty() {
format!("MCP 连接失败: {}", e)
format!("MCP 连接失败: {e}")
} else {
format!("MCP 连接失败: {}. Stderr: {}", e, stderr_content)
format!("MCP 连接失败: {e}. Stderr: {stderr_content}")
};
error!(
@@ -506,7 +504,7 @@ impl McpClientManager {
return Err(McpError::ConnectionFailed(error_msg));
}
Err(_) => {
let error_msg = format!("MCP 连接超时({}秒)", timeout_secs);
let error_msg = format!("MCP 连接超时({timeout_secs}秒)");
error!(server_name = %name, timeout = timeout_secs, "MCP 连接超时");
self.emit_server_error(name, &error_msg);
return Err(McpError::Timeout);
@@ -872,7 +870,7 @@ impl McpClientManager {
error = %e,
"工具调用失败"
);
McpError::ToolCallFailed(format!("{}", e))
McpError::ToolCallFailed(format!("{e}"))
})?;
// 5. 转换结果为 McpToolResult
@@ -939,7 +937,7 @@ impl McpClientManager {
let content: Vec<McpContent> = result
.content
.into_iter()
.map(|c| Self::convert_content(c))
.map(Self::convert_content)
.collect();
McpToolResult {
@@ -1135,7 +1133,7 @@ impl McpClientManager {
};
let get_prompt_param = rmcp::model::GetPromptRequestParam {
name: actual_prompt_name.clone().into(),
name: actual_prompt_name.clone(),
arguments: args,
};
@@ -1147,7 +1145,7 @@ impl McpClientManager {
error = %e,
"获取提示词失败"
);
McpError::ToolCallFailed(format!("获取提示词失败: {}", e))
McpError::ToolCallFailed(format!("获取提示词失败: {e}"))
})?;
// 5. 转换结果为 McpPromptResult
@@ -1200,8 +1198,7 @@ impl McpClientManager {
// 提示词未找到
Err(McpError::ToolNotFound(format!(
"提示词不存在: {}",
prompt_name
"提示词不存在: {prompt_name}"
)))
}
@@ -1210,7 +1207,7 @@ impl McpClientManager {
let messages: Vec<McpPromptMessage> = result
.messages
.into_iter()
.map(|msg| Self::convert_prompt_message(msg))
.map(Self::convert_prompt_message)
.collect();
McpPromptResult {
@@ -1396,7 +1393,7 @@ impl McpClientManager {
error = %e,
"读取资源失败"
);
McpError::ToolCallFailed(format!("读取资源失败: {}", e))
McpError::ToolCallFailed(format!("读取资源失败: {e}"))
})?;
// 5. 转换结果为 McpResourceContent
@@ -1447,7 +1444,7 @@ impl McpClientManager {
}
// 资源未找到
Err(McpError::ToolNotFound(format!("资源不存在: {}", uri)))
Err(McpError::ToolNotFound(format!("资源不存在: {uri}")))
}
/// 转换 rmcp ReadResourceResult 为 McpResourceContent
+6 -8
View File
@@ -86,9 +86,7 @@ pub fn build_extraction_prompt(context: &ExtractionContext) -> String {
]
```
只提取真正重要的信息。如果没有新信息,返回空数组 []。"#,
existing_summary = existing_summary,
messages_text = messages_text
只提取真正重要的信息。如果没有新信息,返回空数组 []。"#
)
}
@@ -138,18 +136,18 @@ pub async fn call_claude_api(api_key: &str, prompt: &str, model: &str) -> Result
.json(&request)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
.map_err(|e| format!("Request failed: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("API error {}: {}", status, body));
return Err(format!("API error {status}: {body}"));
}
let body: ClaudeResponse = response
.json()
.await
.map_err(|e| format!("JSON parse failed: {}", e))?;
.map_err(|e| format!("JSON parse failed: {e}"))?;
Ok(body
.content
@@ -184,7 +182,7 @@ fn parse_extraction_response(response: &str) -> Result<Vec<ExtractedMemory>, Str
let json_end = response.rfind(']').ok_or("No JSON array end found")?;
let json_str = &response[json_start..=json_end];
serde_json::from_str(json_str).map_err(|e| format!("JSON parse failed: {}", e))
serde_json::from_str(json_str).map_err(|e| format!("JSON parse failed: {e}"))
}
fn validate_memories(memories: Vec<ExtractedMemory>) -> Result<Vec<ExtractedMemory>, String> {
@@ -205,7 +203,7 @@ fn convert_to_unified_memory(extracted: ExtractedMemory, session_id: &str) -> Un
.as_secs() as i64;
UnifiedMemory {
id: format!("mem_{}", now),
id: format!("mem_{now}"),
session_id: session_id.to_string(),
memory_type: MemoryType::Conversation,
category: extracted.category,
+6 -6
View File
@@ -30,7 +30,7 @@ pub struct UserFeedback {
/// Record user feedback
pub fn record_feedback(db: &Connection, feedback: &UserFeedback) -> Result<(), String> {
let action_json = serde_json::to_string(&feedback.action)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
.map_err(|e| format!("JSON serialization failed: {e}"))?;
let sql = r#"
INSERT INTO memory_feedback (id, memory_id, action, session_id, created_at)
@@ -47,7 +47,7 @@ pub fn record_feedback(db: &Connection, feedback: &UserFeedback) -> Result<(), S
feedback.created_at,
],
)
.map_err(|e| format!("Insert failed: {}", e))?;
.map_err(|e| format!("Insert failed: {e}"))?;
tracing::info!("[Feedback] Recorded: {:?}", feedback.action);
Ok(())
@@ -69,7 +69,7 @@ pub fn get_recent_feedbacks(
let mut stmt = db
.prepare(sql)
.map_err(|e| format!("Prepare failed: {}", e))?;
.map_err(|e| format!("Prepare failed: {e}"))?;
let feedbacks = stmt
.query_map(params![session_id, limit as i64], |row| {
@@ -90,9 +90,9 @@ pub fn get_recent_feedbacks(
created_at,
})
})
.map_err(|e| format!("Query failed: {}", e))?
.map_err(|e| format!("Query failed: {e}"))?
.collect::<Result<Vec<_>, rusqlite::Error>>()
.map_err(|e| format!("Collection failed: {}", e))?;
.map_err(|e| format!("Collection failed: {e}"))?;
Ok(feedbacks)
}
@@ -176,7 +176,7 @@ pub fn generate_feedback_id() -> String {
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
format!("feedback_{}", now)
format!("feedback_{now}")
}
/// Get current timestamp
+4 -4
View File
@@ -150,11 +150,11 @@ async fn check_recent_memories(
let sql = "SELECT COUNT(*) FROM unified_memory WHERE session_id = ?1 AND archived = 0";
let mut stmt = db
.prepare(sql)
.map_err(|e| format!("Prepare failed: {}", e))?;
.map_err(|e| format!("Prepare failed: {e}"))?;
let count: i64 = stmt
.query_row(params![session_id], |row| row.get(0))
.map_err(|e| format!("Query failed: {}", e))?;
.map_err(|e| format!("Query failed: {e}"))?;
Ok(CheckResult {
should_proceed: (count as usize) < limit,
@@ -184,14 +184,14 @@ async fn check_time_interval(
let sql = "SELECT MAX(created_at) FROM unified_memory WHERE session_id = ?1 AND archived = 0";
let mut stmt = db
.prepare(sql)
.map_err(|e| format!("Prepare failed: {}", e))?;
.map_err(|e| format!("Prepare failed: {e}"))?;
let max_time: Option<i64> = stmt.query_row(params![session_id], |row| row.get(0)).ok();
if let Some(last_time) = max_time {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| format!("Time error: {}", e))?
.map_err(|e| format!("Time error: {e}"))?
.as_secs() as i64;
let hours_since = (now - last_time) / 3600;
@@ -37,7 +37,7 @@ pub struct UnifiedMemory {
}
/// 记忆类型
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryType {
/// 从对话历史自动提取
@@ -47,7 +47,7 @@ pub enum MemoryType {
}
/// 记忆分类(参考 LobeHub 的 5 层架构)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MemoryCategory {
/// 身份信息:关于你是谁的稳定信息
+7 -7
View File
@@ -62,7 +62,7 @@ pub fn semantic_search(
AND archived = 0";
let sql = if let Some(_cat) = category {
format!("{} AND category = ?", sql)
format!("{sql} AND category = ?")
} else {
sql.to_string()
};
@@ -79,7 +79,7 @@ pub fn semantic_search(
};
while let Ok(Some(row)) = rows.next() {
let memory = parse_memory_from_row(&row)?;
let memory = parse_memory_from_row(row)?;
memories.push(memory);
}
@@ -131,14 +131,14 @@ fn parse_memory_from_row(
let archived: i64 = row.get(16)?;
// Parse JSON fields
let memory_type: crate::models::MemoryType = serde_json::from_str(&memory_type_json)
.map_err(|e| format!("Invalid memory type: {}", e))?;
let memory_type: crate::models::MemoryType =
serde_json::from_str(&memory_type_json).map_err(|e| format!("Invalid memory type: {e}"))?;
let category: crate::models::MemoryCategory =
serde_json::from_str(&category_json).map_err(|e| format!("Invalid category: {}", e))?;
serde_json::from_str(&category_json).map_err(|e| format!("Invalid category: {e}"))?;
let tags: Vec<String> =
serde_json::from_str(&tags_json).map_err(|e| format!("Invalid tags: {}", e))?;
serde_json::from_str(&tags_json).map_err(|e| format!("Invalid tags: {e}"))?;
let source: crate::models::MemorySource =
serde_json::from_str(&source_json).map_err(|e| format!("Invalid source: {}", e))?;
serde_json::from_str(&source_json).map_err(|e| format!("Invalid source: {e}"))?;
// Parse embedding from BLOB (f32 array)
let embedding = if let Some(blob) = embedding_blob {
@@ -47,8 +47,7 @@ impl OpenAICustomProvider {
&& url.contains("/api/anthropic")
{
eprintln!(
"[OPENAI_CUSTOM] 提示: URL '{}' 返回 {},疑似协议不匹配。若上游是 Anthropic 兼容网关,请改用 /v1/messages + x-api-key。",
url, status
"[OPENAI_CUSTOM] 提示: URL '{url}' 返回 {status},疑似协议不匹配。若上游是 Anthropic 兼容网关,请改用 /v1/messages + x-api-key。"
);
}
}
@@ -78,7 +78,7 @@ impl WorkflowService {
let mut workflows = self.workflows.write().await;
let workflow = workflows
.get_mut(workflow_id)
.ok_or_else(|| anyhow::anyhow!("工作流不存在: {}", workflow_id))?;
.ok_or_else(|| anyhow::anyhow!("工作流不存在: {workflow_id}"))?;
let current_index = workflow.current_step_index;
if current_index >= workflow.steps.len() {
@@ -113,7 +113,7 @@ impl WorkflowService {
let mut workflows = self.workflows.write().await;
let workflow = workflows
.get_mut(workflow_id)
.ok_or_else(|| anyhow::anyhow!("工作流不存在: {}", workflow_id))?;
.ok_or_else(|| anyhow::anyhow!("工作流不存在: {workflow_id}"))?;
let current_index = workflow.current_step_index;
if current_index >= workflow.steps.len() {
@@ -146,7 +146,7 @@ impl WorkflowService {
let mut workflows = self.workflows.write().await;
let workflow = workflows
.get_mut(workflow_id)
.ok_or_else(|| anyhow::anyhow!("工作流不存在: {}", workflow_id))?;
.ok_or_else(|| anyhow::anyhow!("工作流不存在: {workflow_id}"))?;
if step_index >= workflow.steps.len() {
return Err(anyhow::anyhow!("步骤索引无效"));
@@ -179,7 +179,7 @@ impl WorkflowService {
let mut workflows = self.workflows.write().await;
let workflow = workflows
.get_mut(workflow_id)
.ok_or_else(|| anyhow::anyhow!("工作流不存在: {}", workflow_id))?;
.ok_or_else(|| anyhow::anyhow!("工作流不存在: {workflow_id}"))?;
if step_index >= workflow.steps.len() {
return Err(anyhow::anyhow!("步骤索引无效"));
@@ -221,12 +221,11 @@ impl WorkflowService {
.enumerate()
.map(|(i, mut step)| {
// 根据模式调整可跳过性
if *mode == CreationMode::Fast {
if step.definition.step_type == StepType::Research
|| step.definition.step_type == StepType::Polish
{
step.definition.behavior.skippable = true;
}
if *mode == CreationMode::Fast
&& (step.definition.step_type == StepType::Research
|| step.definition.step_type == StepType::Polish)
{
step.definition.behavior.skippable = true;
}
// 第一个步骤设为 Active
@@ -39,6 +39,12 @@ const SUPPORTED_DOCUMENT_TYPES: &[&str] = &["pdf", "doc", "docx", "txt", "md", "
/// 支持的图片类型
const SUPPORTED_IMAGE_TYPES: &[&str] = &["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"];
/// 支持的音频类型
const SUPPORTED_AUDIO_TYPES: &[&str] = &["mp3", "wav", "aac", "m4a", "ogg", "flac"];
/// 支持的视频类型
const SUPPORTED_VIDEO_TYPES: &[&str] = &["mp4", "mov", "avi", "mkv", "webm", "flv"];
/// 支持的数据类型
const SUPPORTED_DATA_TYPES: &[&str] = &["csv", "json", "xml", "xlsx", "xls"];
@@ -289,7 +295,7 @@ impl MaterialService {
/// - 失败返回 MaterialError
///
/// # 注意
/// 对于二进制文件(如图片、PDF),返回文件描述而非内容。
/// 对于二进制文件(如图片、音视频、PDF),返回文件描述而非内容。
pub fn get_material_content(conn: &Connection, id: &str) -> Result<String, MaterialError> {
let material =
MaterialDao::get(conn, id)?.ok_or_else(|| MaterialError::NotFound(id.to_string()))?;
@@ -356,8 +362,7 @@ impl MaterialService {
// 验证源文件存在
if !source.exists() {
return Err(MaterialError::FileReadError(format!(
"文件不存在: {}",
source_path
"文件不存在: {source_path}"
)));
}
@@ -465,6 +470,8 @@ impl MaterialService {
let is_valid = match material_type {
"document" => SUPPORTED_DOCUMENT_TYPES.contains(&extension),
"image" => SUPPORTED_IMAGE_TYPES.contains(&extension),
"audio" => SUPPORTED_AUDIO_TYPES.contains(&extension),
"video" => SUPPORTED_VIDEO_TYPES.contains(&extension),
"data" => SUPPORTED_DATA_TYPES.contains(&extension),
"text" => extension == "txt" || extension == "md",
"link" => true, // 链接类型不需要文件
@@ -473,8 +480,7 @@ impl MaterialService {
if !is_valid {
return Err(MaterialError::UnsupportedFileType(format!(
".{} (类型: {})",
extension, material_type
".{extension} (类型: {material_type})"
)));
}
@@ -499,6 +505,20 @@ impl MaterialService {
"webp" => "image/webp",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
// 音频
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"aac" => "audio/aac",
"m4a" => "audio/mp4",
"ogg" => "audio/ogg",
"flac" => "audio/flac",
// 视频
"mp4" => "video/mp4",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
"mkv" => "video/x-matroska",
"webm" => "video/webm",
"flv" => "video/x-flv",
// 数据
"csv" => "text/csv",
"json" => "application/json",
@@ -558,6 +578,14 @@ impl MaterialService {
// 图片类型:返回描述
Ok(Self::format_material_description(material))
}
"audio" => {
// 音频类型:返回描述
Ok(Self::format_material_description(material))
}
"video" => {
// 视频类型:返回描述
Ok(Self::format_material_description(material))
}
"data" => {
// 数据类型:尝试读取 CSV/JSON
if let Some(ref file_path) = material.file_path {
@@ -587,9 +615,8 @@ impl MaterialService {
/// 读取文本文件
fn read_text_file(file_path: &str) -> Result<String, MaterialError> {
fs::read_to_string(file_path).map_err(|e| {
MaterialError::FileReadError(format!("读取文件失败: {} - {}", file_path, e))
})
fs::read_to_string(file_path)
.map_err(|e| MaterialError::FileReadError(format!("读取文件失败: {file_path} - {e}")))
}
/// 格式化素材描述
@@ -597,7 +624,7 @@ impl MaterialService {
let mut desc = format!("[素材: {}]", material.name);
if let Some(ref description) = material.description {
desc.push_str(&format!("\n描述: {}", description));
desc.push_str(&format!("\n描述: {description}"));
}
if !material.tags.is_empty() {
@@ -711,10 +738,10 @@ mod tests {
for i in 1..=3 {
let req = UploadMaterialRequest {
project_id: "project-1".to_string(),
name: format!("素材{}", i),
name: format!("素材{i}"),
material_type: "text".to_string(),
file_path: None,
content: Some(format!("内容{}", i)),
content: Some(format!("内容{i}")),
tags: None,
description: None,
};
@@ -731,11 +758,11 @@ mod tests {
create_test_project(&conn, "project-1");
// 创建不同类型的素材
let types = vec!["document", "image", "text"];
let types = ["document", "image", "text"];
for (i, t) in types.iter().enumerate() {
let req = UploadMaterialRequest {
project_id: "project-1".to_string(),
name: format!("素材{}", i),
name: format!("素材{i}"),
material_type: t.to_string(),
file_path: None,
content: Some("内容".to_string()),
@@ -881,10 +908,10 @@ mod tests {
for i in 1..=3 {
let req = UploadMaterialRequest {
project_id: "project-1".to_string(),
name: format!("素材{}", i),
name: format!("素材{i}"),
material_type: "text".to_string(),
file_path: None,
content: Some(format!("内容{}", i)),
content: Some(format!("内容{i}")),
tags: None,
description: None,
};
@@ -913,6 +940,16 @@ mod tests {
assert!(MaterialService::validate_file_type("png", "image").is_ok());
assert!(MaterialService::validate_file_type("pdf", "image").is_err());
// 音频类型
assert!(MaterialService::validate_file_type("mp3", "audio").is_ok());
assert!(MaterialService::validate_file_type("wav", "audio").is_ok());
assert!(MaterialService::validate_file_type("jpg", "audio").is_err());
// 视频类型
assert!(MaterialService::validate_file_type("mp4", "video").is_ok());
assert!(MaterialService::validate_file_type("webm", "video").is_ok());
assert!(MaterialService::validate_file_type("mp3", "video").is_err());
// 数据类型
assert!(MaterialService::validate_file_type("csv", "data").is_ok());
assert!(MaterialService::validate_file_type("json", "data").is_ok());
@@ -938,6 +975,14 @@ mod tests {
MaterialService::infer_mime_type("json"),
Some("application/json".to_string())
);
assert_eq!(
MaterialService::infer_mime_type("mp3"),
Some("audio/mpeg".to_string())
);
assert_eq!(
MaterialService::infer_mime_type("mp4"),
Some("video/mp4".to_string())
);
assert_eq!(MaterialService::infer_mime_type("unknown"), None);
}
@@ -1010,7 +1055,7 @@ mod tests {
for i in 1..=3 {
let req = UploadMaterialRequest {
project_id: "project-1".to_string(),
name: format!("素材{}", i),
name: format!("素材{i}"),
material_type: "text".to_string(),
file_path: None,
content: Some("内容".to_string()),
+1 -1
View File
@@ -29,7 +29,7 @@ impl McpService {
let servers = McpDao::get_all(&conn).map_err(|e| e.to_string())?;
Ok(servers
.iter()
.any(|s| s.name == name && exclude_id.map_or(true, |id| s.id != id)))
.any(|s| s.name == name && exclude_id.is_none_or(|id| s.id != id)))
}
/// 验证服务器配置
@@ -489,7 +489,7 @@ mod tests {
for i in 1..=2 {
let req = CreatePersonaRequest {
project_id: "project-1".to_string(),
name: format!("人设{}", i),
name: format!("人设{i}"),
description: None,
style: "测试".to_string(),
tone: None,
@@ -151,7 +151,7 @@ impl ProjectContextBuilder {
created_at, updated_at, icon, color, is_favorite, is_archived, tags_json
FROM workspaces WHERE id = ?",
rusqlite::params![project_id],
|row| Self::row_to_workspace(row),
Self::row_to_workspace,
);
match result {
@@ -273,7 +273,7 @@ impl ProjectContextBuilder {
// 添加描述
if let Some(ref desc) = persona.description {
lines.push(format!("描述: {}", desc));
lines.push(format!("描述: {desc}"));
}
// 添加写作风格
@@ -281,12 +281,12 @@ impl ProjectContextBuilder {
// 添加语气
if let Some(ref tone) = persona.tone {
lines.push(format!("语气: {}", tone));
lines.push(format!("语气: {tone}"));
}
// 添加目标读者
if let Some(ref audience) = persona.target_audience {
lines.push(format!("目标读者: {}", audience));
lines.push(format!("目标读者: {audience}"));
}
// 添加禁用词
@@ -339,7 +339,7 @@ impl ProjectContextBuilder {
// 添加描述
if let Some(ref desc) = material.description {
lines.push(format!("描述: {}", desc));
lines.push(format!("描述: {desc}"));
}
// 添加标签
@@ -350,7 +350,7 @@ impl ProjectContextBuilder {
// 添加内容摘要(仅文本类型)
if let Some(ref content) = material.content {
let summary = Self::truncate_content(content, 500);
lines.push(format!("内容:\n{}", summary));
lines.push(format!("内容:\n{summary}"));
}
lines.push(String::new());
@@ -364,6 +364,8 @@ impl ProjectContextBuilder {
match material_type {
"document" => "文档",
"image" => "图片",
"audio" => "语音",
"video" => "视频",
"text" => "文本",
"data" => "数据",
"link" => "链接",
@@ -377,7 +379,7 @@ impl ProjectContextBuilder {
content.to_string()
} else {
let truncated: String = content.chars().take(max_len).collect();
format!("{}...", truncated)
format!("{truncated}...")
}
}
@@ -397,17 +399,17 @@ impl ProjectContextBuilder {
// 添加标题风格
if let Some(ref title_style) = template.title_style {
lines.push(format!("**标题风格**: {}", title_style));
lines.push(format!("**标题风格**: {title_style}"));
}
// 添加段落风格
if let Some(ref paragraph_style) = template.paragraph_style {
lines.push(format!("**段落风格**: {}", paragraph_style));
lines.push(format!("**段落风格**: {paragraph_style}"));
}
// 添加结尾风格
if let Some(ref ending_style) = template.ending_style {
lines.push(format!("**结尾风格**: {}", ending_style));
lines.push(format!("**结尾风格**: {ending_style}"));
}
// 添加 Emoji 使用规则
@@ -417,16 +419,16 @@ impl ProjectContextBuilder {
"minimal" => "少量或不使用 emoji 表情,保持简洁",
_ => "适度使用 emoji 表情",
};
lines.push(format!("**Emoji 使用**: {}", emoji_desc));
lines.push(format!("**Emoji 使用**: {emoji_desc}"));
// 添加话题标签规则
if let Some(ref hashtag_rules) = template.hashtag_rules {
lines.push(format!("**话题标签**: {}", hashtag_rules));
lines.push(format!("**话题标签**: {hashtag_rules}"));
}
// 添加图片规则
if let Some(ref image_rules) = template.image_rules {
lines.push(format!("**配图建议**: {}", image_rules));
lines.push(format!("**配图建议**: {image_rules}"));
}
lines.join("\n")
@@ -726,6 +728,8 @@ mod tests {
"文档"
);
assert_eq!(ProjectContextBuilder::format_material_type("image"), "图片");
assert_eq!(ProjectContextBuilder::format_material_type("audio"), "语音");
assert_eq!(ProjectContextBuilder::format_material_type("video"), "视频");
assert_eq!(ProjectContextBuilder::format_material_type("text"), "文本");
assert_eq!(ProjectContextBuilder::format_material_type("data"), "数据");
assert_eq!(ProjectContextBuilder::format_material_type("link"), "链接");
@@ -259,10 +259,7 @@ impl ProviderPoolService {
client_type: Option<&proxycast_core::models::client_type::ClientType>,
) -> Result<Option<ProviderCredential>, String> {
if is_custom_provider_id(provider_type) {
eprintln!(
"[SELECT_CREDENTIAL] custom provider '{}' 使用智能降级路径",
provider_type
);
eprintln!("[SELECT_CREDENTIAL] custom provider '{provider_type}' 使用智能降级路径");
return Ok(None);
}
@@ -462,17 +459,12 @@ impl ProviderPoolService {
}
Ok(None) => {
eprintln!(
"[select_credential_with_fallback] custom provider '{}' 不存在,继续使用解析类型 {:?}",
custom_provider_id,
pt
"[select_credential_with_fallback] custom provider '{custom_provider_id}' 不存在,继续使用解析类型 {pt:?}"
);
}
Err(e) => {
eprintln!(
"[select_credential_with_fallback] 查询 custom provider '{}' 失败: {},继续使用解析类型 {:?}",
custom_provider_id,
e,
pt
"[select_credential_with_fallback] 查询 custom provider '{custom_provider_id}' 失败: {e},继续使用解析类型 {pt:?}"
);
}
}
+3 -3
View File
@@ -249,7 +249,7 @@ impl SwitchService {
// Step 2: 执行文件 I/O(在后台线程,不持有锁)
if ctx.app_type_enum != AppType::ProxyCast {
let current_for_backfill = ctx.current_provider.clone();
let app_type_for_sync = ctx.app_type_enum.clone();
let app_type_for_sync = ctx.app_type_enum;
let target_id = id.to_string();
// 使用 spawn_blocking 将文件 I/O 移到后台线程
@@ -293,7 +293,7 @@ impl SwitchService {
// Step 2b: 同步新配置(在后台线程)
let target_for_sync = ctx.target_provider.clone();
let current_for_restore = ctx.current_provider.clone();
let app_type_for_sync = ctx.app_type_enum.clone();
let app_type_for_sync = ctx.app_type_enum;
tokio::task::spawn_blocking(move || {
info!("验证目标配置可同步性");
@@ -333,7 +333,7 @@ impl SwitchService {
if let Some(ref current) = ctx.current_provider {
warn!("数据库更新失败,尝试恢复原配置文件");
let current_clone = current.clone();
let app_type_clone = ctx.app_type_enum.clone();
let app_type_clone = ctx.app_type_enum;
// 在后台线程恢复
let _ = tokio::task::spawn_blocking(move || {
if let Err(restore_error) =
@@ -298,7 +298,7 @@ mod tests {
for i in 1..=2 {
let req = CreateTemplateRequest {
project_id: "project-1".to_string(),
name: format!("模板{}", i),
name: format!("模板{i}"),
platform: "xiaohongshu".to_string(),
title_style: None,
paragraph_style: None,
@@ -79,7 +79,7 @@ mod xunfei {
println!(" 识别结果: {:?}", r.text);
}
Err(e) => {
panic!("❌ 讯飞连接失败: {:?}", e);
panic!("❌ 讯飞连接失败: {e:?}");
}
}
}
@@ -104,7 +104,7 @@ mod xunfei {
println!(" 语言: {:?}", r.language);
}
Err(e) => {
panic!("❌ 讯飞识别失败: {:?}", e);
panic!("❌ 讯飞识别失败: {e:?}");
}
}
}
@@ -140,7 +140,7 @@ mod xunfei {
// 仍然尝试发送,看服务端如何处理
let result = client.transcribe(&audio).await;
println!("短音频测试结果: {:?}", result);
println!("短音频测试结果: {result:?}");
}
#[tokio::test]
@@ -162,7 +162,7 @@ mod xunfei {
println!(" 识别结果: {:?}", r.text);
}
Err(e) => {
panic!("❌ 讯飞长音频测试失败: {:?}", e);
panic!("❌ 讯飞长音频测试失败: {e:?}");
}
}
}
@@ -196,7 +196,7 @@ mod baidu {
println!(" 识别结果: {:?}", r.text);
}
Err(e) => {
panic!("❌ 百度连接失败: {:?}", e);
panic!("❌ 百度连接失败: {e:?}");
}
}
}
@@ -233,7 +233,7 @@ mod openai {
println!(" 识别结果: {:?}", r.text);
}
Err(e) => {
panic!("❌ OpenAI 连接失败: {:?}", e);
panic!("❌ OpenAI 连接失败: {e:?}");
}
}
}
@@ -261,7 +261,7 @@ async fn test_all_configured_asr_services() {
println!("✅ 讯飞: 连接正常");
passed += 1;
}
Err(e) => println!("❌ 讯飞: {:?}", e),
Err(e) => println!("❌ 讯飞: {e:?}"),
}
} else {
println!("⏭️ 讯飞: 未配置");
@@ -280,7 +280,7 @@ async fn test_all_configured_asr_services() {
println!("✅ 百度: 连接正常");
passed += 1;
}
Err(e) => println!("❌ 百度: {:?}", e),
Err(e) => println!("❌ 百度: {e:?}"),
}
} else {
println!("⏭️ 百度: 未配置");
@@ -297,7 +297,7 @@ async fn test_all_configured_asr_services() {
println!("✅ OpenAI: 连接正常");
passed += 1;
}
Err(e) => println!("❌ OpenAI: {:?}", e),
Err(e) => println!("❌ OpenAI: {e:?}"),
}
} else {
println!("⏭️ OpenAI: 未配置");
@@ -307,7 +307,7 @@ async fn test_all_configured_asr_services() {
}
println!("\n========== 测试结果 ==========");
println!("测试: {}/{} 通过", passed, tested);
println!("测试: {passed}/{tested} 通过");
if tested > 0 {
assert_eq!(passed, tested, "部分 ASR 服务测试失败");
+14
View File
@@ -117,6 +117,20 @@ impl AsterAgentWrapper {
pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result<SessionDetail, String> {
proxycast_agent::session_store::get_session_sync(db, session_id)
}
/// 重命名会话
pub fn rename_session_sync(
db: &DbConnection,
session_id: &str,
name: &str,
) -> Result<(), String> {
proxycast_agent::session_store::rename_session_sync(db, session_id, name)
}
/// 删除会话
pub fn delete_session_sync(db: &DbConnection, session_id: &str) -> Result<(), String> {
proxycast_agent::session_store::delete_session_sync(db, session_id)
}
}
#[cfg(test)]
+8 -11
View File
@@ -78,6 +78,13 @@ pub struct AppStates {
pub shared_logger: Arc<telemetry::RequestLogger>,
}
type TelemetryInit = (
crate::commands::telemetry_cmd::TelemetryState,
Arc<parking_lot::RwLock<telemetry::StatsAggregator>>,
Arc<parking_lot::RwLock<telemetry::TokenTracker>>,
Arc<telemetry::RequestLogger>,
);
/// 初始化所有应用状态
pub fn init_states(config: &Config) -> Result<AppStates, String> {
// 核心状态
@@ -277,17 +284,7 @@ fn init_plugin_installer() -> Result<PluginInstallerState, String> {
}
/// 初始化遥测系统
fn init_telemetry(
config: &Config,
) -> Result<
(
crate::commands::telemetry_cmd::TelemetryState,
Arc<parking_lot::RwLock<telemetry::StatsAggregator>>,
Arc<parking_lot::RwLock<telemetry::TokenTracker>>,
Arc<telemetry::RequestLogger>,
),
String,
> {
fn init_telemetry(config: &Config) -> Result<TelemetryInit, String> {
let shared_stats = Arc::new(parking_lot::RwLock::new(
telemetry::StatsAggregator::with_defaults(),
));
+5
View File
@@ -961,6 +961,8 @@ pub fn run() {
commands::aster_agent_cmd::aster_session_create,
commands::aster_agent_cmd::aster_session_list,
commands::aster_agent_cmd::aster_session_get,
commands::aster_agent_cmd::aster_session_rename,
commands::aster_agent_cmd::aster_session_delete,
commands::aster_agent_cmd::aster_agent_confirm,
commands::aster_agent_cmd::aster_agent_submit_elicitation_response,
// Models config commands
@@ -1160,6 +1162,7 @@ pub fn run() {
commands::persona_cmd::list_brand_persona_templates,
// Material commands
commands::material_cmd::upload_material,
commands::material_cmd::import_material_from_url,
commands::material_cmd::list_materials,
commands::material_cmd::get_material,
commands::material_cmd::update_material,
@@ -1245,6 +1248,8 @@ pub fn run() {
commands::unified_memory_cmd::unified_memory_analyze,
commands::memory_search_cmd::unified_memory_semantic_search,
commands::memory_search_cmd::unified_memory_hybrid_search,
commands::memory_feedback_cmd::unified_memory_feedback,
commands::memory_feedback_cmd::get_memory_feedback_stats,
// Voice Test commands
commands::voice_test_cmd::test_tts,
commands::voice_test_cmd::get_available_voices,
+2
View File
@@ -21,6 +21,7 @@ use super::types::{AppState, LogState, TrayManagerState};
/// Tauri setup hook
///
/// 在应用启动时执行初始化逻辑
#[allow(clippy::too_many_arguments)]
pub fn setup_app(
app: &mut App,
state: AppState,
@@ -116,6 +117,7 @@ pub fn setup_app(
}
/// 异步启动服务器
#[allow(clippy::too_many_arguments)]
async fn start_server_async(
state: AppState,
logs: LogState,
+25 -16
View File
@@ -25,7 +25,7 @@ fn truncate_string(s: &str, max_chars: usize) -> String {
s.to_string()
} else {
let truncated: String = s.chars().take(max_chars).collect();
format!("{}...", truncated)
format!("{truncated}...")
}
}
@@ -74,7 +74,7 @@ pub async fn agent_start_process(
) -> Result<AgentProcessStatus, String> {
tracing::info!("[Agent] 初始化 Aster Agent");
let (host, port, running) = {
let (host, port, gateway_running) = {
let state = app_state.read().await;
(
state.config.server.host.clone(),
@@ -83,18 +83,18 @@ pub async fn agent_start_process(
)
};
if !running {
return Err("ProxyCast API Server 未运行,请先启动服务器".to_string());
}
agent_state.init_agent_with_db(&db).await?;
let base_url = format!("http://{host}:{port}");
let base_url = if gateway_running {
Some(format!("http://{host}:{port}"))
} else {
None
};
let exposed_port = if gateway_running { Some(port) } else { None };
Ok(AgentProcessStatus {
running: true,
base_url: Some(base_url),
port: Some(port),
base_url,
port: exposed_port,
})
}
@@ -116,14 +116,23 @@ pub async fn agent_get_process_status(
if initialized {
let state = app_state.read().await;
let base_url = format!(
"http://{}:{}",
state.config.server.host, state.config.server.port
);
let gateway_running = state.running;
let base_url = if gateway_running {
Some(format!(
"http://{}:{}",
state.config.server.host, state.config.server.port
))
} else {
None
};
Ok(AgentProcessStatus {
running: true,
base_url: Some(base_url),
port: Some(state.config.server.port),
base_url,
port: if gateway_running {
Some(state.config.server.port)
} else {
None
},
})
} else {
Ok(AgentProcessStatus {
+25 -6
View File
@@ -235,7 +235,7 @@ impl WorkspaceSandboxedBashTool {
}
let sandbox_type = detect_best_sandbox();
let sandbox_type_name = format!("{:?}", sandbox_type);
let sandbox_type_name = format!("{sandbox_type:?}");
if sandbox_type_name == "None" {
return Err(
"未检测到可用本地 sandbox 执行器(macOS 需 sandbox-exec,Linux 需 bwrap/firejail)"
@@ -371,7 +371,7 @@ impl WorkspaceSandboxedBashTool {
}
if exit_code != 0 && output.is_empty() {
output = format!("Command exited with code {}", exit_code);
output = format!("Command exited with code {exit_code}");
}
if output.len() <= MAX_OUTPUT_LENGTH {
@@ -623,8 +623,7 @@ async fn apply_workspace_sandbox_permissions(
];
let allow_shell_pattern = format!(
r"^\s*(?:cd\s+({}|\.|\./|\.\./)|pwd|ls(?:\s+[^;&|]+)?|find\s+({}|\.|\./|\.\./)[^;&|]*|rg\b[^;&|]*|grep\b[^;&|]*|cat\s+({}|\.|\./|\.\./)[^;&|]*)\s*$",
escaped_root, escaped_root, escaped_root
r"^\s*(?:cd\s+({escaped_root}|\.|\./|\.\./)|pwd|ls(?:\s+[^;&|]+)?|find\s+({escaped_root}|\.|\./|\.\./)[^;&|]*|rg\b[^;&|]*|grep\b[^;&|]*|cat\s+({escaped_root}|\.|\./|\.\./)[^;&|]*)\s*$"
);
permissions.push(ToolPermission {
@@ -905,8 +904,7 @@ pub async fn aster_agent_chat_stream(
workspace_root
);
return Err(format!(
"workspace_mismatch|会话工作目录与 workspace 不匹配: session={}, workspace={}",
session_dir, workspace_root
"workspace_mismatch|会话工作目录与 workspace 不匹配: session={session_dir}, workspace={workspace_root}"
));
}
}
@@ -1149,6 +1147,27 @@ pub async fn aster_session_get(
AsterAgentWrapper::get_session_sync(&db, &session_id)
}
/// 重命名会话
#[tauri::command]
pub async fn aster_session_rename(
db: State<'_, DbConnection>,
session_id: String,
name: String,
) -> Result<(), String> {
tracing::info!("[AsterAgent] 重命名会话: {}", session_id);
AsterAgentWrapper::rename_session_sync(&db, &session_id, &name)
}
/// 删除会话
#[tauri::command]
pub async fn aster_session_delete(
db: State<'_, DbConnection>,
session_id: String,
) -> Result<(), String> {
tracing::info!("[AsterAgent] 删除会话: {}", session_id);
AsterAgentWrapper::delete_session_sync(&db, &session_id)
}
/// 确认权限请求
#[derive(Debug, Deserialize)]
pub struct ConfirmRequest {
@@ -64,7 +64,7 @@ pub async fn execute_ecommerce_review_reply(
request
.template
.as_ref()
.map(|t| format!("\n自定义模板: {}", t))
.map(|t| format!("\n自定义模板: {t}"))
.unwrap_or_default()
);
+4 -18
View File
@@ -8,7 +8,7 @@ use std::process::Stdio;
use tokio::process::Command;
/// Codex CLI 状态
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CodexCliStatus {
/// CLI 是否已安装
pub installed: bool,
@@ -24,19 +24,6 @@ pub struct CodexCliStatus {
pub error: Option<String>,
}
impl Default for CodexCliStatus {
fn default() -> Self {
Self {
installed: false,
version: None,
logged_in: false,
auth_type: None,
api_key_prefix: None,
error: None,
}
}
}
/// 检查 Codex CLI 状态
#[tauri::command]
pub async fn check_codex_cli_status() -> Result<CodexCliStatus, String> {
@@ -64,8 +51,7 @@ pub async fn check_codex_cli_status() -> Result<CodexCliStatus, String> {
}
Err(e) => {
status.error = Some(format!(
"Codex CLI 未安装。请运行: npm i -g @openai/codex\n错误: {}",
e
"Codex CLI 未安装。请运行: npm i -g @openai/codex\n错误: {e}"
));
return Ok(status);
}
@@ -83,7 +69,7 @@ pub async fn check_codex_cli_status() -> Result<CodexCliStatus, String> {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{}{}", stdout, stderr);
let combined = format!("{stdout}{stderr}");
tracing::debug!("[CodexCli] login status output: {}", combined);
@@ -96,7 +82,7 @@ pub async fn check_codex_cli_status() -> Result<CodexCliStatus, String> {
if combined.contains("API key") || combined.contains("api key") {
status.auth_type = Some("api_key".to_string());
// 提取 API Key 前缀
if let Some(key_part) = combined.split('-').last() {
if let Some(key_part) = combined.split('-').next_back() {
let key = key_part.trim();
if !key.is_empty() {
status.api_key_prefix = Some(key.to_string());
+10 -11
View File
@@ -24,19 +24,18 @@ pub async fn upload_avatar(file_path: String, app: AppHandle) -> Result<UploadRe
// 验证文件是否存在
if !source_path.exists() {
return Err(format!("文件不存在: {}", file_path));
return Err(format!("文件不存在: {file_path}"));
}
// 验证文件大小(限制 5MB)
let file_size = std::fs::metadata(&source_path)
.map_err(|e| format!("无法读取文件元数据: {}", e))?
.map_err(|e| format!("无法读取文件元数据: {e}"))?
.len();
const MAX_SIZE: u64 = 5 * 1024 * 1024; // 5MB
if file_size > MAX_SIZE {
return Err(format!(
"文件过大: {} bytes (最大 {} bytes)",
file_size, MAX_SIZE
"文件过大: {file_size} bytes (最大 {MAX_SIZE} bytes)"
));
}
@@ -47,19 +46,19 @@ pub async fn upload_avatar(file_path: String, app: AppHandle) -> Result<UploadRe
.unwrap_or("");
if !["jpg", "jpeg", "png", "gif", "webp"].contains(&extension.to_lowercase().as_str()) {
return Err(format!("不支持的文件类型: {}", extension));
return Err(format!("不支持的文件类型: {extension}"));
}
// 获取资源目录
let resource_dir = app
.path()
.resource_dir()
.map_err(|e| format!("无法获取资源目录: {}", e))?;
.map_err(|e| format!("无法获取资源目录: {e}"))?;
let avatars_dir = resource_dir.join("resources/avatars");
// 创建目录(如果不存在)
std::fs::create_dir_all(&avatars_dir).map_err(|e| format!("无法创建头像目录: {}", e))?;
std::fs::create_dir_all(&avatars_dir).map_err(|e| format!("无法创建头像目录: {e}"))?;
// 生成唯一文件名
let file_name = format!(
@@ -71,12 +70,12 @@ pub async fn upload_avatar(file_path: String, app: AppHandle) -> Result<UploadRe
let dest_path = avatars_dir.join(&file_name);
// 复制文件
std::fs::copy(&source_path, &dest_path).map_err(|e| format!("无法复制文件: {}", e))?;
std::fs::copy(&source_path, &dest_path).map_err(|e| format!("无法复制文件: {e}"))?;
tracing::info!("[文件上传] 头像已保存: {:?}", dest_path);
// 返回相对路径作为 URL
let url = format!("resources/avatars/{}", file_name);
let url = format!("resources/avatars/{file_name}");
Ok(UploadResult {
url,
@@ -93,13 +92,13 @@ pub async fn delete_avatar(url: String, app: AppHandle) -> Result<(), String> {
let resource_dir = app
.path()
.resource_dir()
.map_err(|e| format!("无法获取资源目录: {}", e))?;
.map_err(|e| format!("无法获取资源目录: {e}"))?;
let file_path = resource_dir.join(&url);
// 删除文件
if file_path.exists() {
std::fs::remove_file(&file_path).map_err(|e| format!("无法删除文件: {}", e))?;
std::fs::remove_file(&file_path).map_err(|e| format!("无法删除文件: {e}"))?;
tracing::info!("[文件上传] 头像已删除: {:?}", file_path);
}
+5 -7
View File
@@ -356,13 +356,11 @@ pub async fn get_local_kiro_credential_uuid(
let refresh_token = creds.get("refreshToken").and_then(|v| v.as_str());
// 比较 token
let matches = match (local_access_token, access_token) {
(Some(l), Some(r)) if l == r => true,
_ => match (local_refresh_token, refresh_token) {
(Some(l), Some(r)) if l == r => true,
_ => false,
},
};
let matches = matches!((local_access_token, access_token), (Some(l), Some(r)) if l == r)
|| matches!(
(local_refresh_token, refresh_token),
(Some(l), Some(r)) if l == r
);
if matches {
return Ok(Some(cred_display.uuid.clone()));
+378
View File
@@ -12,7 +12,15 @@
//! - Requirements 7.5: 素材预览
//! - Requirements 7.6: 素材删除
use base64::Engine;
use reqwest::header::CONTENT_TYPE;
use serde::Deserialize;
use std::fs;
use std::path::Path;
use tauri::State;
use tracing::warn;
use url::Url;
use uuid::Uuid;
use crate::database::DbConnection;
use crate::models::project_model::{
@@ -20,6 +28,240 @@ use crate::models::project_model::{
};
use proxycast_services::material_service::MaterialService;
const IMPORT_MAX_FILE_SIZE: usize = 50 * 1024 * 1024;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportMaterialFromUrlRequest {
pub project_id: String,
pub name: String,
#[serde(rename = "type")]
pub material_type: String,
pub url: String,
pub tags: Option<Vec<String>>,
pub description: Option<String>,
}
fn sanitize_extension(extension: &str) -> Option<String> {
let normalized = extension.trim().trim_start_matches('.').to_lowercase();
if normalized.is_empty() || normalized.len() > 12 {
return None;
}
if normalized.chars().all(|c| c.is_ascii_alphanumeric()) {
Some(normalized)
} else {
None
}
}
fn extension_from_name(name: &str) -> Option<String> {
Path::new(name)
.extension()
.and_then(|value| value.to_str())
.and_then(sanitize_extension)
}
fn extension_from_url(raw_url: &str) -> Option<String> {
let parsed = Url::parse(raw_url).ok()?;
let filename = parsed.path_segments()?.next_back()?;
if filename.is_empty() {
return None;
}
let extension = filename.rsplit_once('.')?.1;
sanitize_extension(extension)
}
fn extension_from_mime(mime_type: &str) -> Option<&'static str> {
match mime_type {
"image/jpeg" => Some("jpg"),
"image/png" => Some("png"),
"image/gif" => Some("gif"),
"image/webp" => Some("webp"),
"image/svg+xml" => Some("svg"),
"image/bmp" => Some("bmp"),
"audio/mpeg" | "audio/mp3" => Some("mp3"),
"audio/wav" => Some("wav"),
"audio/aac" => Some("aac"),
"audio/ogg" => Some("ogg"),
"audio/flac" => Some("flac"),
"video/mp4" => Some("mp4"),
"video/webm" => Some("webm"),
"video/quicktime" => Some("mov"),
"application/pdf" => Some("pdf"),
"application/json" => Some("json"),
"text/plain" => Some("txt"),
"text/markdown" => Some("md"),
_ => None,
}
}
fn default_extension_by_material_type(material_type: &str) -> &'static str {
match material_type {
"image" => "png",
"audio" => "mp3",
"video" => "mp4",
"data" => "json",
"text" => "txt",
"document" => "txt",
_ => "png",
}
}
fn resolve_import_extension(
request: &ImportMaterialFromUrlRequest,
mime_type: Option<&str>,
normalized_material_type: &str,
) -> String {
extension_from_name(&request.name)
.or_else(|| extension_from_url(&request.url))
.or_else(|| {
mime_type
.and_then(extension_from_mime)
.map(|value| value.to_string())
})
.unwrap_or_else(|| default_extension_by_material_type(normalized_material_type).to_string())
}
fn normalize_material_name(
raw_name: &str,
raw_url: &str,
material_type: &str,
extension: &str,
) -> String {
let trimmed = raw_name.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
if let Some(name_from_url) = Url::parse(raw_url)
.ok()
.and_then(|url| {
url.path_segments()
.and_then(|mut segments| segments.next_back().map(|value| value.to_string()))
})
.map(|value| value.trim().to_string())
.filter(|name| !name.is_empty())
{
return name_from_url;
}
let prefix = match material_type {
"image" => "导入图片",
"audio" => "导入语音",
"video" => "导入视频",
"data" => "导入数据",
"text" => "导入文本",
_ => "导入素材",
};
format!("{prefix}.{extension}")
}
fn decode_data_url(raw_url: &str) -> Result<(Vec<u8>, Option<String>), String> {
let (header, payload) = raw_url
.split_once(',')
.ok_or_else(|| "data URL 格式不正确".to_string())?;
if !header.starts_with("data:") {
return Err("不支持的 URL 协议,仅支持 http(s) 或 data URL".to_string());
}
let meta = &header[5..];
let mut mime_type: Option<String> = None;
let mut is_base64 = false;
if !meta.is_empty() {
let mut segments = meta.split(';');
if let Some(first) = segments.next() {
let first_trimmed = first.trim();
if !first_trimmed.is_empty() {
mime_type = Some(first_trimmed.to_lowercase());
}
}
is_base64 = segments.any(|segment| segment.eq_ignore_ascii_case("base64"));
}
if !is_base64 {
return Err("暂不支持非 base64 的 data URL".to_string());
}
let decoded = base64::engine::general_purpose::STANDARD
.decode(payload.trim())
.map_err(|e| format!("data URL 解码失败: {e}"))?;
if decoded.len() > IMPORT_MAX_FILE_SIZE {
return Err(format!(
"文件过大: {} bytes (最大 {} bytes)",
decoded.len(),
IMPORT_MAX_FILE_SIZE
));
}
Ok((decoded, mime_type))
}
async fn download_remote_file(raw_url: &str) -> Result<(Vec<u8>, Option<String>), String> {
let parsed_url = Url::parse(raw_url).map_err(|e| format!("URL 无效: {e}"))?;
let scheme = parsed_url.scheme().to_lowercase();
if scheme != "http" && scheme != "https" {
return Err("仅支持 http(s) 协议".to_string());
}
let response = reqwest::get(parsed_url)
.await
.map_err(|e| format!("下载失败: {e}"))?;
if !response.status().is_success() {
return Err(format!("下载失败: HTTP {}", response.status()));
}
if let Some(content_length) = response.content_length() {
if content_length > IMPORT_MAX_FILE_SIZE as u64 {
return Err(format!(
"文件过大: {content_length} bytes (最大 {IMPORT_MAX_FILE_SIZE} bytes)"
));
}
}
let mime_type = response
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(';').next())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_lowercase());
let bytes = response
.bytes()
.await
.map_err(|e| format!("读取下载内容失败: {e}"))?;
if bytes.len() > IMPORT_MAX_FILE_SIZE {
return Err(format!(
"文件过大: {} bytes (最大 {} bytes)",
bytes.len(),
IMPORT_MAX_FILE_SIZE
));
}
Ok((bytes.to_vec(), mime_type))
}
async fn load_material_bytes(raw_url: &str) -> Result<(Vec<u8>, Option<String>), String> {
if raw_url.starts_with("data:") {
decode_data_url(raw_url)
} else {
download_remote_file(raw_url).await
}
}
fn create_temp_file(bytes: &[u8], extension: &str) -> Result<String, String> {
let file_name = format!("proxycast-material-{}.{}", Uuid::new_v4(), extension);
let file_path = std::env::temp_dir().join(file_name);
fs::write(&file_path, bytes).map_err(|e| format!("写入临时文件失败: {e}"))?;
Ok(file_path.to_string_lossy().to_string())
}
// ============================================================================
// Tauri 命令
// ============================================================================
@@ -58,6 +300,73 @@ pub async fn upload_material(
MaterialService::upload_material(&conn, req).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn import_material_from_url(
db: State<'_, DbConnection>,
req: ImportMaterialFromUrlRequest,
) -> Result<Material, String> {
let normalized_material_type = req.material_type.trim().to_lowercase();
if normalized_material_type.is_empty() {
return Err("素材类型不能为空".to_string());
}
let normalized_url = req.url.trim();
if normalized_url.is_empty() {
return Err("URL 不能为空".to_string());
}
if normalized_material_type == "link" {
let upload_req = UploadMaterialRequest {
project_id: req.project_id,
name: normalize_material_name(&req.name, normalized_url, "link", "txt"),
material_type: normalized_material_type,
file_path: None,
content: Some(normalized_url.to_string()),
tags: req.tags,
description: req.description,
};
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
return MaterialService::upload_material(&conn, upload_req).map_err(|e| e.to_string());
}
let (bytes, mime_type) = load_material_bytes(normalized_url).await?;
let extension = resolve_import_extension(
&req,
mime_type.as_deref(),
normalized_material_type.as_str(),
);
let temp_file_path = create_temp_file(&bytes, &extension)?;
let upload_req = UploadMaterialRequest {
project_id: req.project_id,
name: normalize_material_name(
&req.name,
normalized_url,
normalized_material_type.as_str(),
&extension,
),
material_type: normalized_material_type,
file_path: Some(temp_file_path.clone()),
content: None,
tags: req.tags,
description: req.description,
};
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let result = MaterialService::upload_material(&conn, upload_req).map_err(|e| e.to_string());
if let Err(err) = fs::remove_file(&temp_file_path) {
warn!(
path = %temp_file_path,
error = %err,
"导入素材后删除临时文件失败"
);
}
result
}
/// 获取项目的素材列表
///
/// 获取指定项目下的所有素材,支持按类型、标签和关键词筛选。
@@ -274,3 +583,72 @@ pub async fn get_materials_content(
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
MaterialService::get_materials_content(&conn, &project_id).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn make_import_request(
name: &str,
url: &str,
material_type: &str,
) -> ImportMaterialFromUrlRequest {
ImportMaterialFromUrlRequest {
project_id: "project-1".to_string(),
name: name.to_string(),
material_type: material_type.to_string(),
url: url.to_string(),
tags: None,
description: None,
}
}
#[test]
fn test_decode_data_url_success() {
let raw = "data:text/plain;base64,aGVsbG8=";
let (bytes, mime_type) = decode_data_url(raw).expect("应成功解析 data URL");
assert_eq!(bytes, b"hello");
assert_eq!(mime_type.as_deref(), Some("text/plain"));
}
#[test]
fn test_decode_data_url_non_base64_should_fail() {
let raw = "data:text/plain,hello";
let error = decode_data_url(raw).expect_err("非 base64 data URL 应失败");
assert!(error.contains("非 base64"));
}
#[test]
fn test_resolve_import_extension_priority() {
let req_with_name = make_import_request("my-file.webp", "https://a.test/b/c.png", "image");
assert_eq!(
resolve_import_extension(&req_with_name, Some("image/jpeg"), "image"),
"webp"
);
let req_with_url = make_import_request("", "https://a.test/b/c.jpeg", "image");
assert_eq!(
resolve_import_extension(&req_with_url, Some("image/png"), "image"),
"jpeg"
);
let req_with_mime = make_import_request("", "https://a.test/download", "image");
assert_eq!(
resolve_import_extension(&req_with_mime, Some("image/png"), "image"),
"png"
);
}
#[test]
fn test_normalize_material_name_fallback() {
let from_name =
normalize_material_name(" 已命名.png ", "https://a.test/x/y.png", "image", "png");
assert_eq!(from_name, "已命名.png");
let from_url = normalize_material_name("", "https://a.test/x/y.png", "image", "png");
assert_eq!(from_url, "y.png");
let fallback = normalize_material_name("", "invalid-url", "audio", "mp3");
assert_eq!(fallback, "导入语音.mp3");
}
}
+1 -1
View File
@@ -182,7 +182,7 @@ pub async fn mcp_start_server(
let server = servers
.iter()
.find(|s| s.name == name)
.ok_or_else(|| format!("服务器配置不存在: {}", name))?;
.ok_or_else(|| format!("服务器配置不存在: {name}"))?;
// 2. 解析服务器配置
let config = parse_server_config(&server.server_config);
@@ -29,7 +29,7 @@ pub async fn unified_memory_feedback(
};
let conn = db.lock().unwrap();
record_feedback(&*conn, &feedback)?;
record_feedback(&conn, &feedback)?;
Ok(())
}
@@ -40,7 +40,7 @@ pub async fn get_memory_feedback_stats(
session_id: String,
) -> Result<FeedbackStats, String> {
let conn = db.lock().unwrap();
let feedbacks = get_recent_feedbacks(&*conn, &session_id, 50)?;
let feedbacks = get_recent_feedbacks(&conn, &session_id, 50)?;
let approval_rate = calculate_approval_rate(&feedbacks);
let total = feedbacks.len();
@@ -458,7 +458,7 @@ fn build_markdown_entry(
let category = infer_category(file_type, &tags, title, &summary);
Some(MemoryEntryPreview {
id: format!("{}:{}:{}", session_id, file_type, index),
id: format!("{session_id}:{file_type}:{index}"),
session_id: session_id.to_string(),
file_type: file_type.to_string(),
category,
@@ -553,7 +553,7 @@ fn parse_error_entries(session_id: &str, content: &str) -> Vec<MemoryEntryPrevie
MemoryEntryPreview {
id: if record.id.is_empty() {
format!("{}:error_log:{}", session_id, index)
format!("{session_id}:error_log:{index}")
} else {
record.id
},
@@ -592,7 +592,7 @@ fn infer_category(file_type: &str, tags: &[String], title: &str, summary: &str)
}
}
let text = format!("{} {}", title, summary).to_lowercase();
let text = format!("{title} {summary}").to_lowercase();
if contains_any(&text, &["我是", "我叫", "my name", "i am", "身份", "职业"]) {
return "identity".to_string();
@@ -700,7 +700,7 @@ fn truncate_text(input: &str, max_chars: usize) -> String {
let mut chars = input.chars();
let prefix: String = chars.by_ref().take(max_chars).collect();
if chars.next().is_some() {
format!("{}…", prefix)
format!("{prefix}…")
} else {
prefix
}
@@ -920,7 +920,7 @@ fn build_fingerprint(content: &str) -> String {
.filter(|ch| !ch.is_whitespace())
.take(120)
.collect::<String>();
format!("fp:{}", compact)
format!("fp:{compact}")
}
fn is_duplicate_memory(existing_entries: &[MemoryEntry], fingerprint: &str, summary: &str) -> bool {
+24 -23
View File
@@ -138,7 +138,7 @@ pub async fn unified_memory_semantic_search(
"No available OpenAI credential. Please add OpenAI API Key in settings.",
))
}
Err(e) => return Err(format!("Failed to get credential: {}", e)),
Err(e) => return Err(format!("Failed to get credential: {e}")),
};
let api_key = match credential.credential {
@@ -158,17 +158,17 @@ pub async fn unified_memory_semantic_search(
let query_embedding = proxycast_embedding::get_embedding(&options.query, &api_key, None)
.await
.map_err(|e| format!("Failed to get embedding: {}", e))?;
.map_err(|e| format!("Failed to get embedding: {e}"))?;
let results = {
let conn = db.lock().unwrap();
search::semantic_search(
&*conn,
&conn,
&query_embedding,
options.category.as_ref(),
options.min_similarity,
)
.map_err(|e| format!("Semantic search failed: {}", e).to_string())
.map_err(|e| format!("Semantic search failed: {e}").to_string())
}?;
tracing::info!("[Semantic Search] Returning {} results", results.len());
@@ -210,7 +210,7 @@ pub async fn unified_memory_hybrid_search(
"No available OpenAI credential. Please add OpenAI API Key in settings.",
))
}
Err(e) => return Err(format!("Failed to get credential: {}", e)),
Err(e) => return Err(format!("Failed to get credential: {e}")),
};
// Extract API key from credential
@@ -234,7 +234,7 @@ pub async fn unified_memory_hybrid_search(
// Get query embedding
let query_embedding = proxycast_embedding::get_embedding(&options.query, &api_key, None)
.await
.map_err(|e| format!("Failed to get embedding: {}", e))?;
.map_err(|e| format!("Failed to get embedding: {e}"))?;
// Calculate keyword weight (1.0 - semantic_weight)
let keyword_weight = 1.0 - options.semantic_weight;
@@ -248,12 +248,12 @@ pub async fn unified_memory_hybrid_search(
let semantic_results = {
let conn = db.lock().unwrap();
search::semantic_search(
&*conn,
&conn,
&query_embedding,
options.category.as_ref(),
options.min_similarity,
)
.map_err(|e| format!("Hybrid semantic search failed: {}", e).to_string())
.map_err(|e| format!("Hybrid semantic search failed: {e}").to_string())
}?;
tracing::info!(
@@ -265,25 +265,25 @@ pub async fn unified_memory_hybrid_search(
let keyword_results: Vec<UnifiedMemory> = {
let conn = db.lock().unwrap();
let query_clean = options.query.replace('%', "\\%").replace('_', "\\_");
let search_pattern = format!("%{}%", query_clean);
let search_pattern = format!("%{query_clean}%");
let limit = options.limit.unwrap_or(50) as i64;
let sql = "SELECT id, session_id, memory_type, category, title, content, summary, tags, confidence, importance, access_count, last_accessed_at, source, created_at, updated_at, archived FROM unified_memory WHERE archived = 0 AND (title LIKE ?1 OR summary LIKE ?1) ORDER BY updated_at DESC LIMIT ?";
let mut stmt = conn.prepare(&sql)
.map_err(|e| format!("Failed to prepare statement: {}", e))?;
let mut stmt = conn.prepare(sql)
.map_err(|e| format!("Failed to prepare statement: {e}"))?;
let memories = stmt
.query_map(params![search_pattern, limit], |row| {
parse_memory_row(row)
})
.map_err(|e| format!("Query execution failed: {}", e))?
.map_err(|e| format!("Query execution failed: {e}"))?
.collect::<Result<Vec<_>, rusqlite::Error>>()
.map_err(|e| format!("Result collection failed: {}", e))?;
.map_err(|e| format!("Result collection failed: {e}"))?;
tracing::info!("[Hybrid Search] Keyword: {} results", memories.len());
Ok(memories)
}.map_err(|e: std::io::Error| format!("Hybrid keyword search failed: {}", e).to_string())?;
}.map_err(|e: std::io::Error| format!("Hybrid keyword search failed: {e}").to_string())?;
// Merge and deduplicate results
let mut merged = std::collections::HashMap::new();
@@ -291,20 +291,21 @@ pub async fn unified_memory_hybrid_search(
// Add semantic results with weighted scores
for memory in semantic_results {
let id = memory.id.clone();
if !merged.contains_key(&id) {
merged.insert(id, (memory, options.semantic_weight));
if let std::collections::hash_map::Entry::Vacant(e) = merged.entry(id) {
e.insert((memory, options.semantic_weight));
}
}
// Add keyword results with weighted scores
for memory in keyword_results {
let id = memory.id.clone();
if !merged.contains_key(&id) {
merged.insert(id, (memory, keyword_weight));
} else {
// Memory already in semantic results, add keyword weight to existing score
if let Some((existing_mem, existing_score)) = merged.get_mut(&id) {
*existing_score += keyword_weight;
match merged.entry(id) {
std::collections::hash_map::Entry::Vacant(e) => {
e.insert((memory, keyword_weight));
}
std::collections::hash_map::Entry::Occupied(mut e) => {
// Memory already in semantic results, add keyword weight to existing score
e.get_mut().1 += keyword_weight;
}
}
}
@@ -312,7 +313,7 @@ pub async fn unified_memory_hybrid_search(
// Convert to Vec and sort by combined score
let mut results: Vec<(UnifiedMemory, f32)> = merged
.into_iter()
.map(|(id, (memory, score))| (memory, score))
.map(|(_, (memory, score))| (memory, score))
.collect();
// Sort by combined score (descending)
+1 -1
View File
@@ -349,7 +349,7 @@ pub async fn generate_persona(
3. 偏好词是创作时优先使用的词汇
4. 直接返回 JSON,不要任何额外文字"#;
let user_prompt = format!("{}\n\n请为以下描述生成人设配置:{}", system_prompt, prompt);
let user_prompt = format!("{system_prompt}\n\n请为以下描述生成人设配置:{prompt}");
let cancel_token = agent_state.create_cancel_token(&session_id).await;
+5 -7
View File
@@ -149,6 +149,7 @@ pub struct SkillExecutionResult {
/// - 3.3: 使用 Aster Agent 执行(支持工具调用)
/// - 3.5: 返回 SkillExecutionResult
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn execute_skill(
app_handle: tauri::AppHandle,
db: State<'_, DbConnection>,
@@ -180,7 +181,7 @@ pub async fn execute_skill(
if skill.disable_model_invocation {
return Err(format_skill_error(
SKILL_ERR_EXECUTE_FAILED,
format!("Skill '{}' 已禁用模型调用,无法执行", skill_name),
format!("Skill '{skill_name}' 已禁用模型调用,无法执行"),
));
}
@@ -329,7 +330,7 @@ async fn execute_skill_prompt(
let mut final_output = String::new();
let mut has_error = false;
let mut error_message: Option<String> = None;
let event_name = format!("skill-exec-{}", execution_id);
let event_name = format!("skill-exec-{execution_id}");
match stream_result {
Ok(mut stream) => {
@@ -423,7 +424,7 @@ async fn execute_skill_workflow(
) -> Result<SkillExecutionResult, String> {
let steps = &skill.workflow_steps;
let total_steps = steps.len();
let event_name = format!("skill-exec-{}", execution_id);
let event_name = format!("skill-exec-{execution_id}");
let mut steps_completed = Vec::new();
let mut accumulated_context = user_input.to_string();
let mut final_output = String::new();
@@ -463,10 +464,7 @@ async fn execute_skill_workflow(
let step_input = if idx == 0 {
accumulated_context.clone()
} else {
format!(
"原始需求:{}\n\n前序步骤输出:\n{}",
user_input, accumulated_context
)
format!("原始需求:{user_input}\n\n前序步骤输出:\n{accumulated_context}")
};
let user_message = Message::user().with_text(&step_input);
+3 -3
View File
@@ -1043,7 +1043,7 @@ fn infer_importance(candidate: &MemorySourceCandidate) -> u8 {
}
fn infer_category_from_text(title: &str, summary: &str, content: &str) -> MemoryCategory {
let combined = format!("{} {} {}", title, summary, content).to_lowercase();
let combined = format!("{title} {summary} {content}").to_lowercase();
if contains_any(&combined, &["我是", "我叫", "my name", "i am", "身份"]) {
return MemoryCategory::Identity;
@@ -1105,7 +1105,7 @@ fn is_duplicate(
fn build_fingerprint(content: &str) -> String {
let normalized = normalize_text(content);
let compact = normalized.chars().take(120).collect::<String>();
format!("fp:{}", compact)
format!("fp:{compact}")
}
fn normalize_tags(tags: Vec<String>) -> Vec<String> {
@@ -1148,7 +1148,7 @@ fn truncate_text(input: &str, max_chars: usize) -> String {
let mut chars = input.chars();
let prefix: String = chars.by_ref().take(max_chars).collect();
if chars.next().is_some() {
format!("{}…", prefix)
format!("{prefix}…")
} else {
prefix
}
+3 -3
View File
@@ -19,7 +19,7 @@ pub async fn get_usage_stats(
) -> Result<UsageStatsResponse, String> {
tracing::info!("[使用统计] 获取统计数据,时间范围: {}", time_range);
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?;
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
conversation_statistics_service::get_usage_stats_from_db(&time_range, &conn)
}
@@ -32,7 +32,7 @@ pub async fn get_model_usage_ranking(
) -> Result<Vec<ModelUsage>, String> {
tracing::info!("[使用统计] 获取模型使用排行,时间范围: {}", time_range);
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?;
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
conversation_statistics_service::get_model_usage_ranking_from_db(&time_range, &conn)
}
@@ -45,7 +45,7 @@ pub async fn get_daily_usage_trends(
) -> Result<Vec<DailyUsage>, String> {
tracing::info!("[使用统计] 获取每日使用趋势,时间范围: {}", time_range);
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?;
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
conversation_statistics_service::get_daily_usage_trends_from_db(&time_range, &conn)
}
+1 -1
View File
@@ -55,7 +55,7 @@ pub async fn test_tts(
_ => {
return Ok(TtsTestResult {
success: false,
error: Some(format!("不支持的 TTS 服务: {}", service)),
error: Some(format!("不支持的 TTS 服务: {service}")),
audio_path: None,
});
}
+1
View File
@@ -2178,6 +2178,7 @@ proptest! {
// ============================================================================
/// 生成随机的 OAuth 凭证条目(用于 Codex/iFlow)
#[allow(dead_code)]
fn arb_oauth_credential_entry() -> impl Strategy<Value = CredentialEntry> {
(
"[a-z]{3,10}-[0-9]{1,5}".prop_map(|s| s),
+2 -2
View File
@@ -86,7 +86,7 @@ pub fn build_tray_menu<R: Runtime>(
let start_server = MenuItem::with_id(
app,
menu_ids::START_SERVER,
"▶️ 启动服务器",
"▶️ 开启团队共享",
!state.server_running,
None::<&str>,
)
@@ -96,7 +96,7 @@ pub fn build_tray_menu<R: Runtime>(
let stop_server = MenuItem::with_id(
app,
menu_ids::STOP_SERVER,
"⏹️ 停止服务器",
"⏹️ 关闭团队共享",
state.server_running,
None::<&str>,
)
+10 -10
View File
@@ -73,7 +73,7 @@ pub fn handle_menu_event<R: Runtime>(app: &AppHandle<R>, menu_id: &str) {
///
/// # Requirements
/// - 3.1: WHEN API 服务器已停止且用户点击托盘菜单中的"启动服务器"
/// THEN 系统托盘 SHALL 启动 API 服务器并更新托盘图标以反映运行状态
/// THEN 系统托盘 SHALL 启动 API 服务器并更新托盘图标以反映运行状态
fn handle_start_server<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求启动服务器");
@@ -87,7 +87,7 @@ fn handle_start_server<R: Runtime>(app: &AppHandle<R>) {
///
/// # Requirements
/// - 3.2: WHEN API 服务器正在运行且用户点击托盘菜单中的"停止服务器"
/// THEN 系统托盘 SHALL 停止 API 服务器并更新托盘图标以反映停止状态
/// THEN 系统托盘 SHALL 停止 API 服务器并更新托盘图标以反映停止状态
fn handle_stop_server<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求停止服务器");
@@ -101,7 +101,7 @@ fn handle_stop_server<R: Runtime>(app: &AppHandle<R>) {
///
/// # Requirements
/// - 3.3: WHEN 用户点击托盘菜单中的"刷新所有 Token"
/// THEN 系统托盘 SHALL 触发凭证池中所有凭证的 Token 刷新
/// THEN 系统托盘 SHALL 触发凭证池中所有凭证的 Token 刷新
fn handle_refresh_tokens<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求刷新所有 Token");
@@ -115,7 +115,7 @@ fn handle_refresh_tokens<R: Runtime>(app: &AppHandle<R>) {
///
/// # Requirements
/// - 3.4: WHEN 用户点击托盘菜单中的"健康检查"
/// THEN 系统托盘 SHALL 对所有凭证执行健康检查并更新健康状态
/// THEN 系统托盘 SHALL 对所有凭证执行健康检查并更新健康状态
fn handle_health_check<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求执行健康检查");
@@ -129,7 +129,7 @@ fn handle_health_check<R: Runtime>(app: &AppHandle<R>) {
///
/// # Requirements
/// - 4.1: WHEN 用户点击托盘菜单中的"打开主窗口"
/// THEN 系统托盘 SHALL 显示并聚焦主应用程序窗口
/// THEN 系统托盘 SHALL 显示并聚焦主应用程序窗口
fn handle_open_window<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求打开主窗口");
@@ -159,7 +159,7 @@ fn handle_open_window<R: Runtime>(app: &AppHandle<R>) {
///
/// # Requirements
/// - 4.2: WHEN 用户点击托盘菜单中的"复制 API 地址"
/// THEN 系统托盘 SHALL 将当前 API 服务器地址复制到系统剪贴板
/// THEN 系统托盘 SHALL 将当前 API 服务器地址复制到系统剪贴板
fn handle_copy_api_address<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求复制 API 地址");
@@ -226,7 +226,7 @@ fn handle_copy_api_address<R: Runtime>(app: &AppHandle<R>) {
///
/// # Requirements
/// - 4.3: WHEN 用户点击托盘菜单中的"打开日志目录"
/// THEN 系统托盘 SHALL 在系统文件管理器中打开应用程序日志目录
/// THEN 系统托盘 SHALL 在系统文件管理器中打开应用程序日志目录
fn handle_open_log_dir<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求打开日志目录");
@@ -260,7 +260,7 @@ fn handle_open_log_dir<R: Runtime>(app: &AppHandle<R>) {
///
/// # Requirements
/// - 4.4: WHEN 用户点击托盘菜单中的"退出"
/// THEN 系统托盘 SHALL 优雅地停止 API 服务器并终止应用程序
/// THEN 系统托盘 SHALL 优雅地停止 API 服务器并终止应用程序
fn handle_quit<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求退出应用");
@@ -277,9 +277,9 @@ fn handle_quit<R: Runtime>(app: &AppHandle<R>) {
///
/// # Requirements
/// - 5.1: WHEN 用户在托盘菜单中切换"开机自启"
/// THEN 系统托盘 SHALL 启用或禁用应用程序的登录时启动设置
/// THEN 系统托盘 SHALL 启用或禁用应用程序的登录时启动设置
/// - 5.2: WHEN 托盘菜单显示时
/// THEN 系统托盘 SHALL 使用勾选标记显示"开机自启"切换的当前状态
/// THEN 系统托盘 SHALL 使用勾选标记显示"开机自启"切换的当前状态
fn handle_auto_start_toggle<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求切换开机自启状态");
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.64.0",
"version": "0.67.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.66.0",
"version": "0.67.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
+2 -2
View File
@@ -26,7 +26,7 @@ interface SidebarProps {
const navItems = [
{ id: "agent" as Page, label: "AI Agent", icon: Bot },
{ id: "api-server" as Page, label: "API Server", icon: Globe },
{ id: "api-server" as Page, label: "团队共享网关", icon: Globe },
{ id: "provider-pool" as Page, label: "凭证池", icon: Database },
{ id: "tools" as Page, label: "工具", icon: Wrench },
{ id: "plugins" as Page, label: "插件中心", icon: Puzzle },
@@ -38,7 +38,7 @@ export function Sidebar({ currentPage, onNavigate }: SidebarProps) {
<div className="w-56 border-r bg-card p-4">
<div className="mb-8">
<h1 className="text-xl font-bold">ProxyCast</h1>
<p className="text-xs text-muted-foreground">AI API Proxy</p>
<p className="text-xs text-muted-foreground">AI Agent + 团队网关</p>
</div>
<nav className="space-y-1">
{navItems.map((item) => (
@@ -9,7 +9,6 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { ProviderIcon } from "@/icons/providers";
import { getDefaultProvider } from "@/hooks/useTauri";
import { useConfiguredProviders } from "@/hooks/useConfiguredProviders";
import { useProviderModels } from "@/hooks/useProviderModels";
import { isAliasProvider } from "@/lib/constants/providerMappings";
@@ -39,29 +38,12 @@ export const ChatModelSelector: React.FC<ChatModelSelectorProps> = ({
popoverSide = "top",
}) => {
const [open, setOpen] = useState(false);
const [serverDefaultProvider, setServerDefaultProvider] = useState<
string | null
>(null);
const hasInitialized = useRef(false);
const modelRef = useRef(model);
modelRef.current = model;
const { providers: configuredProviders } = useConfiguredProviders();
useEffect(() => {
const loadDefaultProvider = async () => {
try {
const defaultProvider = await getDefaultProvider();
setServerDefaultProvider(defaultProvider);
} catch (error) {
console.error("[ChatModelSelector] 获取默认 Provider 失败:", error);
setServerDefaultProvider("");
}
};
void loadDefaultProvider();
}, []);
const selectedProvider = useMemo(() => {
return configuredProviders.find(
(provider) => provider.key === providerType,
@@ -74,31 +56,13 @@ export const ChatModelSelector: React.FC<ChatModelSelectorProps> = ({
useEffect(() => {
if (hasInitialized.current) return;
if (configuredProviders.length === 0) return;
if (serverDefaultProvider === null) return;
const serverDefaultInList = configuredProviders.find(
(provider) => provider.key === serverDefaultProvider,
);
hasInitialized.current = true;
if (serverDefaultInList) {
if (providerType !== serverDefaultProvider) {
setProviderType(serverDefaultProvider);
}
return;
}
if (!selectedProvider) {
setProviderType(configuredProviders[0].key);
}
}, [
configuredProviders,
providerType,
selectedProvider,
serverDefaultProvider,
setProviderType,
]);
}, [configuredProviders, selectedProvider, setProviderType]);
useEffect(() => {
if (
@@ -213,8 +177,6 @@ export const ChatModelSelector: React.FC<ChatModelSelectorProps> = ({
</div>
) : (
configuredProviders.map((provider) => {
const isServerDefault =
serverDefaultProvider === provider.key;
const isSelected = providerType === provider.key;
return (
@@ -225,9 +187,7 @@ export const ChatModelSelector: React.FC<ChatModelSelectorProps> = ({
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left",
isSelected
? "bg-primary/10 text-primary font-medium"
: isServerDefault
? "hover:bg-muted text-foreground hover:text-foreground"
: "hover:bg-muted text-muted-foreground/50 hover:text-muted-foreground",
: "hover:bg-muted text-muted-foreground hover:text-foreground",
)}
>
<span className="flex items-center gap-2 min-w-0">
+3 -24
View File
@@ -1,12 +1,10 @@
/**
* Agent Chat Hook 统一导出
*
* 根据配置自动选择 Native 或 Aster 后端
* 当前默认统一走 Aster 后端
*/
import { useAgentChat } from "./useAgentChat";
import { useAsterAgentChat } from "./useAsterAgentChat";
import { getAgentBackend } from "../config";
export type { Topic } from "./useAgentChat";
@@ -20,29 +18,10 @@ interface UseAgentChatUnifiedOptions {
/**
* 统一的 Agent Chat Hook
*
* 根据 localStorage 配置自动选择后端:
* - "native": 使用原有的 Native Agent 后端
* - "aster": 使用新的 Aster Agent 后端
*
* 切换方式:
* localStorage.setItem("proxycast_agent_backend", "aster")
* 为避免双 Hook 并发导致的副作用,统一直接走 Aster。
*/
export function useAgentChatUnified(options: UseAgentChatUnifiedOptions) {
const backend = getAgentBackend();
// 根据配置选择 hook
// 注意:React hooks 规则要求 hooks 调用顺序一致
// 这里我们总是调用两个 hook,但只使用其中一个的结果
const nativeResult = useAgentChat(options);
const asterResult = useAsterAgentChat(options);
if (backend === "aster") {
console.log("[AgentChat] 使用 Aster 后端");
return asterResult;
}
console.log("[AgentChat] 使用 Native 后端");
return nativeResult;
return useAsterAgentChat(options);
}
// 重新导出原有 hooks,便于直接使用
@@ -0,0 +1,268 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockStartAgentProcess,
mockStopAgentProcess,
mockGetAgentProcessStatus,
mockCreateAgentSession,
mockSendAgentMessageStream,
mockListAgentSessions,
mockDeleteAgentSession,
mockGetAgentSessionMessages,
mockRenameAgentSession,
mockGenerateAgentTitle,
mockParseStreamEvent,
mockConfirmAsterAction,
mockSubmitAsterElicitationResponse,
mockStopAsterSession,
mockSafeListen,
mockGetProviderConfig,
} = vi.hoisted(() => ({
mockStartAgentProcess: vi.fn(),
mockStopAgentProcess: vi.fn(),
mockGetAgentProcessStatus: vi.fn(),
mockCreateAgentSession: vi.fn(),
mockSendAgentMessageStream: vi.fn(),
mockListAgentSessions: vi.fn(),
mockDeleteAgentSession: vi.fn(),
mockGetAgentSessionMessages: vi.fn(),
mockRenameAgentSession: vi.fn(),
mockGenerateAgentTitle: vi.fn(),
mockParseStreamEvent: vi.fn((payload: unknown) => payload),
mockConfirmAsterAction: vi.fn(),
mockSubmitAsterElicitationResponse: vi.fn(),
mockStopAsterSession: vi.fn(),
mockSafeListen: vi.fn(),
mockGetProviderConfig: vi.fn(),
}));
vi.mock("@/lib/api/agent", () => ({
startAgentProcess: mockStartAgentProcess,
stopAgentProcess: mockStopAgentProcess,
getAgentProcessStatus: mockGetAgentProcessStatus,
createAgentSession: mockCreateAgentSession,
sendAgentMessageStream: mockSendAgentMessageStream,
listAgentSessions: mockListAgentSessions,
deleteAgentSession: mockDeleteAgentSession,
getAgentSessionMessages: mockGetAgentSessionMessages,
renameAgentSession: mockRenameAgentSession,
generateAgentTitle: mockGenerateAgentTitle,
parseStreamEvent: mockParseStreamEvent,
confirmAsterAction: mockConfirmAsterAction,
submitAsterElicitationResponse: mockSubmitAsterElicitationResponse,
stopAsterSession: mockStopAsterSession,
}));
vi.mock("@/lib/dev-bridge", () => ({
safeListen: mockSafeListen,
}));
vi.mock("@/lib/artifact/hooks/useArtifactParser", () => ({
useArtifactParser: () => ({
startParsing: vi.fn(),
appendChunk: vi.fn(),
finalizeParsing: vi.fn(),
reset: vi.fn(),
}),
}));
vi.mock("./skillCommand", () => ({
parseSkillSlashCommand: vi.fn(() => null),
tryExecuteSlashSkillCommand: vi.fn(async () => false),
}));
vi.mock("../utils/sessionRecovery", () => ({
isValidSessionId: vi.fn(() => true),
resolveRestorableSessionId: vi.fn(() => null),
}));
vi.mock("../types", () => {
const providerConfig = {
claude: { models: ["claude-sonnet-4-5", "claude-opus-4"] },
gemini: { models: ["gemini-2.5-pro", "gemini-2.5-flash"] },
deepseek: { models: ["deepseek-reasoner", "deepseek-chat"] },
};
return {
PROVIDER_CONFIG: providerConfig,
getProviderConfig: mockGetProviderConfig,
};
});
import { useAgentChat } from "./useAgentChat";
interface HookHarness {
getValue: () => ReturnType<typeof useAgentChat>;
unmount: () => void;
}
function mountHook(workspaceId = "ws-test"): HookHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let hookValue: ReturnType<typeof useAgentChat> | null = null;
function TestComponent() {
hookValue = useAgentChat({ workspaceId });
return null;
}
act(() => {
root.render(<TestComponent />);
});
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
async function flushEffects() {
await act(async () => {
await Promise.resolve();
});
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
localStorage.clear();
sessionStorage.clear();
mockStartAgentProcess.mockResolvedValue(undefined);
mockStopAgentProcess.mockResolvedValue(undefined);
mockGetAgentProcessStatus.mockResolvedValue({ running: false });
mockCreateAgentSession.mockResolvedValue({ session_id: "session-created" });
mockSendAgentMessageStream.mockResolvedValue(undefined);
mockListAgentSessions.mockResolvedValue([]);
mockDeleteAgentSession.mockResolvedValue(undefined);
mockGetAgentSessionMessages.mockResolvedValue([]);
mockRenameAgentSession.mockResolvedValue(undefined);
mockGenerateAgentTitle.mockResolvedValue("新话题");
mockConfirmAsterAction.mockResolvedValue(undefined);
mockSubmitAsterElicitationResponse.mockResolvedValue(undefined);
mockStopAsterSession.mockResolvedValue(undefined);
mockParseStreamEvent.mockImplementation((payload: unknown) => payload);
mockSafeListen.mockResolvedValue(() => {});
mockGetProviderConfig.mockResolvedValue({
claude: { models: ["claude-sonnet-4-5", "claude-opus-4"] },
gemini: { models: ["gemini-2.5-pro", "gemini-2.5-flash"] },
deepseek: { models: ["deepseek-reasoner", "deepseek-chat"] },
});
});
afterEach(() => {
localStorage.clear();
sessionStorage.clear();
});
describe("useAgentChat 偏好持久化", () => {
it("应将旧全局偏好迁移到当前工作区", async () => {
localStorage.setItem("agent_pref_provider", JSON.stringify("gemini"));
localStorage.setItem("agent_pref_model", JSON.stringify("gemini-2.5-pro"));
const workspaceId = "ws-native-migrate";
const harness = mountHook(workspaceId);
try {
await flushEffects();
const value = harness.getValue();
expect(value.providerType).toBe("gemini");
expect(value.model).toBe("gemini-2.5-pro");
expect(
JSON.parse(
localStorage.getItem(`agent_pref_provider_${workspaceId}`) || "null",
),
).toBe("gemini");
expect(
JSON.parse(
localStorage.getItem(`agent_pref_model_${workspaceId}`) || "null",
),
).toBe("gemini-2.5-pro");
expect(
JSON.parse(
localStorage.getItem(`agent_pref_migrated_${workspaceId}`) ||
"false",
),
).toBe(true);
} finally {
harness.unmount();
}
});
it("应优先使用工作区偏好而不是旧全局偏好", async () => {
localStorage.setItem("agent_pref_provider", JSON.stringify("claude"));
localStorage.setItem("agent_pref_model", JSON.stringify("claude-opus-4"));
localStorage.setItem(
"agent_pref_provider_ws-native-scoped",
JSON.stringify("deepseek"),
);
localStorage.setItem(
"agent_pref_model_ws-native-scoped",
JSON.stringify("deepseek-reasoner"),
);
const harness = mountHook("ws-native-scoped");
try {
await flushEffects();
const value = harness.getValue();
expect(value.providerType).toBe("deepseek");
expect(value.model).toBe("deepseek-reasoner");
} finally {
harness.unmount();
}
});
it("无工作区时应保留全局模型偏好(切主题不丢失)", async () => {
const firstMount = mountHook("");
try {
await flushEffects();
act(() => {
firstMount.getValue().setProviderType("gemini");
firstMount.getValue().setModel("gemini-2.5-pro");
});
await flushEffects();
} finally {
firstMount.unmount();
}
const secondMount = mountHook("");
try {
await flushEffects();
const value = secondMount.getValue();
expect(value.providerType).toBe("gemini");
expect(value.model).toBe("gemini-2.5-pro");
expect(JSON.parse(localStorage.getItem("agent_pref_provider_global") || "null")).toBe(
"gemini",
);
expect(JSON.parse(localStorage.getItem("agent_pref_model_global") || "null")).toBe(
"gemini-2.5-pro",
);
} finally {
secondMount.unmount();
}
});
});
+132 -11
View File
@@ -155,6 +155,106 @@ const setTitleManuallyEdited = (sessionId: string, edited: boolean) => {
savePersisted(`${TITLE_EDITED_KEY_PREFIX}${sessionId}`, edited);
};
const DEFAULT_AGENT_PROVIDER = "claude";
const DEFAULT_AGENT_MODEL = PROVIDER_CONFIG["claude"]?.models[0] || "";
const GLOBAL_PROVIDER_PREF_KEY = "agent_pref_provider_global";
const GLOBAL_MODEL_PREF_KEY = "agent_pref_model_global";
const GLOBAL_MIGRATED_PREF_KEY = "agent_pref_migrated_global";
const loadPersistedString = (key: string): string | null => {
try {
const stored = localStorage.getItem(key);
if (stored === null) {
return null;
}
try {
const parsed = JSON.parse(stored);
return typeof parsed === "string" ? parsed : stored;
} catch {
return stored;
}
} catch (e) {
console.error(e);
return null;
}
};
interface AgentPreferences {
providerType: string;
model: string;
}
interface AgentPreferenceKeys {
providerKey: string;
modelKey: string;
migratedKey: string;
}
const getAgentPreferenceKeys = (
workspaceId?: string | null,
): AgentPreferenceKeys => {
const resolvedWorkspaceId = workspaceId?.trim();
if (!resolvedWorkspaceId) {
return {
providerKey: GLOBAL_PROVIDER_PREF_KEY,
modelKey: GLOBAL_MODEL_PREF_KEY,
migratedKey: GLOBAL_MIGRATED_PREF_KEY,
};
}
return {
providerKey: `agent_pref_provider_${resolvedWorkspaceId}`,
modelKey: `agent_pref_model_${resolvedWorkspaceId}`,
migratedKey: `agent_pref_migrated_${resolvedWorkspaceId}`,
};
};
const resolveWorkspaceAgentPreferences = (
workspaceId?: string | null,
): AgentPreferences => {
const { providerKey, modelKey, migratedKey } =
getAgentPreferenceKeys(workspaceId);
const scopedProvider = loadPersistedString(providerKey);
const scopedModel = loadPersistedString(modelKey);
if (scopedProvider || scopedModel) {
return {
providerType: scopedProvider || DEFAULT_AGENT_PROVIDER,
model: scopedModel || DEFAULT_AGENT_MODEL,
};
}
const migrated = loadPersisted<boolean>(migratedKey, false);
if (!migrated) {
const legacyProvider =
loadPersistedString("agent_pref_provider") ||
loadPersistedString(GLOBAL_PROVIDER_PREF_KEY);
const legacyModel =
loadPersistedString("agent_pref_model") ||
loadPersistedString(GLOBAL_MODEL_PREF_KEY);
if (legacyProvider) {
savePersisted(providerKey, legacyProvider);
}
if (legacyModel) {
savePersisted(modelKey, legacyModel);
}
savePersisted(migratedKey, true);
return {
providerType: legacyProvider || DEFAULT_AGENT_PROVIDER,
model: legacyModel || DEFAULT_AGENT_MODEL,
};
}
return {
providerType: DEFAULT_AGENT_PROVIDER,
model: DEFAULT_AGENT_MODEL,
};
};
/** useAgentChat 的配置选项 */
interface UseAgentChatOptions {
/** 系统提示词(用于内容创作等场景) */
@@ -198,16 +298,15 @@ export function useAgentChat(options: UseAgentChatOptions) {
useState<ProviderConfigMap>(PROVIDER_CONFIG);
const [isConfigLoading, setIsConfigLoading] = useState(true);
// Configuration State (Persistent)
const defaultProvider = "claude";
const defaultModel = PROVIDER_CONFIG["claude"]?.models[0] || "";
const initialPreferencesRef = useRef<AgentPreferences>(
resolveWorkspaceAgentPreferences(workspaceId),
);
const [providerType, setProviderType] = useState(() =>
loadPersisted("agent_pref_provider", defaultProvider),
);
const [model, setModel] = useState(() =>
loadPersisted("agent_pref_model", defaultModel),
// Provider/Model(按工作区保存)
const [providerType, setProviderType] = useState(
() => initialPreferencesRef.current.providerType,
);
const [model, setModel] = useState(() => initialPreferencesRef.current.model);
// Session State
const [sessionId, setSessionId] = useState<string | null>(() => {
@@ -277,6 +376,12 @@ export function useAgentChat(options: UseAgentChatOptions) {
const hydratedSessionRef = useRef<string | null>(null);
const skipAutoRestoreRef = useRef(false);
const sessionResetVersionRef = useRef(0);
const scopedProviderPrefKeyRef = useRef<string>(
getAgentPreferenceKeys(workspaceId).providerKey,
);
const scopedModelPrefKeyRef = useRef<string>(
getAgentPreferenceKeys(workspaceId).modelKey,
);
// Artifact 解析器 - 用于流式解析 AI 响应中的 artifact
const {
@@ -303,12 +408,28 @@ export function useAgentChat(options: UseAgentChatOptions) {
loadConfig();
}, []);
// Persistence Effects
// workspace 变化时恢复 Provider/Model 偏好
useEffect(() => {
savePersisted("agent_pref_provider", providerType);
const { providerKey, modelKey } = getAgentPreferenceKeys(workspaceId);
scopedProviderPrefKeyRef.current = providerKey;
scopedModelPrefKeyRef.current = modelKey;
const scopedPreferences = resolveWorkspaceAgentPreferences(workspaceId);
setProviderType(scopedPreferences.providerType);
setModel(scopedPreferences.model);
savePersisted(providerKey, scopedPreferences.providerType);
savePersisted(modelKey, scopedPreferences.model);
}, [workspaceId]);
// 持久化 provider/model(仅写当前工作区)
useEffect(() => {
savePersisted(scopedProviderPrefKeyRef.current, providerType);
}, [providerType]);
useEffect(() => {
savePersisted("agent_pref_model", model);
savePersisted(scopedModelPrefKeyRef.current, model);
}, [model]);
// 当 provider 改变时,检查当前模型是否兼容
@@ -8,6 +8,8 @@ const {
mockCreateAsterSession,
mockListAsterSessions,
mockGetAsterSession,
mockRenameAsterSession,
mockDeleteAsterSession,
mockStopAsterSession,
mockConfirmAsterAction,
mockSubmitAsterElicitationResponse,
@@ -20,6 +22,8 @@ const {
mockCreateAsterSession: vi.fn(),
mockListAsterSessions: vi.fn(),
mockGetAsterSession: vi.fn(),
mockRenameAsterSession: vi.fn(),
mockDeleteAsterSession: vi.fn(),
mockStopAsterSession: vi.fn(),
mockConfirmAsterAction: vi.fn(),
mockSubmitAsterElicitationResponse: vi.fn(),
@@ -38,6 +42,8 @@ vi.mock("@/lib/api/agent", () => ({
createAsterSession: mockCreateAsterSession,
listAsterSessions: mockListAsterSessions,
getAsterSession: mockGetAsterSession,
renameAsterSession: mockRenameAsterSession,
deleteAsterSession: mockDeleteAsterSession,
stopAsterSession: mockStopAsterSession,
confirmAsterAction: mockConfirmAsterAction,
submitAsterElicitationResponse: mockSubmitAsterElicitationResponse,
@@ -134,6 +140,8 @@ beforeEach(() => {
id: "session-from-api",
messages: [],
});
mockRenameAsterSession.mockResolvedValue(undefined);
mockDeleteAsterSession.mockResolvedValue(undefined);
mockStopAsterSession.mockResolvedValue(undefined);
mockConfirmAsterAction.mockResolvedValue(undefined);
mockSubmitAsterElicitationResponse.mockResolvedValue(undefined);
@@ -229,3 +237,196 @@ describe("useAsterAgentChat.confirmAction", () => {
}
});
});
describe("useAsterAgentChat 偏好持久化", () => {
it("应将旧全局偏好迁移到当前工作区", async () => {
localStorage.setItem("agent_pref_provider", JSON.stringify("gemini"));
localStorage.setItem("agent_pref_model", JSON.stringify("gemini-2.5-pro"));
const workspaceId = "ws-migrate";
const harness = mountHook(workspaceId);
try {
await flushEffects();
const value = harness.getValue();
expect(value.providerType).toBe("gemini");
expect(value.model).toBe("gemini-2.5-pro");
expect(
JSON.parse(
localStorage.getItem(`agent_pref_provider_${workspaceId}`) || "null",
),
).toBe("gemini");
expect(
JSON.parse(
localStorage.getItem(`agent_pref_model_${workspaceId}`) || "null",
),
).toBe("gemini-2.5-pro");
expect(
JSON.parse(
localStorage.getItem(`agent_pref_migrated_${workspaceId}`) ||
"false",
),
).toBe(true);
} finally {
harness.unmount();
}
});
it("应优先使用工作区偏好而不是旧全局偏好", async () => {
localStorage.setItem("agent_pref_provider", JSON.stringify("claude"));
localStorage.setItem("agent_pref_model", JSON.stringify("claude-legacy"));
localStorage.setItem(
"agent_pref_provider_ws-prefer-scoped",
JSON.stringify("deepseek"),
);
localStorage.setItem(
"agent_pref_model_ws-prefer-scoped",
JSON.stringify("deepseek-reasoner"),
);
const harness = mountHook("ws-prefer-scoped");
try {
await flushEffects();
const value = harness.getValue();
expect(value.providerType).toBe("deepseek");
expect(value.model).toBe("deepseek-reasoner");
} finally {
harness.unmount();
}
});
it("无工作区时应保留全局模型偏好(切主题不丢失)", async () => {
const firstMount = mountHook("");
try {
await flushEffects();
act(() => {
firstMount.getValue().setProviderType("gemini");
firstMount.getValue().setModel("gemini-2.5-pro");
});
await flushEffects();
} finally {
firstMount.unmount();
}
const secondMount = mountHook("");
try {
await flushEffects();
const value = secondMount.getValue();
expect(value.providerType).toBe("gemini");
expect(value.model).toBe("gemini-2.5-pro");
expect(JSON.parse(localStorage.getItem("agent_pref_provider_global") || "null")).toBe(
"gemini",
);
expect(JSON.parse(localStorage.getItem("agent_pref_model_global") || "null")).toBe(
"gemini-2.5-pro",
);
} finally {
secondMount.unmount();
}
});
});
describe("useAsterAgentChat 兼容接口", () => {
it("triggerAIGuide 应仅生成 assistant 占位消息", async () => {
const harness = mountHook("ws-guide");
try {
await flushEffects();
await act(async () => {
await harness.getValue().triggerAIGuide();
});
const value = harness.getValue();
expect(value.messages).toHaveLength(1);
expect(value.messages[0]?.role).toBe("assistant");
expect(mockSendAsterMessageStream).toHaveBeenCalledTimes(1);
expect(mockSendAsterMessageStream.mock.calls[0]?.[0]).toBe("");
} finally {
harness.unmount();
}
});
it("renameTopic 应调用后端并刷新话题标题", async () => {
const createdAt = Math.floor(Date.now() / 1000);
mockListAsterSessions
.mockResolvedValue([
{
id: "topic-1",
name: "新标题",
created_at: createdAt,
messages_count: 2,
},
])
.mockResolvedValueOnce([
{
id: "topic-1",
name: "旧标题",
created_at: createdAt,
messages_count: 2,
},
]);
const harness = mountHook("ws-rename");
try {
await flushEffects();
await flushEffects();
await act(async () => {
await harness.getValue().renameTopic("topic-1", "新标题");
});
expect(mockRenameAsterSession).toHaveBeenCalledTimes(1);
expect(mockRenameAsterSession).toHaveBeenCalledWith("topic-1", "新标题");
const renamedTopic = harness
.getValue()
.topics.find((topic) => topic.id === "topic-1");
expect(renamedTopic?.title).toBe("新标题");
} finally {
harness.unmount();
}
});
it("deleteTopic 应调用后端并刷新话题列表", async () => {
const createdAt = Math.floor(Date.now() / 1000);
let currentSessions = [
{
id: "topic-1",
name: "旧标题",
created_at: createdAt,
messages_count: 2,
},
];
mockListAsterSessions.mockImplementation(async () => currentSessions);
mockDeleteAsterSession.mockImplementation(async () => {
currentSessions = [];
});
const harness = mountHook("ws-delete");
try {
await flushEffects();
await flushEffects();
await act(async () => {
await harness.getValue().deleteTopic("topic-1");
});
expect(mockDeleteAsterSession).toHaveBeenCalledTimes(1);
expect(mockDeleteAsterSession).toHaveBeenCalledWith("topic-1");
const deletedTopic = harness
.getValue()
.topics.find((topic) => topic.id === "topic-1");
expect(deletedTopic).toBeUndefined();
} finally {
harness.unmount();
}
});
});
@@ -15,6 +15,8 @@ import {
createAsterSession,
listAsterSessions,
getAsterSession,
renameAsterSession,
deleteAsterSession,
stopAsterSession,
confirmAsterAction,
submitAsterElicitationResponse,
@@ -151,6 +153,106 @@ const saveTransient = (key: string, value: unknown) => {
}
};
const DEFAULT_AGENT_PROVIDER = "claude";
const DEFAULT_AGENT_MODEL = "claude-sonnet-4-5";
const GLOBAL_PROVIDER_PREF_KEY = "agent_pref_provider_global";
const GLOBAL_MODEL_PREF_KEY = "agent_pref_model_global";
const GLOBAL_MIGRATED_PREF_KEY = "agent_pref_migrated_global";
const loadPersistedString = (key: string): string | null => {
try {
const stored = localStorage.getItem(key);
if (stored === null) {
return null;
}
try {
const parsed = JSON.parse(stored);
return typeof parsed === "string" ? parsed : stored;
} catch {
return stored;
}
} catch (e) {
console.error(e);
return null;
}
};
interface AgentPreferences {
providerType: string;
model: string;
}
interface AgentPreferenceKeys {
providerKey: string;
modelKey: string;
migratedKey: string;
}
const getAgentPreferenceKeys = (
workspaceId?: string | null,
): AgentPreferenceKeys => {
const resolvedWorkspaceId = workspaceId?.trim();
if (!resolvedWorkspaceId) {
return {
providerKey: GLOBAL_PROVIDER_PREF_KEY,
modelKey: GLOBAL_MODEL_PREF_KEY,
migratedKey: GLOBAL_MIGRATED_PREF_KEY,
};
}
return {
providerKey: `agent_pref_provider_${resolvedWorkspaceId}`,
modelKey: `agent_pref_model_${resolvedWorkspaceId}`,
migratedKey: `agent_pref_migrated_${resolvedWorkspaceId}`,
};
};
const resolveWorkspaceAgentPreferences = (
workspaceId?: string | null,
): AgentPreferences => {
const { providerKey, modelKey, migratedKey } =
getAgentPreferenceKeys(workspaceId);
const scopedProvider = loadPersistedString(providerKey);
const scopedModel = loadPersistedString(modelKey);
if (scopedProvider || scopedModel) {
return {
providerType: scopedProvider || DEFAULT_AGENT_PROVIDER,
model: scopedModel || DEFAULT_AGENT_MODEL,
};
}
const migrated = loadPersisted<boolean>(migratedKey, false);
if (!migrated) {
const legacyProvider =
loadPersistedString("agent_pref_provider") ||
loadPersistedString(GLOBAL_PROVIDER_PREF_KEY);
const legacyModel =
loadPersistedString("agent_pref_model") ||
loadPersistedString(GLOBAL_MODEL_PREF_KEY);
if (legacyProvider) {
savePersisted(providerKey, legacyProvider);
}
if (legacyModel) {
savePersisted(modelKey, legacyModel);
}
savePersisted(migratedKey, true);
return {
providerType: legacyProvider || DEFAULT_AGENT_PROVIDER,
model: legacyModel || DEFAULT_AGENT_MODEL,
};
}
return {
providerType: DEFAULT_AGENT_PROVIDER,
model: DEFAULT_AGENT_MODEL,
};
};
/**
* 将前端 Provider 类型映射到 Aster Provider 名称
*/
@@ -243,13 +345,15 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
const [isSending, setIsSending] = useState(false);
const [pendingActions, setPendingActions] = useState<ActionRequired[]>([]);
// Provider/Model(本地状态)
const initialPreferencesRef = useRef<AgentPreferences>(
resolveWorkspaceAgentPreferences(workspaceId),
);
// Provider/Model(按工作区保存)
const [providerType, setProviderType] = useState(
() => localStorage.getItem("agent_pref_provider") || "claude",
);
const [model, setModel] = useState(
() => localStorage.getItem("agent_pref_model") || "claude-sonnet-4-5",
() => initialPreferencesRef.current.providerType,
);
const [model, setModel] = useState(() => initialPreferencesRef.current.model);
// Refs
const unlistenRef = useRef<UnlistenFn | null>(null);
@@ -257,14 +361,35 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
const restoredWorkspaceRef = useRef<string | null>(null);
const hydratedSessionRef = useRef<string | null>(null);
const skipAutoRestoreRef = useRef(false);
const scopedProviderPrefKeyRef = useRef<string>(
getAgentPreferenceKeys(workspaceId).providerKey,
);
const scopedModelPrefKeyRef = useRef<string>(
getAgentPreferenceKeys(workspaceId).modelKey,
);
// 持久化 provider/model
// workspace 变化时恢复 Provider/Model 偏好
useEffect(() => {
localStorage.setItem("agent_pref_provider", providerType);
const { providerKey, modelKey } = getAgentPreferenceKeys(workspaceId);
scopedProviderPrefKeyRef.current = providerKey;
scopedModelPrefKeyRef.current = modelKey;
const scopedPreferences = resolveWorkspaceAgentPreferences(workspaceId);
setProviderType(scopedPreferences.providerType);
setModel(scopedPreferences.model);
savePersisted(providerKey, scopedPreferences.providerType);
savePersisted(modelKey, scopedPreferences.model);
}, [workspaceId]);
// 持久化 provider/model(仅写当前工作区)
useEffect(() => {
savePersisted(scopedProviderPrefKeyRef.current, providerType);
}, [providerType]);
useEffect(() => {
localStorage.setItem("agent_pref_model", model);
savePersisted(scopedModelPrefKeyRef.current, model);
}, [model]);
useEffect(() => {
@@ -443,16 +568,8 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
images: MessageImage[],
_webSearch?: boolean,
_thinking?: boolean,
skipUserMessage = false,
) => {
// 用户消息
const userMsg: Message = {
id: crypto.randomUUID(),
role: "user",
content,
images: images.length > 0 ? images : undefined,
timestamp: new Date(),
};
// 助手消息占位符
const assistantMsgId = crypto.randomUUID();
const assistantMsg: Message = {
@@ -465,7 +582,19 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
contentParts: [],
};
setMessages((prev) => [...prev, userMsg, assistantMsg]);
if (skipUserMessage) {
setMessages((prev) => [...prev, assistantMsg]);
} else {
// 用户消息
const userMsg: Message = {
id: crypto.randomUUID(),
role: "user",
content,
images: images.length > 0 ? images : undefined,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMsg, assistantMsg]);
}
setIsSending(true);
currentAssistantMsgIdRef.current = assistantMsgId;
@@ -794,6 +923,19 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
[pendingActions, sessionId],
);
// 兼容 Native 接口:权限响应处理
const handlePermissionResponse = useCallback(
async (response: ConfirmResponse) => {
await confirmAction(response);
},
[confirmAction],
);
// 兼容 Native 接口:触发 AI 引导(仅生成助手消息,不注入用户气泡)
const triggerAIGuide = useCallback(async () => {
await sendMessage("", [], false, false, true);
}, [sendMessage]);
// 清空消息
const clearMessages = useCallback(() => {
setMessages([]);
@@ -945,17 +1087,51 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
// 删除话题
const deleteTopic = useCallback(
async (topicId: string) => {
// TODO: 实现后端删除
setTopics((prev) => prev.filter((t) => t.id !== topicId));
if (topicId === sessionId) {
setSessionId(null);
setMessages([]);
try {
await deleteAsterSession(topicId);
await loadTopics();
if (topicId === sessionId) {
setSessionId(null);
setMessages([]);
setPendingActions([]);
hydratedSessionRef.current = null;
restoredWorkspaceRef.current = null;
saveTransient(getScopedSessionKey(), null);
savePersisted(getScopedPersistedSessionKey(), null);
}
toast.success("话题已删除");
} catch (error) {
console.error("[AsterChat] 删除话题失败:", error);
toast.error("删除话题失败");
}
toast.success("话题已删除");
},
[sessionId],
[
getScopedPersistedSessionKey,
getScopedSessionKey,
loadTopics,
sessionId,
],
);
// 重命名话题(持久化)
const renameTopic = useCallback(async (topicId: string, newTitle: string) => {
const normalizedTitle = newTitle.trim();
if (!normalizedTitle) {
return;
}
try {
await renameAsterSession(topicId, normalizedTitle);
await loadTopics();
toast.success("话题已重命名");
} catch (error) {
console.error("[AsterChat] 重命名话题失败:", error);
toast.error("重命名失败");
}
}, [loadTopics]);
// 兼容接口
const handleStartProcess = useCallback(async () => {
// Aster 不需要单独启动进程
@@ -989,11 +1165,14 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
clearMessages,
deleteMessage,
editMessage,
handlePermissionResponse,
triggerAIGuide,
topics,
sessionId,
switchTopic,
deleteTopic,
renameTopic,
loadTopics,
// Aster 特有功能
+21 -3
View File
@@ -9,7 +9,7 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { toast } from "sonner";
import styled from "styled-components";
import { useAgentChat } from "./hooks/useAgentChat";
import { useAgentChatUnified } from "./hooks";
import { useSessionFiles } from "./hooks/useSessionFiles";
import { useContentSync } from "./hooks/useContentSync";
import { ChatNavbar } from "./components/ChatNavbar";
@@ -213,6 +213,8 @@ export function AgentChatPage({
onBackToProjectManagement,
hideInlineStepProgress = false,
onWorkflowProgressChange,
initialUserPrompt,
onInitialUserPromptConsumed,
newChatAt,
onRecommendationClick: _onRecommendationClick,
onHasMessagesChange,
@@ -231,6 +233,8 @@ export function AgentChatPage({
onWorkflowProgressChange?: (
snapshot: WorkflowProgressSnapshot | null,
) => void;
initialUserPrompt?: string;
onInitialUserPromptConsumed?: () => void;
newChatAt?: number;
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
onHasMessagesChange?: (hasMessages: boolean) => void;
@@ -421,7 +425,7 @@ export function AgentChatPage({
switchTopic: originalSwitchTopic,
deleteTopic,
renameTopic,
} = useAgentChat({
} = useAgentChatUnified({
systemPrompt,
onWriteFile: (content, fileName) => {
// 使用 ref 调用最新的 handleWriteFile
@@ -1527,6 +1531,7 @@ export function AgentChatPage({
// - 画布内容为空(canvasState 没有实际内容)
// - 尚未触发过引导
const canvasEmpty = isCanvasStateEmpty(canvasState);
const pendingInitialPrompt = (initialUserPrompt || "").trim();
if (
contentId &&
@@ -1537,8 +1542,18 @@ export function AgentChatPage({
canvasEmpty &&
!hasTriggeredGuide.current
) {
console.log("[AgentChatPage] 自动触发 AI 创作引导");
hasTriggeredGuide.current = true;
if (pendingInitialPrompt) {
console.log("[AgentChatPage] 自动发送首条创作意图消息");
void (async () => {
await handleSend([], false, false, pendingInitialPrompt);
onInitialUserPromptConsumed?.();
})();
return;
}
console.log("[AgentChatPage] 自动触发 AI 创作引导");
triggerAIGuideRef.current();
}
}, [
@@ -1548,6 +1563,9 @@ export function AgentChatPage({
systemPrompt,
isSending,
canvasState,
initialUserPrompt,
handleSend,
onInitialUserPromptConsumed,
]);
// 当 contentId 变化时重置引导状态
+99 -20
View File
@@ -61,6 +61,21 @@ interface ApiServerPageProps {
hideHeader?: boolean;
}
type GatewayMode = "local" | "lan";
const getGatewayModeByHost = (host?: string | null): GatewayMode => {
const normalized = (host || "").trim().toLowerCase();
if (!normalized) return "local";
if (
normalized === "127.0.0.1" ||
normalized === "localhost" ||
normalized === "::1"
) {
return "local";
}
return "lan";
};
// Provider 到 API 类型的映射
type ApiType = "openai" | "anthropic" | "gemini";
const getProviderApiType = (provider: string): ApiType => {
@@ -429,11 +444,11 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
await reloadCredentials();
await startServer();
await fetchStatus();
setMessage({ type: "success", text: "服务已启动" });
setMessage({ type: "success", text: "共享网关已开启" });
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
setError(errMsg);
setMessage({ type: "error", text: `启动失败: ${errMsg}` });
setMessage({ type: "error", text: `开启失败: ${errMsg}` });
}
setLoading(false);
};
@@ -443,17 +458,17 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
try {
await stopServer();
await fetchStatus();
setMessage({ type: "success", text: "服务已停止" });
setMessage({ type: "success", text: "共享网关已关闭" });
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
setError(errMsg);
setMessage({ type: "error", text: `停止失败: ${errMsg}` });
setMessage({ type: "error", text: `关闭失败: ${errMsg}` });
}
setLoading(false);
};
// 自动保存监听地址
const handleHostChange = async (newHost: string) => {
const handleHostChange = async (newHost: string): Promise<boolean> => {
setEditHost(newHost);
// 如果配置已加载,自动保存
@@ -469,12 +484,15 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
await saveConfig(newConfig);
setConfig(newConfig);
console.log("[DEBUG] handleHostChange - 自动保存地址:", newHost);
return true;
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
console.error("[DEBUG] handleHostChange - 保存失败:", errMsg);
setMessage({ type: "error", text: `保存地址失败: ${errMsg}` });
return false;
}
}
return true;
};
const handleSaveServerConfig = async () => {
@@ -508,7 +526,7 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
console.log("[DEBUG] handleSaveServerConfig - saveConfig completed");
await fetchConfig();
setMessage({ type: "success", text: "服务器配置已保存" });
setMessage({ type: "success", text: "网关配置已保存" });
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
console.error("[DEBUG] handleSaveServerConfig - error:", errMsg);
@@ -877,6 +895,25 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
return Array.from(options);
}, [networkInfo, editHost, status]);
const gatewayMode = useMemo(
() =>
getGatewayModeByHost(
status?.running ? status.host : editHost || "127.0.0.1",
),
[status, editHost],
);
const handleGatewayModeChange = async (mode: GatewayMode) => {
const nextHost = mode === "local" ? "127.0.0.1" : "0.0.0.0";
if (nextHost === editHost) return;
const saved = await handleHostChange(nextHost);
if (!saved) return;
setMessage({
type: "success",
text: mode === "local" ? "已切换为仅本机模式" : "已切换为内网共享模式",
});
};
// 动态生成测试端点
const testEndpoints = useMemo(() => {
if (!testModel) return [];
@@ -1048,7 +1085,7 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-end gap-3">
<h2 className="text-2xl font-bold">API Server</h2>
<h2 className="text-2xl font-bold">团队共享网关(内网)</h2>
<div className="flex items-center gap-2 text-sm text-muted-foreground pb-0.5">
<span className="flex items-center gap-1.5">
<span
@@ -1063,7 +1100,7 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
</div>
</div>
<p className="text-muted-foreground text-sm mt-1">
本地代理服务器,支持 OpenAI/Anthropic 格式
Agent 默认直连 Provider;需要给内网同事接入时再开启共享网关
</p>
</div>
</div>
@@ -1089,8 +1126,8 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
{/* Tabs */}
<div className="flex gap-2 border-b overflow-x-auto">
{[
{ id: "server" as TabId, name: "服务器控制" },
{ id: "logs" as TabId, name: "系统日志" },
{ id: "server" as TabId, name: "网关控制" },
{ id: "logs" as TabId, name: "网关日志" },
].map((tab) => (
<button
key={tab.id}
@@ -1111,9 +1148,9 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
<div className="space-y-4">
{/* Server Control - 紧凑版 */}
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-4">
<div className="flex flex-wrap items-center gap-4">
<button
className={`rounded-lg px-4 py-1.5 text-sm font-medium text-white disabled:opacity-50 ${
className={`shrink-0 whitespace-nowrap rounded-lg px-4 py-1.5 text-sm font-medium leading-none text-white disabled:opacity-50 ${
status?.running
? "bg-red-600 hover:bg-red-700"
: "bg-green-600 hover:bg-green-700"
@@ -1124,12 +1161,43 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
{loading
? "处理中..."
: status?.running
? "停止服务"
: "启动服务"}
? "关闭共享"
: "开启共享"}
</button>
<div className="flex items-center gap-3 text-sm flex-wrap">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">监听地址:</span>
<span className="text-muted-foreground">共享模式:</span>
<div className="inline-flex rounded-md border border-input p-0.5">
<button
onClick={() => {
void handleGatewayModeChange("local");
}}
disabled={status?.running}
className={`whitespace-nowrap rounded px-2 py-1 text-xs transition-colors ${
gatewayMode === "local"
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground"
} disabled:cursor-not-allowed disabled:opacity-50`}
>
仅本机
</button>
<button
onClick={() => {
void handleGatewayModeChange("lan");
}}
disabled={status?.running}
className={`whitespace-nowrap rounded px-2 py-1 text-xs transition-colors ${
gatewayMode === "lan"
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground"
} disabled:cursor-not-allowed disabled:opacity-50`}
>
内网共享
</button>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">共享地址:</span>
<Select.Root
value={
status?.running ? status.host : editHost || "127.0.0.1"
@@ -1211,24 +1279,35 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
onClick={handleSaveServerConfig}
disabled={loading || status?.running}
className="rounded-md border border-input bg-background px-4 py-1.5 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50"
title={status?.running ? "请先停止服务再修改配置" : ""}
title={status?.running ? "请先关闭共享再修改配置" : ""}
>
保存
</button>
</div>
</div>
{!status?.running && (
<div className="mt-3 flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
<span>ℹ️</span>
<span>
仅本机模式仅允许当前设备访问;内网共享模式会对同网段设备开放
</span>
</div>
)}
{status?.running && (
<div className="mt-3 flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
<span>ℹ️</span>
<span>修改配置需要先停止服务</span>
<span>
当前处于{gatewayMode === "local" ? "仅本机" : "内网共享"}
模式。修改配置需要先关闭共享
</span>
</div>
)}
{hostMismatch && (
<div className="mt-3 flex items-center gap-2 rounded-md bg-blue-50 dark:bg-blue-950/20 px-3 py-2 text-xs text-blue-700 dark:text-blue-400">
<span>ℹ️</span>
<span>
配置的地址 {config?.server.host} 不可用,已自动切换到{" "}
{status?.host}。 停止服务后可更新配置。
配置的共享地址 {config?.server.host} 不可用,已自动切换到{" "}
{status?.host}。关闭共享后可更新配置。
</span>
</div>
)}
@@ -1403,7 +1482,7 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
{/* API Testing */}
<div className="rounded-lg border bg-card p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-semibold">API 测试</h3>
<h3 className="font-semibold">网关 API 测试</h3>
<button
onClick={runAllTests}
disabled={!status?.running || testEndpoints.length === 0}
+1 -1
View File
@@ -112,7 +112,7 @@ export function LogsTab() {
>
{logs.length === 0 ? (
<p className="text-center text-muted-foreground">
暂无日志,软件运行时将显示系统日志
暂无日志,软件运行时将显示网关与系统日志
</p>
) : (
logs.map((log, i) => (
@@ -0,0 +1,247 @@
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GeneratedImage } from "./types";
import {
cleanupMountedRoots,
flushEffects,
renderIntoDom,
setReactActEnvironment,
silenceConsole,
type MountedRoot,
} from "./test-utils";
const {
mockBackfillImagesToResource,
mockGenerateImage,
mockDeleteImage,
mockNewImage,
mockToast,
} = vi.hoisted(() => ({
mockBackfillImagesToResource: vi.fn(),
mockGenerateImage: vi.fn(),
mockDeleteImage: vi.fn(),
mockNewImage: vi.fn(),
mockToast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("sonner", () => ({
toast: mockToast,
}));
vi.mock("@/hooks/useProjects", () => ({
useProjects: () => {
const defaultProject = {
id: "project-default",
name: "默认项目",
workspaceType: "persistent",
rootPath: "/tmp/default",
isDefault: true,
icon: undefined,
color: undefined,
isFavorite: false,
isArchived: false,
tags: [],
createdAt: Date.now(),
updatedAt: Date.now(),
};
return {
projects: [defaultProject],
filteredProjects: [defaultProject],
defaultProject,
loading: false,
error: null,
filter: {},
setFilter: vi.fn(),
refresh: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
getOrCreateDefault: vi.fn(),
};
},
}));
vi.mock("./useImageGen", async () => {
const React = await import("react");
const images: GeneratedImage[] = [
{
id: "img-1",
url: "https://example.com/1.png",
prompt: "第一张提示词",
model: "fal-ai/nano-banana-pro",
size: "1024x1024",
providerId: "fal",
providerName: "Fal",
createdAt: 1700000000000,
status: "complete",
},
{
id: "img-2",
url: "https://example.com/2.png",
prompt: "第二张提示词",
model: "fal-ai/nano-banana-pro",
size: "1024x1024",
providerId: "fal",
providerName: "Fal",
createdAt: 1700000001000,
status: "complete",
},
];
return {
useImageGen: () => {
const [selectedImageId, setSelectedImageId] = React.useState<string | null>(
images[0].id,
);
const selectedImage =
images.find((image) => image.id === selectedImageId) ?? images[0];
return {
availableProviders: [
{
id: "fal",
type: "fal",
name: "Fal",
enabled: true,
api_key_count: 1,
api_host: "https://fal.run",
},
],
selectedProvider: {
id: "fal",
type: "fal",
name: "Fal",
enabled: true,
api_key_count: 1,
api_host: "https://fal.run",
},
selectedProviderId: "fal",
setSelectedProviderId: vi.fn(),
providersLoading: false,
availableModels: [
{
id: "fal-ai/nano-banana-pro",
name: "Nano Banana Pro",
supportedSizes: ["1024x1024"],
},
],
selectedModel: {
id: "fal-ai/nano-banana-pro",
name: "Nano Banana Pro",
supportedSizes: ["1024x1024"],
},
selectedModelId: "fal-ai/nano-banana-pro",
setSelectedModelId: vi.fn(),
selectedSize: "1024x1024",
setSelectedSize: vi.fn(),
images,
selectedImage,
selectedImageId,
setSelectedImageId,
generating: false,
savingToResource: false,
generateImage: mockGenerateImage,
backfillImagesToResource: mockBackfillImagesToResource,
deleteImage: mockDeleteImage,
newImage: mockNewImage,
};
},
};
});
import ImageGenPage from "./ImageGenPage";
const mountedRoots: MountedRoot[] = [];
function renderPage(): HTMLDivElement {
return renderIntoDom(<ImageGenPage />, mountedRoots).container;
}
function findButtonByText(container: HTMLElement, text: string): HTMLButtonElement {
const target = Array.from(container.querySelectorAll("button")).find((node) =>
node.textContent?.includes(text),
);
if (!target) {
throw new Error(`未找到按钮: ${text}`);
}
return target as HTMLButtonElement;
}
function getPromptChip(container: HTMLElement): HTMLButtonElement {
const label = Array.from(container.querySelectorAll("div")).find(
(node) => node.textContent === "当前图片提示词",
);
if (!label || !label.parentElement) {
throw new Error("未找到提示词历史区域");
}
const chip = label.parentElement.querySelector("button");
if (!chip) {
throw new Error("未找到提示词历史按钮");
}
return chip as HTMLButtonElement;
}
beforeEach(() => {
setReactActEnvironment();
localStorage.clear();
vi.clearAllMocks();
silenceConsole();
mockBackfillImagesToResource.mockResolvedValue({
total: 2,
saved: 2,
failed: 0,
skipped: 0,
errors: [],
});
});
afterEach(() => {
cleanupMountedRoots(mountedRoots);
vi.restoreAllMocks();
localStorage.clear();
});
describe("ImageGenPage", () => {
it("应仅显示当前选中图片的提示词历史", async () => {
const container = renderPage();
const chipBefore = getPromptChip(container);
expect(chipBefore.textContent).toContain("第一张提示词");
const secondHistoryItem = container.querySelector<HTMLElement>(
'[role="button"][title="第二张提示词"]',
);
expect(secondHistoryItem).not.toBeNull();
act(() => {
secondHistoryItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const chipAfter = getPromptChip(container);
expect(chipAfter.textContent).toContain("第二张提示词");
});
it("点击补录按钮应使用目标项目触发历史补录", async () => {
const container = renderPage();
await flushEffects();
const backfillButton = findButtonByText(container, "补录历史到资源库");
expect(backfillButton.disabled).toBe(false);
await act(async () => {
backfillButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(mockBackfillImagesToResource).toHaveBeenCalledTimes(1);
expect(mockBackfillImagesToResource).toHaveBeenCalledWith("project-default");
expect(mockToast.success).toHaveBeenCalled();
});
});
+232
View File
@@ -19,8 +19,15 @@ import {
ExternalLink,
X,
} from "lucide-react";
import { toast } from "sonner";
import { useImageGen } from "./useImageGen";
import type { GeneratedImage } from "./types";
import { useProjects } from "@/hooks/useProjects";
import {
getStoredResourceProjectId,
onResourceProjectChange,
setStoredResourceProjectId,
} from "@/lib/resourceProjectSelection";
import type { Page } from "@/types/page";
interface ImageGenPageProps {
@@ -292,6 +299,25 @@ const Select = styled.select`
}
`;
const FullButton = styled.button<{ $disabled?: boolean }>`
width: 100%;
height: 34px;
border-radius: 10px;
border: 1px solid hsl(var(--border));
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 13px;
cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")};
opacity: ${({ $disabled }) => ($disabled ? 0.65 : 1)};
&:hover {
border-color: ${({ $disabled }) =>
$disabled ? "hsl(var(--border))" : "hsl(var(--primary) / 0.4)"};
background: ${({ $disabled }) =>
$disabled ? "hsl(var(--background))" : "hsl(var(--accent) / 0.4)"};
}
`;
const SmallButton = styled.button`
display: inline-flex;
align-items: center;
@@ -624,6 +650,52 @@ const GenerateButton = styled.button<{ $disabled: boolean }>`
justify-content: center;
`;
const PromptHistoryDock = styled.div`
width: 78%;
max-width: 860px;
min-width: 520px;
margin: 8px auto 0;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
@media (max-width: 1100px) {
width: 90%;
min-width: 0;
}
`;
const PromptHistoryLabel = styled.div`
color: hsl(var(--muted-foreground));
white-space: nowrap;
`;
const PromptHistoryChip = styled.button<{ $active: boolean }>`
flex: 1;
max-width: 100%;
border: 1px solid
${({ $active }) => ($active ? "hsl(var(--primary))" : "hsl(var(--border))")};
border-radius: 999px;
background: ${({ $active }) =>
$active ? "hsl(var(--primary) / 0.14)" : "hsl(var(--muted) / 0.35)"};
color: ${({ $active }) =>
$active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"};
padding: 4px 10px;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
&:hover {
border-color: hsl(var(--primary));
color: hsl(var(--primary));
}
`;
const Status = styled.div`
margin-top: 8px;
font-size: 12px;
@@ -752,11 +824,15 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
selectedImageId,
setSelectedImageId,
generating,
savingToResource,
generateImage,
backfillImagesToResource,
deleteImage,
newImage,
} = useImageGen();
const { projects, defaultProject, loading: projectsLoading } = useProjects();
const [prompt, setPrompt] = useState("");
const [resolutionPreset, setResolutionPreset] =
useState<ResolutionPreset>("1k");
@@ -768,9 +844,20 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
[],
);
const [isDraggingUpload, setIsDraggingUpload] = useState(false);
const [targetProjectId, setTargetProjectId] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const availableProjects = useMemo(
() => projects.filter((project) => !project.isArchived),
[projects],
);
const selectedTargetProject = useMemo(
() => availableProjects.find((project) => project.id === targetProjectId),
[availableProjects, targetProjectId],
);
const supportedSizes = useMemo(() => {
return selectedModel?.supportedSizes || FALLBACK_SUPPORTED_SIZES;
}, [selectedModel]);
@@ -785,6 +872,59 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
}
}, [resolvedSize, selectedSize, setSelectedSize]);
useEffect(() => {
if (projectsLoading) {
return;
}
setTargetProjectId((current) => {
if (current && availableProjects.some((project) => project.id === current)) {
return current;
}
const storedProjectId = getStoredResourceProjectId({ includeLegacy: true });
if (
storedProjectId &&
availableProjects.some((project) => project.id === storedProjectId)
) {
return storedProjectId;
}
const preferredProject =
(defaultProject && !defaultProject.isArchived ? defaultProject : null) ??
availableProjects[0];
return preferredProject?.id || "";
});
}, [projectsLoading, availableProjects, defaultProject]);
useEffect(() => {
setStoredResourceProjectId(targetProjectId, {
source: "image-gen-target",
syncLegacy: true,
emitEvent: true,
});
}, [targetProjectId]);
useEffect(() => {
return onResourceProjectChange((detail) => {
if (detail.source !== "resources") {
return;
}
const nextProjectId = detail.projectId;
if (!nextProjectId || nextProjectId === targetProjectId) {
return;
}
if (!availableProjects.some((project) => project.id === nextProjectId)) {
return;
}
setTargetProjectId(nextProjectId);
});
}, [availableProjects, targetProjectId]);
const canGenerate =
!!prompt.trim() && !!selectedProvider && !!selectedModelId && !generating;
@@ -792,6 +932,13 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
return resolveBatchImages(images, selectedImageId);
}, [images, selectedImageId]);
const selectedPromptHistory = useMemo(() => {
return selectedImage?.prompt.trim() || "";
}, [selectedImage]);
const isFalProvider =
selectedProvider?.id === "fal" || selectedProvider?.type === "fal";
const shouldShowBatchGrid = selectedBatchImages.length > 1;
const handleCountSelect = (count: number) => {
@@ -846,6 +993,7 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
imageCount,
referenceImages: referenceImages.map((item) => item.url),
size: resolvedSize,
targetProjectId: targetProjectId || undefined,
});
setPrompt("");
} catch (error) {
@@ -853,6 +1001,29 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
}
};
const handleBackfillToResource = async () => {
if (!targetProjectId) {
toast.error("请先选择目标资源库");
return;
}
try {
const result = await backfillImagesToResource(targetProjectId);
if (result.failed > 0) {
toast.error(`补录完成:成功 ${result.saved},失败 ${result.failed}`);
} else {
toast.success(`补录完成:新增 ${result.saved},跳过 ${result.skipped}`);
}
if (result.errors.length > 0) {
console.warn("[ImageGen] 历史补录失败详情:", result.errors);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
toast.error(`补录失败: ${message}`);
}
};
const handlePromptKeyDown = (
event: React.KeyboardEvent<HTMLTextAreaElement>,
) => {
@@ -919,6 +1090,37 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
<Hint>当前服务商:{selectedProvider?.name || "未选择"}</Hint>
</Section>
<Section>
<SectionTitle>目标资源库</SectionTitle>
<Select
value={targetProjectId}
onChange={(event) => setTargetProjectId(event.target.value)}
disabled={projectsLoading}
>
<option value="">不自动入库</option>
{availableProjects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</Select>
<Hint>
{targetProjectId
? `生成成功后会自动写入「${selectedTargetProject?.name || "已选项目"}」资源库`
: "未启用自动入库,生成结果仅保存在当前页面历史"}
</Hint>
<FullButton
type="button"
onClick={() => {
void handleBackfillToResource();
}}
$disabled={savingToResource || !targetProjectId || images.length === 0}
disabled={savingToResource || !targetProjectId || images.length === 0}
>
{savingToResource ? "补录中..." : "补录历史到资源库"}
</FullButton>
</Section>
<Section>
<SectionTitle>参考图</SectionTitle>
{referenceImages.length > 0 ? (
@@ -971,6 +1173,11 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
hidden
onChange={handleUploadChange}
/>
<Hint>
{isFalProvider
? "Fal 上传参考图会启用图片编辑参数;Nano Banana 会优先尝试 /edit 接口。"
: "上传参考图会随请求发送给模型,是否执行编辑由模型能力决定。"}
</Hint>
</Section>
<Section>
@@ -1045,6 +1252,18 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
</Section>
<Status>实际输出尺寸:{resolvedSize}</Status>
{selectedImage?.status === "complete" && targetProjectId && (
<Status>
{selectedImage.resourceMaterialId &&
selectedImage.resourceProjectId === targetProjectId
? "当前图片已同步到资源库"
: selectedImage.resourceSaveError
? `当前图片入库失败:${selectedImage.resourceSaveError}`
: savingToResource
? "当前图片正在同步到资源库..."
: "当前图片尚未同步到资源库"}
</Status>
)}
</ControlPanel>
<Workspace>
@@ -1170,6 +1389,18 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
)}
</GenerateButton>
</PromptDock>
{selectedPromptHistory && (
<PromptHistoryDock>
<PromptHistoryLabel>当前图片提示词</PromptHistoryLabel>
<PromptHistoryChip
$active={selectedPromptHistory === prompt.trim()}
title={selectedPromptHistory}
onClick={() => setPrompt(selectedPromptHistory)}
>
{selectedPromptHistory}
</PromptHistoryChip>
</PromptHistoryDock>
)}
{!selectedProvider && (
<Status>
@@ -1195,6 +1426,7 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
$active={image.id === selectedImageId}
role="button"
tabIndex={0}
title={image.prompt || "历史图片"}
onClick={() => setSelectedImageId(image.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
+70
View File
@@ -0,0 +1,70 @@
import { act } from "react";
import type { ReactElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { vi } from "vitest";
export interface MountedRoot {
root: Root;
container: HTMLDivElement;
}
export function setReactActEnvironment() {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
}
export function renderIntoDom(
element: ReactElement,
mountedRoots: MountedRoot[],
): MountedRoot {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(element);
});
const mounted = { root, container };
mountedRoots.push(mounted);
return mounted;
}
export function cleanupMountedRoots(mountedRoots: MountedRoot[]) {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
}
export async function flushEffects() {
await act(async () => {
await Promise.resolve();
});
}
export async function waitForCondition(
condition: () => boolean,
timeout = 40,
errorMessage = "等待条件超时",
): Promise<void> {
for (let i = 0; i < timeout; i += 1) {
if (condition()) {
return;
}
await flushEffects();
}
throw new Error(errorMessage);
}
export function silenceConsole() {
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
}
+4
View File
@@ -22,6 +22,10 @@ export interface GeneratedImage {
createdAt: number;
status: "pending" | "generating" | "complete" | "error";
error?: string;
resourceMaterialId?: string;
resourceProjectId?: string;
resourceSavedAt?: number;
resourceSaveError?: string;
}
/** 图片生成请求 */
@@ -0,0 +1,174 @@
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupMountedRoots,
flushEffects,
renderIntoDom,
setReactActEnvironment,
silenceConsole,
type MountedRoot,
} from "./test-utils";
const { mockGetNextApiKey, mockInvoke } = vi.hoisted(() => ({
mockGetNextApiKey: vi.fn(),
mockInvoke: vi.fn(),
}));
vi.mock("@/hooks/useApiKeyProvider", () => ({
useApiKeyProvider: () => ({
providers: [
{
id: "zhipuai",
type: "zhipuai",
name: "智谱AI",
enabled: true,
api_key_count: 1,
api_host: "https://api.zhipu.test",
},
],
loading: false,
}),
}));
vi.mock("@/lib/api/apiKeyProvider", () => ({
apiKeyProviderApi: {
getNextApiKey: mockGetNextApiKey,
},
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: mockInvoke,
}));
import { useImageGen } from "./useImageGen";
interface HookHarness {
getValue: () => ReturnType<typeof useImageGen>;
}
const mountedRoots: MountedRoot[] = [];
function mountHook(): HookHarness {
let hookValue: ReturnType<typeof useImageGen> | null = null;
function TestComponent() {
hookValue = useImageGen();
return null;
}
renderIntoDom(<TestComponent />, mountedRoots);
return {
getValue: () => {
if (!hookValue) {
throw new Error("hook 尚未初始化");
}
return hookValue;
},
};
}
async function waitForReady(
harness: HookHarness,
timeout = 40,
): Promise<void> {
for (let i = 0; i < timeout; i += 1) {
const value = harness.getValue();
if (value.selectedProvider && value.selectedModelId) {
return;
}
await flushEffects();
}
throw new Error("useImageGen 未在预期时间内就绪");
}
function createSuccessResponse() {
return new Response(
JSON.stringify({
data: [{ url: "https://cdn.example.com/generated.png" }],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}
beforeEach(() => {
setReactActEnvironment();
localStorage.clear();
vi.clearAllMocks();
silenceConsole();
mockGetNextApiKey.mockResolvedValue("test-api-key");
mockInvoke.mockResolvedValue({ id: "material-1" });
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(createSuccessResponse()) as unknown as typeof fetch,
);
});
afterEach(() => {
cleanupMountedRoots(mountedRoots);
vi.unstubAllGlobals();
vi.restoreAllMocks();
localStorage.clear();
});
describe("useImageGen 资源入库", () => {
it("自动入库成功时应回写素材字段", async () => {
const harness = mountHook();
await waitForReady(harness);
await act(async () => {
await harness.getValue().generateImage("生成一张测试图", {
targetProjectId: "project-1",
});
});
const completed = harness
.getValue()
.images.find((image) => image.status === "complete");
expect(completed).toBeDefined();
expect(completed?.resourceMaterialId).toBe("material-1");
expect(completed?.resourceProjectId).toBe("project-1");
expect(typeof completed?.resourceSavedAt).toBe("number");
expect(completed?.resourceSaveError).toBeUndefined();
expect(mockInvoke).toHaveBeenCalledTimes(1);
expect(mockInvoke).toHaveBeenCalledWith(
"import_material_from_url",
expect.objectContaining({
req: expect.objectContaining({
projectId: "project-1",
type: "image",
url: "https://cdn.example.com/generated.png",
}),
}),
);
});
it("自动入库失败时应保留图片并写入错误信息", async () => {
mockInvoke.mockRejectedValueOnce(new Error("resource save failed"));
const harness = mountHook();
await waitForReady(harness);
await act(async () => {
await harness.getValue().generateImage("生成一张失败回写图", {
targetProjectId: "project-1",
});
});
const completed = harness
.getValue()
.images.find((image) => image.status === "complete");
expect(completed).toBeDefined();
expect(completed?.resourceMaterialId).toBeUndefined();
expect(completed?.resourceProjectId).toBeUndefined();
expect(completed?.resourceSaveError).toBe("resource save failed");
});
});
@@ -6,6 +6,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __imageGenFalTestUtils } from "./useImageGen";
import { silenceConsole } from "./test-utils";
const { requestImageFromFal, resolveFalEndpointModelCandidates } =
__imageGenFalTestUtils;
@@ -27,6 +28,7 @@ describe("useImageGen Fal 调用链路", () => {
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
silenceConsole();
});
afterEach(() => {
+278 -5
View File
@@ -4,9 +4,11 @@
* @module components/image-gen/useImageGen
*/
import { useState, useCallback, useEffect, useMemo } from "react";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useApiKeyProvider } from "@/hooks/useApiKeyProvider";
import { apiKeyProviderApi } from "@/lib/api/apiKeyProvider";
import { setStoredResourceProjectId } from "@/lib/resourceProjectSelection";
import type {
GeneratedImage,
ImageGenRequest,
@@ -21,6 +23,7 @@ interface GenerateImageOptions {
imageCount?: number;
referenceImages?: string[];
size?: string;
targetProjectId?: string;
}
interface EndpointAttemptResult {
@@ -33,11 +36,64 @@ interface EndpointRequestOptions {
timeoutMs?: number;
}
interface ImportMaterialFromUrlRequest {
projectId: string;
name: string;
type: "image";
url: string;
tags?: string[];
description?: string;
}
interface BackfillImagesResult {
total: number;
saved: number;
failed: number;
skipped: number;
errors: string[];
}
interface SaveImageToResourceResult {
saved: boolean;
skipped: boolean;
error?: string;
}
const IMAGE_REQUEST_TIMEOUT_MS = 180_000;
const FAL_DEFAULT_API_HOST = "https://fal.run";
const FAL_QUEUE_API_HOST = "https://queue.fal.run";
const FAL_QUEUE_POLL_INTERVAL_MS = 1500;
const FAL_QUEUE_TIMEOUT_MS = 180_000;
const IMAGE_GEN_MATERIAL_TAG = "image-gen";
const IMAGE_MATERIAL_NAME_MAX_LENGTH = 48;
function sanitizeMaterialName(value: string): string {
return value
.replace(/[\\/:*?"<>|]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function formatDateForMaterialName(timestamp: number): string {
const date = new Date(timestamp);
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, "0");
const day = `${date.getDate()}`.padStart(2, "0");
const hour = `${date.getHours()}`.padStart(2, "0");
const minute = `${date.getMinutes()}`.padStart(2, "0");
const second = `${date.getSeconds()}`.padStart(2, "0");
return `${year}${month}${day}-${hour}${minute}${second}`;
}
function buildGeneratedImageMaterialName(image: GeneratedImage): string {
const promptHead = sanitizeMaterialName(image.prompt || "").slice(
0,
IMAGE_MATERIAL_NAME_MAX_LENGTH,
);
const prefix = promptHead || "生成图片";
const timestamp = formatDateForMaterialName(image.createdAt);
return `${prefix}-${timestamp}.png`;
}
function buildProviderEndpoint(apiHost: string, endpointPath: string): string {
const trimmedHost = (apiHost || "").trim().replace(/\/+$/, "");
@@ -1496,6 +1552,8 @@ export function useImageGen() {
const [images, setImages] = useState<GeneratedImage[]>([]);
const [selectedImageId, setSelectedImageId] = useState<string | null>(null);
const [generating, setGenerating] = useState(false);
const [resourceSavingCount, setResourceSavingCount] = useState(0);
const imagesRef = useRef<GeneratedImage[]>([]);
// 过滤出支持图片生成、启用且有 API Key 的 Provider
const availableProviders = useMemo(() => {
@@ -1542,6 +1600,10 @@ export function useImageGen() {
}
}, []);
useEffect(() => {
imagesRef.current = images;
}, [images]);
// 自动选择第一个可用的 Provider
useEffect(() => {
if (!selectedProviderId && availableProviders.length > 0) {
@@ -1564,6 +1626,91 @@ export function useImageGen() {
localStorage.setItem(HISTORY_KEY, JSON.stringify(newImages.slice(0, 50)));
}, []);
const savingToResource = resourceSavingCount > 0;
const saveImageToResource = useCallback(
async (
image: GeneratedImage,
targetProjectId: string,
): Promise<SaveImageToResourceResult> => {
const normalizedTargetProjectId = targetProjectId.trim();
if (!normalizedTargetProjectId) {
return { saved: false, skipped: true, error: "未指定目标资源库" };
}
if (image.status !== "complete" || !image.url) {
return { saved: false, skipped: true };
}
const existing = imagesRef.current.find((item) => item.id === image.id);
if (
existing?.resourceMaterialId &&
existing.resourceProjectId === normalizedTargetProjectId
) {
return { saved: false, skipped: true };
}
const request: ImportMaterialFromUrlRequest = {
projectId: normalizedTargetProjectId,
name: buildGeneratedImageMaterialName(image),
type: "image",
url: image.url,
tags: [IMAGE_GEN_MATERIAL_TAG],
description: `图片生成自动入库(模型:${image.model},尺寸:${image.size})`,
};
setResourceSavingCount((count) => count + 1);
try {
const savedMaterial = await invoke<{ id: string }>(
"import_material_from_url",
{ req: request },
);
const savedAt = Date.now();
setImages((prev) => {
const updated = prev.map((item) =>
item.id === image.id
? {
...item,
resourceMaterialId: savedMaterial.id,
resourceProjectId: normalizedTargetProjectId,
resourceSavedAt: savedAt,
resourceSaveError: undefined,
}
: item,
);
saveHistory(updated);
return updated;
});
setStoredResourceProjectId(normalizedTargetProjectId, {
source: "image-gen-save",
syncLegacy: true,
emitEvent: true,
});
return { saved: true, skipped: false };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
setImages((prev) => {
const updated = prev.map((item) =>
item.id === image.id
? { ...item, resourceSaveError: errorMessage }
: item,
);
saveHistory(updated);
return updated;
});
return { saved: false, skipped: false, error: errorMessage };
} finally {
setResourceSavingCount((count) => Math.max(0, count - 1));
}
},
[saveHistory],
);
// 获取当前选中的 Provider
const selectedProvider = useMemo(() => {
return availableProviders.find((p) => p.id === selectedProviderId);
@@ -1621,6 +1768,7 @@ export function useImageGen() {
);
const requestSize = options?.size || selectedSize;
const referenceImages = options?.referenceImages || [];
const targetProjectId = options?.targetProjectId?.trim() || "";
const baseId = Date.now();
const generationItems: GeneratedImage[] = Array.from(
@@ -1676,15 +1824,31 @@ export function useImageGen() {
requestSize,
);
const completedImage: GeneratedImage = {
...item,
url: imageUrl,
status: "complete",
error: undefined,
};
setImages((prev) => {
const updated = prev.map((img) =>
img.id === item.id
? { ...img, url: imageUrl, status: "complete" as const }
? {
...img,
url: imageUrl,
status: "complete" as const,
error: undefined,
}
: img,
);
saveHistory(updated);
return updated;
});
if (targetProjectId) {
await saveImageToResource(completedImage, targetProjectId);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
@@ -1721,15 +1885,31 @@ export function useImageGen() {
requestSize,
);
const completedImage: GeneratedImage = {
...item,
url: imageUrl,
status: "complete",
error: undefined,
};
setImages((prev) => {
const updated = prev.map((img) =>
img.id === item.id
? { ...img, url: imageUrl, status: "complete" as const }
? {
...img,
url: imageUrl,
status: "complete" as const,
error: undefined,
}
: img,
);
saveHistory(updated);
return updated;
});
if (targetProjectId) {
await saveImageToResource(completedImage, targetProjectId);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
@@ -1826,6 +2006,23 @@ export function useImageGen() {
throw new Error("未返回图片 URL(响应中未检测到可解析图片字段)");
}
const completedImages: GeneratedImage[] = generationItems.flatMap(
(item, index) => {
const imageUrl = urls[index];
if (!imageUrl) {
return [];
}
return [
{
...item,
url: imageUrl,
status: "complete" as const,
error: undefined,
},
];
},
);
setImages((prev) => {
const updated = prev.map((img) => {
const index = generationItems.findIndex(
@@ -1836,7 +2033,12 @@ export function useImageGen() {
const imageUrl = urls[index];
if (imageUrl) {
return { ...img, url: imageUrl, status: "complete" as const };
return {
...img,
url: imageUrl,
status: "complete" as const,
error: undefined,
};
}
return {
@@ -1849,6 +2051,12 @@ export function useImageGen() {
saveHistory(updated);
return updated;
});
if (targetProjectId) {
for (const image of completedImages) {
await saveImageToResource(image, targetProjectId);
}
}
}
} catch (error) {
const errorMessage =
@@ -1869,7 +2077,70 @@ export function useImageGen() {
setGenerating(false);
}
},
[selectedProvider, selectedModelId, selectedSize, saveHistory],
[
selectedProvider,
selectedModelId,
selectedSize,
saveHistory,
saveImageToResource,
],
);
const backfillImagesToResource = useCallback(
async (targetProjectId: string): Promise<BackfillImagesResult> => {
const normalizedTargetProjectId = targetProjectId.trim();
const completedImages = imagesRef.current.filter(
(image) => image.status === "complete" && !!image.url,
);
const result: BackfillImagesResult = {
total: completedImages.length,
saved: 0,
failed: 0,
skipped: 0,
errors: [],
};
if (!normalizedTargetProjectId) {
if (completedImages.length > 0) {
result.failed = completedImages.length;
result.errors.push("未指定目标资源库");
}
return result;
}
for (const image of completedImages) {
if (
image.resourceMaterialId &&
image.resourceProjectId === normalizedTargetProjectId
) {
result.skipped += 1;
continue;
}
const saveResult = await saveImageToResource(
image,
normalizedTargetProjectId,
);
if (saveResult.skipped) {
result.skipped += 1;
continue;
}
if (saveResult.saved) {
result.saved += 1;
continue;
}
result.failed += 1;
if (saveResult.error) {
result.errors.push(`${image.id}: ${saveResult.error}`);
}
}
return result;
},
[saveImageToResource],
);
// 删除图片
@@ -1941,9 +2212,11 @@ export function useImageGen() {
selectedImageId,
setSelectedImageId,
generating,
savingToResource,
// 操作
generateImage,
backfillImagesToResource,
deleteImage,
newImage,
};
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import type { Project } from "@/types/project";
import { getAvailableProjects } from "./projectSelectorUtils";
function createProject(overrides: Partial<Project>): Project {
return {
id: "project-id",
name: "项目",
workspaceType: "general",
rootPath: "/tmp/project",
isDefault: false,
icon: undefined,
color: undefined,
isFavorite: false,
isArchived: false,
tags: [],
createdAt: 1,
updatedAt: 1,
...overrides,
};
}
describe("getAvailableProjects", () => {
it("workspaceType 为 general 时只保留默认项目和通用项目", () => {
const projects = [
createProject({
id: "default",
name: "默认项目",
isDefault: true,
workspaceType: "general",
}),
createProject({
id: "general-1",
name: "通用项目",
workspaceType: "general",
}),
createProject({
id: "social-1",
name: "社媒项目",
workspaceType: "social-media",
}),
];
const result = getAvailableProjects(projects, "general");
expect(result.map((project) => project.id)).toEqual(["default", "general-1"]);
});
it("workspaceType 为其他类型时保留该类型和默认项目,并排除归档", () => {
const projects = [
createProject({
id: "default",
name: "默认项目",
isDefault: true,
workspaceType: "general",
}),
createProject({
id: "social-1",
name: "社媒项目 A",
workspaceType: "social-media",
}),
createProject({
id: "social-archived",
name: "社媒项目归档",
workspaceType: "social-media",
isArchived: true,
}),
createProject({
id: "general-1",
name: "通用项目",
workspaceType: "general",
}),
];
const result = getAvailableProjects(projects, "social-media");
expect(result.map((project) => project.id)).toEqual(["default", "social-1"]);
});
it("未提供 workspaceType 时返回全部未归档项目,默认项目置顶", () => {
const projects = [
createProject({
id: "social-1",
name: "社媒项目",
workspaceType: "social-media",
}),
createProject({
id: "default",
name: "默认项目",
isDefault: true,
workspaceType: "general",
}),
createProject({
id: "general-1",
name: "通用项目",
workspaceType: "general",
}),
createProject({
id: "archived",
name: "归档项目",
workspaceType: "general",
isArchived: true,
}),
];
const result = getAvailableProjects(projects);
expect(result.map((project) => project.id)).toEqual([
"default",
"social-1",
"general-1",
]);
});
});
+2 -15
View File
@@ -14,6 +14,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useProjects } from "@/hooks/useProjects";
import { getAvailableProjects } from "./projectSelectorUtils";
import { FolderIcon, StarIcon } from "lucide-react";
export interface ProjectSelectorProps {
@@ -56,21 +57,7 @@ export function ProjectSelector({
// 过滤项目:排除归档 + 按主题类型筛选
const availableProjects = useMemo(() => {
let filtered = projects.filter((p) => !p.isArchived);
// 按主题类型筛选(默认项目始终显示)
if (workspaceType && workspaceType !== "general") {
filtered = filtered.filter(
(p) => p.isDefault || p.workspaceType === workspaceType,
);
}
// 默认项目排在最前面
return filtered.sort((a, b) => {
if (a.isDefault && !b.isDefault) return -1;
if (!a.isDefault && b.isDefault) return 1;
return 0;
});
return getAvailableProjects(projects, workspaceType);
}, [projects, workspaceType]);
// 查找当前选中的项目
@@ -21,6 +21,8 @@ import {
FileTextIcon,
DatabaseIcon,
LinkIcon,
Music2Icon,
VideoIcon,
ExternalLinkIcon,
PaletteIcon,
LayoutIcon,
@@ -37,6 +39,8 @@ export interface MaterialPreviewDialogProps {
const MaterialTypeIcons: Record<MaterialType, typeof FileIcon> = {
document: FileIcon,
image: ImageIcon,
audio: Music2Icon,
video: VideoIcon,
text: FileTextIcon,
data: DatabaseIcon,
link: LinkIcon,
@@ -66,7 +70,12 @@ export function MaterialPreviewDialog({
}
// 处理图片类型
if (material.type === "image" && material.filePath) {
if (
(material.type === "image" ||
material.type === "audio" ||
material.type === "video") &&
material.filePath
) {
const src = convertFileSrc(material.filePath);
setImageSrc(src);
}
@@ -109,6 +118,32 @@ export function MaterialPreviewDialog({
</div>
);
case "audio":
return imageSrc ? (
<div className="p-4 bg-muted/30 rounded-lg">
<audio controls className="w-full" src={imageSrc}>
当前浏览器不支持音频预览
</audio>
</div>
) : (
<div className="flex items-center justify-center h-48 bg-muted/30 rounded-lg">
<p className="text-muted-foreground">无法加载音频</p>
</div>
);
case "video":
return imageSrc ? (
<div className="p-4 bg-muted/30 rounded-lg">
<video controls className="max-h-[420px] w-full rounded" src={imageSrc}>
当前浏览器不支持视频预览
</video>
</div>
) : (
<div className="flex items-center justify-center h-48 bg-muted/30 rounded-lg">
<p className="text-muted-foreground">无法加载视频</p>
</div>
);
case "link":
return (
<div className="p-4 bg-muted/30 rounded-lg">
@@ -5,7 +5,8 @@
* @requirements 7.1, 7.2
*/
import React, { useState, useRef } from "react";
import React, { useState } from "react";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import {
Dialog,
DialogContent,
@@ -24,24 +25,75 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { UploadIcon, FileIcon, Loader2Icon, XIcon } from "lucide-react";
import {
UploadIcon,
FileIcon,
Loader2Icon,
XIcon,
FolderOpenIcon,
} from "lucide-react";
import type { MaterialType, UploadMaterialRequest } from "@/types/material";
export interface MaterialUploadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: string;
onUpload: (data: UploadMaterialRequest, file?: File) => Promise<void>;
onUpload: (data: UploadMaterialRequest) => Promise<void>;
}
const TYPE_OPTIONS: { value: MaterialType; label: string }[] = [
{ value: "document", label: "文档" },
{ value: "image", label: "图片" },
{ value: "audio", label: "语音" },
{ value: "video", label: "视频" },
{ value: "text", label: "文本" },
{ value: "data", label: "数据" },
{ value: "link", label: "链接" },
];
const IMAGE_EXTENSIONS = new Set([
"jpg",
"jpeg",
"png",
"gif",
"webp",
"svg",
"bmp",
]);
const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "aac", "m4a", "ogg", "flac"]);
const VIDEO_EXTENSIONS = new Set(["mp4", "mov", "avi", "mkv", "webm", "flv"]);
const DATA_EXTENSIONS = new Set(["csv", "json", "xml", "xlsx", "xls"]);
const TEXT_EXTENSIONS = new Set(["txt", "md"]);
const extractFileNameFromPath = (filePath: string): string => {
const normalized = filePath.replace(/\\/g, "/");
const name = normalized.split("/").pop();
return name && name.trim() ? name : "未命名文件";
};
const inferMaterialTypeFromPath = (filePath: string): MaterialType => {
const extension = filePath.split(".").pop()?.toLowerCase();
if (!extension) {
return "document";
}
if (IMAGE_EXTENSIONS.has(extension)) {
return "image";
}
if (AUDIO_EXTENSIONS.has(extension)) {
return "audio";
}
if (VIDEO_EXTENSIONS.has(extension)) {
return "video";
}
if (DATA_EXTENSIONS.has(extension)) {
return "data";
}
if (TEXT_EXTENSIONS.has(extension)) {
return "text";
}
return "document";
};
export function MaterialUploadDialog({
open,
onOpenChange,
@@ -54,8 +106,7 @@ export function MaterialUploadDialog({
const [description, setDescription] = useState("");
const [tags, setTags] = useState("");
const [content, setContent] = useState("");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
const resetForm = () => {
setName("");
@@ -63,24 +114,33 @@ export function MaterialUploadDialog({
setDescription("");
setTags("");
setContent("");
setSelectedFile(null);
setSelectedFilePath(null);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
if (!name) setName(file.name);
// 根据文件类型自动设置素材类型
if (file.type.startsWith("image/")) setType("image");
else if (file.type.includes("json") || file.type.includes("csv"))
setType("data");
else setType("document");
const handleFileSelect = async () => {
const selected = await openDialog({
title: "选择素材文件",
directory: false,
multiple: false,
});
if (!selected || Array.isArray(selected)) {
return;
}
setSelectedFilePath(selected);
if (!name.trim()) {
setName(extractFileNameFromPath(selected));
}
setType(inferMaterialTypeFromPath(selected));
};
const handleUpload = async () => {
if (!name.trim()) return;
const requiresFile = !["text", "link"].includes(type);
if (requiresFile && !selectedFilePath) {
return;
}
setUploading(true);
try {
await onUpload(
@@ -97,8 +157,8 @@ export function MaterialUploadDialog({
: [],
content:
type === "text" || type === "link" ? content.trim() : undefined,
filePath: selectedFilePath ?? undefined,
},
selectedFile || undefined,
);
resetForm();
onOpenChange(false);
@@ -124,22 +184,19 @@ export function MaterialUploadDialog({
{/* 文件选择区域 */}
<div
className="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => fileInputRef.current?.click()}
onClick={() => {
void handleFileSelect();
}}
>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileSelect}
accept="image/*,.pdf,.doc,.docx,.txt,.md,.json,.csv"
/>
{selectedFile ? (
{selectedFilePath ? (
<div className="flex items-center justify-center gap-2">
<FileIcon className="h-8 w-8 text-primary" />
<div className="text-left">
<p className="font-medium">{selectedFile.name}</p>
<p className="text-xs text-muted-foreground">
{(selectedFile.size / 1024).toFixed(1)} KB
<p className="font-medium">
{extractFileNameFromPath(selectedFilePath)}
</p>
<p className="text-xs text-muted-foreground truncate max-w-[280px]">
{selectedFilePath}
</p>
</div>
<Button
@@ -148,7 +205,7 @@ export function MaterialUploadDialog({
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
setSelectedFile(null);
setSelectedFilePath(null);
}}
>
<XIcon className="h-4 w-4" />
@@ -158,15 +215,28 @@ export function MaterialUploadDialog({
<>
<UploadIcon className="h-10 w-10 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
点击或拖拽文件到此处
点击选择本地文件
</p>
<p className="text-xs text-muted-foreground mt-1">
支持图片、文档、数据文件
支持图片、文档、音视频、数据文件
</p>
</>
)}
</div>
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => {
void handleFileSelect();
}}
>
<FolderOpenIcon className="h-4 w-4 mr-1" />
重新选择文件
</Button>
</div>
{/* 素材名称 */}
<div className="space-y-2">
<Label htmlFor="material-name">素材名称 *</Label>
@@ -241,7 +311,14 @@ export function MaterialUploadDialog({
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleUpload} disabled={uploading || !name.trim()}>
<Button
onClick={handleUpload}
disabled={
uploading ||
!name.trim() ||
(!selectedFilePath && !["text", "link"].includes(type))
}
>
{uploading ? (
<Loader2Icon className="h-4 w-4 mr-1 animate-spin" />
) : (
@@ -0,0 +1,28 @@
import type { Project } from "@/types/project";
/**
* 计算可选项目列表
*
* 规则:
* 1. 始终排除归档项目
* 2. 提供 workspaceType 时仅显示该类型项目 + 默认项目
* 3. 默认项目固定置顶
*/
export function getAvailableProjects(
projects: Project[],
workspaceType?: string,
): Project[] {
let filtered = projects.filter((project) => !project.isArchived);
if (workspaceType) {
filtered = filtered.filter(
(project) => project.isDefault || project.workspaceType === workspaceType,
);
}
return [...filtered].sort((a, b) => {
if (a.isDefault && !b.isDefault) return -1;
if (!a.isDefault && b.isDefault) return 1;
return 0;
});
}
+5 -3
View File
@@ -18,6 +18,8 @@ import {
FileTextIcon,
DatabaseIcon,
LinkIcon,
Music2Icon,
VideoIcon,
TrashIcon,
EyeIcon,
PaletteIcon,
@@ -39,6 +41,8 @@ export interface MaterialTabProps {
const MaterialTypeIcons: Record<MaterialType, typeof FileIcon> = {
document: FileIcon,
image: ImageIcon,
audio: Music2Icon,
video: VideoIcon,
text: FileTextIcon,
data: DatabaseIcon,
link: LinkIcon,
@@ -71,9 +75,7 @@ export function MaterialTab({ projectId }: MaterialTabProps) {
setFilter({ ...filter, searchQuery: query });
};
const handleUpload = async (data: UploadMaterialRequest, _file?: File) => {
// TODO: 文件上传需要使用 Tauri 文件对话框获取路径
// 目前仅支持文本/链接类型的素材
const handleUpload = async (data: UploadMaterialRequest) => {
await upload(data);
};
@@ -356,7 +356,7 @@ export const ProviderPoolPage = forwardRef<
<div>
<h2 className="text-2xl font-bold">凭证池</h2>
<p className="text-muted-foreground text-sm">
管理多个 AI 服务凭证,自动轮询负载均衡。在 API Server 选择默认
管理多个 AI 服务凭证,自动轮询负载均衡。在团队共享网关中选择默认
Provider 后自动使用对应凭证
</p>
</div>
+173 -10
View File
@@ -54,6 +54,11 @@ import {
TableRow,
} from "@/components/ui/table";
import { useProjects } from "@/hooks/useProjects";
import {
getStoredResourceProjectId,
onResourceProjectChange,
setStoredResourceProjectId,
} from "@/lib/resourceProjectSelection";
import { cn } from "@/lib/utils";
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
import type { Page, PageParams } from "@/types/page";
@@ -98,6 +103,12 @@ const resourceCategoryLabelMap: Record<ResourceViewCategory, string> = {
video: "视频",
};
const mediaCategoryLabelMap: Record<"image" | "audio" | "video", string> = {
image: "图片",
audio: "语音",
video: "视频",
};
const sortFieldLabelMap: Record<"updatedAt" | "createdAt" | "name", string> = {
updatedAt: "更新时间",
createdAt: "创建时间",
@@ -140,22 +151,39 @@ const getFileExtension = (filename: string): string => {
return filename.slice(index + 1).toLowerCase();
};
const getResourceMediaType = (
item: ResourceItem,
): "image" | "audio" | "video" | null => {
if (item.kind !== "file") return null;
const normalizedMimeType = item.mimeType?.toLowerCase() ?? "";
if (normalizedMimeType.startsWith("image/")) return "image";
if (normalizedMimeType.startsWith("audio/")) return "audio";
if (normalizedMimeType.startsWith("video/")) return "video";
const normalizedFileType = item.fileType?.toLowerCase() ?? "";
if (normalizedFileType === "image") return "image";
if (normalizedFileType === "audio") return "audio";
if (normalizedFileType === "video") return "video";
const extension = getFileExtension(item.filePath || item.name);
if (imageExtensions.has(extension)) return "image";
if (audioExtensions.has(extension)) return "audio";
if (videoExtensions.has(extension)) return "video";
return null;
};
const isImageResource = (item: ResourceItem): boolean => {
if (item.kind !== "file") return false;
const fileType = (item.fileType || getFileExtension(item.name)).toLowerCase();
return item.mimeType?.toLowerCase().startsWith("image/") ?? imageExtensions.has(fileType);
return getResourceMediaType(item) === "image";
};
const isAudioResource = (item: ResourceItem): boolean => {
if (item.kind !== "file") return false;
const fileType = (item.fileType || getFileExtension(item.name)).toLowerCase();
return item.mimeType?.toLowerCase().startsWith("audio/") ?? audioExtensions.has(fileType);
return getResourceMediaType(item) === "audio";
};
const isVideoResource = (item: ResourceItem): boolean => {
if (item.kind !== "file") return false;
const fileType = (item.fileType || getFileExtension(item.name)).toLowerCase();
return item.mimeType?.toLowerCase().startsWith("video/") ?? videoExtensions.has(fileType);
return getResourceMediaType(item) === "video";
};
const matchResourceCategory = (
@@ -163,7 +191,11 @@ const matchResourceCategory = (
category: ResourceViewCategory,
): boolean => {
if (category === "all") return true;
if (category === "document") return item.kind === "document";
if (category === "document") {
if (item.kind === "document") return true;
if (item.kind !== "file") return false;
return !isImageResource(item) && !isAudioResource(item) && !isVideoResource(item);
}
if (category === "image") return isImageResource(item);
if (category === "audio") return isAudioResource(item);
return isVideoResource(item);
@@ -258,6 +290,12 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
const [previewTitle, setPreviewTitle] = useState("");
const [previewContent, setPreviewContent] = useState("");
const [previewLoading, setPreviewLoading] = useState(false);
const [crossProjectMediaHint, setCrossProjectMediaHint] = useState<{
projectId: string;
projectName: string;
count: number;
category: "image" | "audio" | "video";
} | null>(null);
const availableProjects = useMemo(
() => projects.filter((project) => !project.isArchived),
@@ -308,6 +346,15 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
useEffect(() => {
if (projectId || projectsLoading) return;
const storedProjectId = getStoredResourceProjectId({ includeLegacy: true });
if (
storedProjectId &&
availableProjects.some((project) => project.id === storedProjectId)
) {
setProjectId(storedProjectId);
return;
}
const preferredProject =
(defaultProject && !defaultProject.isArchived ? defaultProject : null) ??
availableProjects[0];
@@ -322,11 +369,110 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
setProjectId,
]);
useEffect(() => {
setStoredResourceProjectId(projectId, {
source: "resources",
emitEvent: true,
});
}, [projectId]);
useEffect(() => {
return onResourceProjectChange((detail) => {
if (
detail.source !== "image-gen-target" &&
detail.source !== "image-gen-save"
) {
return;
}
if (!detail.projectId || detail.projectId === projectId) {
return;
}
if (!availableProjects.some((project) => project.id === detail.projectId)) {
return;
}
setProjectId(detail.projectId);
});
}, [availableProjects, projectId, setProjectId]);
useEffect(() => {
if (!projectId) return;
void loadResources();
}, [projectId, loadResources]);
useEffect(() => {
if (
loading ||
!projectId ||
(viewCategory !== "image" &&
viewCategory !== "audio" &&
viewCategory !== "video")
) {
setCrossProjectMediaHint(null);
return;
}
if (categoryCounts[viewCategory] > 0) {
setCrossProjectMediaHint(null);
return;
}
const candidateProjects = availableProjects.filter(
(project) => project.id !== projectId,
);
if (candidateProjects.length === 0) {
setCrossProjectMediaHint(null);
return;
}
let disposed = false;
void (async () => {
const results = await Promise.all(
candidateProjects.map(async (project) => {
try {
const materials = await invoke<unknown[]>("list_materials", {
projectId: project.id,
project_id: project.id,
filter: { type: viewCategory },
});
return {
projectId: project.id,
projectName: project.name,
count: materials.length,
};
} catch {
return {
projectId: project.id,
projectName: project.name,
count: 0,
};
}
}),
);
if (disposed) {
return;
}
const matched = results.find((item) => item.count > 0);
if (!matched) {
setCrossProjectMediaHint(null);
return;
}
setCrossProjectMediaHint({
...matched,
category: viewCategory,
});
})();
return () => {
disposed = true;
};
}, [availableProjects, categoryCounts, loading, projectId, viewCategory]);
const handleCreateFolder = useCallback(async () => {
const name = window.prompt("请输入文件夹名称");
if (!name?.trim()) return;
@@ -718,6 +864,23 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
当前为「{resourceCategoryLabelMap[viewCategory]}」分类视图,展示整个资源库内该分类内容
</div>
)}
{crossProjectMediaHint && (
<div className="mt-2 flex items-center justify-between gap-3 rounded-md border border-amber-300/70 bg-amber-50 px-3 py-2 text-sm text-amber-900">
<span className="truncate">
当前资源库暂无{mediaCategoryLabelMap[crossProjectMediaHint.category]},检测到「
{crossProjectMediaHint.projectName}」包含 {crossProjectMediaHint.count} 个
{mediaCategoryLabelMap[crossProjectMediaHint.category]}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setProjectId(crossProjectMediaHint.projectId)}
>
切换查看
</Button>
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-auto p-5">
@@ -39,10 +39,27 @@ const IMAGE_EXTENSIONS = new Set([
"bmp",
]);
const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "aac", "m4a", "ogg", "flac"]);
const VIDEO_EXTENSIONS = new Set(["mp4", "mov", "avi", "mkv", "webm", "flv"]);
const DATA_EXTENSIONS = new Set(["csv", "json", "xml", "xlsx", "xls"]);
const TEXT_EXTENSIONS = new Set(["txt", "md"]);
const KNOWN_MATERIAL_TYPES = new Set<MaterialType>([
"document",
"image",
"audio",
"video",
"text",
"data",
"link",
"icon",
"color",
"layout",
]);
const toTimestampMs = (value: number | undefined): number => {
if (!value || Number.isNaN(value)) {
return Date.now();
@@ -71,6 +88,75 @@ const parseResourceMetadata = (value: unknown): ResourceMetadata => {
};
};
const getPathExtension = (value: string | undefined): string => {
if (!value) {
return "";
}
const normalized = value.replace(/\\/g, "/");
const fileName = normalized.split("/").pop() || normalized;
const dotIndex = fileName.lastIndexOf(".");
if (dotIndex < 0 || dotIndex === fileName.length - 1) {
return "";
}
return fileName.slice(dotIndex + 1).toLowerCase();
};
const inferMaterialTypeFromMime = (
mimeType: string | undefined,
): MaterialType | null => {
const normalized = mimeType?.toLowerCase().trim();
if (!normalized) {
return null;
}
if (normalized.startsWith("image/")) return "image";
if (normalized.startsWith("audio/")) return "audio";
if (normalized.startsWith("video/")) return "video";
if (normalized.startsWith("text/")) return "text";
if (
normalized.includes("json") ||
normalized.includes("xml") ||
normalized.includes("spreadsheet")
) {
return "data";
}
return null;
};
const normalizeMaterialType = (
rawMaterialType: string | undefined,
mimeType: string | undefined,
filePath: string | undefined,
fileName: string,
): MaterialType => {
const normalizedRaw = rawMaterialType?.toLowerCase().trim();
if (normalizedRaw && KNOWN_MATERIAL_TYPES.has(normalizedRaw as MaterialType)) {
return normalizedRaw as MaterialType;
}
const mimeInferred = inferMaterialTypeFromMime(mimeType);
if (mimeInferred) {
return mimeInferred;
}
const extension =
getPathExtension(filePath) || getPathExtension(fileName) || "";
if (!extension) {
return "document";
}
if (IMAGE_EXTENSIONS.has(extension)) return "image";
if (AUDIO_EXTENSIONS.has(extension)) return "audio";
if (VIDEO_EXTENSIONS.has(extension)) return "video";
if (DATA_EXTENSIONS.has(extension)) return "data";
if (TEXT_EXTENSIONS.has(extension)) return "text";
return "document";
};
const mapContentToResource = (item: ContentListItem): ResourceItem | null => {
const metadata = parseResourceMetadata(item.metadata);
@@ -92,13 +178,21 @@ const mapMaterialToResource = (
item: RawMaterial,
fallbackProjectId: string,
): ResourceItem => {
const materialType = (item.type ?? item.material_type ?? "document").toString();
const name = item.name ?? "未命名文件";
const filePath = item.filePath ?? item.file_path;
const mimeType = item.mimeType ?? item.mime_type;
const materialType = normalizeMaterialType(
(item.type ?? item.material_type)?.toString(),
mimeType,
filePath,
name,
);
const projectId = (item.projectId ?? item.project_id ?? fallbackProjectId).toString();
return {
id: item.id,
projectId,
name: item.name ?? "未命名文件",
name,
kind: "file",
sourceType: "material",
parentId: null,
@@ -106,8 +200,8 @@ const mapMaterialToResource = (
updatedAt: toTimestampMs(item.createdAt ?? item.created_at),
size: item.fileSize ?? item.file_size,
fileType: materialType,
mimeType: item.mimeType ?? item.mime_type,
filePath: item.filePath ?? item.file_path,
mimeType,
filePath,
description: item.description,
tags: item.tags ?? [],
};
@@ -120,13 +214,19 @@ const extractFileName = (filePath: string): string => {
};
const inferMaterialType = (filePath: string): MaterialType => {
const extension = filePath.split(".").pop()?.toLowerCase();
const extension = getPathExtension(filePath);
if (!extension) {
return "document";
}
if (IMAGE_EXTENSIONS.has(extension)) {
return "image";
}
if (AUDIO_EXTENSIONS.has(extension)) {
return "audio";
}
if (VIDEO_EXTENSIONS.has(extension)) {
return "video";
}
if (DATA_EXTENSIONS.has(extension)) {
return "data";
}
@@ -144,7 +244,11 @@ export const fetchProjectResources = async (
sort_by: "updated_at",
sort_order: "desc",
}),
invoke<RawMaterial[]>("list_materials", { projectId, filter: null }),
invoke<RawMaterial[]>("list_materials", {
projectId,
project_id: projectId,
filter: null,
}),
]);
const contentResources = contents
+1 -1
View File
@@ -226,7 +226,7 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode {
case SettingsTabs.ApiServer:
return (
<>
<SettingHeader title="API Server" />
<SettingHeader title="团队共享网关(内网)" />
<ApiServerPage hideHeader />
</>
);
@@ -142,7 +142,7 @@ export function useSettingsCategory(): CategoryGroup[] {
items: [
{
key: SettingsTabs.ApiServer,
label: t("settings.tab.apiServer", "API Server"),
label: t("settings.tab.apiServer", "团队共享网关"),
icon: Server,
},
{
+2 -2
View File
@@ -270,11 +270,11 @@ export function AboutSection() {
<div className="space-y-2">
<QAItem
question="ProxyCast 是什么?"
answer="ProxyCast 是一个本地 AI API 代理服务,可以将 Kiro、Gemini CLI 等工具的凭证转换为标准的 OpenAI/Anthropic API,供 Claude Code、Cherry Studio、Cursor 等工具使用。"
answer="ProxyCast 是一个 AI Agent 工作台,并提供可选的内网团队共享网关。你可以本机直连使用,也可以把统一的 OpenAI/Anthropic 兼容接口分发给同网段同事。"
/>
<QAItem
question="如何开始使用?"
answer="1. 在「凭证池」添加你的凭证(如 Kiro 凭证文件或 Claude API Key);2. 在「API Server」启动服务并选择默认 Provider;3. 在你的 AI 工具中配置 API 地址为 http://localhost:8999"
answer="1. 在「凭证池」添加你的凭证(如 Kiro 凭证文件或 Claude API Key);2. 直接在「AI Agent」开始使用(默认推荐);3. 如需给团队共享,在「团队共享网关(内网)」开启共享并选择默认 Provider;4. 在内网工具中配置 API 地址(如 http://localhost:8999)"
/>
<QAItem
question="什么是配置切换?"
+29
View File
@@ -7,10 +7,29 @@ Workspace 相关的 React 组件。
| 文件 | 说明 |
|------|------|
| `index.ts` | 组件导出 |
| `WorkbenchPage.tsx` | 主题工作台页面(项目管理 / 项目详情 / 作业) |
| `WorkbenchPage.test.tsx` | Workbench 左侧栏模式行为测试 |
| `WorkspaceSelector.tsx` | Workspace 选择器下拉组件 |
| `utils/creationIntentPrompt.ts` | 新建文稿创作意图构建与校验 |
| `utils/creationIntentPrompt.test.ts` | 创作意图构建与校验测试 |
## 组件
### WorkbenchPage
主题工作台主页面,按 `workspaceMode` 分三种模式:
- `project-management`:项目管理态
- `project-detail`:项目详情态
- `workspace`:三栏作业态(对话/画布)
左侧栏默认规则(按模式切换时生效):
- `project-management` / `project-detail`:默认展开
- `workspace`:默认折叠
用户仍可通过按钮或 `Cmd/Ctrl + B` 手动切换左侧栏展开状态。
### WorkspaceSelector
Workspace 选择器组件,用于切换和管理工作目录。
@@ -27,3 +46,13 @@ import { WorkspaceSelector } from '@/components/workspace';
## 相关 Hook
- `useWorkspace` - Workspace 管理 Hook
## 测试
```bash
# 验证 Workbench 左侧栏模式行为
npx vitest --run "src/components/workspace/WorkbenchPage.test.tsx"
# 验证 workspace 组件相关测试
npx vitest --run "src/components/workspace/WorkbenchPage.test.tsx" "src/components/workspace/utils/creationIntentPrompt.test.ts"
```
@@ -0,0 +1,255 @@
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useWorkbenchStore } from "@/stores/useWorkbenchStore";
const {
mockListProjects,
mockListContents,
mockGetContent,
mockCreateProject,
mockCreateContent,
mockUpdateContent,
} = vi.hoisted(() => ({
mockListProjects: vi.fn(),
mockListContents: vi.fn(),
mockGetContent: vi.fn(),
mockCreateProject: vi.fn(),
mockCreateContent: vi.fn(),
mockUpdateContent: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@/components/agent", () => ({
AgentChatPage: ({
onBackToProjectManagement,
}: {
onBackToProjectManagement?: () => void;
}) => (
<div data-testid="agent-chat-page">
<button
type="button"
onClick={() => {
onBackToProjectManagement?.();
}}
>
从聊天返回项目管理
</button>
</div>
),
}));
vi.mock("@/components/projects/ProjectDetailPage", () => ({
ProjectDetailPage: ({
onBack,
onNavigateToChat,
}: {
onBack?: () => void;
onNavigateToChat?: () => void;
}) => (
<div data-testid="project-detail-page">
<button
type="button"
onClick={() => {
onBack?.();
}}
>
返回项目管理
</button>
<button
type="button"
onClick={() => {
onNavigateToChat?.();
}}
>
进入作业
</button>
</div>
),
}));
vi.mock("@/lib/api/project", () => ({
listProjects: mockListProjects,
listContents: mockListContents,
getContent: mockGetContent,
createProject: mockCreateProject,
createContent: mockCreateContent,
updateContent: mockUpdateContent,
getWorkspaceProjectsRoot: vi.fn(async () => "/tmp/workspace"),
getProjectByRootPath: vi.fn(async () => null),
resolveProjectRootPath: vi.fn(async (name: string) => `/tmp/workspace/${name}`),
getCreateProjectErrorMessage: vi.fn((message: string) => message),
extractErrorMessage: vi.fn(() => "mock-error"),
formatRelativeTime: vi.fn(() => "刚刚"),
getContentTypeLabel: vi.fn(() => "文稿"),
getDefaultContentTypeForProject: vi.fn(() => "post"),
getProjectTypeLabel: vi.fn((theme: string) =>
theme === "social-media" ? "社媒内容" : theme,
),
}));
import { WorkbenchPage } from "./WorkbenchPage";
interface RenderResult {
container: HTMLDivElement;
root: Root;
}
const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = [];
function renderPage(
props: Partial<ComponentProps<typeof WorkbenchPage>> = {},
): RenderResult {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(<WorkbenchPage theme="social-media" {...props} />);
});
mountedRoots.push({ container, root });
return { container, root };
}
async function flushEffects(times = 3): Promise<void> {
for (let i = 0; i < times; i += 1) {
await act(async () => {
await Promise.resolve();
});
}
}
function getLeftSidebar(container: HTMLElement): HTMLElement | null {
const matched = Array.from(container.querySelectorAll("aside")).find((aside) =>
aside.className.includes("bg-muted/20"),
);
return (matched as HTMLElement | undefined) ?? null;
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
localStorage.clear();
vi.clearAllMocks();
useWorkbenchStore.getState().setLeftSidebarCollapsed(true);
mockListProjects.mockResolvedValue([
{
id: "project-1",
name: "社媒项目A",
workspaceType: "social-media",
rootPath: "/tmp/workspace/project-1",
isDefault: false,
createdAt: Date.now(),
updatedAt: Date.now(),
isFavorite: false,
isArchived: false,
tags: [],
},
]);
mockListContents.mockResolvedValue([
{
id: "content-1",
project_id: "project-1",
title: "文稿A",
content_type: "post",
status: "draft",
order: 0,
word_count: 0,
created_at: Date.now(),
updated_at: Date.now(),
},
]);
mockGetContent.mockResolvedValue({
id: "content-1",
metadata: { creationMode: "guided" },
});
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) {
break;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
localStorage.clear();
});
describe("WorkbenchPage 左侧栏模式行为", () => {
it("项目管理模式默认展开左侧栏", async () => {
const { container } = renderPage({ viewMode: "project-management" });
await flushEffects();
const leftSidebar = getLeftSidebar(container);
expect(leftSidebar).not.toBeNull();
expect(leftSidebar?.className).toContain("w-[260px]");
expect(container.textContent).toContain("主题项目管理");
});
it("项目详情模式默认展开左侧栏", async () => {
const { container } = renderPage({
viewMode: "project-detail",
projectId: "project-1",
});
await flushEffects();
const leftSidebar = getLeftSidebar(container);
expect(leftSidebar).not.toBeNull();
expect(leftSidebar?.className).toContain("w-[260px]");
expect(container.textContent).toContain("主题项目管理");
});
it("作业模式默认收起左侧栏", async () => {
const { container } = renderPage({
viewMode: "workspace",
projectId: "project-1",
contentId: "content-1",
});
await flushEffects();
expect(getLeftSidebar(container)).toBeNull();
expect(container.textContent).not.toContain("主题项目管理");
});
it("从作业返回项目管理后自动展开左侧栏", async () => {
const { container } = renderPage({
viewMode: "workspace",
projectId: "project-1",
contentId: "content-1",
});
await flushEffects();
const backButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("从聊天返回项目管理"),
);
expect(backButton).not.toBeUndefined();
act(() => {
backButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushEffects();
const leftSidebar = getLeftSidebar(container);
expect(leftSidebar).not.toBeNull();
expect(leftSidebar?.className).toContain("w-[260px]");
expect(container.textContent).toContain("主题项目管理");
});
});
+304 -44
View File
@@ -30,6 +30,14 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
@@ -69,6 +77,16 @@ import type { WorkflowProgressSnapshot } from "@/components/agent/chat";
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
import { ProjectDetailPage } from "@/components/projects/ProjectDetailPage";
import type { CreationMode } from "@/components/content-creator/types";
import {
buildCreationIntentMetadata,
buildCreationIntentPrompt,
createInitialCreationIntentValues,
getCreationIntentFields,
type CreationIntentFieldKey,
type CreationIntentFormValues,
type CreationIntentInput,
validateCreationIntent,
} from "@/components/workspace/utils/creationIntentPrompt";
export interface WorkbenchPageProps {
onNavigate?: (page: Page, params?: PageParams) => void;
@@ -80,8 +98,10 @@ export interface WorkbenchPageProps {
}
type WorkspaceMode = WorkspaceViewMode;
type CreateContentDialogStep = "mode" | "intent";
const DEFAULT_CREATION_MODE: CreationMode = "guided";
const MIN_CREATION_INTENT_LENGTH = 10;
const CREATION_MODE_OPTIONS: Array<{
value: CreationMode;
@@ -177,12 +197,19 @@ export function WorkbenchPage({
const [createProjectDialogOpen, setCreateProjectDialogOpen] = useState(false);
const [createContentDialogOpen, setCreateContentDialogOpen] = useState(false);
const [createContentDialogStep, setCreateContentDialogStep] =
useState<CreateContentDialogStep>("mode");
const [newProjectName, setNewProjectName] = useState("");
const [workspaceProjectsRoot, setWorkspaceProjectsRoot] = useState("");
const [creatingProject, setCreatingProject] = useState(false);
const [creatingContent, setCreatingContent] = useState(false);
const [selectedCreationMode, setSelectedCreationMode] =
useState<CreationMode>(DEFAULT_CREATION_MODE);
const [creationIntentValues, setCreationIntentValues] =
useState<CreationIntentFormValues>(() => createInitialCreationIntentValues());
const [creationIntentError, setCreationIntentError] = useState("");
const [pendingInitialPromptsByContentId, setPendingInitialPromptsByContentId] =
useState<Record<string, string>>({});
const [contentCreationModes, setContentCreationModes] = useState<
Record<string, CreationMode>
>({});
@@ -219,11 +246,35 @@ export function WorkbenchPage({
);
}, [contents, contentQuery]);
const creationIntentInput = useMemo<CreationIntentInput>(
() => ({
creationMode: selectedCreationMode,
values: creationIntentValues,
}),
[selectedCreationMode, creationIntentValues],
);
const currentCreationIntentFields = useMemo(
() => getCreationIntentFields(selectedCreationMode),
[selectedCreationMode],
);
const currentIntentLength = useMemo(
() => validateCreationIntent(creationIntentInput, MIN_CREATION_INTENT_LENGTH)
.length,
[creationIntentInput],
);
const handleEnterWorkspace = useCallback(
(contentId: string) => {
(
contentId: string,
options?: {
showChatPanel?: boolean;
},
) => {
setSelectedContentId(contentId);
setWorkspaceMode("workspace");
setShowChatPanel(false);
setShowChatPanel(options?.showChatPanel ?? false);
setActiveRightDrawer(null);
setLeftSidebarCollapsed(true);
},
@@ -237,7 +288,8 @@ export function WorkbenchPage({
setWorkspaceMode("project-detail");
setActiveRightDrawer(null);
}, [selectedProjectId]);
setLeftSidebarCollapsed(false);
}, [selectedProjectId, setLeftSidebarCollapsed]);
const loadProjects = useCallback(async () => {
setProjectsLoading(true);
@@ -348,21 +400,60 @@ export function WorkbenchPage({
}
}, [loadProjects, newProjectName, theme]);
const resetCreateContentDialogState = useCallback(() => {
setCreateContentDialogStep("mode");
setSelectedCreationMode(DEFAULT_CREATION_MODE);
setCreationIntentValues(createInitialCreationIntentValues());
setCreationIntentError("");
}, []);
const handleOpenCreateContentDialog = useCallback(() => {
if (!selectedProjectId) {
return;
}
setSelectedCreationMode(DEFAULT_CREATION_MODE);
resetCreateContentDialogState();
setCreateContentDialogOpen(true);
}, [selectedProjectId]);
}, [resetCreateContentDialogState, selectedProjectId]);
const handleCreationIntentValueChange = useCallback(
(key: CreationIntentFieldKey, value: string) => {
setCreationIntentValues((previous) => ({
...previous,
[key]: value,
}));
if (creationIntentError) {
setCreationIntentError("");
}
},
[creationIntentError],
);
const handleGoToIntentStep = useCallback(() => {
setCreateContentDialogStep("intent");
setCreationIntentError("");
}, []);
const handleCreateContent = useCallback(
async (creationMode: CreationMode) => {
async () => {
if (!selectedProjectId) {
return;
}
const validation = validateCreationIntent(
creationIntentInput,
MIN_CREATION_INTENT_LENGTH,
);
if (!validation.valid) {
setCreationIntentError(validation.message || "请完善创作意图");
return;
}
const initialUserPrompt = buildCreationIntentPrompt(creationIntentInput);
const creationIntentMetadata = buildCreationIntentMetadata(
creationIntentInput,
);
setCreatingContent(true);
try {
const defaultType = getDefaultContentTypeForProject(
@@ -373,17 +464,23 @@ export function WorkbenchPage({
title: `新${getContentTypeLabel(defaultType)}`,
content_type: defaultType,
metadata: {
creationMode,
creationMode: selectedCreationMode,
creationIntent: creationIntentMetadata,
},
});
setContentCreationModes((previous) => ({
...previous,
[created.id]: creationMode,
[created.id]: selectedCreationMode,
}));
setPendingInitialPromptsByContentId((previous) => ({
...previous,
[created.id]: initialUserPrompt,
}));
setCreateContentDialogOpen(false);
resetCreateContentDialogState();
await loadContents(selectedProjectId);
handleEnterWorkspace(created.id);
handleEnterWorkspace(created.id, { showChatPanel: true });
toast.success("已创建新文稿");
} catch (error) {
console.error("创建文稿失败:", error);
@@ -392,9 +489,28 @@ export function WorkbenchPage({
setCreatingContent(false);
}
},
[handleEnterWorkspace, loadContents, selectedProjectId, theme],
[
creationIntentInput,
handleEnterWorkspace,
loadContents,
resetCreateContentDialogState,
selectedCreationMode,
selectedProjectId,
theme,
],
);
const consumePendingInitialPrompt = useCallback((contentId: string) => {
setPendingInitialPromptsByContentId((previous) => {
if (!previous[contentId]) {
return previous;
}
const next = { ...previous };
delete next[contentId];
return next;
});
}, []);
const handleQuickSaveCurrent = useCallback(async () => {
if (!selectedContentId || !selectedProjectId) {
return;
@@ -427,9 +543,7 @@ export function WorkbenchPage({
setWorkspaceMode(nextMode);
const isWorkspaceMode = nextMode === "workspace";
setShowChatPanel(!isWorkspaceMode);
if (isWorkspaceMode) {
setLeftSidebarCollapsed(true);
}
setLeftSidebarCollapsed(isWorkspaceMode);
setActiveRightDrawer(null);
setContents([]);
void loadProjects();
@@ -599,7 +713,8 @@ export function WorkbenchPage({
setWorkspaceMode("project-management");
setShowChatPanel(true);
setActiveRightDrawer(null);
}, []);
setLeftSidebarCollapsed(false);
}, [setLeftSidebarCollapsed]);
useEffect(() => {
if (workspaceMode !== "workspace") {
@@ -986,6 +1101,17 @@ export function WorkbenchPage({
projectId={selectedProjectId}
contentId={selectedContentId}
theme={theme}
initialUserPrompt={
selectedContentId
? pendingInitialPromptsByContentId[selectedContentId]
: undefined
}
onInitialUserPromptConsumed={() => {
if (!selectedContentId) {
return;
}
consumePendingInitialPrompt(selectedContentId);
}}
initialCreationMode={
(selectedContentId &&
contentCreationModes[selectedContentId]) ||
@@ -1255,6 +1381,9 @@ export function WorkbenchPage({
onOpenChange={(open) => {
if (!creatingContent) {
setCreateContentDialogOpen(open);
if (!open) {
resetCreateContentDialogState();
}
}
}}
>
@@ -1262,56 +1391,187 @@ export function WorkbenchPage({
<DialogHeader>
<DialogTitle>新建文稿</DialogTitle>
<DialogDescription>
请选择本次创作模式,创建后将直接进入作业界面。
{createContentDialogStep === "mode"
? "先选择创作模式,再填写创作意图。"
: "填写创作意图后将进入 AI 对话,并按所选模式自动开始写稿。"}
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-2">
{CREATION_MODE_OPTIONS.map((modeOption) => (
<Button
key={modeOption.value}
type="button"
variant={
selectedCreationMode === modeOption.value
? "default"
: "outline"
}
className="h-auto justify-start py-3"
onClick={() => setSelectedCreationMode(modeOption.value)}
disabled={creatingContent}
>
<div className="text-left">
<div className="text-sm font-medium">{modeOption.label}</div>
<div
className={cn(
"text-xs mt-1",
<div className="py-2 space-y-3">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
步骤 {createContentDialogStep === "mode" ? "1/2" : "2/2"}
</span>
<span>
{createContentDialogStep === "mode"
? "选择创作模式"
: "填写创作意图"}
</span>
</div>
{createContentDialogStep === "mode" ? (
<div className="grid gap-2">
{CREATION_MODE_OPTIONS.map((modeOption) => (
<Button
key={modeOption.value}
type="button"
variant={
selectedCreationMode === modeOption.value
? "text-primary-foreground/80"
? "default"
: "outline"
}
className="h-auto justify-start py-3"
onClick={() => setSelectedCreationMode(modeOption.value)}
disabled={creatingContent}
>
<div className="text-left">
<div className="text-sm font-medium">
{modeOption.label}
</div>
<div
className={cn(
"text-xs mt-1",
selectedCreationMode === modeOption.value
? "text-primary-foreground/80"
: "text-muted-foreground",
)}
>
{modeOption.description}
</div>
</div>
</Button>
))}
</div>
) : (
<div className="space-y-3">
{currentCreationIntentFields.map((field) => (
<div key={field.key} className="grid gap-2">
<Label>{field.label}</Label>
{field.options && field.options.length > 0 ? (
<Select
value={creationIntentValues[field.key] || undefined}
onValueChange={(value) =>
handleCreationIntentValueChange(field.key, value)
}
disabled={creatingContent}
>
<SelectTrigger>
<SelectValue placeholder={field.placeholder} />
</SelectTrigger>
<SelectContent>
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : field.multiline ? (
<Textarea
id={`creation-intent-${field.key}`}
value={creationIntentValues[field.key]}
onChange={(event) =>
handleCreationIntentValueChange(
field.key,
event.target.value,
)
}
placeholder={field.placeholder}
className="min-h-[84px] resize-y"
disabled={creatingContent}
/>
) : (
<Input
id={`creation-intent-${field.key}`}
value={creationIntentValues[field.key]}
onChange={(event) =>
handleCreationIntentValueChange(
field.key,
event.target.value,
)
}
placeholder={field.placeholder}
disabled={creatingContent}
/>
)}
</div>
))}
<div className="grid gap-2">
<Label htmlFor="creation-intent-extra">补充要求</Label>
<Textarea
id="creation-intent-extra"
value={creationIntentValues.extraRequirements}
onChange={(event) =>
handleCreationIntentValueChange(
"extraRequirements",
event.target.value,
)
}
placeholder="可补充风格、禁忌词、信息来源、输出格式等"
className="min-h-[96px] resize-y"
disabled={creatingContent}
/>
</div>
<div className="space-y-1">
<p
className={cn(
"text-xs",
currentIntentLength < MIN_CREATION_INTENT_LENGTH
? "text-destructive"
: "text-muted-foreground",
)}
>
{modeOption.description}
</div>
创作意图字数:{currentIntentLength}/
{MIN_CREATION_INTENT_LENGTH}
</p>
{creationIntentError && (
<p className="text-xs text-destructive">
{creationIntentError}
</p>
)}
</div>
</Button>
))}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setCreateContentDialogOpen(false)}
onClick={() => {
if (createContentDialogStep === "intent") {
setCreateContentDialogStep("mode");
setCreationIntentError("");
return;
}
setCreateContentDialogOpen(false);
resetCreateContentDialogState();
}}
disabled={creatingContent}
>
取消
{createContentDialogStep === "mode" ? "取消" : "上一步"}
</Button>
<Button
onClick={() => {
void handleCreateContent(selectedCreationMode);
if (createContentDialogStep === "mode") {
handleGoToIntentStep();
return;
}
void handleCreateContent();
}}
disabled={!selectedProjectId || creatingContent}
disabled={
!selectedProjectId ||
creatingContent ||
(createContentDialogStep === "intent" &&
currentIntentLength < MIN_CREATION_INTENT_LENGTH)
}
>
{creatingContent ? "创建中..." : "创建并进入作业"}
{createContentDialogStep === "mode"
? "下一步"
: creatingContent
? "创建中..."
: "创建并进入作业"}
</Button>
</DialogFooter>
</DialogContent>
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
buildCreationIntentMetadata,
buildCreationIntentPrompt,
createInitialCreationIntentValues,
getCreationIntentFields,
getCreationIntentText,
validateCreationIntent,
} from "./creationIntentPrompt";
describe("creationIntentPrompt", () => {
it("应返回对应模式的字段定义", () => {
const guidedFields = getCreationIntentFields("guided");
const frameworkFields = getCreationIntentFields("framework");
const fastFields = getCreationIntentFields("fast");
expect(guidedFields.map((item) => item.key)).toEqual([
"topic",
"targetAudience",
"goal",
"constraints",
]);
expect(frameworkFields.map((item) => item.key)).toContain("outline");
expect(frameworkFields.map((item) => item.key)).toContain("mustInclude");
expect(guidedFields.find((item) => item.key === "targetAudience")?.options)
.toBeTruthy();
expect(fastFields.find((item) => item.key === "contentType")?.options)
.toBeTruthy();
});
it("应正确校验最小意图长度", () => {
const input = {
creationMode: "fast" as const,
values: {
...createInitialCreationIntentValues(),
topic: "短",
},
};
const result = validateCreationIntent(input, 10);
expect(result.valid).toBe(false);
expect(result.length).toBe(1);
});
it("应正确拼装意图正文文本", () => {
const text = getCreationIntentText({
creationMode: "hybrid",
values: {
...createInitialCreationIntentValues(),
topic: "AI 写作流程优化",
targetAudience: "内容运营团队",
corePoints: "先出框架,再填充细节",
tone: "专业简洁",
extraRequirements: "给出可执行步骤",
},
});
expect(text).toContain("AI 写作流程优化");
expect(text).toContain("先出框架,再填充细节");
expect(text).toContain("给出可执行步骤");
});
it("应输出结构化首条提示词", () => {
const prompt = buildCreationIntentPrompt({
creationMode: "framework",
values: {
...createInitialCreationIntentValues(),
topic: "社媒选题方法论",
targetAudience: "新媒体编辑",
outline: "1. 选题来源\n2. 判断标准\n3. 实操案例",
mustInclude: "案例与可复制模板",
extraRequirements: "语气务实,避免空话",
},
});
expect(prompt).toContain("[创作模式] 框架模式");
expect(prompt).toContain("[创作主题] 社媒选题方法论");
expect(prompt).toContain("[框架提纲]");
expect(prompt).toContain("[补充要求] 语气务实,避免空话");
expect(prompt).toContain("[执行要求]");
});
it("应生成可持久化的 metadata", () => {
const metadata = buildCreationIntentMetadata({
creationMode: "guided",
values: {
...createInitialCreationIntentValues(),
topic: "品牌故事写作",
targetAudience: "潜在客户",
goal: "提升品牌信任",
},
});
expect(metadata.mode).toBe("guided");
expect(metadata.topic).toBe("品牌故事写作");
expect(metadata["创作主题"]).toBe("品牌故事写作");
expect(metadata.intentText).toBeTruthy();
});
});
@@ -0,0 +1,315 @@
import type { CreationMode } from "@/components/content-creator/types";
export type CreationIntentFieldKey =
| "topic"
| "targetAudience"
| "goal"
| "constraints"
| "contentType"
| "length"
| "corePoints"
| "tone"
| "outline"
| "mustInclude"
| "extraRequirements";
export interface CreationIntentFormValues {
topic: string;
targetAudience: string;
goal: string;
constraints: string;
contentType: string;
length: string;
corePoints: string;
tone: string;
outline: string;
mustInclude: string;
extraRequirements: string;
}
export interface CreationIntentFieldDefinition {
key: CreationIntentFieldKey;
label: string;
placeholder: string;
multiline?: boolean;
options?: Array<{
value: string;
label: string;
}>;
}
export interface CreationIntentInput {
creationMode: CreationMode;
values: CreationIntentFormValues;
}
export interface CreationIntentValidationResult {
valid: boolean;
length: number;
message?: string;
}
const CREATION_MODE_LABELS: Record<CreationMode, string> = {
guided: "引导模式",
fast: "快速模式",
hybrid: "混合模式",
framework: "框架模式",
};
const TARGET_AUDIENCE_OPTIONS = [
{ value: "泛用户", label: "泛用户" },
{ value: "学生群体", label: "学生群体" },
{ value: "职场新人", label: "职场新人" },
{ value: "职场管理者", label: "职场管理者" },
{ value: "创业者", label: "创业者" },
{ value: "宝妈群体", label: "宝妈群体" },
];
const GOAL_OPTIONS = [
{ value: "快速起稿并可直接发布", label: "快速起稿并可直接发布" },
{ value: "沉淀方法论并建立专业感", label: "沉淀方法论并建立专业感" },
{ value: "提升互动率与转化率", label: "提升互动率与转化率" },
{ value: "建立品牌认知与信任", label: "建立品牌认知与信任" },
];
const CONTENT_TYPE_OPTIONS = [
{ value: "小红书笔记", label: "小红书笔记" },
{ value: "公众号长文", label: "公众号长文" },
{ value: "知乎回答", label: "知乎回答" },
{ value: "短视频口播稿", label: "短视频口播稿" },
{ value: "通用文档", label: "通用文档" },
];
const LENGTH_OPTIONS = [
{ value: "300-500 字", label: "300-500 字" },
{ value: "500-800 字", label: "500-800 字" },
{ value: "800-1200 字", label: "800-1200 字" },
{ value: "1200 字以上", label: "1200 字以上" },
];
const TONE_OPTIONS = [
{ value: "专业理性", label: "专业理性" },
{ value: "轻松口语", label: "轻松口语" },
{ value: "故事化叙述", label: "故事化叙述" },
{ value: "干货清单式", label: "干货清单式" },
];
const CREATION_INTENT_FIELD_MAP: Record<
CreationIntentFieldKey,
CreationIntentFieldDefinition
> = {
topic: {
key: "topic",
label: "创作主题",
placeholder: "例如:春季敏感肌修护指南",
},
targetAudience: {
key: "targetAudience",
label: "目标读者",
placeholder: "请选择目标读者",
options: TARGET_AUDIENCE_OPTIONS,
},
goal: {
key: "goal",
label: "目标结果",
placeholder: "请选择目标结果",
options: GOAL_OPTIONS,
},
constraints: {
key: "constraints",
label: "限制条件",
placeholder: "例如:不要夸张承诺,避免医学术语堆砌",
multiline: true,
},
contentType: {
key: "contentType",
label: "输出体裁",
placeholder: "请选择输出体裁",
options: CONTENT_TYPE_OPTIONS,
},
length: {
key: "length",
label: "目标篇幅",
placeholder: "请选择目标篇幅",
options: LENGTH_OPTIONS,
},
corePoints: {
key: "corePoints",
label: "核心观点",
placeholder: "例如:先稳屏障,再做功效叠加",
multiline: true,
},
tone: {
key: "tone",
label: "语气风格",
placeholder: "请选择语气风格",
options: TONE_OPTIONS,
},
outline: {
key: "outline",
label: "框架提纲",
placeholder: "可填写章节结构或小标题框架",
multiline: true,
},
mustInclude: {
key: "mustInclude",
label: "必须覆盖点",
placeholder: "例如:适用人群、方法步骤、避坑清单",
multiline: true,
},
extraRequirements: {
key: "extraRequirements",
label: "补充要求",
placeholder: "补充你希望 AI 注意的细节",
multiline: true,
},
};
const CREATION_INTENT_FIELDS_BY_MODE: Record<
CreationMode,
CreationIntentFieldDefinition[]
> = {
guided: [
CREATION_INTENT_FIELD_MAP.topic,
CREATION_INTENT_FIELD_MAP.targetAudience,
CREATION_INTENT_FIELD_MAP.goal,
CREATION_INTENT_FIELD_MAP.constraints,
],
fast: [
CREATION_INTENT_FIELD_MAP.topic,
CREATION_INTENT_FIELD_MAP.contentType,
CREATION_INTENT_FIELD_MAP.length,
],
hybrid: [
CREATION_INTENT_FIELD_MAP.topic,
CREATION_INTENT_FIELD_MAP.corePoints,
CREATION_INTENT_FIELD_MAP.targetAudience,
CREATION_INTENT_FIELD_MAP.tone,
],
framework: [
CREATION_INTENT_FIELD_MAP.topic,
CREATION_INTENT_FIELD_MAP.outline,
CREATION_INTENT_FIELD_MAP.targetAudience,
CREATION_INTENT_FIELD_MAP.mustInclude,
],
};
function normalizeValue(value: string | undefined): string {
return (value || "").trim();
}
function getRelevantFieldEntries(
input: CreationIntentInput,
): Array<readonly [CreationIntentFieldDefinition, string]> {
const fieldDefs = CREATION_INTENT_FIELDS_BY_MODE[input.creationMode] || [];
return fieldDefs
.map((field) => [field, normalizeValue(input.values[field.key])] as const)
.filter(([, value]) => value.length > 0);
}
function getExtraRequirements(input: CreationIntentInput): string {
return normalizeValue(input.values.extraRequirements);
}
export function getCreationModeLabel(mode: CreationMode): string {
return CREATION_MODE_LABELS[mode];
}
export function createInitialCreationIntentValues(): CreationIntentFormValues {
return {
topic: "",
targetAudience: "",
goal: "",
constraints: "",
contentType: "",
length: "",
corePoints: "",
tone: "",
outline: "",
mustInclude: "",
extraRequirements: "",
};
}
export function getCreationIntentFields(
mode: CreationMode,
): CreationIntentFieldDefinition[] {
return CREATION_INTENT_FIELDS_BY_MODE[mode] || [];
}
export function getCreationIntentText(input: CreationIntentInput): string {
const parts = getRelevantFieldEntries(input).map(([, value]) => value);
const extraRequirements = getExtraRequirements(input);
if (extraRequirements) {
parts.push(extraRequirements);
}
return parts.join("\n");
}
export function validateCreationIntent(
input: CreationIntentInput,
minLength = 10,
): CreationIntentValidationResult {
const normalizedLength = getCreationIntentText(input).replace(/\s+/g, "")
.length;
if (normalizedLength < minLength) {
return {
valid: false,
length: normalizedLength,
message: `创作意图至少需要 ${minLength} 个字,当前 ${normalizedLength} 个字`,
};
}
return {
valid: true,
length: normalizedLength,
};
}
export function buildCreationIntentMetadata(
input: CreationIntentInput,
): Record<string, unknown> {
const metadata: Record<string, unknown> = {
mode: input.creationMode,
modeLabel: getCreationModeLabel(input.creationMode),
intentText: getCreationIntentText(input),
};
for (const [field, value] of getRelevantFieldEntries(input)) {
metadata[field.key] = value;
metadata[field.label] = value;
}
const extraRequirements = getExtraRequirements(input);
if (extraRequirements) {
metadata.extraRequirements = extraRequirements;
metadata["补充要求"] = extraRequirements;
}
return metadata;
}
export function buildCreationIntentPrompt(input: CreationIntentInput): string {
const modeLabel = getCreationModeLabel(input.creationMode);
const lines: string[] = [`[创作模式] ${modeLabel}`];
const topic = normalizeValue(input.values.topic);
if (topic) {
lines.push(`[创作意图] 围绕“${topic}”完成本次内容创作`);
} else {
lines.push("[创作意图] 请按以下信息完成本次内容创作");
}
for (const [field, value] of getRelevantFieldEntries(input)) {
lines.push(`[${field.label}] ${value}`);
}
const extraRequirements = getExtraRequirements(input);
if (extraRequirements) {
lines.push(`[补充要求] ${extraRequirements}`);
}
lines.push("[执行要求] 请严格按上述信息开始,并按所选模式推进。");
return lines.join("\n");
}
+9 -2
View File
@@ -68,8 +68,15 @@ export function useMaterials(projectId: string | null): UseMaterialsReturn {
setError(null);
const [list, total] = await Promise.all([
invoke<Material[]>("list_materials", { projectId, filter: null }),
invoke<number>("get_material_count", { projectId }),
invoke<Material[]>("list_materials", {
projectId,
project_id: projectId,
filter: null,
}),
invoke<number>("get_material_count", {
projectId,
project_id: projectId,
}),
]);
setMaterials(list);
+1 -1
View File
@@ -363,7 +363,7 @@ export async function setDefaultProvider(provider: string): Promise<string> {
/**
* 更新 Provider 的环境变量
*
* 当用户在 API Server 页面选择一个 API Key Provider 时调用
* 当用户在团队共享网关页面选择一个 API Key Provider 时调用
* 会更新 ~/.claude/settings.json 和 shell 配置文件中的环境变量
*
* @param providerType Provider 类型(如 "anthropic", "openai", "gemini")
+29 -13
View File
@@ -8,6 +8,9 @@
"浅色模式": "Light Mode",
"AI Agent": "AI Agent",
"API Server": "API Server",
"团队共享网关": "Team Gateway",
"团队共享网关(内网)": "Team Gateway (LAN)",
"AI Agent + 团队网关": "AI Agent + Team Gateway",
"加载侧边栏插件失败:": "Failed to load sidebar plugins:",
"// === Settings Page (src/components/settings/SettingsPage.tsx) ===": "",
"通用": "General",
@@ -58,6 +61,8 @@
"检测中...": "Detecting...",
"已安装": "Installed",
"未安装": "Not Installed",
"ProxyCast 是一个 AI Agent 工作台,并提供可选的内网团队共享网关。你可以本机直连使用,也可以把统一的 OpenAI/Anthropic 兼容接口分发给同网段同事。": "ProxyCast is an AI Agent workspace with an optional LAN team gateway. Use it locally, or distribute a unified OpenAI/Anthropic-compatible endpoint to teammates on the same network.",
"1. 在「凭证池」添加你的凭证(如 Kiro 凭证文件或 Claude API Key);2. 直接在「AI Agent」开始使用(默认推荐);3. 如需给团队共享,在「团队共享网关(内网)」开启共享并选择默认 Provider;4. 在内网工具中配置 API 地址(如 http://localhost:8999)": "1. Add credentials in Credential Pool (e.g., Kiro credential file or Claude API key); 2. Start directly in AI Agent (recommended); 3. If team sharing is needed, enable sharing in Team Gateway (LAN) and choose a default Provider; 4. Configure API address in LAN tools (e.g., http://localhost:8999).",
"检查更新失败": "Failed to check for updates",
"安装程序已启动,应用将自动关闭以完成更新": "Installer launched, app will close to complete update",
"下载失败,请手动下载": "Download failed, please download manually",
@@ -117,7 +122,7 @@
"等待中": "Pending",
"已取消": "Cancelled",
"// === Provider Pool Page (src/components/provider-pool/ProviderPoolPage.tsx) ===": "",
"管理多个 AI 服务凭证,自动轮询负载均衡。在 API Server 选择默认 Provider 后自动使用对应凭证": "Manage multiple AI service credentials with automatic load balancing. Credentials are automatically used after selecting default Provider in API Server",
"管理多个 AI 服务凭证,自动轮询负载均衡。在团队共享网关中选择默认 Provider 后自动使用对应凭证": "Manage multiple AI service credentials with automatic load balancing. Credentials are automatically used after selecting default Provider in Team Gateway.",
"导入配置": "Import Configuration",
"从高级设置导入 Private 凭证": "Import Private credentials from advanced settings",
"OAuth 凭证": "OAuth Credentials",
@@ -152,27 +157,37 @@
"当前类型无可用凭证,请先在凭证池中添加": "No credentials available for current type, please add in Credential Pool first",
"插件": "Plugin",
"// === API Server Page (src/components/api-server/ApiServerPage.tsx) ===": "",
"本地代理服务器,支持 OpenAI/Anthropic 格式": "Local proxy server supporting OpenAI/Anthropic formats",
"Agent 默认直连 Provider;需要给内网同事接入时再开启共享网关": "Agent connects to providers directly by default; enable the gateway only when LAN teammates need access.",
"局域网": "LAN",
"运行中": "Running",
"已停止": "Stopped",
"请求": "Request",
"服务器控制": "Server Control",
"网关控制": "Gateway Control",
"路由端点": "Route Endpoints",
"系统日志": "System Logs",
"停止服务": "Stop Server",
"启动服务": "Start Server",
"网关日志": "Gateway Logs",
"关闭共享": "Stop Sharing",
"开启共享": "Start Sharing",
"共享模式:": "Sharing Mode:",
"仅本机": "Local Only",
"内网共享": "LAN Sharing",
"共享地址:": "Shared Address:",
"请先关闭共享再修改配置": "Stop sharing before changing settings",
"仅本机模式仅允许当前设备访问;内网共享模式会对同网段设备开放": "Local-only mode allows access from this device only; LAN sharing opens access to devices on the same subnet.",
"当前处于": "Current mode: ",
"模式。修改配置需要先关闭共享": ". Stop sharing before changing settings.",
"处理中...": "Processing...",
"端口:": "Port:",
"默认 Provider": "Default Provider",
"已切换到": "Switched to",
"切换失败:": "Switch failed:",
"服务已启动": "Server started",
"服务已停止": "Server stopped",
"启动失败:": "Start failed:",
"停止失败:": "Stop failed:",
"服务器配置已保存": "Server configuration saved",
"API 测试": "API Testing",
"共享网关已开启": "Gateway sharing enabled",
"共享网关已关闭": "Gateway sharing disabled",
"开启失败:": "Enable failed:",
"关闭失败:": "Disable failed:",
"网关配置已保存": "Gateway configuration saved",
"网关 API 测试": "Gateway API Testing",
"已切换为仅本机模式": "Switched to local-only mode",
"已切换为内网共享模式": "Switched to LAN sharing mode",
"测试全部": "Test All",
"健康检查": "Health Check",
"模型列表": "Model List",
@@ -883,6 +898,7 @@
"清空": "Clear All",
"暂无日志": "No logs",
"软件运行时将显示系统日志": "System logs will be displayed when the software is running",
"暂无日志,软件运行时将显示网关与系统日志": "No logs yet. Gateway and system logs will appear while the app is running.",
"// === components\\api-server\\ModelsTab.tsx ===": "",
"智谱": "Zhipu",
"月之暗面": "Moonshot",
@@ -3727,4 +3743,4 @@
"在下方输入详细的提示词以开始创作。": "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"
}
}
+29 -13
View File
@@ -8,6 +8,9 @@
"浅色模式": "浅色模式",
"AI Agent": "AI Agent",
"API Server": "API Server",
"团队共享网关": "团队共享网关",
"团队共享网关(内网)": "团队共享网关(内网)",
"AI Agent + 团队网关": "AI Agent + 团队网关",
"加载侧边栏插件失败:": "加载侧边栏插件失败:",
"// === Settings Page (src/components/settings/SettingsPage.tsx) ===": "",
"通用": "通用",
@@ -58,6 +61,8 @@
"检测中...": "检测中...",
"已安装": "已安装",
"未安装": "未安装",
"ProxyCast 是一个 AI Agent 工作台,并提供可选的内网团队共享网关。你可以本机直连使用,也可以把统一的 OpenAI/Anthropic 兼容接口分发给同网段同事。": "ProxyCast 是一个 AI Agent 工作台,并提供可选的内网团队共享网关。你可以本机直连使用,也可以把统一的 OpenAI/Anthropic 兼容接口分发给同网段同事。",
"1. 在「凭证池」添加你的凭证(如 Kiro 凭证文件或 Claude API Key);2. 直接在「AI Agent」开始使用(默认推荐);3. 如需给团队共享,在「团队共享网关(内网)」开启共享并选择默认 Provider;4. 在内网工具中配置 API 地址(如 http://localhost:8999)": "1. 在「凭证池」添加你的凭证(如 Kiro 凭证文件或 Claude API Key);2. 直接在「AI Agent」开始使用(默认推荐);3. 如需给团队共享,在「团队共享网关(内网)」开启共享并选择默认 Provider;4. 在内网工具中配置 API 地址(如 http://localhost:8999)",
"检查更新失败": "检查更新失败",
"安装程序已启动,应用将自动关闭以完成更新": "安装程序已启动,应用将自动关闭以完成更新",
"下载失败,请手动下载": "下载失败,请手动下载",
@@ -91,7 +96,7 @@
"已复制 cURL 命令": "已复制 cURL 命令",
"已导出 JSON 文件": "已导出 JSON 文件",
"// === Provider Pool Page (src/components/provider-pool/ProviderPoolPage.tsx) ===": "",
"管理多个 AI 服务凭证,自动轮询负载均衡。在 API Server 选择默认 Provider 后自动使用对应凭证": "管理多个 AI 服务凭证,自动轮询负载均衡。在 API Server 选择默认 Provider 后自动使用对应凭证",
"管理多个 AI 服务凭证,自动轮询负载均衡。在团队共享网关中选择默认 Provider 后自动使用对应凭证": "管理多个 AI 服务凭证,自动轮询负载均衡。在团队共享网关中选择默认 Provider 后自动使用对应凭证",
"导入配置": "导入配置",
"从高级设置导入 Private 凭证": "从高级设置导入 Private 凭证",
"OAuth 凭证": "OAuth 凭证",
@@ -126,27 +131,37 @@
"当前类型无可用凭证,请先在凭证池中添加": "当前类型无可用凭证,请先在凭证池中添加",
"插件": "插件",
"// === API Server Page (src/components/api-server/ApiServerPage.tsx) ===": "",
"本地代理服务器,支持 OpenAI/Anthropic 格式": "本地代理服务器,支持 OpenAI/Anthropic 格式",
"Agent 默认直连 Provider;需要给内网同事接入时再开启共享网关": "Agent 默认直连 Provider;需要给内网同事接入时再开启共享网关",
"局域网": "局域网",
"运行中": "运行中",
"已停止": "已停止",
"请求": "请求",
"服务器控制": "服务器控制",
"网关控制": "网关控制",
"路由端点": "路由端点",
"系统日志": "系统日志",
"停止服务": "停止服务",
"启动服务": "启动服务",
"网关日志": "网关日志",
"关闭共享": "关闭共享",
"开启共享": "开启共享",
"共享模式:": "共享模式:",
"仅本机": "仅本机",
"内网共享": "内网共享",
"共享地址:": "共享地址:",
"请先关闭共享再修改配置": "请先关闭共享再修改配置",
"仅本机模式仅允许当前设备访问;内网共享模式会对同网段设备开放": "仅本机模式仅允许当前设备访问;内网共享模式会对同网段设备开放",
"当前处于": "当前处于",
"模式。修改配置需要先关闭共享": "模式。修改配置需要先关闭共享",
"处理中...": "处理中...",
"端口:": "端口:",
"默认 Provider": "默认 Provider",
"已切换到": "已切换到",
"切换失败:": "切换失败:",
"服务已启动": "服务已启动",
"服务已停止": "服务已停止",
"启动失败:": "启动失败:",
"停止失败:": "停止失败:",
"服务器配置已保存": "服务器配置已保存",
"API 测试": "API 测试",
"共享网关已开启": "共享网关已开启",
"共享网关已关闭": "共享网关已关闭",
"开启失败:": "开启失败:",
"关闭失败:": "关闭失败:",
"网关配置已保存": "网关配置已保存",
"网关 API 测试": "网关 API 测试",
"已切换为仅本机模式": "已切换为仅本机模式",
"已切换为内网共享模式": "已切换为内网共享模式",
"测试全部": "测试全部",
"健康检查": "健康检查",
"模型列表": "模型列表",
@@ -857,6 +872,7 @@
"清空": "清空",
"暂无日志": "暂无日志",
"软件运行时将显示系统日志": "软件运行时将显示系统日志",
"暂无日志,软件运行时将显示网关与系统日志": "暂无日志,软件运行时将显示网关与系统日志",
"// === components\\api-server\\ModelsTab.tsx ===": "",
"智谱": "智谱",
"月之暗面": "月之暗面",
@@ -3708,4 +3724,4 @@
"在下方输入详细的提示词以开始创作。": "在下方输入详细的提示词以开始创作。",
"描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...": "描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...",
"新建图片": "新建图片"
}
}
+17
View File
@@ -806,6 +806,23 @@ export async function getAsterSession(
return await safeInvoke("aster_session_get", { sessionId });
}
/**
* 重命名 Aster 会话
*/
export async function renameAsterSession(
sessionId: string,
name: string,
): Promise<void> {
return await safeInvoke("aster_session_rename", { sessionId, name });
}
/**
* 删除 Aster 会话
*/
export async function deleteAsterSession(sessionId: string): Promise<void> {
return await safeInvoke("aster_session_delete", { sessionId });
}
/**
* 确认 Aster Agent 权限请求
*/
+119
View File
@@ -0,0 +1,119 @@
const RESOURCE_PROJECT_STORAGE_KEY = "proxycast-resource-project-id";
const LEGACY_IMAGE_GEN_PROJECT_KEY = "image-gen-target-project-id";
const RESOURCE_PROJECT_CHANGE_EVENT = "proxycast:resource-project-change";
export type ResourceProjectChangeSource =
| "resources"
| "image-gen-target"
| "image-gen-save"
| "unknown";
export interface ResourceProjectChangeDetail {
projectId: string | null;
source: ResourceProjectChangeSource;
}
interface SetStoredResourceProjectIdOptions {
source?: ResourceProjectChangeSource;
syncLegacy?: boolean;
emitEvent?: boolean;
}
interface GetStoredResourceProjectIdOptions {
includeLegacy?: boolean;
}
const normalizeProjectId = (value: string | null | undefined): string | null => {
if (!value) return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const hasWindow = (): boolean => {
return typeof window !== "undefined";
};
export const getStoredResourceProjectId = (
options?: GetStoredResourceProjectIdOptions,
): string | null => {
if (!hasWindow()) {
return null;
}
const normalizedPrimary = normalizeProjectId(
localStorage.getItem(RESOURCE_PROJECT_STORAGE_KEY),
);
if (normalizedPrimary) {
return normalizedPrimary;
}
if (!options?.includeLegacy) {
return null;
}
return normalizeProjectId(localStorage.getItem(LEGACY_IMAGE_GEN_PROJECT_KEY));
};
export const setStoredResourceProjectId = (
projectId: string | null | undefined,
options?: SetStoredResourceProjectIdOptions,
): void => {
if (!hasWindow()) {
return;
}
const normalizedProjectId = normalizeProjectId(projectId);
if (normalizedProjectId) {
localStorage.setItem(RESOURCE_PROJECT_STORAGE_KEY, normalizedProjectId);
} else {
localStorage.removeItem(RESOURCE_PROJECT_STORAGE_KEY);
}
if (options?.syncLegacy) {
if (normalizedProjectId) {
localStorage.setItem(LEGACY_IMAGE_GEN_PROJECT_KEY, normalizedProjectId);
} else {
localStorage.removeItem(LEGACY_IMAGE_GEN_PROJECT_KEY);
}
}
if (options?.emitEvent === false) {
return;
}
window.dispatchEvent(
new CustomEvent<ResourceProjectChangeDetail>(RESOURCE_PROJECT_CHANGE_EVENT, {
detail: {
projectId: normalizedProjectId,
source: options?.source ?? "unknown",
},
}),
);
};
export const onResourceProjectChange = (
listener: (detail: ResourceProjectChangeDetail) => void,
): (() => void) => {
if (!hasWindow()) {
return () => undefined;
}
const handler = (event: Event) => {
if (!(event instanceof CustomEvent)) {
return;
}
const detail = (event as CustomEvent<ResourceProjectChangeDetail>).detail;
listener({
projectId: normalizeProjectId(detail?.projectId),
source: detail?.source ?? "unknown",
});
};
const eventHandler: (event: Event) => void = handler;
window.addEventListener(RESOURCE_PROJECT_CHANGE_EVENT, eventHandler);
return () => {
window.removeEventListener(RESOURCE_PROJECT_CHANGE_EVENT, eventHandler);
};
};
+4
View File
@@ -17,6 +17,8 @@
export type MaterialType =
| "document"
| "image"
| "audio"
| "video"
| "text"
| "data"
| "link"
@@ -30,6 +32,8 @@ export type MaterialType =
export const MaterialTypeLabels: Record<MaterialType, string> = {
document: "文档",
image: "图片",
audio: "语音",
video: "视频",
text: "文本",
data: "数据",
link: "链接",