feat: release v0.81.0 with full pending changes

This commit is contained in:
coso
2026-03-08 07:53:37 +08:00
parent 7516671610
commit 2888352a00
181 changed files with 32791 additions and 2237 deletions
+16 -16
View File
@@ -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",
+2 -2
View File
@@ -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"
+40
View File
@@ -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"));
}
}
+222 -3
View File
@@ -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(&current_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(&current_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() {
+1 -1
View File
@@ -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(&current) 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">
# {标题}
![封面图]({图片URL或占位符})
## 导语
{导语内容}
## 正文
{正文内容}
## 结尾
{结尾与行动号召}
## 配图说明
- 提示词:{用于生成封面图的 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/...}
+15
View File
@@ -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) {
+11
View File
@@ -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,
+12
View File
@@ -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
+167
View File
@@ -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(),
+537 -1
View File
@@ -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)
}
+3
View File
@@ -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(
+630 -18
View 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![封面图]({SOCIAL_POST_FALLBACK_COVER_URL})");
}
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("![") {
continue;
}
let open = trimmed.find("](")?;
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![封面图](https://img.example/cover.png)\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);
}
}
+522
View File
@@ -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);
}
}
+2
View File
@@ -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,
+160
View File
@@ -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"));
}
}
+2
View File
@@ -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 -1
View File
@@ -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",