mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v0.67.0
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.66.0",
|
||||
"version": "0.67.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+15
-15
@@ -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",
|
||||
|
||||
@@ -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 为空"
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
/// 身份信息:关于你是谁的稳定信息
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 服务测试失败");
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 特有功能
|
||||
|
||||
@@ -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 变化时重置引导状态
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 === " ") {
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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="什么是配置切换?"
|
||||
|
||||
@@ -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("主题项目管理");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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 @@
|
||||
"在下方输入详细的提示词以开始创作。": "在下方输入详细的提示词以开始创作。",
|
||||
"描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...": "描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...",
|
||||
"新建图片": "新建图片"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 权限请求
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -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: "链接",
|
||||
|
||||
Reference in New Issue
Block a user