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