mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: release v0.81.0 with full pending changes
This commit is contained in:
Generated
+16
-16
@@ -6952,7 +6952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -7054,7 +7054,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-agent"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-trait",
|
||||
@@ -7079,7 +7079,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-config"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"parking_lot",
|
||||
@@ -7095,7 +7095,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-core"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
dependencies = [
|
||||
"aster-models",
|
||||
"async-trait",
|
||||
@@ -7135,7 +7135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-credential"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -7170,7 +7170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-gateway"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -7191,7 +7191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-infra"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -7211,7 +7211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-mcp"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -7243,7 +7243,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-processor"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"parking_lot",
|
||||
@@ -7262,7 +7262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-providers"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -7316,7 +7316,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-server"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-stream",
|
||||
@@ -7361,7 +7361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-server-utils"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"futures",
|
||||
@@ -7376,7 +7376,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-services"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -7417,7 +7417,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-skills"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -7433,7 +7433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-terminal"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7460,7 +7460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-websocket"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
edition = "2021"
|
||||
authors = ["coso"]
|
||||
repository = "https://github.com/aiclientproxy/proxycast"
|
||||
@@ -191,7 +191,7 @@ version = "2.4"
|
||||
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.80.0"
|
||||
version = "0.81.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -570,6 +570,9 @@ pub struct ContentCreatorConfig {
|
||||
/// 启用的主题列表
|
||||
#[serde(default = "default_enabled_themes")]
|
||||
pub enabled_themes: Vec<String>,
|
||||
/// 全局媒体生成默认设置
|
||||
#[serde(default)]
|
||||
pub media_defaults: MediaGenerationDefaultsConfig,
|
||||
}
|
||||
|
||||
fn default_enabled_themes() -> Vec<String> {
|
||||
@@ -587,10 +590,47 @@ impl Default for ContentCreatorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled_themes: default_enabled_themes(),
|
||||
media_defaults: MediaGenerationDefaultsConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_media_generation_allow_fallback() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaGenerationPreferenceConfig {
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
alias = "preferred_provider_id"
|
||||
)]
|
||||
pub preferred_provider_id: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
alias = "preferred_model_id"
|
||||
)]
|
||||
pub preferred_model_id: Option<String>,
|
||||
#[serde(
|
||||
default = "default_media_generation_allow_fallback",
|
||||
alias = "allow_fallback"
|
||||
)]
|
||||
pub allow_fallback: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct MediaGenerationDefaultsConfig {
|
||||
#[serde(default)]
|
||||
pub image: MediaGenerationPreferenceConfig,
|
||||
#[serde(default)]
|
||||
pub video: MediaGenerationPreferenceConfig,
|
||||
#[serde(default)]
|
||||
pub voice: MediaGenerationPreferenceConfig,
|
||||
}
|
||||
|
||||
// ============ 导航栏配置类型 ============
|
||||
|
||||
/// 导航栏模块配置
|
||||
|
||||
@@ -206,6 +206,44 @@ impl AgentRunDao {
|
||||
|
||||
iter.collect()
|
||||
}
|
||||
|
||||
pub fn list_runs_by_session(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AgentRun>, rusqlite::Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, source, source_ref, session_id, status, started_at, finished_at, duration_ms,
|
||||
error_code, error_message, metadata, created_at, updated_at
|
||||
FROM agent_runs
|
||||
WHERE session_id = ?1
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
|
||||
let iter = stmt.query_map(params![session_id, limit as i64], |row| {
|
||||
let status_raw: String = row.get(4)?;
|
||||
let status =
|
||||
AgentRunStatus::try_from(status_raw.as_str()).unwrap_or(AgentRunStatus::Error);
|
||||
Ok(AgentRun {
|
||||
id: row.get(0)?,
|
||||
source: row.get(1)?,
|
||||
source_ref: row.get(2)?,
|
||||
session_id: row.get(3)?,
|
||||
status,
|
||||
started_at: row.get(5)?,
|
||||
finished_at: row.get(6)?,
|
||||
duration_ms: row.get(7)?,
|
||||
error_code: row.get(8)?,
|
||||
error_message: row.get(9)?,
|
||||
metadata: row.get(10)?,
|
||||
created_at: row.get(11)?,
|
||||
updated_at: row.get(12)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
iter.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -292,4 +330,36 @@ mod tests {
|
||||
assert_eq!(fetched.status, AgentRunStatus::Success);
|
||||
assert_eq!(fetched.duration_ms, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_runs_by_session_should_filter_and_sort() {
|
||||
let conn = setup_conn();
|
||||
|
||||
let mut run_1 = sample_run("run-a-1", AgentRunStatus::Success);
|
||||
run_1.session_id = Some("session-a".to_string());
|
||||
run_1.started_at = "2026-03-06T10:00:00Z".to_string();
|
||||
run_1.created_at = run_1.started_at.clone();
|
||||
run_1.updated_at = run_1.started_at.clone();
|
||||
AgentRunDao::create_run(&conn, &run_1).expect("写入 run-a-1 失败");
|
||||
|
||||
let mut run_2 = sample_run("run-b-1", AgentRunStatus::Running);
|
||||
run_2.session_id = Some("session-b".to_string());
|
||||
run_2.started_at = "2026-03-06T11:00:00Z".to_string();
|
||||
run_2.created_at = run_2.started_at.clone();
|
||||
run_2.updated_at = run_2.started_at.clone();
|
||||
AgentRunDao::create_run(&conn, &run_2).expect("写入 run-b-1 失败");
|
||||
|
||||
let mut run_3 = sample_run("run-a-2", AgentRunStatus::Error);
|
||||
run_3.session_id = Some("session-a".to_string());
|
||||
run_3.started_at = "2026-03-06T12:00:00Z".to_string();
|
||||
run_3.created_at = run_3.started_at.clone();
|
||||
run_3.updated_at = run_3.started_at.clone();
|
||||
AgentRunDao::create_run(&conn, &run_3).expect("写入 run-a-2 失败");
|
||||
|
||||
let runs = AgentRunDao::list_runs_by_session(&conn, "session-a", 10)
|
||||
.expect("按 session 查询执行记录失败");
|
||||
assert_eq!(runs.len(), 2);
|
||||
assert_eq!(runs[0].id, "run-a-2");
|
||||
assert_eq!(runs[1].id, "run-a-1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +223,29 @@ impl SessionFileStorage {
|
||||
fs::read_to_string(&file_path).map_err(|e| format!("读取文件失败: {e}"))
|
||||
}
|
||||
|
||||
/// 解析会话文件的绝对路径
|
||||
pub fn resolve_file_path(&self, session_id: &str, file_name: &str) -> Result<String, String> {
|
||||
let files_dir = self.get_files_dir(session_id);
|
||||
let file_path = files_dir.join(file_name);
|
||||
|
||||
if !file_path.exists() {
|
||||
return Err("文件不存在".to_string());
|
||||
}
|
||||
|
||||
let canonical_file_path = file_path
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("解析文件路径失败: {e}"))?;
|
||||
let canonical_files_dir = files_dir
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("解析会话目录失败: {e}"))?;
|
||||
|
||||
if !canonical_file_path.starts_with(&canonical_files_dir) {
|
||||
return Err("非法文件路径".to_string());
|
||||
}
|
||||
|
||||
Ok(canonical_file_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// 删除会话文件
|
||||
pub fn delete_file(&self, session_id: &str, file_name: &str) -> Result<(), String> {
|
||||
let file_path = self.get_files_dir(session_id).join(file_name);
|
||||
@@ -426,4 +449,18 @@ mod tests {
|
||||
storage.delete_session("test-session-4").unwrap();
|
||||
assert!(!storage.session_exists("test-session-4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_file_path() {
|
||||
let (storage, _temp) = create_test_storage();
|
||||
storage.create_session("test-session-5").unwrap();
|
||||
storage
|
||||
.save_file("test-session-5", "demo.md", "content")
|
||||
.unwrap();
|
||||
|
||||
let resolved = storage
|
||||
.resolve_file_path("test-session-5", "demo.md")
|
||||
.unwrap();
|
||||
assert!(resolved.ends_with("/test-session-5/files/demo.md"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,18 +91,119 @@ impl WorkspaceType {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_image_generation_allow_fallback() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 图片生成偏好设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkspaceImageGenerationSettings {
|
||||
/// 默认图片 Provider ID
|
||||
#[serde(
|
||||
skip_serializing_if = "Option::is_none",
|
||||
alias = "preferred_provider_id"
|
||||
)]
|
||||
pub preferred_provider_id: Option<String>,
|
||||
/// 默认图片模型 ID
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "preferred_model_id")]
|
||||
pub preferred_model_id: Option<String>,
|
||||
/// 默认图片 Provider 不可用时是否允许回退自动选择
|
||||
#[serde(
|
||||
default = "default_image_generation_allow_fallback",
|
||||
alias = "allow_fallback"
|
||||
)]
|
||||
pub allow_fallback: bool,
|
||||
}
|
||||
|
||||
/// 视频生成偏好设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkspaceVideoGenerationSettings {
|
||||
#[serde(
|
||||
skip_serializing_if = "Option::is_none",
|
||||
alias = "preferred_provider_id"
|
||||
)]
|
||||
pub preferred_provider_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "preferred_model_id")]
|
||||
pub preferred_model_id: Option<String>,
|
||||
#[serde(
|
||||
default = "default_image_generation_allow_fallback",
|
||||
alias = "allow_fallback"
|
||||
)]
|
||||
pub allow_fallback: bool,
|
||||
}
|
||||
|
||||
impl Default for WorkspaceVideoGenerationSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
preferred_provider_id: None,
|
||||
preferred_model_id: None,
|
||||
allow_fallback: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 语音生成偏好设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkspaceVoiceGenerationSettings {
|
||||
#[serde(
|
||||
skip_serializing_if = "Option::is_none",
|
||||
alias = "preferred_provider_id"
|
||||
)]
|
||||
pub preferred_provider_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "preferred_model_id")]
|
||||
pub preferred_model_id: Option<String>,
|
||||
#[serde(
|
||||
default = "default_image_generation_allow_fallback",
|
||||
alias = "allow_fallback"
|
||||
)]
|
||||
pub allow_fallback: bool,
|
||||
}
|
||||
|
||||
impl Default for WorkspaceVoiceGenerationSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
preferred_provider_id: None,
|
||||
preferred_model_id: None,
|
||||
allow_fallback: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WorkspaceImageGenerationSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
preferred_provider_id: None,
|
||||
preferred_model_id: None,
|
||||
allow_fallback: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace 级别设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkspaceSettings {
|
||||
/// Workspace 级 MCP 配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "mcp_config")]
|
||||
pub mcp_config: Option<serde_json::Value>,
|
||||
/// 默认 provider
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "default_provider")]
|
||||
pub default_provider: Option<String>,
|
||||
/// 自动压缩 context
|
||||
#[serde(default)]
|
||||
#[serde(default, alias = "auto_compact")]
|
||||
pub auto_compact: bool,
|
||||
/// 图片生成偏好
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "image_generation")]
|
||||
pub image_generation: Option<WorkspaceImageGenerationSettings>,
|
||||
/// 视频生成偏好
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "video_generation")]
|
||||
pub video_generation: Option<WorkspaceVideoGenerationSettings>,
|
||||
/// 语音生成偏好
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "voice_generation")]
|
||||
pub voice_generation: Option<WorkspaceVoiceGenerationSettings>,
|
||||
}
|
||||
|
||||
/// 项目统计信息
|
||||
@@ -330,4 +431,122 @@ mod tests {
|
||||
let debug_str = format!("{wt:?}");
|
||||
assert_eq!(debug_str, "SocialMedia");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_settings_accepts_legacy_snake_case() {
|
||||
let settings: WorkspaceSettings = serde_json::from_str(
|
||||
r#"{
|
||||
"default_provider": "openai",
|
||||
"auto_compact": true,
|
||||
"image_generation": {
|
||||
"preferred_provider_id": "new-api",
|
||||
"preferred_model_id": "gpt-image-1",
|
||||
"allow_fallback": false
|
||||
},
|
||||
"video_generation": {
|
||||
"preferred_provider_id": "doubao-video",
|
||||
"preferred_model_id": "seedance-1-5-pro-251215",
|
||||
"allow_fallback": true
|
||||
},
|
||||
"voice_generation": {
|
||||
"preferred_provider_id": "openai-tts",
|
||||
"preferred_model_id": "gpt-4o-mini-tts",
|
||||
"allow_fallback": false
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(settings.default_provider.as_deref(), Some("openai"));
|
||||
assert!(settings.auto_compact);
|
||||
let image_generation = settings.image_generation.expect("应解析图片配置");
|
||||
assert_eq!(
|
||||
image_generation.preferred_provider_id.as_deref(),
|
||||
Some("new-api")
|
||||
);
|
||||
assert_eq!(
|
||||
image_generation.preferred_model_id.as_deref(),
|
||||
Some("gpt-image-1")
|
||||
);
|
||||
assert!(!image_generation.allow_fallback);
|
||||
let video_generation = settings.video_generation.expect("应解析视频配置");
|
||||
assert_eq!(
|
||||
video_generation.preferred_provider_id.as_deref(),
|
||||
Some("doubao-video")
|
||||
);
|
||||
assert_eq!(
|
||||
video_generation.preferred_model_id.as_deref(),
|
||||
Some("seedance-1-5-pro-251215")
|
||||
);
|
||||
assert!(video_generation.allow_fallback);
|
||||
let voice_generation = settings.voice_generation.expect("应解析语音配置");
|
||||
assert_eq!(
|
||||
voice_generation.preferred_provider_id.as_deref(),
|
||||
Some("openai-tts")
|
||||
);
|
||||
assert_eq!(
|
||||
voice_generation.preferred_model_id.as_deref(),
|
||||
Some("gpt-4o-mini-tts")
|
||||
);
|
||||
assert!(!voice_generation.allow_fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_settings_serializes_to_camel_case() {
|
||||
let settings = WorkspaceSettings {
|
||||
image_generation: Some(WorkspaceImageGenerationSettings {
|
||||
preferred_provider_id: Some("new-api".to_string()),
|
||||
preferred_model_id: Some("gpt-image-1".to_string()),
|
||||
allow_fallback: false,
|
||||
}),
|
||||
video_generation: Some(WorkspaceVideoGenerationSettings {
|
||||
preferred_provider_id: Some("doubao-video".to_string()),
|
||||
preferred_model_id: Some("seedance-1-5-pro-251215".to_string()),
|
||||
allow_fallback: true,
|
||||
}),
|
||||
voice_generation: Some(WorkspaceVoiceGenerationSettings {
|
||||
preferred_provider_id: Some("openai-tts".to_string()),
|
||||
preferred_model_id: Some("gpt-4o-mini-tts".to_string()),
|
||||
allow_fallback: false,
|
||||
}),
|
||||
..WorkspaceSettings::default()
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(
|
||||
value
|
||||
.get("imageGeneration")
|
||||
.and_then(|item| item.get("preferredProviderId"))
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("new-api")
|
||||
);
|
||||
assert_eq!(
|
||||
value
|
||||
.get("imageGeneration")
|
||||
.and_then(|item| item.get("preferredModelId"))
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("gpt-image-1")
|
||||
);
|
||||
assert_eq!(
|
||||
value
|
||||
.get("imageGeneration")
|
||||
.and_then(|item| item.get("allowFallback"))
|
||||
.and_then(|item| item.as_bool()),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
value
|
||||
.get("videoGeneration")
|
||||
.and_then(|item| item.get("preferredProviderId"))
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("doubao-video")
|
||||
);
|
||||
assert_eq!(
|
||||
value
|
||||
.get("voiceGeneration")
|
||||
.and_then(|item| item.get("preferredModelId"))
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("gpt-4o-mini-tts")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,9 +256,11 @@ impl OpenAICustomProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fn base_url_parent(&self) -> Option<String> {
|
||||
let base = self.get_base_url();
|
||||
fn parent_base_url(base: &str) -> Option<String> {
|
||||
let base = base.trim();
|
||||
if base.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut url = Url::parse(base)
|
||||
.or_else(|_| Url::parse(&format!("http://{base}")))
|
||||
@@ -288,6 +290,11 @@ impl OpenAICustomProvider {
|
||||
Some(url.to_string().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn base_url_parent(&self) -> Option<String> {
|
||||
let base = self.get_base_url();
|
||||
Self::parent_base_url(&base)
|
||||
}
|
||||
|
||||
fn build_urls_with_fallbacks(&self, endpoint: &str) -> Vec<String> {
|
||||
let mut urls: Vec<String> = Vec::new();
|
||||
|
||||
@@ -300,8 +307,13 @@ impl OpenAICustomProvider {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent_base) = self.base_url_parent() {
|
||||
let u = Self::build_url_from_base(&parent_base, endpoint);
|
||||
let mut parent_base = self.base_url_parent();
|
||||
for _ in 0..6 {
|
||||
let Some(current_parent) = parent_base else {
|
||||
break;
|
||||
};
|
||||
|
||||
let u = Self::build_url_from_base(¤t_parent, endpoint);
|
||||
if !urls.iter().any(|x| x == &u) {
|
||||
urls.push(u.clone());
|
||||
}
|
||||
@@ -312,6 +324,8 @@ impl OpenAICustomProvider {
|
||||
urls.push(u2);
|
||||
}
|
||||
}
|
||||
|
||||
parent_base = Self::parent_base_url(¤t_parent);
|
||||
}
|
||||
|
||||
urls
|
||||
@@ -766,6 +780,19 @@ mod tests {
|
||||
assert!(description.contains("[InputExamples]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_urls_with_fallbacks_supports_nested_proxy_path() {
|
||||
let provider = OpenAICustomProvider::with_config(
|
||||
"sk-test".to_string(),
|
||||
Some("http://127.0.0.1:3030/openai/v1".to_string()),
|
||||
);
|
||||
let urls = provider.build_urls_with_fallbacks("chat/completions");
|
||||
|
||||
assert!(urls.contains(&"http://127.0.0.1:3030/openai/v1/chat/completions".to_string()));
|
||||
assert!(urls.contains(&"http://127.0.0.1:3030/openai/chat/completions".to_string()));
|
||||
assert!(urls.contains(&"http://127.0.0.1:3030/v1/chat/completions".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_openai_compatible_non_stream_and_stream_both_normalized() {
|
||||
if !OpenAICustomProvider::tool_calling_v2_enabled() {
|
||||
|
||||
@@ -87,7 +87,7 @@ fn should_create_backup() -> bool {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
#[allow(dead_code)]
|
||||
enum ShellConfigSyntax {
|
||||
Posix,
|
||||
PowerShell,
|
||||
|
||||
@@ -9,10 +9,6 @@ use std::process::Command;
|
||||
use tracing;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::ptr;
|
||||
#[cfg(target_os = "windows")]
|
||||
use winapi::um::winnt::KEY_READ;
|
||||
#[cfg(target_os = "windows")]
|
||||
use winreg::{enums::*, RegKey};
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ impl ProviderCredentialClientCompat for ProviderCredential {
|
||||
true
|
||||
}
|
||||
}
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -1288,23 +1288,120 @@ impl ProviderPoolService {
|
||||
|
||||
// OpenAI API 健康检查
|
||||
// 与 OpenAI Provider 保持一致的 URL 处理逻辑
|
||||
fn is_version_path_segment(segment: &str) -> bool {
|
||||
segment.starts_with('v')
|
||||
&& segment.len() >= 2
|
||||
&& segment[1..].chars().all(|c| c.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn build_openai_url_from_base(base_url: &str, endpoint: &str) -> String {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let has_version = base
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.map(Self::is_version_path_segment)
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_version {
|
||||
format!("{base}/{endpoint}")
|
||||
} else {
|
||||
format!("{base}/v1/{endpoint}")
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_base_url(base_url: &str) -> Option<String> {
|
||||
let base = base_url.trim();
|
||||
if base.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut url = reqwest::Url::parse(base)
|
||||
.or_else(|_| reqwest::Url::parse(&format!("http://{base}")))
|
||||
.ok()?;
|
||||
|
||||
let path = url.path().trim_end_matches('/');
|
||||
if path.is_empty() || path == "/" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut segments: Vec<&str> = path
|
||||
.split('/')
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect();
|
||||
if segments.is_empty() {
|
||||
return None;
|
||||
}
|
||||
segments.pop();
|
||||
|
||||
let new_path = if segments.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{}", segments.join("/"))
|
||||
};
|
||||
|
||||
url.set_path(&new_path);
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
|
||||
Some(url.to_string().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn push_openai_url_candidates(urls: &mut Vec<String>, base_url: &str, endpoint: &str) {
|
||||
if base_url.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let primary = Self::build_openai_url_from_base(base_url, endpoint);
|
||||
if !urls.iter().any(|url| url == &primary) {
|
||||
urls.push(primary.clone());
|
||||
}
|
||||
|
||||
if primary.contains("/v1/") {
|
||||
let no_v1 = primary.replacen("/v1/", "/", 1);
|
||||
if !urls.iter().any(|url| url == &no_v1) {
|
||||
urls.push(no_v1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_openai_health_check_urls(base_url: Option<&str>) -> Vec<String> {
|
||||
let raw_base = base_url
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("https://api.openai.com");
|
||||
let normalized_base = raw_base.trim_end_matches('/').to_string();
|
||||
|
||||
let mut urls = Vec::new();
|
||||
let mut visited = HashSet::new();
|
||||
visited.insert(normalized_base.clone());
|
||||
|
||||
Self::push_openai_url_candidates(&mut urls, &normalized_base, "chat/completions");
|
||||
|
||||
let mut current = normalized_base;
|
||||
for _ in 0..6 {
|
||||
let Some(parent) = Self::parent_base_url(¤t) else {
|
||||
break;
|
||||
};
|
||||
if !visited.insert(parent.clone()) {
|
||||
break;
|
||||
}
|
||||
Self::push_openai_url_candidates(&mut urls, &parent, "chat/completions");
|
||||
current = parent;
|
||||
}
|
||||
|
||||
if urls.is_empty() {
|
||||
urls.push("https://api.openai.com/v1/chat/completions".to_string());
|
||||
}
|
||||
urls
|
||||
}
|
||||
|
||||
async fn check_openai_health(
|
||||
&self,
|
||||
api_key: &str,
|
||||
base_url: Option<&str>,
|
||||
model: &str,
|
||||
) -> Result<(), String> {
|
||||
// base_url 应该不带 /v1,在这里拼接
|
||||
// 但为了兼容用户可能输入带 /v1 的情况,这里做智能处理
|
||||
let base = base_url.unwrap_or("https://api.openai.com");
|
||||
let base = base.trim_end_matches('/');
|
||||
|
||||
// 如果用户输入了带 /v1 的 URL,直接使用;否则拼接 /v1
|
||||
let url = if base.ends_with("/v1") {
|
||||
format!("{base}/chat/completions")
|
||||
} else {
|
||||
format!("{base}/v1/chat/completions")
|
||||
};
|
||||
let urls = Self::build_openai_health_check_urls(base_url);
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"model": model,
|
||||
@@ -1312,29 +1409,66 @@ impl ProviderPoolService {
|
||||
"max_tokens": 10
|
||||
});
|
||||
|
||||
tracing::debug!("[HEALTH_CHECK] OpenAI API URL: {}, model: {}", url, model);
|
||||
let mut last_error: Option<String> = None;
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(api_key)
|
||||
.json(&request_body)
|
||||
.timeout(self.health_check_timeout)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求失败: {e}"))?;
|
||||
for (index, url) in urls.iter().enumerate() {
|
||||
tracing::debug!("[HEALTH_CHECK] OpenAI API URL: {}, model: {}", url, model);
|
||||
|
||||
let response = match self
|
||||
.client
|
||||
.post(url)
|
||||
.bearer_auth(api_key)
|
||||
.json(&request_body)
|
||||
.timeout(self.health_check_timeout)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
let message = format!("请求失败: {error}");
|
||||
last_error = Some(message.clone());
|
||||
if index + 1 < urls.len() {
|
||||
tracing::warn!(
|
||||
"[HEALTH_CHECK] OpenAI API URL {} 请求失败,继续尝试后续候选: {}",
|
||||
url,
|
||||
message
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
|
||||
if response.status().is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
Err(format!(
|
||||
let message = format!(
|
||||
"HTTP {} - {}",
|
||||
status,
|
||||
body.chars().take(200).collect::<String>()
|
||||
))
|
||||
);
|
||||
last_error = Some(message.clone());
|
||||
|
||||
let can_retry_next_url = matches!(
|
||||
status,
|
||||
reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::METHOD_NOT_ALLOWED
|
||||
);
|
||||
if can_retry_next_url && index + 1 < urls.len() {
|
||||
tracing::warn!(
|
||||
"[HEALTH_CHECK] OpenAI API URL {} 返回 {},尝试下一个候选 URL",
|
||||
url,
|
||||
status
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
return Err(message);
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| "OpenAI 健康检查失败".to_string()))
|
||||
}
|
||||
|
||||
// Claude API 健康检查
|
||||
@@ -2183,4 +2317,21 @@ mod tests {
|
||||
PoolProviderType::OpenAI
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_openai_health_check_urls_supports_nested_base_path() {
|
||||
let urls = ProviderPoolService::build_openai_health_check_urls(Some(
|
||||
"http://127.0.0.1:3030/openai/v1",
|
||||
));
|
||||
|
||||
assert!(urls.contains(&"http://127.0.0.1:3030/openai/v1/chat/completions".to_string()));
|
||||
assert!(urls.contains(&"http://127.0.0.1:3030/openai/chat/completions".to_string()));
|
||||
assert!(urls.contains(&"http://127.0.0.1:3030/v1/chat/completions".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_openai_health_check_urls_defaults_to_official_endpoint() {
|
||||
let urls = ProviderPoolService::build_openai_health_check_urls(None);
|
||||
assert_eq!(urls[0], "https://api.openai.com/v1/chat/completions");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,7 +626,7 @@ impl WSLShellProc {
|
||||
input_rx: mpsc::Receiver<BlockInputUnion>,
|
||||
block_file: Option<Arc<BlockFile>>,
|
||||
) -> Result<Self, TerminalError> {
|
||||
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||||
use portable_pty::{native_pty_system, PtySize};
|
||||
|
||||
tracing::info!(
|
||||
"[WSLShellProc] 创建 WSL 进程: block_id={}, distro={}, size={}x{}",
|
||||
|
||||
@@ -427,6 +427,8 @@ impl RpcHandler {
|
||||
.require_db()
|
||||
.await
|
||||
.map_err(|e| RpcError::internal_error(e.message))?;
|
||||
AgentScheduler::init_tables(&db)
|
||||
.map_err(|e| RpcError::internal_error(format!("init cron tables failed: {e}")))?;
|
||||
let conn = proxycast_core::database::lock_db(&db)
|
||||
.map_err(|e| RpcError::internal_error(format!("DB lock failed: {e}")))?;
|
||||
let raw_tasks = SchedulerDao::list_tasks(
|
||||
@@ -478,6 +480,8 @@ impl RpcHandler {
|
||||
.require_db()
|
||||
.await
|
||||
.map_err(|e| RpcError::internal_error(e.message))?;
|
||||
AgentScheduler::init_tables(&db)
|
||||
.map_err(|e| RpcError::internal_error(format!("init cron tables failed: {e}")))?;
|
||||
let execution_id = Uuid::new_v4().to_string();
|
||||
let task = {
|
||||
let conn = proxycast_core::database::lock_db(&db)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: broadcast_generate
|
||||
description: 将文章整理为可转播客音频的源文本(下游负责真实音频合成)。
|
||||
allowed-tools: proxycast_create_broadcast_generation_task
|
||||
argument-hint: 输入原文、目标听众、语气、预计时长、重点段落。
|
||||
when-to-use: 用户希望把现有文稿转成播客内容,但不要求你直接写主持稿。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
---
|
||||
|
||||
你是 ProxyCast 的播客内容整理助手。
|
||||
|
||||
## 工作目标
|
||||
|
||||
将用户提供的图文内容整理成“适合下游音频转换”的文稿包,保持事实准确、结构清晰、可听性强。
|
||||
|
||||
## 执行规则
|
||||
|
||||
- 保留原文核心观点与证据,不随意新增事实。
|
||||
- 清理不利于朗读的内容(超长句、无意义链接堆叠、重复段)。
|
||||
- 输出的是“可播报文本材料”,不是完整主持人口播脚本。
|
||||
- 必须调用 `proxycast_create_broadcast_generation_task` 创建任务。
|
||||
- `payload` 中至少包含:`title`、`audience`、`tone`、`durationHintMinutes`、`content`。
|
||||
|
||||
## 输出格式(固定)
|
||||
|
||||
仅输出任务提交摘要(不要再写 `<write_file>`):
|
||||
|
||||
- 任务类型:broadcast_generate
|
||||
- 任务 ID:{task_id}
|
||||
- 任务文件:{path}
|
||||
- 状态:pending_submit
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: cover_generate
|
||||
description: 为文章或视频生成平台封面图,并写回主稿(封面场景优先使用本技能)。
|
||||
allowed-tools: social_generate_cover_image, proxycast_create_cover_generation_task
|
||||
argument-hint: 输入平台、标题、受众、视觉风格、尺寸要求。
|
||||
when-to-use: 用户明确要求“封面图”时使用,不要被普通配图任务替代。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
---
|
||||
|
||||
你是 ProxyCast 的封面生成助手。
|
||||
|
||||
## 工作目标
|
||||
|
||||
围绕当前主稿主题生成一张“可发布”的封面图,并给出可追溯的生成信息。
|
||||
|
||||
## 执行规则
|
||||
|
||||
- 封面任务优先,不要退化成普通插图。
|
||||
- 根据平台特性控制视觉:主体清晰、构图简洁、避免密集小字。
|
||||
- 默认尺寸 `1024x1024`,用户指定时优先按用户要求。
|
||||
- 使用 `social_generate_cover_image` 生成封面。
|
||||
- 生成后必须调用 `proxycast_create_cover_generation_task` 创建任务。
|
||||
- 工具失败时不能中断:保留占位、给出重试建议并提交失败任务记录。
|
||||
|
||||
## 输出格式(固定)
|
||||
|
||||
仅输出任务提交摘要(不要再写 `<write_file>`):
|
||||
|
||||
- 任务类型:cover_generate
|
||||
- 任务 ID:{task_id}
|
||||
- 任务文件:{path}
|
||||
- 状态:{pending_submit}
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: image_generate
|
||||
description: 根据文本描述生成配图素材(非封面场景)。
|
||||
allowed-tools: proxycast_create_image_generation_task
|
||||
argument-hint: 输入主题、画面主体、风格、构图、数量、尺寸。
|
||||
when-to-use: 用户需要普通配图、插图或概念图时使用;封面需求优先交给 cover_generate。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
---
|
||||
|
||||
你是 ProxyCast 的通用配图助手。
|
||||
|
||||
## 工作目标
|
||||
|
||||
将用户需求转成高质量配图提示词与任务参数,确保生成结果可直接用于正文配图。
|
||||
|
||||
## 执行规则
|
||||
|
||||
- 先判断是否属于封面需求;封面需求请转 `cover_generate`。
|
||||
- 提示词必须包含主体、场景、风格,不要空泛。
|
||||
- 若用户给了参考素材,需体现在参数中。
|
||||
- 必须调用 `proxycast_create_image_generation_task` 创建任务。
|
||||
- `payload` 中至少包含:`prompt`、`style`、`size`、`count`、`usage`。
|
||||
|
||||
## 输出格式(固定)
|
||||
|
||||
仅输出任务提交摘要(不要再写 `<write_file>`):
|
||||
|
||||
- 任务类型:image_generate
|
||||
- 任务 ID:{task_id}
|
||||
- 任务文件:{path}
|
||||
- 状态:pending_submit
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: library
|
||||
description: 【外部资产库】读取项目参考资料(/project)或风格参考(/styles)。
|
||||
allowed-tools: list_directory, read_file
|
||||
argument-hint: 输入要读取的目录、文件路径、目标主题与提取重点。
|
||||
when-to-use: 需要读取项目内参考资料,或提炼风格样例时使用。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
---
|
||||
|
||||
你是 ProxyCast 的资料库读取助手。
|
||||
|
||||
## 工作目标
|
||||
|
||||
从可访问的资料目录中读取内容,提炼与当前任务最相关的信息,输出结构化摘要供后续写作或改写使用。
|
||||
|
||||
## 执行规则
|
||||
|
||||
- 常规任务优先读取 `/project` 资料;仅在用户明确要求时读取 `/styles`。
|
||||
- 避免全量扫库,先列目录再按需读取目标文件。
|
||||
- 提取结论时要标注来源文件路径,便于追溯。
|
||||
- 不编造不存在的文件或内容。
|
||||
|
||||
## 输出格式(固定)
|
||||
|
||||
<write_file path="library-notes/{yyyyMMdd-HHmmss}-{slug}.md">
|
||||
# 资料提炼结果
|
||||
|
||||
## 读取范围
|
||||
- 目录:{已读取目录}
|
||||
- 文件:{已读取文件路径列表}
|
||||
|
||||
## 核心结论
|
||||
- {结论 1}
|
||||
- {结论 2}
|
||||
- {结论 3}
|
||||
|
||||
## 风格提示(可选)
|
||||
- {仅在读取 /styles 时输出}
|
||||
|
||||
## 来源
|
||||
- {文件路径 A}
|
||||
- {文件路径 B}
|
||||
</write_file>
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: modal_resource_search
|
||||
description: 提交资源检索任务(图片、背景音乐、音效等),供前端资源面板消费。
|
||||
allowed-tools: proxycast_create_modal_resource_search_task
|
||||
argument-hint: 输入资源类型、关键词、风格、用途、数量与限制条件。
|
||||
when-to-use: 用户需要为当前内容补充外部素材资源时使用。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
---
|
||||
|
||||
你是 ProxyCast 的资源检索编排助手。
|
||||
|
||||
## 工作目标
|
||||
|
||||
把素材需求结构化为“可执行检索任务”,并输出简明候选清单,方便用户快速确认。
|
||||
|
||||
## 执行规则
|
||||
|
||||
- 先明确资源类型(图片/BGM/音效)和使用场景。
|
||||
- 检索关键词控制在 1-3 个核心词,避免长句。
|
||||
- 优先给出高相关候选,不要堆无关结果。
|
||||
- 必须调用 `proxycast_create_modal_resource_search_task` 创建任务。
|
||||
- `payload` 中至少包含:`resourceType`、`query`、`usage`、`count`。
|
||||
|
||||
## 输出格式(固定)
|
||||
|
||||
仅输出任务提交摘要(不要再写 `<write_file>`):
|
||||
|
||||
- 任务类型:modal_resource_search
|
||||
- 任务 ID:{task_id}
|
||||
- 任务文件:{path}
|
||||
- 状态:pending_submit
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: research
|
||||
description: 联网信息检索与趋势调研(优先产出可引用结论,而非原始片段堆砌)。
|
||||
allowed-tools: search_query
|
||||
argument-hint: 输入调研主题、目标平台、时间范围、输出深度与关注维度。
|
||||
when-to-use: 用户需要事实核验、最新信息补充、行业/平台趋势调研时使用。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
---
|
||||
|
||||
你是 ProxyCast 的调研助手。
|
||||
|
||||
## 工作目标
|
||||
|
||||
通过可用检索能力产出“结论 + 证据来源 + 可执行建议”的调研结果。
|
||||
|
||||
## 执行规则
|
||||
|
||||
- 优先使用 1-3 个核心关键词,不要用冗长问句直接检索。
|
||||
- 如需“最新”信息,检索词必须包含年份(当前年份:2026)。
|
||||
- 检索后先去噪再归纳,不直接粘贴零散片段。
|
||||
- 事实不确定时要显式标注“待确认”,不要伪造结论。
|
||||
- 输出最多 3 条关键来源,强调可追溯。
|
||||
|
||||
## 输出格式(固定)
|
||||
|
||||
<write_file path="research-notes/{yyyyMMdd-HHmmss}-{slug}.md">
|
||||
# 调研结果
|
||||
|
||||
## 研究问题
|
||||
{问题描述}
|
||||
|
||||
## 核心结论
|
||||
- {结论 1}
|
||||
- {结论 2}
|
||||
- {结论 3}
|
||||
|
||||
## 证据与来源
|
||||
- {来源名称/站点}(日期:{YYYY-MM-DD}):{一句证据摘要}
|
||||
- {来源名称/站点}(日期:{YYYY-MM-DD}):{一句证据摘要}
|
||||
|
||||
## 建议动作
|
||||
- {建议 1}
|
||||
- {建议 2}
|
||||
|
||||
## 备注
|
||||
- 检索关键词:{关键词列表}
|
||||
- 不确定项:{如有则列出}
|
||||
</write_file>
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: social_post_with_cover
|
||||
description: 生成可直接发布的社媒成稿(默认公众号风格)并自动生成 1 张头图,最终以 write_file 落盘。
|
||||
allowed-tools: social_generate_cover_image, search_query
|
||||
argument-hint: 输入主题、平台(如公众号/小红书)、目标受众、语气、字数、转化目标和已知素材。
|
||||
when-to-use: 用户需要“社媒文章 + 封面图”一体化输出,且希望直接复制发布。
|
||||
version: 1.2.0
|
||||
execution-mode: prompt
|
||||
---
|
||||
|
||||
你是资深社媒内容策划与文案编辑,请根据用户输入生成高质量社媒文章,并调用工具生成封面图。
|
||||
|
||||
## 工作目标
|
||||
|
||||
1. 先输出完整社媒文章(默认以“可直接发布到微信公众号”的长文标准执行)。
|
||||
2. 调用 `social_generate_cover_image` 生成 1 张封面图(头图)。
|
||||
3. 将文章与图片结果整合为一份可直接发布的 Markdown 主稿。
|
||||
4. 最终必须落盘为一个 `social-posts/*.md` 文件(通过 `<write_file>` 标签输出)。
|
||||
|
||||
## 执行规则
|
||||
|
||||
### A. 上下文与检索规则(必须遵守)
|
||||
|
||||
- 优先吸收并使用用户输入中的上下文(例如 `[生效上下文]`、`[历史内容]`、素材、链接、摘要)。
|
||||
- 如果已有上下文足够,直接基于上下文写作,不要忽略用户提供信息。
|
||||
- 如果上下文不足且工具可用,优先调用 `search_query` 进行 2-4 次检索,再融合关键信息写作。
|
||||
- 检索信息必须“去噪整合”,不要原样堆砌搜索片段。
|
||||
- 未检索到可靠信息时,明确“基于现有上下文与通用经验”输出,不得编造具体来源。
|
||||
|
||||
### B. 文案生成规则(公众号优先)
|
||||
|
||||
- 必须匹配用户指定的平台语气(如公众号、小红书、微博、LinkedIn 等)。
|
||||
- 未指定平台时,默认按“公众号可发布长文”执行(专业、清晰、有实用价值)。
|
||||
- 标题要具体,不要“空泛鸡汤式标题党”。
|
||||
- 结构清晰:标题、导语、正文分节、结尾 CTA。
|
||||
- 段落要短,适合移动端阅读;尽量给出可执行建议或案例。
|
||||
- 技术类内容允许出现少量代码示例,但不要大段无解释代码堆砌。
|
||||
- 严禁在正文中输出过程元数据或结构化字段(如 `article_path`、`cover_meta_path`、`execution_id`、JSON/YAML)。
|
||||
|
||||
### C. 封面图生成规则
|
||||
|
||||
- 使用文章主题与目标受众提炼成可视化提示词。
|
||||
- 封面图风格要求:
|
||||
- 主体明确
|
||||
- 构图简洁
|
||||
- 适合社媒封面阅读
|
||||
- 不包含复杂小字
|
||||
- 默认尺寸使用 `1024x1024`(除非用户明确指定)。
|
||||
- 调用工具参数至少包含:
|
||||
- `prompt`
|
||||
- `size`
|
||||
|
||||
### D. 失败降级规则(必须遵守)
|
||||
|
||||
- 如果工具调用失败:
|
||||
- 文章仍必须完整输出;
|
||||
- 封面图位置使用占位文本;
|
||||
- 提供简洁重试建议;
|
||||
- 不要中断任务,不要让用户“先确认再继续”。
|
||||
|
||||
## 输出格式(固定)
|
||||
|
||||
请严格按以下格式输出,且最终结果必须在一个 `<write_file>` 块内:
|
||||
|
||||
```markdown
|
||||
<write_file path="social-posts/{yyyyMMdd-HHmmss}-{slug}.md">
|
||||
# {标题}
|
||||
|
||||

|
||||
|
||||
## 导语
|
||||
{导语内容}
|
||||
|
||||
## 正文
|
||||
{正文内容}
|
||||
|
||||
## 结尾
|
||||
{结尾与行动号召}
|
||||
|
||||
## 配图说明
|
||||
- 提示词:{用于生成封面图的 prompt}
|
||||
- 尺寸:{size}
|
||||
- 状态:{成功/失败}
|
||||
- 备注:{失败时给出一句重试建议;成功时可留空}
|
||||
|
||||
## 参考信息
|
||||
- 来源:{如有检索,写来源名称或站点}(日期:{YYYY-MM-DD})
|
||||
- 来源:{可选,最多 3 条}
|
||||
</write_file>
|
||||
```
|
||||
|
||||
补充约束:
|
||||
- 只输出一个主稿 `<write_file>`,不要输出多个版本文件。
|
||||
- 不要在 `<write_file>` 之外重复正文全文。
|
||||
- `<write_file>` 内只能放“最终可发布主稿”,不要混入发布包 JSON 或过程日志。
|
||||
|
||||
## 质量检查清单
|
||||
|
||||
- 标题是否有传播性与主题相关性。
|
||||
- 正文是否贴合目标受众。
|
||||
- 是否明确吸收了用户给定上下文与约束。
|
||||
- 若使用检索,是否将结论转化为可读内容并附简要来源。
|
||||
- 封面图提示词是否与文案核心一致。
|
||||
- 输出是否可直接复制发布。
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: typesetting
|
||||
description: 优化文稿排版与可读性,不改变原始事实与核心表达。
|
||||
allowed-tools: proxycast_create_typesetting_task
|
||||
argument-hint: 输入目标平台、语气要求、段落长度偏好、标题层级规范。
|
||||
when-to-use: 用户希望提升文本可读性、结构清晰度、发布观感时使用。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
---
|
||||
|
||||
你是 ProxyCast 的排版优化助手。
|
||||
|
||||
## 工作目标
|
||||
|
||||
在不改变原意与事实的前提下,优化文稿结构、层级、段落节奏与视觉可读性。
|
||||
|
||||
## 执行规则
|
||||
|
||||
- 不新增未经用户确认的观点与事实。
|
||||
- 不改变原文立场,仅做结构化与可读性优化。
|
||||
- 控制段落长度,优先移动端阅读体验。
|
||||
- 标题层级清晰,列表格式统一。
|
||||
- 必须调用 `proxycast_create_typesetting_task` 创建任务。
|
||||
- `payload` 中至少包含:`targetPlatform`、`rules`、`content`。
|
||||
|
||||
## 输出格式(固定)
|
||||
|
||||
仅输出任务提交摘要(不要再写 `<write_file>`):
|
||||
|
||||
- 任务类型:typesetting
|
||||
- 任务 ID:{task_id}
|
||||
- 任务文件:{path}
|
||||
- 状态:pending_submit
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: url_parse
|
||||
description: 解析外部 URL 内容,并沉淀为可阅读的文本结果。
|
||||
allowed-tools: proxycast_create_url_parse_task
|
||||
argument-hint: 输入 URL、抽取目标(摘要/要点/全文清洗)、输出格式要求。
|
||||
when-to-use: 用户提供链接并希望抽取正文、要点或可引用信息时使用。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
---
|
||||
|
||||
你是 ProxyCast 的链接解析助手。
|
||||
|
||||
## 工作目标
|
||||
|
||||
围绕用户提供的 URL 产出“可阅读、可引用、可继续加工”的文本结果。
|
||||
|
||||
## 执行规则
|
||||
|
||||
- 先校验 URL 是否完整可读;不完整时先提示补全。
|
||||
- 若当前会话存在可用抓取工具,则优先工具抓取;否则明确降级为“基于用户提供内容整理”。
|
||||
- 提炼时区分“原文信息”与“你的归纳”,避免混淆。
|
||||
- 必须调用 `proxycast_create_url_parse_task` 创建任务。
|
||||
- `payload` 中至少包含:`url`、`summary`、`keyPoints`、`extractStatus`。
|
||||
|
||||
## 输出格式(固定)
|
||||
|
||||
仅输出任务提交摘要(不要再写 `<write_file>`):
|
||||
|
||||
- 任务类型:url_parse
|
||||
- 任务 ID:{task_id}
|
||||
- 任务文件:{path}
|
||||
- 状态:pending_submit
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: video_generate
|
||||
description: 提交视频生成任务,并触发前端视频生成流程。
|
||||
allowed-tools: proxycast_create_video_generation_task
|
||||
argument-hint: 输入主题、受众、平台、时长、画幅、风格、素材来源。
|
||||
when-to-use: 用户要求生成视频,或将现有文稿改编为短视频。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
---
|
||||
|
||||
你是 ProxyCast 的视频任务编排助手。
|
||||
|
||||
## 工作目标
|
||||
|
||||
将用户需求整理成“可执行的视频任务”,交由后续视频流程处理,不要伪造“已生成完成”的结果。
|
||||
|
||||
## 执行规则
|
||||
|
||||
- 先吸收用户输入、当前会话上下文、已有文稿与素材引用。
|
||||
- 上下文不足时,最多补问 1 个关键问题(例如时长或画幅)。
|
||||
- 输出聚焦“镜头意图 + 生成参数”,不要写成长文。
|
||||
- 必须调用 `proxycast_create_video_generation_task` 创建真实任务。
|
||||
- `projectId` 必须来自当前工作区项目;不要虚构 providerId/model。
|
||||
- 禁止伪造“视频已生成完成”。
|
||||
|
||||
## 输出格式(固定)
|
||||
|
||||
仅输出任务创建结果摘要(不要再写 `<write_file>`):
|
||||
|
||||
- 任务类型:video_generate
|
||||
- 任务 ID:{task_id}
|
||||
- Provider:{provider_id}
|
||||
- 模型:{model}
|
||||
- 状态:{pending/processing/...}
|
||||
@@ -29,6 +29,7 @@ use crate::logger;
|
||||
use crate::mcp::McpManagerState;
|
||||
use crate::plugin;
|
||||
use crate::services::heartbeat_service::{HeartbeatService, HeartbeatServiceState};
|
||||
use crate::skills::ensure_default_local_skills;
|
||||
use crate::telemetry;
|
||||
use crate::voice::recording_service::{create_recording_service_state, RecordingServiceState};
|
||||
use proxycast_core::config::{Config, ConfigManager};
|
||||
@@ -240,6 +241,20 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
|
||||
database::dao::skills::SkillDao::init_default_skill_repos(&conn)
|
||||
.map_err(|e| format!("初始化默认技能仓库失败: {e}"))?;
|
||||
}
|
||||
match ensure_default_local_skills() {
|
||||
Ok(installed) if installed.is_empty() => {
|
||||
tracing::info!("[Bootstrap] 默认本地 Skills 已存在,跳过写入");
|
||||
}
|
||||
Ok(installed) => {
|
||||
tracing::info!(
|
||||
"[Bootstrap] 默认本地 Skills 安装完成: {}",
|
||||
installed.join(", ")
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!("[Bootstrap] 安装默认本地 Skills 失败: {}", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化调度器表,避免运行期健康检查出现缺表错误
|
||||
if let Err(error) = AgentScheduler::init_tables(&db) {
|
||||
|
||||
@@ -985,6 +985,7 @@ pub fn run() {
|
||||
// Execution run commands
|
||||
commands::execution_run_cmd::execution_run_list,
|
||||
commands::execution_run_cmd::execution_run_get,
|
||||
commands::execution_run_cmd::execution_run_get_theme_workbench_state,
|
||||
// Ecommerce Review Reply commands
|
||||
commands::ecommerce_review_reply_cmd::execute_ecommerce_review_reply,
|
||||
// Provider Pool commands
|
||||
@@ -1204,6 +1205,7 @@ pub fn run() {
|
||||
commands::aster_agent_cmd::aster_session_delete,
|
||||
commands::aster_agent_cmd::aster_agent_confirm,
|
||||
commands::aster_agent_cmd::aster_agent_submit_elicitation_response,
|
||||
commands::theme_context_cmd::aster_agent_theme_context_search,
|
||||
// Models config commands
|
||||
commands::models_cmd::get_models_config,
|
||||
commands::models_cmd::save_models_config,
|
||||
@@ -1358,10 +1360,18 @@ pub fn run() {
|
||||
commands::session_files_cmd::session_files_update_meta,
|
||||
commands::session_files_cmd::session_files_save_file,
|
||||
commands::session_files_cmd::session_files_read_file,
|
||||
commands::session_files_cmd::session_files_resolve_file_path,
|
||||
commands::session_files_cmd::session_files_delete_file,
|
||||
commands::session_files_cmd::session_files_list_files,
|
||||
commands::session_files_cmd::session_files_cleanup_expired,
|
||||
commands::session_files_cmd::session_files_cleanup_empty,
|
||||
// Image Upload commands
|
||||
commands::image_upload_cmd::upload_image_to_session,
|
||||
commands::image_upload_cmd::read_image_from_session,
|
||||
// Document Import commands
|
||||
commands::document_import_cmd::import_document,
|
||||
commands::document_import_cmd::import_document_to_session,
|
||||
commands::document_import_cmd::save_exported_document,
|
||||
// General Chat commands
|
||||
commands::general_chat_cmd::general_chat_create_session,
|
||||
commands::general_chat_cmd::general_chat_list_sessions,
|
||||
@@ -1462,6 +1472,7 @@ pub fn run() {
|
||||
// Content commands
|
||||
commands::content_cmd::content_create,
|
||||
commands::content_cmd::content_get,
|
||||
commands::content_cmd::content_get_theme_workbench_document_state,
|
||||
commands::content_cmd::content_list,
|
||||
commands::content_cmd::content_update,
|
||||
commands::content_cmd::content_delete,
|
||||
|
||||
@@ -8,6 +8,7 @@ use tauri::{App, Manager};
|
||||
// use crate::agent::tools::{set_term_scrollback_tool_app_handle, set_terminal_tool_app_handle};
|
||||
use crate::agent::AsterAgentState;
|
||||
use crate::database;
|
||||
use crate::skills::ensure_default_local_skills;
|
||||
use crate::telemetry;
|
||||
use crate::tray::{TrayIconStatus, TrayManager, TrayStateSnapshot};
|
||||
use proxycast_scheduler::AgentScheduler;
|
||||
@@ -79,6 +80,17 @@ pub fn setup_app(
|
||||
database::dao::skills::SkillDao::init_default_skill_repos(&conn)
|
||||
.expect("Failed to initialize default skill repos");
|
||||
}
|
||||
match ensure_default_local_skills() {
|
||||
Ok(installed) if installed.is_empty() => {
|
||||
tracing::info!("[启动] 默认本地 Skills 已存在,跳过写入");
|
||||
}
|
||||
Ok(installed) => {
|
||||
tracing::info!("[启动] 默认本地 Skills 安装完成: {}", installed.join(", "));
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!("[启动] 安装默认本地 Skills 失败: {}", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化调度器数据库表
|
||||
if let Err(e) = AgentScheduler::init_tables(&db) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,8 @@ use crate::database::DbConnection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
const THEME_WORKBENCH_DOCUMENT_META_KEY: &str = "theme_workbench_document_v1";
|
||||
|
||||
/// 内容列表项(用于前端展示)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContentListItem {
|
||||
@@ -78,6 +80,114 @@ impl From<Content> for ContentDetail {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThemeWorkbenchVersionState {
|
||||
pub id: String,
|
||||
pub created_at: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
pub is_current: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThemeWorkbenchDocumentState {
|
||||
pub content_id: String,
|
||||
pub current_version_id: String,
|
||||
pub version_count: usize,
|
||||
pub versions: Vec<ThemeWorkbenchVersionState>,
|
||||
}
|
||||
|
||||
fn is_valid_topic_branch_status(status: &str) -> bool {
|
||||
matches!(status, "in_progress" | "pending" | "merged" | "candidate")
|
||||
}
|
||||
|
||||
fn parse_theme_workbench_document_state(
|
||||
content_id: &str,
|
||||
metadata: Option<&serde_json::Value>,
|
||||
) -> Option<ThemeWorkbenchDocumentState> {
|
||||
let metadata = metadata?.as_object()?;
|
||||
let raw = metadata
|
||||
.get(THEME_WORKBENCH_DOCUMENT_META_KEY)?
|
||||
.as_object()?;
|
||||
|
||||
let versions_raw = raw.get("versions")?.as_array()?;
|
||||
if versions_raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let current_version_id = raw.get("currentVersionId")?.as_str()?.trim().to_string();
|
||||
if current_version_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let status_map = raw
|
||||
.get("versionStatusMap")
|
||||
.and_then(|value| value.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let versions: Vec<ThemeWorkbenchVersionState> = versions_raw
|
||||
.iter()
|
||||
.filter_map(|version| {
|
||||
let version_obj = version.as_object()?;
|
||||
let id = version_obj.get("id")?.as_str()?.trim().to_string();
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let created_at = version_obj
|
||||
.get("createdAt")
|
||||
.and_then(|value| value.as_i64())
|
||||
.or_else(|| {
|
||||
version_obj
|
||||
.get("created_at")
|
||||
.and_then(|value| value.as_i64())
|
||||
})?;
|
||||
|
||||
let description = version_obj
|
||||
.get("description")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string);
|
||||
|
||||
let status = status_map
|
||||
.get(&id)
|
||||
.and_then(|value| value.as_str())
|
||||
.filter(|value| is_valid_topic_branch_status(value))
|
||||
.map(ToString::to_string);
|
||||
|
||||
Some(ThemeWorkbenchVersionState {
|
||||
is_current: id == current_version_id,
|
||||
id,
|
||||
created_at,
|
||||
description,
|
||||
status,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if versions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !versions
|
||||
.iter()
|
||||
.any(|version| version.id == current_version_id)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ThemeWorkbenchDocumentState {
|
||||
content_id: content_id.to_string(),
|
||||
current_version_id,
|
||||
version_count: versions.len(),
|
||||
versions,
|
||||
})
|
||||
}
|
||||
|
||||
/// 创建内容请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateContentRequest {
|
||||
@@ -163,6 +273,18 @@ pub async fn content_get(
|
||||
Ok(content.map(|c| c.into()))
|
||||
}
|
||||
|
||||
/// 获取主题工作台文稿版本状态(从 content.metadata 解析)
|
||||
#[tauri::command]
|
||||
pub async fn content_get_theme_workbench_document_state(
|
||||
db: State<'_, DbConnection>,
|
||||
id: String,
|
||||
) -> Result<Option<ThemeWorkbenchDocumentState>, String> {
|
||||
let manager = ContentManager::new(db.inner().clone());
|
||||
let content = manager.get(&id)?;
|
||||
Ok(content
|
||||
.and_then(|item| parse_theme_workbench_document_state(&item.id, item.metadata.as_ref())))
|
||||
}
|
||||
|
||||
/// 列出项目的所有内容
|
||||
#[tauri::command]
|
||||
pub async fn content_list(
|
||||
@@ -237,3 +359,48 @@ pub async fn content_stats(
|
||||
let manager = ContentManager::new(db.inner().clone());
|
||||
manager.get_project_stats(&project_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_theme_workbench_document_state, THEME_WORKBENCH_DOCUMENT_META_KEY};
|
||||
|
||||
#[test]
|
||||
fn test_parse_theme_workbench_document_state_success() {
|
||||
let metadata = serde_json::json!({
|
||||
THEME_WORKBENCH_DOCUMENT_META_KEY: {
|
||||
"currentVersionId": "v2",
|
||||
"versions": [
|
||||
{ "id": "v1", "createdAt": 1700000000000_i64, "description": "初稿" },
|
||||
{ "id": "v2", "createdAt": 1700000100000_i64, "description": "修订版" }
|
||||
],
|
||||
"versionStatusMap": {
|
||||
"v1": "merged",
|
||||
"v2": "in_progress"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = parse_theme_workbench_document_state("content-1", Some(&metadata))
|
||||
.expect("should parse");
|
||||
assert_eq!(parsed.content_id, "content-1");
|
||||
assert_eq!(parsed.current_version_id, "v2");
|
||||
assert_eq!(parsed.version_count, 2);
|
||||
assert_eq!(parsed.versions[0].status.as_deref(), Some("merged"));
|
||||
assert!(parsed.versions[1].is_current);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_theme_workbench_document_state_rejects_invalid_current_version() {
|
||||
let metadata = serde_json::json!({
|
||||
THEME_WORKBENCH_DOCUMENT_META_KEY: {
|
||||
"currentVersionId": "v-not-exists",
|
||||
"versions": [
|
||||
{ "id": "v1", "createdAt": 1700000000000_i64, "description": "初稿" }
|
||||
],
|
||||
"versionStatusMap": { "v1": "merged" }
|
||||
}
|
||||
});
|
||||
|
||||
assert!(parse_theme_workbench_document_state("content-1", Some(&metadata)).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! 文档导入 Tauri 命令
|
||||
//!
|
||||
//! 提供文档导入和解析功能。
|
||||
|
||||
use crate::commands::session_files_cmd::SessionFilesState;
|
||||
use std::path::Path;
|
||||
use tauri::State;
|
||||
|
||||
/// 支持的文档格式
|
||||
const SUPPORTED_DOC_EXTENSIONS: &[&str] = &["md", "txt"];
|
||||
|
||||
/// 文档文件最大大小(5MB)
|
||||
const MAX_DOC_SIZE: u64 = 5 * 1024 * 1024;
|
||||
|
||||
/// 验证文件是否为支持的文档格式
|
||||
fn is_supported_document(file_path: &str) -> bool {
|
||||
let path = Path::new(file_path);
|
||||
if let Some(ext) = path.extension() {
|
||||
let ext_str = ext.to_string_lossy().to_lowercase();
|
||||
return SUPPORTED_DOC_EXTENSIONS.contains(&ext_str.as_str());
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 导入文档内容
|
||||
///
|
||||
/// # 参数
|
||||
/// - `file_path`: 本地文档文件路径
|
||||
///
|
||||
/// # 返回
|
||||
/// 返回文档的文本内容
|
||||
#[tauri::command]
|
||||
pub async fn import_document(file_path: String) -> Result<String, String> {
|
||||
// 验证文件格式
|
||||
if !is_supported_document(&file_path) {
|
||||
return Err(format!(
|
||||
"不支持的文档格式。支持的格式:{}",
|
||||
SUPPORTED_DOC_EXTENSIONS.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
let path = Path::new(&file_path);
|
||||
if !path.exists() {
|
||||
return Err("文件不存在".to_string());
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
let metadata = std::fs::metadata(path).map_err(|e| format!("读取文件元数据失败: {}", e))?;
|
||||
if metadata.len() > MAX_DOC_SIZE {
|
||||
return Err(format!(
|
||||
"文档文件过大(最大 {}MB)",
|
||||
MAX_DOC_SIZE / 1024 / 1024
|
||||
));
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
let content = std::fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}", e))?;
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
/// 导入文档并保存到会话
|
||||
///
|
||||
/// # 参数
|
||||
/// - `session_id`: 会话ID
|
||||
/// - `file_path`: 本地文档文件路径
|
||||
///
|
||||
/// # 返回
|
||||
/// 返回文档内容和保存的文件名
|
||||
#[tauri::command]
|
||||
pub async fn import_document_to_session(
|
||||
state: State<'_, SessionFilesState>,
|
||||
session_id: String,
|
||||
file_path: String,
|
||||
) -> Result<(String, String), String> {
|
||||
// 导入文档内容
|
||||
let content = import_document(file_path.clone()).await?;
|
||||
|
||||
// 生成文件名
|
||||
let path = Path::new(&file_path);
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or("无效的文件名")?;
|
||||
|
||||
// 保存到会话文件系统
|
||||
super::session_files_cmd::session_files_save_file(
|
||||
state,
|
||||
session_id,
|
||||
file_name.to_string(),
|
||||
content.clone(),
|
||||
)?;
|
||||
|
||||
Ok((content, file_name.to_string()))
|
||||
}
|
||||
|
||||
/// 保存导出的文档到指定路径
|
||||
///
|
||||
/// # 参数
|
||||
/// - `file_path`: 用户选择的目标文件路径
|
||||
/// - `content`: 要写入的文本内容
|
||||
#[tauri::command]
|
||||
pub async fn save_exported_document(file_path: String, content: String) -> Result<(), String> {
|
||||
let path = Path::new(&file_path);
|
||||
|
||||
if file_path.trim().is_empty() {
|
||||
return Err("导出路径不能为空".to_string());
|
||||
}
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
if parent.as_os_str().is_empty() {
|
||||
return std::fs::write(path, content).map_err(|e| format!("保存导出文件失败: {}", e));
|
||||
}
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建导出目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
std::fs::write(path, content).map_err(|e| format!("保存导出文件失败: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::agent::AsterAgentState;
|
||||
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
|
||||
use crate::commands::skill_exec_cmd::{execute_skill, SkillExecutionResult};
|
||||
use crate::config::GlobalConfigManagerState;
|
||||
use crate::database::DbConnection;
|
||||
@@ -46,6 +47,7 @@ pub struct EcommerceReviewReplyRequest {
|
||||
pub async fn execute_ecommerce_review_reply(
|
||||
app_handle: tauri::AppHandle,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
aster_state: State<'_, AsterAgentState>,
|
||||
request: EcommerceReviewReplyRequest,
|
||||
@@ -74,6 +76,7 @@ pub async fn execute_ecommerce_review_reply(
|
||||
execute_skill(
|
||||
app_handle,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
config_manager,
|
||||
aster_state,
|
||||
"ecommerce-review-reply".to_string(),
|
||||
|
||||
@@ -2,11 +2,64 @@
|
||||
//!
|
||||
//! 提供对 `agent_runs` 的只读查询能力,供前端查看 chat / skill / heartbeat 执行摘要。
|
||||
|
||||
use crate::database::dao::agent_run::AgentRun;
|
||||
use crate::database::dao::agent_run::{AgentRun, AgentRunDao, AgentRunStatus};
|
||||
use crate::database::DbConnection;
|
||||
use crate::services::execution_tracker_service::ExecutionTracker;
|
||||
use chrono::Utc;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tauri::State;
|
||||
|
||||
const STALE_RUN_TIMEOUT_SECONDS: i64 = 180;
|
||||
|
||||
fn parse_run_time(raw: &str) -> Option<chrono::DateTime<Utc>> {
|
||||
chrono::DateTime::parse_from_rfc3339(raw)
|
||||
.ok()
|
||||
.map(|parsed| parsed.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
fn collect_stale_run_ids(runs: &[AgentRun], now: chrono::DateTime<Utc>) -> Vec<String> {
|
||||
runs.iter()
|
||||
.filter(|run| matches!(run.status, AgentRunStatus::Running | AgentRunStatus::Queued))
|
||||
.filter_map(|run| {
|
||||
let started_at = parse_run_time(run.started_at.as_str())?;
|
||||
let elapsed_seconds = now.signed_duration_since(started_at).num_seconds();
|
||||
if elapsed_seconds > STALE_RUN_TIMEOUT_SECONDS {
|
||||
Some(run.id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mark_stale_runs_as_timeout(
|
||||
db: &DbConnection,
|
||||
stale_run_ids: &[String],
|
||||
finished_at: &str,
|
||||
) -> Result<(), String> {
|
||||
if stale_run_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
for run_id in stale_run_ids {
|
||||
AgentRunDao::finish_run(
|
||||
&conn,
|
||||
run_id,
|
||||
AgentRunStatus::Timeout,
|
||||
finished_at,
|
||||
None,
|
||||
Some("run_stale_timeout"),
|
||||
Some("运行状态已超时,自动回收"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("回收超时运行记录失败: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execution_run_list(
|
||||
db: State<'_, DbConnection>,
|
||||
@@ -31,3 +84,486 @@ pub async fn execution_run_get(
|
||||
let tracker = ExecutionTracker::new(db.inner().clone());
|
||||
tracker.get_run(id)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct ThemeWorkbenchRunTodoItem {
|
||||
pub run_id: String,
|
||||
pub execution_id: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub artifact_paths: Vec<String>,
|
||||
pub title: String,
|
||||
pub gate_key: String,
|
||||
pub status: AgentRunStatus,
|
||||
pub source: String,
|
||||
pub source_ref: Option<String>,
|
||||
pub started_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct ThemeWorkbenchRunTerminalItem {
|
||||
pub run_id: String,
|
||||
pub execution_id: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub artifact_paths: Vec<String>,
|
||||
pub title: String,
|
||||
pub gate_key: String,
|
||||
pub status: AgentRunStatus,
|
||||
pub source: String,
|
||||
pub source_ref: Option<String>,
|
||||
pub started_at: String,
|
||||
pub finished_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct ThemeWorkbenchRunState {
|
||||
pub run_state: String,
|
||||
pub current_gate_key: String,
|
||||
pub queue_items: Vec<ThemeWorkbenchRunTodoItem>,
|
||||
pub latest_terminal: Option<ThemeWorkbenchRunTerminalItem>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
fn normalize_gate_key(raw: &str) -> Option<String> {
|
||||
let normalized = raw.trim().to_lowercase();
|
||||
match normalized.as_str() {
|
||||
"topic_select" | "write_mode" | "publish_confirm" => Some(normalized),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_gate_key_from_probe(probe: &str) -> String {
|
||||
let normalized = probe.to_lowercase();
|
||||
if normalized.contains("publish")
|
||||
|| normalized.contains("adapt")
|
||||
|| normalized.contains("distribution")
|
||||
|| normalized.contains("release")
|
||||
|| normalized.contains("发布")
|
||||
|| normalized.contains("分发")
|
||||
|| normalized.contains("平台适配")
|
||||
{
|
||||
return "publish_confirm".to_string();
|
||||
}
|
||||
if normalized.contains("topic")
|
||||
|| normalized.contains("research")
|
||||
|| normalized.contains("trend")
|
||||
|| normalized.contains("idea")
|
||||
|| normalized.contains("选题")
|
||||
|| normalized.contains("方向")
|
||||
|| normalized.contains("调研")
|
||||
|| normalized.contains("洞察")
|
||||
{
|
||||
return "topic_select".to_string();
|
||||
}
|
||||
"write_mode".to_string()
|
||||
}
|
||||
|
||||
fn derive_run_title(run: &AgentRun) -> String {
|
||||
let parsed_metadata = run
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|raw| serde_json::from_str::<Value>(raw).ok());
|
||||
|
||||
let skill_title = parsed_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("skill_name"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!("执行技能 {value}"));
|
||||
if let Some(title) = skill_title {
|
||||
return title;
|
||||
}
|
||||
|
||||
let task_title = parsed_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("task_name"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!("执行任务 {value}"));
|
||||
if let Some(title) = task_title {
|
||||
return title;
|
||||
}
|
||||
|
||||
let source_ref_title = run
|
||||
.source_ref
|
||||
.as_ref()
|
||||
.map(String::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!("运行节点 {value}"));
|
||||
if let Some(title) = source_ref_title {
|
||||
return title;
|
||||
}
|
||||
|
||||
match run.source.as_str() {
|
||||
"skill" => "执行主题工作台技能".to_string(),
|
||||
"heartbeat" => "执行定时任务".to_string(),
|
||||
_ => "执行主题工作台编排".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_run_gate_key(run: &AgentRun, title: &str) -> String {
|
||||
let parsed_metadata = run
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|raw| serde_json::from_str::<Value>(raw).ok());
|
||||
|
||||
if let Some(value) = parsed_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("gate_key"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_gate_key)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
let metadata_probe = parsed_metadata
|
||||
.as_ref()
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default();
|
||||
let source_ref_probe = run.source_ref.clone().unwrap_or_default();
|
||||
let probe = format!(
|
||||
"{} {} {} {}",
|
||||
title, source_ref_probe, run.source, metadata_probe
|
||||
);
|
||||
infer_gate_key_from_probe(probe.as_str())
|
||||
}
|
||||
|
||||
fn derive_current_gate_key(queue_items: &[ThemeWorkbenchRunTodoItem]) -> String {
|
||||
queue_items
|
||||
.iter()
|
||||
.find(|item| item.status == AgentRunStatus::Running)
|
||||
.map(|item| item.gate_key.clone())
|
||||
.or_else(|| queue_items.first().map(|item| item.gate_key.clone()))
|
||||
.unwrap_or_else(|| "idle".to_string())
|
||||
}
|
||||
|
||||
fn derive_run_execution_id(run: &AgentRun) -> Option<String> {
|
||||
let parsed_metadata = run
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|raw| serde_json::from_str::<Value>(raw).ok());
|
||||
|
||||
parsed_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("execution_id")
|
||||
.or_else(|| value.get("version_id"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn derive_run_artifact_paths(run: &AgentRun) -> Vec<String> {
|
||||
let parsed_metadata = run
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|raw| serde_json::from_str::<Value>(raw).ok());
|
||||
|
||||
parsed_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("artifact_paths"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|paths| {
|
||||
paths
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execution_run_get_theme_workbench_state(
|
||||
db: State<'_, DbConnection>,
|
||||
session_id: String,
|
||||
limit: Option<usize>,
|
||||
) -> Result<ThemeWorkbenchRunState, String> {
|
||||
let trimmed_session_id = session_id.trim();
|
||||
if trimmed_session_id.is_empty() {
|
||||
return Err("session_id 不能为空".to_string());
|
||||
}
|
||||
|
||||
let safe_limit = limit.unwrap_or(3).clamp(1, 10);
|
||||
let tracker = ExecutionTracker::new(db.inner().clone());
|
||||
let mut runs = tracker.list_runs_by_session(trimmed_session_id, safe_limit * 5)?;
|
||||
let now = Utc::now();
|
||||
let stale_run_ids = collect_stale_run_ids(runs.as_slice(), now);
|
||||
if !stale_run_ids.is_empty() {
|
||||
mark_stale_runs_as_timeout(db.inner(), stale_run_ids.as_slice(), &now.to_rfc3339())?;
|
||||
runs = tracker.list_runs_by_session(trimmed_session_id, safe_limit * 5)?;
|
||||
}
|
||||
|
||||
let queue_items: Vec<ThemeWorkbenchRunTodoItem> = runs
|
||||
.iter()
|
||||
.filter(|run| matches!(run.status, AgentRunStatus::Running | AgentRunStatus::Queued))
|
||||
.take(safe_limit)
|
||||
.map(|run| {
|
||||
let title = derive_run_title(run);
|
||||
let gate_key = derive_run_gate_key(run, title.as_str());
|
||||
ThemeWorkbenchRunTodoItem {
|
||||
run_id: run.id.clone(),
|
||||
execution_id: derive_run_execution_id(run),
|
||||
session_id: run.session_id.clone(),
|
||||
artifact_paths: derive_run_artifact_paths(run),
|
||||
title,
|
||||
gate_key,
|
||||
status: run.status.clone(),
|
||||
source: run.source.clone(),
|
||||
source_ref: run.source_ref.clone(),
|
||||
started_at: run.started_at.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let run_state = if queue_items.is_empty() {
|
||||
"idle".to_string()
|
||||
} else {
|
||||
"auto_running".to_string()
|
||||
};
|
||||
let current_gate_key = derive_current_gate_key(queue_items.as_slice());
|
||||
|
||||
let latest_terminal = runs
|
||||
.iter()
|
||||
.find(|run| {
|
||||
matches!(
|
||||
run.status,
|
||||
AgentRunStatus::Success
|
||||
| AgentRunStatus::Error
|
||||
| AgentRunStatus::Canceled
|
||||
| AgentRunStatus::Timeout
|
||||
)
|
||||
})
|
||||
.map(|run| {
|
||||
let title = derive_run_title(run);
|
||||
let gate_key = derive_run_gate_key(run, title.as_str());
|
||||
ThemeWorkbenchRunTerminalItem {
|
||||
run_id: run.id.clone(),
|
||||
execution_id: derive_run_execution_id(run),
|
||||
session_id: run.session_id.clone(),
|
||||
artifact_paths: derive_run_artifact_paths(run),
|
||||
title,
|
||||
gate_key,
|
||||
status: run.status.clone(),
|
||||
source: run.source.clone(),
|
||||
source_ref: run.source_ref.clone(),
|
||||
started_at: run.started_at.clone(),
|
||||
finished_at: run.finished_at.clone(),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(ThemeWorkbenchRunState {
|
||||
run_state,
|
||||
current_gate_key,
|
||||
queue_items,
|
||||
latest_terminal,
|
||||
updated_at: Utc::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_run_with_metadata(metadata: Option<Value>) -> AgentRun {
|
||||
AgentRun {
|
||||
id: "run-test-1".to_string(),
|
||||
source: "skill".to_string(),
|
||||
source_ref: Some("topic_research".to_string()),
|
||||
session_id: Some("session-test".to_string()),
|
||||
status: AgentRunStatus::Running,
|
||||
started_at: "2026-03-06T00:00:00Z".to_string(),
|
||||
finished_at: None,
|
||||
duration_ms: None,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
metadata: metadata.map(|raw| raw.to_string()),
|
||||
created_at: "2026-03-06T00:00:00Z".to_string(),
|
||||
updated_at: "2026-03-06T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_run_gate_key_should_prefer_metadata_gate_key() {
|
||||
let run = sample_run_with_metadata(Some(serde_json::json!({
|
||||
"gate_key": "publish_confirm"
|
||||
})));
|
||||
let gate_key = derive_run_gate_key(&run, "任意标题");
|
||||
assert_eq!(gate_key, "publish_confirm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_run_gate_key_should_fallback_to_probe_inference() {
|
||||
let run = sample_run_with_metadata(None);
|
||||
let gate_key = derive_run_gate_key(&run, "执行选题调研");
|
||||
assert_eq!(gate_key, "topic_select");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_gate_key_should_reject_unknown_values() {
|
||||
assert_eq!(
|
||||
normalize_gate_key("write_mode"),
|
||||
Some("write_mode".to_string())
|
||||
);
|
||||
assert!(normalize_gate_key("unknown_gate").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_current_gate_key_should_prefer_running_item() {
|
||||
let queue_items = vec![
|
||||
ThemeWorkbenchRunTodoItem {
|
||||
run_id: "run-1".to_string(),
|
||||
execution_id: None,
|
||||
session_id: None,
|
||||
artifact_paths: vec![],
|
||||
title: "选题调研".to_string(),
|
||||
gate_key: "topic_select".to_string(),
|
||||
status: AgentRunStatus::Queued,
|
||||
source: "skill".to_string(),
|
||||
source_ref: None,
|
||||
started_at: "2026-03-06T00:00:00Z".to_string(),
|
||||
},
|
||||
ThemeWorkbenchRunTodoItem {
|
||||
run_id: "run-2".to_string(),
|
||||
execution_id: None,
|
||||
session_id: None,
|
||||
artifact_paths: vec![],
|
||||
title: "写作中".to_string(),
|
||||
gate_key: "write_mode".to_string(),
|
||||
status: AgentRunStatus::Running,
|
||||
source: "skill".to_string(),
|
||||
source_ref: None,
|
||||
started_at: "2026-03-06T00:00:01Z".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
derive_current_gate_key(queue_items.as_slice()),
|
||||
"write_mode".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_current_gate_key_should_fallback_to_first_item() {
|
||||
let queue_items = vec![ThemeWorkbenchRunTodoItem {
|
||||
run_id: "run-1".to_string(),
|
||||
execution_id: None,
|
||||
session_id: None,
|
||||
artifact_paths: vec![],
|
||||
title: "选题调研".to_string(),
|
||||
gate_key: "topic_select".to_string(),
|
||||
status: AgentRunStatus::Queued,
|
||||
source: "skill".to_string(),
|
||||
source_ref: None,
|
||||
started_at: "2026-03-06T00:00:00Z".to_string(),
|
||||
}];
|
||||
|
||||
assert_eq!(
|
||||
derive_current_gate_key(queue_items.as_slice()),
|
||||
"topic_select".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_current_gate_key_should_return_idle_when_empty() {
|
||||
assert_eq!(derive_current_gate_key(&[]), "idle".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_run_execution_id_should_prefer_metadata_execution_id() {
|
||||
let run = sample_run_with_metadata(Some(serde_json::json!({
|
||||
"execution_id": "exec-12345",
|
||||
"version_id": "version-legacy",
|
||||
})));
|
||||
assert_eq!(
|
||||
derive_run_execution_id(&run),
|
||||
Some("exec-12345".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_run_artifact_paths_should_parse_non_empty_paths() {
|
||||
let run = sample_run_with_metadata(Some(serde_json::json!({
|
||||
"artifact_paths": [
|
||||
"social-posts/demo.md",
|
||||
" ",
|
||||
"social-posts/demo.cover.json"
|
||||
],
|
||||
})));
|
||||
|
||||
assert_eq!(
|
||||
derive_run_artifact_paths(&run),
|
||||
vec![
|
||||
"social-posts/demo.md".to_string(),
|
||||
"social-posts/demo.cover.json".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_stale_run_ids_should_only_pick_expired_non_terminal_runs() {
|
||||
let now = Utc::now();
|
||||
let stale_started_at =
|
||||
(now - chrono::Duration::seconds(STALE_RUN_TIMEOUT_SECONDS + 20)).to_rfc3339();
|
||||
let fresh_started_at = (now - chrono::Duration::seconds(10)).to_rfc3339();
|
||||
|
||||
let stale_run = AgentRun {
|
||||
id: "run-stale".to_string(),
|
||||
source: "chat".to_string(),
|
||||
source_ref: Some("aster_agent_chat_stream".to_string()),
|
||||
session_id: Some("session-1".to_string()),
|
||||
status: AgentRunStatus::Running,
|
||||
started_at: stale_started_at.clone(),
|
||||
finished_at: None,
|
||||
duration_ms: None,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
metadata: None,
|
||||
created_at: stale_started_at.clone(),
|
||||
updated_at: stale_started_at,
|
||||
};
|
||||
let fresh_run = AgentRun {
|
||||
id: "run-fresh".to_string(),
|
||||
source: "chat".to_string(),
|
||||
source_ref: Some("aster_agent_chat_stream".to_string()),
|
||||
session_id: Some("session-1".to_string()),
|
||||
status: AgentRunStatus::Queued,
|
||||
started_at: fresh_started_at.clone(),
|
||||
finished_at: None,
|
||||
duration_ms: None,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
metadata: None,
|
||||
created_at: fresh_started_at.clone(),
|
||||
updated_at: fresh_started_at,
|
||||
};
|
||||
let terminal_run = AgentRun {
|
||||
id: "run-terminal".to_string(),
|
||||
source: "chat".to_string(),
|
||||
source_ref: Some("aster_agent_chat_stream".to_string()),
|
||||
session_id: Some("session-1".to_string()),
|
||||
status: AgentRunStatus::Success,
|
||||
started_at: now.to_rfc3339(),
|
||||
finished_at: Some(now.to_rfc3339()),
|
||||
duration_ms: Some(1200),
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
metadata: None,
|
||||
created_at: now.to_rfc3339(),
|
||||
updated_at: now.to_rfc3339(),
|
||||
};
|
||||
|
||||
let stale_ids = collect_stale_run_ids(&[stale_run, fresh_run, terminal_run], now);
|
||||
assert_eq!(stale_ids, vec!["run-stale".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! 图片上传 Tauri 命令
|
||||
//!
|
||||
//! 提供图片上传到会话文件系统的功能。
|
||||
|
||||
use crate::commands::session_files_cmd::SessionFilesState;
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use std::path::Path;
|
||||
use tauri::State;
|
||||
|
||||
/// 支持的图片格式
|
||||
const SUPPORTED_IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp"];
|
||||
|
||||
/// 图片文件最大大小(10MB)
|
||||
const MAX_IMAGE_SIZE: u64 = 10 * 1024 * 1024;
|
||||
|
||||
/// 验证文件是否为支持的图片格式
|
||||
fn is_supported_image(file_path: &str) -> bool {
|
||||
let path = Path::new(file_path);
|
||||
if let Some(ext) = path.extension() {
|
||||
let ext_str = ext.to_string_lossy().to_lowercase();
|
||||
return SUPPORTED_IMAGE_EXTENSIONS.contains(&ext_str.as_str());
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 上传图片到会话
|
||||
///
|
||||
/// # 参数
|
||||
/// - `session_id`: 会话ID
|
||||
/// - `file_path`: 本地图片文件路径
|
||||
///
|
||||
/// # 返回
|
||||
/// 返回图片在会话中的访问路径
|
||||
#[tauri::command]
|
||||
pub async fn upload_image_to_session(
|
||||
state: State<'_, SessionFilesState>,
|
||||
session_id: String,
|
||||
file_path: String,
|
||||
) -> Result<String, String> {
|
||||
// 验证文件格式
|
||||
if !is_supported_image(&file_path) {
|
||||
return Err(format!(
|
||||
"不支持的图片格式。支持的格式:{}",
|
||||
SUPPORTED_IMAGE_EXTENSIONS.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
let path = Path::new(&file_path);
|
||||
if !path.exists() {
|
||||
return Err("文件不存在".to_string());
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
let metadata = std::fs::metadata(path).map_err(|e| format!("读取文件元数据失败: {}", e))?;
|
||||
if metadata.len() > MAX_IMAGE_SIZE {
|
||||
return Err(format!(
|
||||
"图片文件过大(最大 {}MB)",
|
||||
MAX_IMAGE_SIZE / 1024 / 1024
|
||||
));
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
let content = std::fs::read(path).map_err(|e| format!("读取文件失败: {}", e))?;
|
||||
|
||||
// 生成文件名
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or("无效的文件名")?;
|
||||
|
||||
// 保存文件(使用 base64 编码存储二进制数据)
|
||||
let base64_content = general_purpose::STANDARD.encode(&content);
|
||||
|
||||
// 调用 session_files_cmd 的函数来保存文件
|
||||
super::session_files_cmd::session_files_save_file(
|
||||
state.clone(),
|
||||
session_id.clone(),
|
||||
file_name.to_string(),
|
||||
base64_content,
|
||||
)?;
|
||||
|
||||
// 返回文件访问路径
|
||||
super::session_files_cmd::session_files_resolve_file_path(
|
||||
state,
|
||||
session_id,
|
||||
file_name.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 从会话中读取图片(返回 base64 编码)
|
||||
#[tauri::command]
|
||||
pub fn read_image_from_session(
|
||||
state: State<SessionFilesState>,
|
||||
session_id: String,
|
||||
file_name: String,
|
||||
) -> Result<String, String> {
|
||||
super::session_files_cmd::session_files_read_file(state, session_id, file_name)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod connect_cmd;
|
||||
pub mod connection_cmd;
|
||||
pub mod content_cmd;
|
||||
pub mod context_memory;
|
||||
pub mod document_import_cmd;
|
||||
pub mod ecommerce_review_reply_cmd;
|
||||
pub mod execution_run_cmd;
|
||||
pub mod external_tools_cmd;
|
||||
@@ -19,6 +20,7 @@ pub mod gateway_tunnel_cmd;
|
||||
pub mod general_chat_cmd;
|
||||
pub mod heartbeat_cmd;
|
||||
pub mod image_search_cmd;
|
||||
pub mod image_upload_cmd;
|
||||
pub mod injection_cmd;
|
||||
pub mod kiro_local;
|
||||
pub mod machine_id_cmd;
|
||||
@@ -57,6 +59,7 @@ pub mod telegram_remote_cmd;
|
||||
pub mod telemetry_cmd;
|
||||
pub mod template_cmd;
|
||||
pub mod terminal_cmd;
|
||||
pub mod theme_context_cmd;
|
||||
pub mod tool_hooks;
|
||||
pub mod tray_cmd;
|
||||
pub mod unified_chat_cmd;
|
||||
|
||||
@@ -112,6 +112,17 @@ pub fn session_files_read_file(
|
||||
storage.read_file(&session_id, &file_name)
|
||||
}
|
||||
|
||||
/// 解析会话文件绝对路径
|
||||
#[tauri::command]
|
||||
pub fn session_files_resolve_file_path(
|
||||
state: State<SessionFilesState>,
|
||||
session_id: String,
|
||||
file_name: String,
|
||||
) -> Result<String, String> {
|
||||
let storage = state.0.lock().map_err(|e| format!("锁定失败: {e}"))?;
|
||||
storage.resolve_file_path(&session_id, &file_name)
|
||||
}
|
||||
|
||||
/// 删除会话文件
|
||||
#[tauri::command]
|
||||
pub fn session_files_delete_file(
|
||||
|
||||
@@ -21,10 +21,15 @@ use tauri::{Emitter, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use aster::conversation::message::Message;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::agent::aster_state::SessionConfigBuilder;
|
||||
use crate::agent::{AsterAgentState, TauriAgentEvent};
|
||||
use crate::commands::aster_agent_cmd::ensure_browser_mcp_tools_registered;
|
||||
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
|
||||
use crate::commands::aster_agent_cmd::{
|
||||
ensure_browser_mcp_tools_registered, ensure_creation_task_tools_registered,
|
||||
ensure_social_image_tool_registered,
|
||||
};
|
||||
use crate::commands::skill_error::{
|
||||
format_skill_error, map_find_skill_error, SKILL_ERR_CATALOG_UNAVAILABLE,
|
||||
SKILL_ERR_EXECUTE_FAILED, SKILL_ERR_PROVIDER_UNAVAILABLE, SKILL_ERR_SESSION_INIT_FAILED,
|
||||
@@ -35,7 +40,7 @@ use crate::database::DbConnection;
|
||||
use crate::services::execution_tracker_service::{ExecutionTracker, RunFinishDecision, RunSource};
|
||||
use crate::services::memory_profile_prompt_service::build_memory_profile_prompt;
|
||||
use crate::skills::TauriExecutionCallback;
|
||||
use proxycast_agent::event_converter::convert_agent_event;
|
||||
use proxycast_agent::event_converter::{convert_agent_event, TauriToolResult};
|
||||
use proxycast_skills::{
|
||||
find_skill_by_name, get_proxycast_skills_dir, load_skills_from_directory, ExecutionCallback,
|
||||
};
|
||||
@@ -130,6 +135,393 @@ pub struct SkillExecutionResult {
|
||||
pub steps_completed: Vec<StepResult>,
|
||||
}
|
||||
|
||||
const SOCIAL_POST_WITH_COVER_SKILL_NAME: &str = "social_post_with_cover";
|
||||
const SOCIAL_POST_OUTPUT_DIR: &str = "social-posts";
|
||||
const SOCIAL_POST_WRITE_TOOL_NAME: &str = "write_file";
|
||||
const SOCIAL_POST_EMPTY_FALLBACK_CONTENT: &str = "# 社媒文案\n\n(生成结果为空,请重试。)";
|
||||
const SOCIAL_POST_FALLBACK_COVER_URL: &str = "cover-generation-failed";
|
||||
const SOCIAL_POST_FALLBACK_COVER_NOTE: &str = "封面图生成失败,可稍后仅重试配图。";
|
||||
const SOCIAL_POST_DEFAULT_IMAGE_SIZE: &str = "1024x1024";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SocialSkillOutputEnvelope {
|
||||
final_output: String,
|
||||
file_path: String,
|
||||
file_content: String,
|
||||
}
|
||||
|
||||
fn infer_theme_workbench_gate_key(skill_name: &str, user_input: &str) -> &'static str {
|
||||
let probe = format!("{} {}", skill_name, user_input).to_lowercase();
|
||||
if probe.contains("publish")
|
||||
|| probe.contains("adapt")
|
||||
|| probe.contains("distribution")
|
||||
|| probe.contains("release")
|
||||
|| probe.contains("发布")
|
||||
|| probe.contains("分发")
|
||||
|| probe.contains("平台适配")
|
||||
{
|
||||
return "publish_confirm";
|
||||
}
|
||||
if probe.contains("topic")
|
||||
|| probe.contains("research")
|
||||
|| probe.contains("trend")
|
||||
|| probe.contains("idea")
|
||||
|| probe.contains("选题")
|
||||
|| probe.contains("方向")
|
||||
|| probe.contains("调研")
|
||||
|| probe.contains("洞察")
|
||||
{
|
||||
return "topic_select";
|
||||
}
|
||||
"write_mode"
|
||||
}
|
||||
|
||||
fn normalize_social_post_output(
|
||||
skill_name: &str,
|
||||
user_input: &str,
|
||||
execution_id: &str,
|
||||
raw_output: &str,
|
||||
) -> Option<SocialSkillOutputEnvelope> {
|
||||
if skill_name != SOCIAL_POST_WITH_COVER_SKILL_NAME {
|
||||
return None;
|
||||
}
|
||||
|
||||
let generated_path = build_social_post_file_path(user_input, execution_id);
|
||||
if let Some((range, existing_path, content)) = extract_first_write_file_block(raw_output) {
|
||||
let normalized_content = normalize_social_markdown_contract(&content);
|
||||
let has_existing_path = existing_path.is_some();
|
||||
let path = existing_path.unwrap_or_else(|| generated_path.clone());
|
||||
|
||||
if has_existing_path {
|
||||
if normalized_content != content {
|
||||
let normalized_block = build_write_file_block(&path, &normalized_content);
|
||||
let mut rebuilt = String::new();
|
||||
rebuilt.push_str(&raw_output[..range.start]);
|
||||
rebuilt.push_str(&normalized_block);
|
||||
rebuilt.push_str(&raw_output[range.end..]);
|
||||
return Some(SocialSkillOutputEnvelope {
|
||||
final_output: rebuilt,
|
||||
file_path: path,
|
||||
file_content: normalized_content,
|
||||
});
|
||||
}
|
||||
return Some(SocialSkillOutputEnvelope {
|
||||
final_output: raw_output.to_string(),
|
||||
file_path: path,
|
||||
file_content: normalized_content,
|
||||
});
|
||||
}
|
||||
|
||||
let normalized_block = build_write_file_block(&path, &normalized_content);
|
||||
let mut rebuilt = String::new();
|
||||
rebuilt.push_str(&raw_output[..range.start]);
|
||||
rebuilt.push_str(&normalized_block);
|
||||
rebuilt.push_str(&raw_output[range.end..]);
|
||||
|
||||
return Some(SocialSkillOutputEnvelope {
|
||||
final_output: rebuilt,
|
||||
file_path: path,
|
||||
file_content: normalized_content,
|
||||
});
|
||||
}
|
||||
|
||||
let normalized_content = normalize_social_markdown_contract(raw_output);
|
||||
Some(SocialSkillOutputEnvelope {
|
||||
final_output: build_write_file_block(&generated_path, &normalized_content),
|
||||
file_path: generated_path,
|
||||
file_content: normalized_content,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_first_write_file_block(
|
||||
raw_output: &str,
|
||||
) -> Option<(std::ops::Range<usize>, Option<String>, String)> {
|
||||
let open_start = raw_output.find("<write_file")?;
|
||||
let open_end_offset = raw_output[open_start..].find('>')?;
|
||||
let open_end = open_start + open_end_offset;
|
||||
let open_tag = &raw_output[open_start..=open_end];
|
||||
|
||||
let content_start = open_end + 1;
|
||||
let close_tag = "</write_file>";
|
||||
let close_offset = raw_output[content_start..].find(close_tag)?;
|
||||
let close_start = content_start + close_offset;
|
||||
let block_end = close_start + close_tag.len();
|
||||
|
||||
let content = raw_output[content_start..close_start].trim().to_string();
|
||||
let path = extract_write_file_path(open_tag);
|
||||
Some((open_start..block_end, path, content))
|
||||
}
|
||||
|
||||
fn extract_write_file_path(open_tag: &str) -> Option<String> {
|
||||
let path_idx = open_tag.find("path")?;
|
||||
let after_path = &open_tag[path_idx + "path".len()..];
|
||||
let equal_idx = after_path.find('=')?;
|
||||
let value = after_path[equal_idx + 1..].trim_start();
|
||||
let quote = value.chars().next()?;
|
||||
if quote != '"' && quote != '\'' {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rest = &value[quote.len_utf8()..];
|
||||
let end_idx = rest.find(quote)?;
|
||||
let path = rest[..end_idx].trim();
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(path.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_social_output_content(content: &str) -> String {
|
||||
let trimmed = content.trim();
|
||||
if trimmed.is_empty() {
|
||||
SOCIAL_POST_EMPTY_FALLBACK_CONTENT.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_social_markdown_contract(content: &str) -> String {
|
||||
let mut normalized = normalize_social_output_content(content);
|
||||
if !normalized.contains(" {
|
||||
normalized = format!("{normalized}\n\n");
|
||||
}
|
||||
|
||||
if !normalized.contains("## 配图说明") {
|
||||
normalized.push_str("\n\n## 配图说明\n");
|
||||
normalized.push_str("- 提示词:未提供\n");
|
||||
normalized.push_str(&format!("- 尺寸:{SOCIAL_POST_DEFAULT_IMAGE_SIZE}\n"));
|
||||
normalized.push_str("- 状态:失败\n");
|
||||
normalized.push_str(&format!("- 备注:{SOCIAL_POST_FALLBACK_COVER_NOTE}\n"));
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
fn extract_cover_url_from_markdown(content: &str) -> Option<String> {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with("?;
|
||||
let close = trimmed.rfind(')')?;
|
||||
if close <= open + 2 {
|
||||
continue;
|
||||
}
|
||||
let url = trimmed[(open + 2)..close].trim();
|
||||
if !url.is_empty() {
|
||||
return Some(url.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_detail_value(content: &str, label: &str) -> Option<String> {
|
||||
let probe = format!("- {label}:");
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(value) = trimmed.strip_prefix(&probe) {
|
||||
let value = value.trim();
|
||||
if !value.is_empty() {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn derive_social_auxiliary_paths(article_path: &str) -> (String, String) {
|
||||
let base = article_path.strip_suffix(".md").unwrap_or(article_path);
|
||||
(
|
||||
format!("{base}.cover.json"),
|
||||
format!("{base}.publish-pack.json"),
|
||||
)
|
||||
}
|
||||
|
||||
fn collect_social_artifact_paths_from_output(output: Option<&str>) -> Vec<String> {
|
||||
let Some(raw_output) = output else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some((_, maybe_path, _)) = extract_first_write_file_block(raw_output) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(article_path) = maybe_path else {
|
||||
return Vec::new();
|
||||
};
|
||||
let (cover_meta_path, publish_pack_path) = derive_social_auxiliary_paths(&article_path);
|
||||
vec![article_path, cover_meta_path, publish_pack_path]
|
||||
}
|
||||
|
||||
fn summarize_social_content(content: &str) -> String {
|
||||
let compact = content
|
||||
.lines()
|
||||
.filter(|line| !line.trim().starts_with('#'))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let compact = compact.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
compact.chars().take(180).collect()
|
||||
}
|
||||
|
||||
fn build_social_auxiliary_file_payloads(
|
||||
execution_id: &str,
|
||||
user_input: &str,
|
||||
article_path: &str,
|
||||
article_content: &str,
|
||||
) -> Vec<(String, String)> {
|
||||
let (cover_meta_path, publish_pack_path) = derive_social_auxiliary_paths(article_path);
|
||||
let cover_url = extract_cover_url_from_markdown(article_content)
|
||||
.unwrap_or_else(|| SOCIAL_POST_FALLBACK_COVER_URL.to_string());
|
||||
let cover_prompt =
|
||||
extract_detail_value(article_content, "提示词").unwrap_or_else(|| "未提供".to_string());
|
||||
let cover_size = extract_detail_value(article_content, "尺寸")
|
||||
.unwrap_or_else(|| SOCIAL_POST_DEFAULT_IMAGE_SIZE.to_string());
|
||||
let cover_status = extract_detail_value(article_content, "状态").unwrap_or_else(|| {
|
||||
if cover_url == SOCIAL_POST_FALLBACK_COVER_URL {
|
||||
"失败".to_string()
|
||||
} else {
|
||||
"成功".to_string()
|
||||
}
|
||||
});
|
||||
let cover_remark = extract_detail_value(article_content, "备注").unwrap_or_else(|| {
|
||||
if cover_status == "失败" {
|
||||
SOCIAL_POST_FALLBACK_COVER_NOTE.to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
});
|
||||
|
||||
let cover_meta = serde_json::json!({
|
||||
"execution_id": execution_id,
|
||||
"article_path": article_path,
|
||||
"cover_url": cover_url,
|
||||
"prompt": cover_prompt,
|
||||
"size": cover_size,
|
||||
"status": cover_status,
|
||||
"remark": cover_remark,
|
||||
"generated_at": Utc::now().to_rfc3339(),
|
||||
});
|
||||
|
||||
let publish_pack = serde_json::json!({
|
||||
"execution_id": execution_id,
|
||||
"pipeline": ["topic_select", "write_mode", "publish_confirm"],
|
||||
"article_path": article_path,
|
||||
"cover_meta_path": cover_meta_path,
|
||||
"source_input": user_input,
|
||||
"recommended_channels": ["xiaohongshu", "wechat"],
|
||||
"summary": summarize_social_content(article_content),
|
||||
"generated_at": Utc::now().to_rfc3339(),
|
||||
});
|
||||
|
||||
vec![
|
||||
(
|
||||
cover_meta_path,
|
||||
serde_json::to_string_pretty(&cover_meta).unwrap_or_else(|_| cover_meta.to_string()),
|
||||
),
|
||||
(
|
||||
publish_pack_path,
|
||||
serde_json::to_string_pretty(&publish_pack)
|
||||
.unwrap_or_else(|_| publish_pack.to_string()),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn build_write_file_block(file_path: &str, file_content: &str) -> String {
|
||||
format!("<write_file path=\"{file_path}\">\n{file_content}\n</write_file>")
|
||||
}
|
||||
|
||||
fn build_social_post_file_path(user_input: &str, execution_id: &str) -> String {
|
||||
let timestamp = Utc::now().format("%Y%m%d-%H%M%S");
|
||||
let slug = build_social_post_slug(user_input);
|
||||
let suffix = build_execution_suffix(execution_id);
|
||||
format!("{SOCIAL_POST_OUTPUT_DIR}/{timestamp}-{slug}-{suffix}.md")
|
||||
}
|
||||
|
||||
fn build_social_post_slug(user_input: &str) -> String {
|
||||
let mut normalized = String::new();
|
||||
let mut last_was_dash = false;
|
||||
|
||||
for ch in user_input.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
normalized.push(ch.to_ascii_lowercase());
|
||||
last_was_dash = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !last_was_dash {
|
||||
normalized.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = normalized.trim_matches('-');
|
||||
let truncated: String = trimmed.chars().take(24).collect();
|
||||
if truncated.is_empty() {
|
||||
"post".to_string()
|
||||
} else {
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
||||
fn build_execution_suffix(execution_id: &str) -> String {
|
||||
let normalized: String = execution_id
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric())
|
||||
.take(6)
|
||||
.collect();
|
||||
if normalized.is_empty() {
|
||||
"run".to_string()
|
||||
} else {
|
||||
normalized.to_ascii_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_social_tool_event_id(execution_id: &str, file_path: &str) -> String {
|
||||
let mut hash: u32 = 0x811c9dc5;
|
||||
for byte in file_path.as_bytes() {
|
||||
hash ^= u32::from(*byte);
|
||||
hash = hash.wrapping_mul(0x01000193);
|
||||
}
|
||||
format!("social-write-{execution_id}-{hash:08x}")
|
||||
}
|
||||
|
||||
fn emit_social_write_file_events(
|
||||
app_handle: &tauri::AppHandle,
|
||||
execution_id: &str,
|
||||
file_path: &str,
|
||||
file_content: &str,
|
||||
) {
|
||||
let event_name = format!("skill-exec-{execution_id}");
|
||||
let tool_id = build_social_tool_event_id(execution_id, file_path);
|
||||
let arguments = serde_json::json!({
|
||||
"path": file_path,
|
||||
"content": file_content,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let tool_start = TauriAgentEvent::ToolStart {
|
||||
tool_name: SOCIAL_POST_WRITE_TOOL_NAME.to_string(),
|
||||
tool_id: tool_id.clone(),
|
||||
arguments: Some(arguments),
|
||||
};
|
||||
if let Err(err) = app_handle.emit(&event_name, &tool_start) {
|
||||
tracing::warn!("[execute_skill] 发送社媒写入工具开始事件失败: {}", err);
|
||||
}
|
||||
|
||||
let tool_end = TauriAgentEvent::ToolEnd {
|
||||
tool_id,
|
||||
result: TauriToolResult {
|
||||
success: true,
|
||||
output: format!("写入社媒文稿: {file_path}"),
|
||||
error: None,
|
||||
images: None,
|
||||
},
|
||||
};
|
||||
if let Err(err) = app_handle.emit(&event_name, &tool_end) {
|
||||
tracing::warn!("[execute_skill] 发送社媒写入工具完成事件失败: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行 Skill
|
||||
///
|
||||
/// 加载并执行指定的 Skill,使用 Aster Agent 系统提供完整的工具集支持。
|
||||
@@ -157,6 +549,7 @@ pub struct SkillExecutionResult {
|
||||
pub async fn execute_skill(
|
||||
app_handle: tauri::AppHandle,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
aster_state: State<'_, AsterAgentState>,
|
||||
skill_name: String,
|
||||
@@ -169,6 +562,8 @@ pub async fn execute_skill(
|
||||
// 生成执行 ID,并优先复用前端会话 ID(提升 /skill 与主会话上下文一致性)
|
||||
let execution_id = execution_id.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
let session_id = session_id.unwrap_or_else(|| format!("skill-exec-{}", Uuid::new_v4()));
|
||||
let inferred_gate_key =
|
||||
infer_theme_workbench_gate_key(skill_name.as_str(), user_input.as_str());
|
||||
let memory_profile_prompt = build_memory_profile_prompt(&config_manager.config());
|
||||
let tracker = ExecutionTracker::new(db.inner().clone());
|
||||
|
||||
@@ -179,6 +574,8 @@ pub async fn execute_skill(
|
||||
Some(session_id.clone()),
|
||||
Some(serde_json::json!({
|
||||
"execution_id": execution_id.clone(),
|
||||
"skill_name": skill_name.clone(),
|
||||
"gate_key": inferred_gate_key,
|
||||
"provider_override": provider_override.clone(),
|
||||
"model_override": model_override.clone(),
|
||||
})),
|
||||
@@ -225,6 +622,27 @@ pub async fn execute_skill(
|
||||
format!("注册浏览器工具失败: {e}"),
|
||||
)
|
||||
})?;
|
||||
ensure_social_image_tool_registered(aster_state.inner(), config_manager.inner())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
format_skill_error(
|
||||
SKILL_ERR_SESSION_INIT_FAILED,
|
||||
format!("注册社媒生图工具失败: {e}"),
|
||||
)
|
||||
})?;
|
||||
ensure_creation_task_tools_registered(
|
||||
aster_state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
&app_handle,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
format_skill_error(
|
||||
SKILL_ERR_SESSION_INIT_FAILED,
|
||||
format!("注册创作任务工具失败: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 4. 配置 Provider(从凭证池选择,支持 fallback)
|
||||
let preferred_provider = provider_override
|
||||
@@ -280,7 +698,7 @@ pub async fn execute_skill(
|
||||
}
|
||||
}
|
||||
|
||||
configure_result.map_err(|e| {
|
||||
let configured_provider = configure_result.map_err(|e| {
|
||||
format_skill_error(
|
||||
SKILL_ERR_PROVIDER_UNAVAILABLE,
|
||||
format!(
|
||||
@@ -289,10 +707,15 @@ pub async fn execute_skill(
|
||||
)
|
||||
})?;
|
||||
|
||||
let resolved_provider = configured_provider.provider_name.clone();
|
||||
let resolved_model = configured_provider.model_name.clone();
|
||||
|
||||
tracing::info!(
|
||||
"[execute_skill] Provider 配置成功: preferred={}, model={}",
|
||||
"[execute_skill] Provider 配置成功: requested={} / {}, resolved={} / {}",
|
||||
preferred_provider,
|
||||
preferred_model
|
||||
preferred_model,
|
||||
resolved_provider,
|
||||
resolved_model
|
||||
);
|
||||
|
||||
// 5. 根据 execution_mode 分支执行
|
||||
@@ -325,15 +748,42 @@ pub async fn execute_skill(
|
||||
}
|
||||
},
|
||||
|result| match result {
|
||||
Ok(exec_result) if exec_result.success => RunFinishDecision {
|
||||
status: crate::database::dao::agent_run::AgentRunStatus::Success,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
metadata: Some(serde_json::json!({
|
||||
"skill_name": skill_name,
|
||||
"execution_id": execution_id,
|
||||
})),
|
||||
},
|
||||
Ok(exec_result) if exec_result.success => {
|
||||
let artifact_paths = if skill_name == SOCIAL_POST_WITH_COVER_SKILL_NAME {
|
||||
collect_social_artifact_paths_from_output(exec_result.output.as_deref())
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let metadata = if skill_name == SOCIAL_POST_WITH_COVER_SKILL_NAME {
|
||||
serde_json::json!({
|
||||
"skill_name": skill_name,
|
||||
"execution_id": execution_id,
|
||||
"workflow": "social_content_pipeline_v1",
|
||||
"version_id": execution_id,
|
||||
"stages": ["topic_select", "write_mode", "publish_confirm"],
|
||||
"artifact_paths": artifact_paths,
|
||||
"provider_override": provider_override,
|
||||
"model_override": model_override,
|
||||
"requested_provider": provider_override,
|
||||
"requested_model": model_override,
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"skill_name": skill_name,
|
||||
"execution_id": execution_id,
|
||||
"provider_override": provider_override,
|
||||
"model_override": model_override,
|
||||
"requested_provider": provider_override,
|
||||
"requested_model": model_override,
|
||||
})
|
||||
};
|
||||
RunFinishDecision {
|
||||
status: crate::database::dao::agent_run::AgentRunStatus::Success,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
metadata: Some(metadata),
|
||||
}
|
||||
}
|
||||
Ok(exec_result) => RunFinishDecision {
|
||||
status: crate::database::dao::agent_run::AgentRunStatus::Error,
|
||||
error_code: Some("skill_execute_failed".to_string()),
|
||||
@@ -342,6 +792,10 @@ pub async fn execute_skill(
|
||||
"skill_name": skill_name,
|
||||
"execution_id": execution_id,
|
||||
"success": false,
|
||||
"provider_override": provider_override,
|
||||
"model_override": model_override,
|
||||
"requested_provider": provider_override,
|
||||
"requested_model": model_override,
|
||||
})),
|
||||
},
|
||||
Err(err) => RunFinishDecision {
|
||||
@@ -351,6 +805,10 @@ pub async fn execute_skill(
|
||||
metadata: Some(serde_json::json!({
|
||||
"skill_name": skill_name,
|
||||
"execution_id": execution_id,
|
||||
"provider_override": provider_override,
|
||||
"model_override": model_override,
|
||||
"requested_provider": provider_override,
|
||||
"requested_model": model_override,
|
||||
})),
|
||||
},
|
||||
},
|
||||
@@ -464,18 +922,49 @@ async fn execute_skill_prompt(
|
||||
}],
|
||||
})
|
||||
} else {
|
||||
callback.on_step_complete("main", &final_output);
|
||||
callback.on_complete(true, Some(&final_output), None);
|
||||
let normalized_output = normalize_social_post_output(
|
||||
&skill.skill_name,
|
||||
user_input,
|
||||
execution_id,
|
||||
&final_output,
|
||||
);
|
||||
let output_for_return = if let Some(ref social_output) = normalized_output {
|
||||
emit_social_write_file_events(
|
||||
app_handle,
|
||||
execution_id,
|
||||
&social_output.file_path,
|
||||
&social_output.file_content,
|
||||
);
|
||||
for (artifact_path, artifact_content) in build_social_auxiliary_file_payloads(
|
||||
execution_id,
|
||||
user_input,
|
||||
&social_output.file_path,
|
||||
&social_output.file_content,
|
||||
) {
|
||||
emit_social_write_file_events(
|
||||
app_handle,
|
||||
execution_id,
|
||||
&artifact_path,
|
||||
&artifact_content,
|
||||
);
|
||||
}
|
||||
social_output.final_output.clone()
|
||||
} else {
|
||||
final_output.clone()
|
||||
};
|
||||
|
||||
callback.on_step_complete("main", &output_for_return);
|
||||
callback.on_complete(true, Some(&output_for_return), None);
|
||||
|
||||
Ok(SkillExecutionResult {
|
||||
success: true,
|
||||
output: Some(final_output.clone()),
|
||||
output: Some(output_for_return.clone()),
|
||||
error: None,
|
||||
steps_completed: vec![StepResult {
|
||||
step_id: "main".to_string(),
|
||||
step_name: skill.display_name.clone(),
|
||||
success: true,
|
||||
output: Some(final_output),
|
||||
output: Some(output_for_return),
|
||||
error: None,
|
||||
}],
|
||||
})
|
||||
@@ -890,6 +1379,107 @@ Body
|
||||
assert_eq!(fm.description, Some("single quoted".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_social_post_output_wraps_plain_markdown() {
|
||||
let normalized = normalize_social_post_output(
|
||||
SOCIAL_POST_WITH_COVER_SKILL_NAME,
|
||||
"春季上新",
|
||||
"exec123456",
|
||||
"# 标题\n\n正文内容",
|
||||
)
|
||||
.expect("should normalize");
|
||||
|
||||
assert!(normalized
|
||||
.final_output
|
||||
.contains("<write_file path=\"social-posts/"));
|
||||
assert!(normalized.final_output.contains("# 标题"));
|
||||
assert!(normalized.file_content.contains("# 标题"));
|
||||
assert!(normalized.file_content.contains(");
|
||||
assert!(normalized.file_content.contains("## 配图说明"));
|
||||
assert!(normalized.file_path.starts_with("social-posts/"));
|
||||
assert!(normalized.file_path.ends_with(".md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_social_post_output_keeps_existing_write_file_block() {
|
||||
let raw_output =
|
||||
"<write_file path=\"social-posts/custom-post.md\">\n# 标题\n\n正文\n</write_file>";
|
||||
let normalized = normalize_social_post_output(
|
||||
SOCIAL_POST_WITH_COVER_SKILL_NAME,
|
||||
"春季上新",
|
||||
"exec123456",
|
||||
raw_output,
|
||||
)
|
||||
.expect("should normalize");
|
||||
|
||||
assert_eq!(normalized.file_path, "social-posts/custom-post.md");
|
||||
assert!(normalized
|
||||
.final_output
|
||||
.contains("social-posts/custom-post.md"));
|
||||
assert!(normalized.file_content.contains("# 标题"));
|
||||
assert!(normalized.file_content.contains(");
|
||||
assert!(normalized.file_content.contains("## 配图说明"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_social_post_output_injects_missing_path() {
|
||||
let raw_output = "前置说明\n<write_file>\n# 标题\n\n正文\n</write_file>\n后置说明";
|
||||
let normalized = normalize_social_post_output(
|
||||
SOCIAL_POST_WITH_COVER_SKILL_NAME,
|
||||
"spring launch",
|
||||
"exec123456",
|
||||
raw_output,
|
||||
)
|
||||
.expect("should normalize");
|
||||
|
||||
assert!(normalized.final_output.contains("前置说明"));
|
||||
assert!(normalized.final_output.contains("后置说明"));
|
||||
assert!(normalized
|
||||
.final_output
|
||||
.contains("<write_file path=\"social-posts/"));
|
||||
assert!(normalized.file_content.contains("# 标题"));
|
||||
assert!(normalized.file_content.contains(");
|
||||
assert!(normalized.file_content.contains("## 配图说明"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_social_auxiliary_file_payloads_should_include_cover_and_publish_pack() {
|
||||
let payloads = build_social_auxiliary_file_payloads(
|
||||
"exec123",
|
||||
"新品发布",
|
||||
"social-posts/demo.md",
|
||||
"# 标题\n\n\n\n## 配图说明\n- 提示词:简洁科技风\n- 尺寸:1024x1024\n- 状态:成功\n- 备注:\n",
|
||||
);
|
||||
|
||||
assert_eq!(payloads.len(), 2);
|
||||
assert!(payloads
|
||||
.iter()
|
||||
.any(|(path, _)| path.ends_with(".cover.json")));
|
||||
assert!(payloads
|
||||
.iter()
|
||||
.any(|(path, _)| path.ends_with(".publish-pack.json")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_social_artifact_paths_from_output_should_expand_auxiliary_files() {
|
||||
let output = "<write_file path=\"social-posts/demo.md\">\n# 标题\n\n正文\n</write_file>";
|
||||
let paths = collect_social_artifact_paths_from_output(Some(output));
|
||||
assert_eq!(paths.len(), 3);
|
||||
assert_eq!(paths[0], "social-posts/demo.md");
|
||||
assert!(paths[1].ends_with(".cover.json"));
|
||||
assert!(paths[2].ends_with(".publish-pack.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_social_post_slug_fallback_to_post() {
|
||||
assert_eq!(build_social_post_slug(""), "post");
|
||||
assert_eq!(build_social_post_slug("!!!"), "post");
|
||||
assert_eq!(
|
||||
build_social_post_slug("Spring Launch 2026"),
|
||||
"spring-launch-2026"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_allowed_tools() {
|
||||
assert_eq!(parse_allowed_tools(None), None);
|
||||
@@ -1014,4 +1604,26 @@ Content 2
|
||||
let skills = load_skills_from_directory(std::path::Path::new("/nonexistent/path"));
|
||||
assert!(skills.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bundled_social_post_with_cover_skill_contract() {
|
||||
let skill_file = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources/default-skills/social_post_with_cover/SKILL.md");
|
||||
|
||||
assert!(skill_file.exists());
|
||||
let content = std::fs::read_to_string(&skill_file).unwrap();
|
||||
let skill = load_skill_from_file("social_post_with_cover", &skill_file).unwrap();
|
||||
|
||||
assert_eq!(skill.skill_name, "social_post_with_cover");
|
||||
assert_eq!(skill.execution_mode, "prompt");
|
||||
assert_eq!(
|
||||
skill.allowed_tools,
|
||||
Some(vec![
|
||||
"social_generate_cover_image".to_string(),
|
||||
"search_query".to_string(),
|
||||
])
|
||||
);
|
||||
assert!(content.contains("<write_file path=\"social-posts/"));
|
||||
assert!(!skill.disable_model_invocation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
//! 主题工作台上下文搜索命令
|
||||
//!
|
||||
//! 为左侧栏“搜索上下文”提供真正的后端检索能力,
|
||||
//! 统一复用 Aster Agent + WebSearch 策略,并返回结构化结果。
|
||||
|
||||
use crate::agent::{AsterAgentState, AsterAgentWrapper};
|
||||
use crate::config::GlobalConfigManagerState;
|
||||
use crate::database::DbConnection;
|
||||
use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile;
|
||||
use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search;
|
||||
use crate::services::web_search_runtime_service::apply_web_search_runtime_env;
|
||||
use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate;
|
||||
use crate::workspace::WorkspaceManager;
|
||||
use proxycast_agent::{
|
||||
resolve_request_tool_policy, stream_reply_with_policy, SessionConfigBuilder,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tauri::State;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
const CONTEXT_SEARCH_SESSION_PREFIX: &str = "__proxycast_theme_context_search__";
|
||||
const FALLBACK_SUMMARY_LENGTH: usize = 420;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ThemeContextSearchMode {
|
||||
Web,
|
||||
Social,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ThemeContextSearchRequest {
|
||||
#[serde(alias = "workspaceId")]
|
||||
pub workspace_id: String,
|
||||
#[serde(default, alias = "projectId")]
|
||||
pub project_id: Option<String>,
|
||||
#[serde(alias = "providerType", alias = "providerId")]
|
||||
pub provider_type: String,
|
||||
pub model: String,
|
||||
pub query: String,
|
||||
pub mode: ThemeContextSearchMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThemeContextSearchCitation {
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThemeContextSearchResponse {
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub citations: Vec<ThemeContextSearchCitation>,
|
||||
pub raw_response: String,
|
||||
pub attempts_summary: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ParsedThemeContextSearchPayload {
|
||||
title: Option<String>,
|
||||
summary: Option<String>,
|
||||
citations: Vec<ThemeContextSearchCitation>,
|
||||
}
|
||||
|
||||
fn normalize_whitespace(value: &str) -> String {
|
||||
value.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn build_context_search_prompt(query: &str, mode: ThemeContextSearchMode) -> String {
|
||||
let social_constraint = match mode {
|
||||
ThemeContextSearchMode::Social => [
|
||||
"优先寻找社交媒体平台、品牌官方账号、媒体社媒账号、KOL/KOC 讨论与趋势帖相关信息。",
|
||||
"如果直接社媒来源不足,可补充官方网站或媒体报道,但摘要必须保留社媒传播视角。",
|
||||
"适当优先关注小红书、微博、公众号、抖音、B站、知乎等中文平台。",
|
||||
]
|
||||
.join("\n"),
|
||||
ThemeContextSearchMode::Web => {
|
||||
"优先提供最新且可信的公开网络资料,兼顾官方来源与主流媒体。".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
[
|
||||
"你是 ProxyCast 的资料检索助手。",
|
||||
"请先执行联网搜索,再输出整理结果。",
|
||||
"你必须返回且仅返回一个 JSON 对象,不要使用 Markdown 代码块,不要输出多余说明。",
|
||||
"JSON 结构如下:",
|
||||
r#"{"title":"","summary":"","citations":[{"title":"","url":""}]}"#,
|
||||
"字段要求:",
|
||||
"1. title:12-28 字中文标题,概括本次检索主题。",
|
||||
"2. summary:180-320 字中文摘要,聚合 3-5 个来源,突出时间点、关键事实、趋势或洞察。",
|
||||
"3. citations:保留 3-5 条最重要来源,必须带可访问 URL。",
|
||||
social_constraint.as_str(),
|
||||
&format!("检索主题:{}", query.trim()),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn strip_code_fence(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.trim_start_matches("```json")
|
||||
.trim_start_matches("```")
|
||||
.trim_end_matches("```")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_citation_title_from_url(url: &str) -> String {
|
||||
Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|parsed| {
|
||||
parsed
|
||||
.host_str()
|
||||
.map(|host| host.trim_start_matches("www.").to_string())
|
||||
})
|
||||
.filter(|host| !host.is_empty())
|
||||
.unwrap_or_else(|| "来源链接".to_string())
|
||||
}
|
||||
|
||||
fn sanitize_url(url: &str) -> String {
|
||||
url.trim_end_matches(&[',', ')', '.', ';', '!', '?'][..])
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn parse_json_payload(raw_response: &str) -> Option<ParsedThemeContextSearchPayload> {
|
||||
let trimmed = raw_response.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let fenced_match = regex::Regex::new(r"```(?:json)?\s*([\s\S]*?)\s*```")
|
||||
.ok()
|
||||
.and_then(|regex| regex.captures(trimmed))
|
||||
.and_then(|captures| captures.get(1).map(|value| value.as_str().to_string()));
|
||||
let json_block_match = regex::Regex::new(r"\{[\s\S]*\}")
|
||||
.ok()
|
||||
.and_then(|regex| regex.find(trimmed))
|
||||
.map(|value| value.as_str().to_string());
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(value) = fenced_match {
|
||||
candidates.push(value);
|
||||
}
|
||||
if let Some(value) = json_block_match {
|
||||
candidates.push(value);
|
||||
}
|
||||
candidates.push(trimmed.to_string());
|
||||
|
||||
for candidate in candidates {
|
||||
let normalized = strip_code_fence(&candidate);
|
||||
let parsed = match serde_json::from_str::<serde_json::Value>(&normalized) {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let citations_raw = parsed
|
||||
.get("citations")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.or_else(|| parsed.get("sources").and_then(serde_json::Value::as_array));
|
||||
|
||||
let citations = citations_raw
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|item| {
|
||||
let url = item
|
||||
.get("url")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let title = item
|
||||
.get("title")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.or_else(|| item.get("name").and_then(serde_json::Value::as_str))
|
||||
.map(normalize_whitespace)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| build_citation_title_from_url(url));
|
||||
Some(ThemeContextSearchCitation {
|
||||
title,
|
||||
url: url.to_string(),
|
||||
})
|
||||
})
|
||||
.take(5)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let title = parsed
|
||||
.get("title")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(normalize_whitespace)
|
||||
.filter(|value| !value.is_empty());
|
||||
let summary = parsed
|
||||
.get("summary")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.or_else(|| parsed.get("content").and_then(serde_json::Value::as_str))
|
||||
.map(normalize_whitespace)
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
return Some(ParsedThemeContextSearchPayload {
|
||||
title,
|
||||
summary,
|
||||
citations,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_citations_from_text(raw_response: &str) -> Vec<ThemeContextSearchCitation> {
|
||||
let mut citations = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
if let Ok(markdown_regex) = regex::Regex::new(r"\[([^\]]+)\]\((https?://[^\s)]+)\)") {
|
||||
for captures in markdown_regex.captures_iter(raw_response) {
|
||||
let url = captures
|
||||
.get(2)
|
||||
.map(|value| sanitize_url(value.as_str()))
|
||||
.unwrap_or_default();
|
||||
if url.is_empty() || !seen.insert(url.clone()) {
|
||||
continue;
|
||||
}
|
||||
let title = captures
|
||||
.get(1)
|
||||
.map(|value| normalize_whitespace(value.as_str()))
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| build_citation_title_from_url(&url));
|
||||
citations.push(ThemeContextSearchCitation { title, url });
|
||||
if citations.len() >= 5 {
|
||||
return citations;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(url_regex) = regex::Regex::new(r"https?://[^\s)\]]+") {
|
||||
for captures in url_regex.find_iter(raw_response) {
|
||||
let url = sanitize_url(captures.as_str());
|
||||
if url.is_empty() || !seen.insert(url.clone()) {
|
||||
continue;
|
||||
}
|
||||
citations.push(ThemeContextSearchCitation {
|
||||
title: build_citation_title_from_url(&url),
|
||||
url,
|
||||
});
|
||||
if citations.len() >= 5 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
citations
|
||||
}
|
||||
|
||||
fn build_fallback_summary(raw_response: &str) -> String {
|
||||
let without_citations = regex::Regex::new(r#""citations"\s*:\s*\[[\s\S]*?\]"#)
|
||||
.ok()
|
||||
.map(|regex| {
|
||||
regex
|
||||
.replace_all(&strip_code_fence(raw_response), "")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|| strip_code_fence(raw_response));
|
||||
let normalized =
|
||||
normalize_whitespace(&without_citations.replace(&['{', '}', '[', ']', '"'][..], " "));
|
||||
|
||||
if normalized.is_empty() {
|
||||
return "暂无可用摘要,请重新尝试检索。".to_string();
|
||||
}
|
||||
|
||||
if normalized.chars().count() <= FALLBACK_SUMMARY_LENGTH {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
let mut summary = normalized
|
||||
.chars()
|
||||
.take(FALLBACK_SUMMARY_LENGTH)
|
||||
.collect::<String>();
|
||||
summary.push_str("...");
|
||||
summary
|
||||
}
|
||||
|
||||
fn build_fallback_title(query: &str, mode: ThemeContextSearchMode) -> String {
|
||||
let suffix = match mode {
|
||||
ThemeContextSearchMode::Social => "社媒搜索上下文",
|
||||
ThemeContextSearchMode::Web => "网络搜索上下文",
|
||||
};
|
||||
format!("{} · {}", query.trim(), suffix)
|
||||
}
|
||||
|
||||
fn normalize_search_result(
|
||||
raw_response: &str,
|
||||
query: &str,
|
||||
mode: ThemeContextSearchMode,
|
||||
attempts_summary: String,
|
||||
) -> ThemeContextSearchResponse {
|
||||
let parsed = parse_json_payload(raw_response);
|
||||
let citations = parsed
|
||||
.as_ref()
|
||||
.filter(|payload| !payload.citations.is_empty())
|
||||
.map(|payload| payload.citations.clone())
|
||||
.unwrap_or_else(|| extract_citations_from_text(raw_response));
|
||||
|
||||
ThemeContextSearchResponse {
|
||||
title: parsed
|
||||
.as_ref()
|
||||
.and_then(|payload| payload.title.clone())
|
||||
.unwrap_or_else(|| build_fallback_title(query, mode)),
|
||||
summary: parsed
|
||||
.as_ref()
|
||||
.and_then(|payload| payload.summary.clone())
|
||||
.unwrap_or_else(|| build_fallback_summary(raw_response)),
|
||||
citations,
|
||||
raw_response: raw_response.to_string(),
|
||||
attempts_summary,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aster_agent_theme_context_search(
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
request: ThemeContextSearchRequest,
|
||||
) -> Result<ThemeContextSearchResponse, String> {
|
||||
let workspace_id = request.workspace_id.trim().to_string();
|
||||
if workspace_id.is_empty() {
|
||||
return Err("workspace_id 必填,请先选择项目工作区".to_string());
|
||||
}
|
||||
|
||||
let provider_type = request.provider_type.trim().to_string();
|
||||
if provider_type.is_empty() {
|
||||
return Err("当前未选择可用模型,无法执行联网搜索".to_string());
|
||||
}
|
||||
|
||||
let model = request.model.trim().to_string();
|
||||
if model.is_empty() {
|
||||
return Err("当前未选择可用模型,无法执行联网搜索".to_string());
|
||||
}
|
||||
|
||||
let query = request.query.trim().to_string();
|
||||
if query.is_empty() {
|
||||
return Err("搜索词不能为空".to_string());
|
||||
}
|
||||
|
||||
if !state.is_initialized().await {
|
||||
state.init_agent_with_db(&db).await?;
|
||||
}
|
||||
|
||||
let manager = WorkspaceManager::new(db.inner().clone());
|
||||
let workspace = manager
|
||||
.get(&workspace_id)
|
||||
.map_err(|error| format!("读取 workspace 失败: {error}"))?
|
||||
.ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?;
|
||||
let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?;
|
||||
let workspace_root = ensured.root_path.to_string_lossy().to_string();
|
||||
|
||||
let runtime_config = config_manager.config();
|
||||
apply_web_search_runtime_env(&runtime_config);
|
||||
|
||||
let project_prompt = request
|
||||
.project_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(|project_id| {
|
||||
match AsterAgentState::build_project_system_prompt(&db, project_id) {
|
||||
Ok(prompt) => Some(prompt),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[ThemeContextSearch] 加载项目上下文失败,降级为基础搜索提示词: {}",
|
||||
error
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let request_tool_policy = resolve_request_tool_policy(Some(true), false);
|
||||
let system_prompt = proxycast_agent::merge_system_prompt_with_request_tool_policy(
|
||||
merge_system_prompt_with_web_search(
|
||||
merge_system_prompt_with_memory_profile(project_prompt, &runtime_config),
|
||||
&runtime_config,
|
||||
),
|
||||
&request_tool_policy,
|
||||
);
|
||||
|
||||
let session_id = format!("{}-{}", CONTEXT_SEARCH_SESSION_PREFIX, Uuid::new_v4());
|
||||
state
|
||||
.configure_provider_from_pool(&db, &provider_type, &model, &session_id)
|
||||
.await?;
|
||||
|
||||
let cancel_token = state.create_cancel_token(&session_id).await;
|
||||
let execution_result = {
|
||||
let agent_arc = state.get_agent_arc();
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Agent not initialized".to_string())?;
|
||||
|
||||
let mut session_config_builder = SessionConfigBuilder::new(&session_id);
|
||||
session_config_builder = session_config_builder.include_context_trace(false);
|
||||
if let Some(prompt) = system_prompt {
|
||||
session_config_builder = session_config_builder.system_prompt(prompt);
|
||||
}
|
||||
|
||||
stream_reply_with_policy(
|
||||
agent,
|
||||
&build_context_search_prompt(&query, request.mode),
|
||||
Some(Path::new(&workspace_root)),
|
||||
session_config_builder.build(),
|
||||
Some(cancel_token.clone()),
|
||||
&request_tool_policy,
|
||||
|_| {},
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
state.remove_cancel_token(&session_id).await;
|
||||
if let Err(error) = AsterAgentWrapper::delete_session_sync(&db, &session_id) {
|
||||
tracing::warn!(
|
||||
"[ThemeContextSearch] 删除临时会话失败: session={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
let execution = execution_result.map_err(|error| error.message)?;
|
||||
let raw_response = execution.text_output.trim().to_string();
|
||||
if raw_response.is_empty() {
|
||||
return Err("上下文搜索未返回可用内容,请重试".to_string());
|
||||
}
|
||||
|
||||
Ok(normalize_search_result(
|
||||
&raw_response,
|
||||
&query,
|
||||
request.mode,
|
||||
execution.attempts_summary,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_parse_json_result() {
|
||||
let result = normalize_search_result(
|
||||
r#"{"title":"智能体市场观察","summary":"市场讨论聚焦推理成本、工作流平台和企业落地节奏。","citations":[{"title":"官方博客","url":"https://example.com/blog"}]}"#,
|
||||
"智能体市场 2026",
|
||||
ThemeContextSearchMode::Web,
|
||||
"WebSearch#1:success".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(result.title, "智能体市场观察");
|
||||
assert!(result.summary.contains("推理成本"));
|
||||
assert_eq!(
|
||||
result.citations,
|
||||
vec![ThemeContextSearchCitation {
|
||||
title: "官方博客".to_string(),
|
||||
url: "https://example.com/blog".to_string(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(result.attempts_summary, "WebSearch#1:success");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_fallback_to_text_and_links_when_json_invalid() {
|
||||
let result = normalize_search_result(
|
||||
[
|
||||
"2026 年社交媒体讨论聚焦 Agent 产品的真实 ROI。",
|
||||
"参考链接:",
|
||||
"[小红书热议](https://example.com/xhs)",
|
||||
"https://example.com/weibo",
|
||||
]
|
||||
.join("\n")
|
||||
.as_str(),
|
||||
"Agent 社媒讨论",
|
||||
ThemeContextSearchMode::Social,
|
||||
"WebSearch#1:success".to_string(),
|
||||
);
|
||||
|
||||
assert!(result.title.contains("Agent 社媒讨论"));
|
||||
assert!(result.summary.contains("真实 ROI"));
|
||||
assert_eq!(
|
||||
result.citations,
|
||||
vec![
|
||||
ThemeContextSearchCitation {
|
||||
title: "小红书热议".to_string(),
|
||||
url: "https://example.com/xhs".to_string(),
|
||||
},
|
||||
ThemeContextSearchCitation {
|
||||
title: "example.com".to_string(),
|
||||
url: "https://example.com/weibo".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_deserialize_theme_context_request_with_aliases() {
|
||||
let request: ThemeContextSearchRequest = serde_json::from_str(
|
||||
r#"{
|
||||
"workspaceId": "workspace-test",
|
||||
"projectId": "project-test",
|
||||
"providerType": "openai",
|
||||
"model": "gpt-4.1",
|
||||
"query": "AI Agent 最新动态",
|
||||
"mode": "web"
|
||||
}"#,
|
||||
)
|
||||
.expect("request should deserialize");
|
||||
|
||||
assert_eq!(request.workspace_id, "workspace-test");
|
||||
assert_eq!(request.project_id.as_deref(), Some("project-test"));
|
||||
assert_eq!(request.provider_type, "openai");
|
||||
assert_eq!(request.model, "gpt-4.1");
|
||||
assert_eq!(request.mode, ThemeContextSearchMode::Web);
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ pub struct WorkspaceListItem {
|
||||
pub workspace_type: String,
|
||||
pub root_path: String,
|
||||
pub is_default: bool,
|
||||
pub settings: WorkspaceSettings,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub icon: Option<String>,
|
||||
@@ -100,6 +101,7 @@ impl From<Workspace> for WorkspaceListItem {
|
||||
workspace_type: ws.workspace_type.as_str().to_string(),
|
||||
root_path: ws.root_path.to_string_lossy().to_string(),
|
||||
is_default: ws.is_default,
|
||||
settings: ws.settings,
|
||||
created_at: ws.created_at.timestamp_millis(),
|
||||
updated_at: ws.updated_at.timestamp_millis(),
|
||||
icon: ws.icon,
|
||||
|
||||
@@ -196,6 +196,16 @@ impl ExecutionTracker {
|
||||
AgentRunDao::get_run(&conn, id).map_err(|e| format!("查询执行记录失败: {e}"))
|
||||
}
|
||||
|
||||
pub fn list_runs_by_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AgentRun>, String> {
|
||||
let conn = self.db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
AgentRunDao::list_runs_by_session(&conn, session_id, limit)
|
||||
.map_err(|e| format!("查询会话执行记录失败: {e}"))
|
||||
}
|
||||
|
||||
pub async fn with_run<T, Fut>(
|
||||
&self,
|
||||
source: RunSource,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const VIDEO_GENERATE_SKILL_NAME: &str = "video_generate";
|
||||
const VIDEO_GENERATE_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/video_generate/SKILL.md");
|
||||
|
||||
const BROADCAST_GENERATE_SKILL_NAME: &str = "broadcast_generate";
|
||||
const BROADCAST_GENERATE_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/broadcast_generate/SKILL.md");
|
||||
|
||||
const COVER_GENERATE_SKILL_NAME: &str = "cover_generate";
|
||||
const COVER_GENERATE_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/cover_generate/SKILL.md");
|
||||
|
||||
const MODAL_RESOURCE_SEARCH_SKILL_NAME: &str = "modal_resource_search";
|
||||
const MODAL_RESOURCE_SEARCH_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/modal_resource_search/SKILL.md");
|
||||
|
||||
const IMAGE_GENERATE_SKILL_NAME: &str = "image_generate";
|
||||
const IMAGE_GENERATE_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/image_generate/SKILL.md");
|
||||
|
||||
const LIBRARY_SKILL_NAME: &str = "library";
|
||||
const LIBRARY_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/library/SKILL.md");
|
||||
|
||||
const URL_PARSE_SKILL_NAME: &str = "url_parse";
|
||||
const URL_PARSE_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/url_parse/SKILL.md");
|
||||
|
||||
const RESEARCH_SKILL_NAME: &str = "research";
|
||||
const RESEARCH_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/research/SKILL.md");
|
||||
|
||||
const TYPESETTING_SKILL_NAME: &str = "typesetting";
|
||||
const TYPESETTING_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/typesetting/SKILL.md");
|
||||
|
||||
const SOCIAL_POST_WITH_COVER_SKILL_NAME: &str = "social_post_with_cover";
|
||||
const SOCIAL_POST_WITH_COVER_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/social_post_with_cover/SKILL.md");
|
||||
|
||||
fn default_skills() -> [(&'static str, &'static str); 10] {
|
||||
[
|
||||
(VIDEO_GENERATE_SKILL_NAME, VIDEO_GENERATE_SKILL_CONTENT),
|
||||
(
|
||||
BROADCAST_GENERATE_SKILL_NAME,
|
||||
BROADCAST_GENERATE_SKILL_CONTENT,
|
||||
),
|
||||
(COVER_GENERATE_SKILL_NAME, COVER_GENERATE_SKILL_CONTENT),
|
||||
(
|
||||
MODAL_RESOURCE_SEARCH_SKILL_NAME,
|
||||
MODAL_RESOURCE_SEARCH_SKILL_CONTENT,
|
||||
),
|
||||
(IMAGE_GENERATE_SKILL_NAME, IMAGE_GENERATE_SKILL_CONTENT),
|
||||
(LIBRARY_SKILL_NAME, LIBRARY_SKILL_CONTENT),
|
||||
(URL_PARSE_SKILL_NAME, URL_PARSE_SKILL_CONTENT),
|
||||
(RESEARCH_SKILL_NAME, RESEARCH_SKILL_CONTENT),
|
||||
(TYPESETTING_SKILL_NAME, TYPESETTING_SKILL_CONTENT),
|
||||
(
|
||||
SOCIAL_POST_WITH_COVER_SKILL_NAME,
|
||||
SOCIAL_POST_WITH_COVER_SKILL_CONTENT,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn skills_root_from_home(home_dir: &Path) -> PathBuf {
|
||||
home_dir.join(".proxycast").join("skills")
|
||||
}
|
||||
|
||||
fn ensure_default_local_skills_in_home(home_dir: &Path) -> Result<Vec<String>, String> {
|
||||
let skills_root = skills_root_from_home(home_dir);
|
||||
fs::create_dir_all(&skills_root)
|
||||
.map_err(|e| format!("创建技能目录失败 {}: {e}", skills_root.display()))?;
|
||||
|
||||
let mut installed = Vec::new();
|
||||
for (skill_name, skill_content) in default_skills() {
|
||||
let skill_dir = skills_root.join(skill_name);
|
||||
let skill_md_path = skill_dir.join("SKILL.md");
|
||||
if skill_md_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
fs::create_dir_all(&skill_dir)
|
||||
.map_err(|e| format!("创建默认技能目录失败 {}: {e}", skill_dir.display()))?;
|
||||
fs::write(&skill_md_path, skill_content)
|
||||
.map_err(|e| format!("写入默认技能失败 {}: {e}", skill_md_path.display()))?;
|
||||
installed.push(skill_name.to_string());
|
||||
}
|
||||
Ok(installed)
|
||||
}
|
||||
|
||||
pub fn ensure_default_local_skills() -> Result<Vec<String>, String> {
|
||||
let home_dir = dirs::home_dir().ok_or_else(|| "无法获取用户 Home 目录".to_string())?;
|
||||
ensure_default_local_skills_in_home(&home_dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_install_default_skill_when_missing() {
|
||||
let temp = tempfile::tempdir().expect("create temp dir");
|
||||
let installed = ensure_default_local_skills_in_home(temp.path()).expect("install");
|
||||
assert!(installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_NAME.to_string()));
|
||||
|
||||
let skill_md_path = temp
|
||||
.path()
|
||||
.join(".proxycast")
|
||||
.join("skills")
|
||||
.join(SOCIAL_POST_WITH_COVER_SKILL_NAME)
|
||||
.join("SKILL.md");
|
||||
assert!(skill_md_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_overwrite_existing_skill() {
|
||||
let temp = tempfile::tempdir().expect("create temp dir");
|
||||
let skill_dir = temp
|
||||
.path()
|
||||
.join(".proxycast")
|
||||
.join("skills")
|
||||
.join(SOCIAL_POST_WITH_COVER_SKILL_NAME);
|
||||
fs::create_dir_all(&skill_dir).expect("create skill dir");
|
||||
let skill_md_path = skill_dir.join("SKILL.md");
|
||||
let existing_content = "custom skill content";
|
||||
fs::write(&skill_md_path, existing_content).expect("write custom skill");
|
||||
|
||||
let installed = ensure_default_local_skills_in_home(temp.path()).expect("install");
|
||||
assert!(
|
||||
!installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_NAME.to_string()),
|
||||
"已存在的 skill 不应被重新安装"
|
||||
);
|
||||
|
||||
let current_content = fs::read_to_string(&skill_md_path).expect("read skill");
|
||||
assert_eq!(current_content, existing_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_embed_social_image_tool_contract_in_default_skill() {
|
||||
assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT
|
||||
.contains("allowed-tools: social_generate_cover_image, search_query"));
|
||||
assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT.contains("## 配图说明"));
|
||||
assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT.contains("状态:{成功/失败}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_embed_core_default_skills() {
|
||||
assert!(VIDEO_GENERATE_SKILL_CONTENT.contains("name: video_generate"));
|
||||
assert!(BROADCAST_GENERATE_SKILL_CONTENT.contains("name: broadcast_generate"));
|
||||
assert!(COVER_GENERATE_SKILL_CONTENT.contains("name: cover_generate"));
|
||||
assert!(MODAL_RESOURCE_SEARCH_SKILL_CONTENT.contains("name: modal_resource_search"));
|
||||
assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("name: image_generate"));
|
||||
assert!(LIBRARY_SKILL_CONTENT.contains("name: library"));
|
||||
assert!(URL_PARSE_SKILL_CONTENT.contains("name: url_parse"));
|
||||
assert!(RESEARCH_SKILL_CONTENT.contains("name: research"));
|
||||
assert!(TYPESETTING_SKILL_CONTENT.contains("name: typesetting"));
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@
|
||||
//! 纯逻辑已迁移到 `proxycast-skills` crate,
|
||||
//! 本模块保留 Tauri 相关实现和兼容导出层。
|
||||
|
||||
mod default_skills;
|
||||
mod execution_callback;
|
||||
mod llm_provider;
|
||||
|
||||
// Tauri 实现(留在主 crate)
|
||||
pub use default_skills::ensure_default_local_skills;
|
||||
pub use execution_callback::TauriExecutionCallback;
|
||||
|
||||
// 兼容导出(实际实现位于 proxycast-skills crate)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.80.0",
|
||||
"version": "0.81.0",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user