chore: release v0.96.0

This commit is contained in:
coso
2026-03-25 22:04:38 +08:00
parent 307b2d3670
commit 0ee56c1098
328 changed files with 40949 additions and 6948 deletions
+9 -9
View File
@@ -12,8 +12,8 @@ use crate::services::memory_profile_prompt_service::{
use aster::conversation::message::Message;
use futures::StreamExt;
use lime_agent::{
convert_agent_event, get_persisted_session_metadata_sync,
merge_system_prompt_with_runtime_agents, TauriAgentEvent, WriteArtifactEventEmitter,
get_persisted_session_metadata_sync, merge_system_prompt_with_runtime_agents,
project_runtime_event, AgentEvent as RuntimeAgentEvent, WriteArtifactEventEmitter,
};
use std::path::Path;
use tauri::{AppHandle, Emitter, Manager};
@@ -103,10 +103,10 @@ impl AsterAgentWrapper {
while let Some(event_result) = stream.next().await {
match event_result {
Ok(agent_event) => {
let tauri_events = convert_agent_event(agent_event);
for mut tauri_event in tauri_events {
let runtime_events = project_runtime_event(agent_event);
for mut runtime_event in runtime_events {
let extra_events =
write_artifact_emitter.process_event(&mut tauri_event);
write_artifact_emitter.process_event(&mut runtime_event);
for extra_event in &extra_events {
if let Err(error) = app.emit(&event_name, extra_event) {
tracing::error!(
@@ -115,13 +115,13 @@ impl AsterAgentWrapper {
);
}
}
if let Err(error) = app.emit(&event_name, &tauri_event) {
if let Err(error) = app.emit(&event_name, &runtime_event) {
tracing::error!("[AsterAgentWrapper] 发送事件失败: {}", error);
}
}
}
Err(error) => {
let error_event = TauriAgentEvent::Error {
let error_event = RuntimeAgentEvent::Error {
message: format!("Stream error: {error}"),
};
let _ = app.emit(&event_name, &error_event);
@@ -129,11 +129,11 @@ impl AsterAgentWrapper {
}
}
let done_event = TauriAgentEvent::FinalDone { usage: None };
let done_event = RuntimeAgentEvent::FinalDone { usage: None };
let _ = app.emit(&event_name, &done_event);
}
Err(error) => {
let error_event = TauriAgentEvent::Error {
let error_event = RuntimeAgentEvent::Error {
message: format!("Agent error: {error}"),
};
let _ = app.emit(&event_name, &error_event);
+3 -4
View File
@@ -24,10 +24,9 @@ pub use credential_bridge::{
create_aster_provider, AsterProviderConfig, CredentialBridge, CredentialBridgeError,
};
pub use lime_agent::{
convert_agent_event, convert_to_tauri_message, initialize_aster_runtime,
ChildSubagentRuntimeStatus, ChildSubagentSession, QueuedTurnSnapshot, QueuedTurnTask,
SubagentControlState, SubagentParentContext, SubagentRuntimeStatus, SubagentRuntimeStatusKind,
TauriAgentEvent,
initialize_aster_runtime, AgentEvent, ChildSubagentRuntimeStatus, ChildSubagentSession,
QueuedTurnSnapshot, QueuedTurnTask, SubagentControlState, SubagentParentContext,
SubagentRuntimeStatus, SubagentRuntimeStatusKind,
};
pub use subagent_scheduler::{
LimeScheduler, LimeSubAgentExecutor, SubAgentProgressEvent, SubAgentRole,
+4 -3
View File
@@ -18,8 +18,9 @@ use lime_agent::{
remove_runtime_queued_turn as remove_runtime_queued_turn_impl,
resume_persisted_runtime_queues_on_startup as resume_persisted_runtime_queues_on_startup_impl,
resume_runtime_queue_if_needed as resume_runtime_queue_if_needed_impl,
submit_runtime_turn as submit_runtime_turn_impl, QueuedTurnSnapshot, QueuedTurnTask,
RuntimeQueueEventEmitter, RuntimeQueueExecutor as SharedRuntimeQueueExecutor, TauriAgentEvent,
submit_runtime_turn as submit_runtime_turn_impl, AgentEvent as RuntimeAgentEvent,
QueuedTurnSnapshot, QueuedTurnTask, RuntimeQueueEventEmitter,
RuntimeQueueExecutor as SharedRuntimeQueueExecutor,
};
use serde_json::Value;
use tauri::{AppHandle, Emitter};
@@ -78,7 +79,7 @@ fn build_runtime_queue_context(
fn build_runtime_queue_event_emitter(app: &AppHandle) -> RuntimeQueueEventEmitter {
let app = app.clone();
std::sync::Arc::new(move |event_name: String, event: TauriAgentEvent| {
std::sync::Arc::new(move |event_name: String, event: RuntimeAgentEvent| {
if let Err(error) = app.emit(&event_name, &event) {
tracing::warn!(
"[AsterAgent][Queue] 发送队列事件失败: event_name={}, error={}",
+45 -3
View File
@@ -11,6 +11,10 @@ pub const LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME: &str =
pub const LIME_CREATE_IMAGE_TASK_TOOL_NAME: &str = "lime_create_image_generation_task";
pub const LIME_CREATE_URL_PARSE_TASK_TOOL_NAME: &str = "lime_create_url_parse_task";
pub const LIME_CREATE_TYPESETTING_TASK_TOOL_NAME: &str = "lime_create_typesetting_task";
pub const LIME_SITE_LIST_TOOL_NAME: &str = "lime_site_list";
pub const LIME_SITE_SEARCH_TOOL_NAME: &str = "lime_site_search";
pub const LIME_SITE_INFO_TOOL_NAME: &str = "lime_site_info";
pub const LIME_SITE_RUN_TOOL_NAME: &str = "lime_site_run";
pub const BROWSER_RUNTIME_TOOL_PREFIX: &str = "mcp__lime-browser__";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -126,6 +130,7 @@ const SEARCH_CAP: &[ToolCapability] = &[ToolCapability::WebSearch];
const SKILL_CAP: &[ToolCapability] = &[ToolCapability::SkillExecution];
const CONTENT_CAP: &[ToolCapability] = &[ToolCapability::ContentCreation];
const BROWSER_CAP: &[ToolCapability] = &[ToolCapability::BrowserRuntime];
const SITE_CAP: &[ToolCapability] = &[ToolCapability::BrowserRuntime, ToolCapability::WebSearch];
const WORKSPACE_IO_CAP: &[ToolCapability] = &[ToolCapability::WorkspaceIo];
const EXECUTION_CAP: &[ToolCapability] = &[ToolCapability::Execution];
const VISION_CAP: &[ToolCapability] = &[ToolCapability::Vision];
@@ -437,6 +442,42 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_SITE_LIST_TOOL_NAME,
profiles: BROWSER_PROFILES,
capabilities: SITE_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_SITE_SEARCH_TOOL_NAME,
profiles: BROWSER_PROFILES,
capabilities: SITE_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_SITE_INFO_TOOL_NAME,
profiles: BROWSER_PROFILES,
capabilities: SITE_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_SITE_RUN_TOOL_NAME,
profiles: BROWSER_PROFILES,
capabilities: SITE_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: BROWSER_RUNTIME_TOOL_PREFIX,
profiles: BROWSER_PROFILES,
@@ -673,14 +714,14 @@ mod tests {
.any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX));
let browser = tool_catalog_entries_for_surface(WorkspaceToolSurface::browser_assist());
assert_eq!(browser.len(), 27);
assert_eq!(browser.len(), 31);
assert!(browser
.iter()
.any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX));
let combined =
tool_catalog_entries_for_surface(WorkspaceToolSurface::creator_with_browser_assist());
assert_eq!(combined.len(), 35);
assert_eq!(combined.len(), 39);
}
#[test]
@@ -699,9 +740,10 @@ mod tests {
let names = workspace_default_allowed_tool_names(
WorkspaceToolSurface::creator_with_browser_assist(),
);
assert_eq!(names.len(), 22);
assert_eq!(names.len(), 26);
assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME));
assert!(names.contains(&"tool_search"));
assert!(names.contains(&LIME_SITE_RUN_TOOL_NAME));
assert!(!names
.iter()
.any(|name| name.starts_with(BROWSER_RUNTIME_TOOL_PREFIX)));
+2 -2
View File
@@ -910,8 +910,8 @@ mod tests {
.map(ToString::to_string)
.collect::<Vec<_>>();
assert_eq!(inventory.counts.catalog_total, 35);
assert_eq!(inventory.counts.catalog_current_total, 34);
assert_eq!(inventory.counts.catalog_total, 39);
assert_eq!(inventory.counts.catalog_current_total, 38);
assert_eq!(inventory.counts.catalog_compat_total, 1);
assert_eq!(inventory.default_allowed_tools, expected_default_allowed);
assert_eq!(
+5 -1
View File
@@ -1051,14 +1051,18 @@ mod tests {
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static LOG_FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
fn unique_log_path() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("系统时间异常")
.as_nanos();
let log_dir = std::env::temp_dir().join(format!("lime-log-tail-test-{nanos}"));
let counter = LOG_FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed);
let log_dir = std::env::temp_dir().join(format!("lime-log-tail-test-{nanos}-{counter}"));
fs::create_dir_all(&log_dir).expect("创建测试日志目录失败");
log_dir.join("lime.log")
}
+37 -7
View File
@@ -3,10 +3,10 @@
//! 包含 Tauri 应用的主入口函数和命令注册。
use std::sync::Arc;
use tauri::Manager;
use tauri::{Emitter, Manager};
#[cfg(target_os = "macos")]
use tauri::{Emitter, Listener};
#[cfg(desktop)]
use tauri::Listener;
use crate::commands;
use crate::tray::{TrayIconStatus, TrayManager, TrayStateSnapshot};
@@ -148,9 +148,9 @@ pub fn run() {
Some(vec!["--minimized"]),
));
// 在 macOS 上注册 Deep Link 插件
// 在桌面端注册 Deep Link 插件
// _Requirements: 1.4_
#[cfg(target_os = "macos")]
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_deep_link::init());
}
@@ -164,6 +164,25 @@ pub fn run() {
if let Some(window) = app.get_webview_window("main") {
reveal_main_window(&window);
}
let deep_link_urls: Vec<String> = args
.iter()
.filter_map(|arg| {
let value = arg.trim();
if value.starts_with("lime://") {
Some(value.to_string())
} else {
None
}
})
.collect();
if !deep_link_urls.is_empty() {
tracing::info!("[单实例] 转发 Deep Link URL: {:?}", deep_link_urls);
if let Err(error) = app.emit("deep-link://new-url", &deep_link_urls) {
tracing::error!("[单实例] 转发 Deep Link URL 失败: {}", error);
}
}
}));
builder
@@ -568,9 +587,9 @@ pub fn run() {
}
}
// 注册 Deep Link 事件处理器(仅 macOS)
// 注册 Deep Link 事件处理器(桌面端)
// _Requirements: 1.4_
#[cfg(target_os = "macos")]
#[cfg(desktop)]
{
let app_handle = app.handle().clone();
app.listen("deep-link://new-url", move |event| {
@@ -1267,6 +1286,15 @@ pub fn run() {
commands::browser_runtime_cmd::close_browser_runtime_debugger_window,
commands::browser_runtime_cmd::launch_browser_session,
commands::browser_runtime_cmd::launch_browser_runtime_assist,
commands::site_capability_cmd::site_list_adapters,
commands::site_capability_cmd::site_search_adapters,
commands::site_capability_cmd::site_get_adapter_info,
commands::site_capability_cmd::site_get_adapter_catalog_status,
commands::site_capability_cmd::site_apply_adapter_catalog_bootstrap,
commands::site_capability_cmd::site_clear_adapter_catalog_cache,
commands::site_capability_cmd::site_run_adapter,
commands::site_capability_cmd::site_debug_run_adapter,
commands::site_capability_cmd::site_save_adapter_result,
// API Key Provider commands
commands::api_key_provider_cmd::get_system_provider_catalog,
commands::api_key_provider_cmd::get_api_key_providers,
@@ -1579,6 +1607,7 @@ pub fn run() {
// Screenshot Chat commands
// _Requirements: 1.1, 1.4, 1.5, 2.2, 2.4, 3.1, 5.1_
commands::screenshot_cmd::get_experimental_config,
commands::screenshot_cmd::get_screenshot_shortcut_runtime_status,
commands::screenshot_cmd::save_experimental_config,
commands::screenshot_cmd::start_screenshot,
commands::screenshot_cmd::validate_shortcut,
@@ -1798,6 +1827,7 @@ pub fn run() {
commands::external_tools_cmd::get_external_tools,
// Voice Input commands
crate::voice::commands::get_voice_input_config,
crate::voice::commands::get_voice_shortcut_runtime_status,
crate::voice::commands::save_voice_input_config,
crate::voice::commands::get_voice_instructions,
crate::voice::commands::save_voice_instruction,
@@ -1,4 +1,5 @@
use super::*;
use lime_agent::AgentEvent as RuntimeAgentEvent;
/// 统一运行时:删除会话。
#[tauri::command]
@@ -79,8 +80,8 @@ pub(crate) fn validate_elicitation_submission(
Ok(trimmed_session_id)
}
pub(crate) fn build_action_resume_runtime_status() -> TauriRuntimeStatus {
TauriRuntimeStatus {
pub(crate) fn build_action_resume_runtime_status() -> AgentRuntimeStatus {
AgentRuntimeStatus {
phase: "routing".to_string(),
title: "已提交补充信息,继续执行中".to_string(),
detail: "补充信息已回填到当前执行链路,正在恢复后续步骤。".to_string(),
@@ -98,7 +99,7 @@ fn emit_action_resume_runtime_status(app: &AppHandle, event_name: &str) {
return;
}
let event = TauriAgentEvent::RuntimeStatus {
let event = RuntimeAgentEvent::RuntimeStatus {
status: build_action_resume_runtime_status(),
};
if let Err(error) = app.emit(event_name, &event) {
+35 -2
View File
@@ -62,6 +62,15 @@ pub struct AsterChatRequest {
/// Provider 配置(可选,如果未配置则使用当前配置)
#[serde(default, alias = "providerConfig")]
pub provider_config: Option<ConfigureProviderRequest>,
/// Provider 偏好(后端会基于该偏好解析最终 provider_config)
#[serde(default, alias = "providerPreference")]
pub provider_preference: Option<String>,
/// 模型偏好(后端会基于该偏好解析最终 model_name)
#[serde(default, alias = "modelPreference")]
pub model_preference: Option<String>,
/// 是否偏好 reasoning 变体
#[serde(default, alias = "thinkingEnabled")]
pub thinking_enabled: Option<bool>,
/// 项目 ID(可选,用于注入项目上下文到 System Prompt)
#[serde(default, alias = "projectId")]
pub project_id: Option<String>,
@@ -101,6 +110,12 @@ pub struct AsterChatRequest {
pub struct AgentTurnConfigSnapshot {
#[serde(default, alias = "providerConfig")]
pub provider_config: Option<ConfigureProviderRequest>,
#[serde(default, alias = "providerPreference")]
pub provider_preference: Option<String>,
#[serde(default, alias = "modelPreference")]
pub model_preference: Option<String>,
#[serde(default, alias = "thinkingEnabled")]
pub thinking_enabled: Option<bool>,
#[serde(default, alias = "executionStrategy")]
pub execution_strategy: Option<AsterExecutionStrategy>,
#[serde(default, alias = "webSearch")]
@@ -148,6 +163,15 @@ impl From<AgentRuntimeSubmitTurnRequest> for AsterChatRequest {
provider_config: turn_config
.as_ref()
.and_then(|config| config.provider_config.clone()),
provider_preference: turn_config
.as_ref()
.and_then(|config| config.provider_preference.clone()),
model_preference: turn_config
.as_ref()
.and_then(|config| config.model_preference.clone()),
thinking_enabled: turn_config
.as_ref()
.and_then(|config| config.thinking_enabled),
project_id: None,
workspace_id: request.workspace_id,
web_search: turn_config.as_ref().and_then(|config| config.web_search),
@@ -223,8 +247,10 @@ pub struct AgentRuntimeSessionDetail {
pub created_at: i64,
pub updated_at: i64,
pub thread_id: String,
pub messages: Vec<lime_agent::event_converter::TauriMessage>,
pub messages: Vec<lime_agent::AgentMessage>,
pub execution_strategy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub execution_runtime: Option<lime_agent::SessionExecutionRuntime>,
pub turns: Vec<lime_core::database::dao::agent_timeline::AgentThreadTurn>,
pub items: Vec<lime_core::database::dao::agent_timeline::AgentThreadItem>,
#[serde(default)]
@@ -463,6 +489,7 @@ impl AgentRuntimeSessionDetail {
thread_id: detail.thread_id,
messages: detail.messages,
execution_strategy: detail.execution_strategy,
execution_runtime: detail.execution_runtime,
turns: detail.turns,
items: detail.items,
todo_items: detail.todo_items,
@@ -955,7 +982,12 @@ fn build_thread_diagnostics(
})
} else if latest_turn_aborted {
last_outcome
.and_then(|outcome| outcome.summary.clone().or_else(|| outcome.primary_cause.clone()))
.and_then(|outcome| {
outcome
.summary
.clone()
.or_else(|| outcome.primary_cause.clone())
})
.or_else(|| Some("最近一次回合已被中断".to_string()))
} else if let Some(incident) = incidents.first() {
incident
@@ -1634,6 +1666,7 @@ mod tests {
workspace_id: None,
messages: Vec::new(),
execution_strategy: None,
execution_runtime: None,
turns,
items,
todo_items: Vec::new(),
+16 -17
View File
@@ -16,14 +16,16 @@ use crate::agent::runtime_queue_service::{
};
use crate::agent::{
AsterAgentState, AsterAgentWrapper, QueuedTurnSnapshot, QueuedTurnTask, SessionDetail,
SessionInfo, SubAgentRole, TauriAgentEvent,
SessionInfo, SubAgentRole,
};
use crate::agent_tools::catalog::{
browser_runtime_tool_prefix, build_mcp_extension_surface, creator_tool_names,
WorkspaceToolSurface, LIME_CREATE_BROADCAST_TASK_TOOL_NAME, LIME_CREATE_COVER_TASK_TOOL_NAME,
LIME_CREATE_IMAGE_TASK_TOOL_NAME, LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME,
LIME_CREATE_TYPESETTING_TASK_TOOL_NAME, LIME_CREATE_URL_PARSE_TASK_TOOL_NAME,
LIME_CREATE_VIDEO_TASK_TOOL_NAME, SOCIAL_IMAGE_TOOL_NAME, TOOL_SEARCH_TOOL_NAME,
LIME_CREATE_VIDEO_TASK_TOOL_NAME, LIME_SITE_INFO_TOOL_NAME, LIME_SITE_LIST_TOOL_NAME,
LIME_SITE_RUN_TOOL_NAME, LIME_SITE_SEARCH_TOOL_NAME, SOCIAL_IMAGE_TOOL_NAME,
TOOL_SEARCH_TOOL_NAME,
};
#[cfg(test)]
use crate::agent_tools::execution::build_workspace_shell_allow_pattern;
@@ -44,6 +46,7 @@ use crate::config::{GlobalConfigManager, GlobalConfigManagerState};
use crate::database::DbConnection;
use crate::mcp::{McpManagerState, McpServerConfig};
use crate::services::agent_timeline_service::AgentTimelineRecorder;
use crate::services::artifact_prompt_service::merge_system_prompt_with_artifact_context;
use crate::services::automation_service::AutomationServiceState;
use crate::services::execution_tracker_service::{ExecutionTracker, RunFinishDecision, RunSource};
use crate::services::memory_profile_prompt_service::{
@@ -79,13 +82,13 @@ use aster::tools::{
};
use async_trait::async_trait;
use futures::{FutureExt, StreamExt};
use lime_agent::event_converter::{TauriMessage, TauriMessageContent};
use lime_agent::mcp_bridge::McpBridgeClient;
#[cfg(test)]
use lime_agent::request_tool_policy::REQUEST_TOOL_POLICY_MARKER;
use lime_agent::request_tool_policy::{
merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy_with_mode,
stream_message_reply_with_policy, ReplyAttemptError, RequestToolPolicy, RequestToolPolicyMode,
StreamReplyExecution,
};
use lime_agent::{
acquire_provider_runtime_permit, acquire_team_runtime_permit,
@@ -100,14 +103,14 @@ use lime_agent::{
release_provider_runtime_permit, release_team_runtime_permit, replace_session_conversation,
resolve_provider_runtime_parallel_budget, resolve_virtual_memory_path,
snapshot_provider_runtime_lease, snapshot_team_runtime_session, summarize_builtin_skill,
virtual_memory_relative_path, write_subagent_control_state, CompactionSessionMetricsUpdate,
ProviderContinuationCapability, ProviderContinuationCapable, ProviderContinuationState,
ProviderRuntimeGovernorSnapshot, RuntimeProjectionSnapshot, SessionStateSnapshot,
SubagentControlState, SubagentCustomizationState, SubagentRuntimeStatus,
SubagentRuntimeStatusKind, SubagentSkillPromptBlock, SubagentSkillSummary, TauriRuntimeStatus,
TeamRuntimeGovernorSnapshot, TurnInputEnvelopeBuilder, TurnPromptAugmentationStageKind,
TurnProviderRoutingSnapshot, TurnRequestToolPolicySnapshot, TurnState, TurnSystemPromptSource,
DURABLE_MEMORY_VIRTUAL_ROOT,
virtual_memory_relative_path, write_subagent_control_state, AgentMessage, AgentMessageContent,
AgentRuntimeStatus, CompactionSessionMetricsUpdate, ProviderContinuationCapability,
ProviderContinuationCapable, ProviderContinuationState, ProviderRuntimeGovernorSnapshot,
RuntimeProjectionSnapshot, SessionStateSnapshot, SubagentControlState,
SubagentCustomizationState, SubagentRuntimeStatus, SubagentRuntimeStatusKind,
SubagentSkillPromptBlock, SubagentSkillSummary, TeamRuntimeGovernorSnapshot,
TurnInputEnvelopeBuilder, TurnPromptAugmentationStageKind, TurnProviderRoutingSnapshot,
TurnRequestToolPolicySnapshot, TurnState, TurnSystemPromptSource, DURABLE_MEMORY_VIRTUAL_ROOT,
};
use lime_services::api_key_provider_service::ApiKeyProviderService;
use lime_services::mcp_service::McpService;
@@ -258,6 +261,7 @@ mod dto;
mod mcp_bridge;
mod prompt_context;
mod reply_runtime;
mod request_model_resolution;
mod run_metadata;
mod runtime_turn;
mod session_runtime;
@@ -335,6 +339,7 @@ use reply_runtime::{
emit_runtime_status_with_projection, ensure_code_execution_extension_enabled,
should_fallback_to_react_from_code_orchestrated, stream_reply_once,
};
use request_model_resolution::resolve_runtime_request_provider_config;
use run_metadata::{
build_chat_run_finish_metadata, build_chat_run_metadata_base, extract_harness_array,
extract_harness_bool, extract_harness_nested_object, extract_harness_string,
@@ -346,12 +351,6 @@ use run_metadata::{
resolve_social_run_artifact_descriptor,
};
pub(crate) use runtime_turn::{build_queued_turn_task, build_runtime_queue_executor};
#[cfg(test)]
pub(crate) use runtime_turn::{
build_runtime_prepared_team_spawn_message, parse_runtime_prepared_team_roles,
plan_runtime_prepared_team_actions, RuntimePreparedTeamAction, RuntimePreparedTeamRole,
RuntimePreparedTeamSessionCandidate,
};
pub(crate) use session_runtime::{
delete_runtime_session_internal, persist_session_provider_routing,
resolve_session_provider_selector,
@@ -232,17 +232,6 @@ fn render_team_roles(role_items: &[serde_json::Value]) -> Vec<String> {
.collect()
}
fn describe_turn_team_reason(reason: &str) -> &'static str {
match reason {
"runtime_team_prepared" => "GUI 已提前准备这次 Team 分工",
"runtime_team_generation_failed" => "GUI 尝试准备 Team 失败,当前任务改由主助手直接推进",
"subagent_disabled" => "当前任务未开启 Team 模式",
"turn_purpose_override" => "当前任务属于特定目的流程,这次不走 Team 分工",
"single_agent_direct" => "GUI 判断当前任务由主助手直接处理更合适",
_ => "GUI 已记录这次 Team 判定",
}
}
pub(crate) fn build_team_preference_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
@@ -271,16 +260,6 @@ pub(crate) fn build_team_preference_system_prompt(
request_metadata,
&["selected_team_roles", "selectedTeamRoles"],
);
let turn_team_decision = extract_harness_string(
request_metadata,
&["turn_team_decision", "turnTeamDecision"],
);
let turn_team_reason =
extract_harness_string(request_metadata, &["turn_team_reason", "turnTeamReason"]);
let turn_team_blueprint = extract_harness_nested_object(
request_metadata,
&["turn_team_blueprint", "turnTeamBlueprint"],
);
if !subagent_mode_enabled {
return None;
@@ -332,78 +311,6 @@ pub(crate) fn build_team_preference_system_prompt(
}
}
match turn_team_decision.as_deref() {
Some("team_prepared") => {
lines.push(
"- 当前任务在 GUI 发送前已经准备好协作分工;请把这份安排当成执行参考,而不是事后建议。"
.to_string(),
);
if let Some(reason) = turn_team_reason.as_deref() {
lines.push(format!(
"- GUI 判定:{}。",
describe_turn_team_reason(reason)
));
}
if let Some(blueprint) = turn_team_blueprint {
let blueprint_label = blueprint
.get("label")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let blueprint_description = blueprint
.get("description")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let rendered_roles = blueprint
.get("roles")
.and_then(serde_json::Value::as_array)
.map(|items| render_team_roles(items))
.unwrap_or_default();
if let Some(label) = blueprint_label {
lines.push(format!("- 当前协作蓝图:{label}。"));
}
if let Some(description) = blueprint_description {
lines.push(format!("- 蓝图说明:{description}"));
}
if !rendered_roles.is_empty() {
lines.push("- 当前协作分工:".to_string());
lines.extend(rendered_roles);
}
}
lines.push(
"- 回复用户时,先说明为什么要拆分协作、谁会先处理哪一部分、主对话会在什么节点带着结果回来同步。不要只播报“已进入 Team 协作”。"
.to_string(),
);
lines.push(
"- 主对话要像项目助理一样汇总目标、分工、关键进展和下一步,而不是只抛出简短状态。"
.to_string(),
);
lines.push(
"- 如果你决定调用 spawn_agent / send_input,应尽早按上述蓝图启动角色,并把蓝图里的 id / label 映射到 blueprintRoleId / blueprintRoleLabel,让各角色承担自己的输出,不要等主 agent 完整处理结束后再补做 team。"
.to_string(),
);
}
Some("single_agent") => {
lines
.push("- 当前任务没有在 GUI 中提前准备 Team,默认先由主助手直接推进。".to_string());
if let Some(reason) = turn_team_reason.as_deref() {
lines.push(format!(
"- GUI 判定:{}。",
describe_turn_team_reason(reason)
));
}
lines.push(
"- 除非执行中出现明确的拆分必要性,否则不要为了形式化 team 而推迟主任务。"
.to_string(),
);
}
_ => {}
}
lines.push(
"- spawn_agent 支持这些结构化字段:blueprintRoleId、blueprintRoleLabel、teamPresetId、profileId、profileName、roleKey、skillIds、skillDirectories、theme、systemOverlay、outputContract。"
.to_string(),
@@ -412,6 +319,10 @@ pub(crate) fn build_team_preference_system_prompt(
"- 如果任务简单、强依赖当前上下文或下一步立即阻塞在结果上,不要为了套用 preset 而滥用 team。"
.to_string(),
);
lines.push(
"- 主对话需要承担协调职责:说明为什么要拆分、谁先处理哪一部分,并在拿到子 agent 结果后主动汇总关键进展、风险和下一步。"
.to_string(),
);
Some(lines.join("\n"))
}
@@ -1,4 +1,5 @@
use super::*;
use lime_agent::{project_runtime_event, AgentEvent as RuntimeAgentEvent};
fn execution_strategy_label(strategy: AsterExecutionStrategy) -> &'static str {
match strategy {
@@ -115,16 +116,45 @@ fn message_suggests_subagent(message: &str) -> bool {
.any(|keyword| normalized.contains(keyword))
}
fn message_suggests_content_generation(message: &str) -> bool {
let normalized = message.to_ascii_lowercase();
[
"生成",
"写一",
"帮我写",
"起草",
"草拟",
"撰写",
"提纲",
"大纲",
"报告",
"方案",
"文档",
"演示",
"ppt",
"slide",
"brief",
"draft",
"outline",
"generate",
"write",
"create a",
]
.iter()
.any(|keyword| normalized.contains(keyword))
}
pub(super) fn build_turn_runtime_statuses(
request: &AsterChatRequest,
effective_strategy: AsterExecutionStrategy,
request_tool_policy: &RequestToolPolicy,
model_name: Option<&str>,
) -> (TauriRuntimeStatus, TauriRuntimeStatus) {
) -> (AgentRuntimeStatus, AgentRuntimeStatus) {
let thinking_enabled = extract_harness_bool(
request.metadata.as_ref(),
&["thinking_enabled", "thinkingEnabled"],
)
.or(request.thinking_enabled)
.unwrap_or(false);
let task_enabled = extract_harness_bool(
request.metadata.as_ref(),
@@ -256,6 +286,15 @@ pub(super) fn build_turn_runtime_statuses(
"优先输出结构化行动路径".to_string(),
],
)
} else if message_suggests_content_generation(&request.message) {
(
"已决定:先生成草稿".to_string(),
"当前请求属于内容生成类,优先基于已有上下文生成一版草稿,信息不足时附带假设说明,而非反复追问。".to_string(),
vec![
"检测到内容生成需求".to_string(),
"先产出可用草稿,再根据反馈迭代".to_string(),
],
)
} else {
(
"已决定:直接回答优先".to_string(),
@@ -268,7 +307,7 @@ pub(super) fn build_turn_runtime_statuses(
};
(
TauriRuntimeStatus {
AgentRuntimeStatus {
phase: "preparing".to_string(),
title: "正在理解意图".to_string(),
detail: "正在判断当前任务应该直接回答、深度思考、规划、联网核实,还是升级为任务协作。"
@@ -276,7 +315,7 @@ pub(super) fn build_turn_runtime_statuses(
checkpoints: initial_checkpoints,
metadata: None,
},
TauriRuntimeStatus {
AgentRuntimeStatus {
phase: "routing".to_string(),
title: decided.0,
detail: decided.1,
@@ -291,7 +330,7 @@ fn emit_projected_runtime_item_event(
event_name: &str,
timeline_recorder: &Arc<Mutex<AgentTimelineRecorder>>,
workspace_root: &str,
event: TauriAgentEvent,
event: RuntimeAgentEvent,
) {
if let Err(error) = app.emit(event_name, &event) {
tracing::warn!("[AsterAgent] 发送 runtime item 投影事件失败: {}", error);
@@ -316,7 +355,7 @@ pub(super) async fn emit_runtime_status_with_projection(
timeline_recorder: &Arc<Mutex<AgentTimelineRecorder>>,
workspace_root: &str,
session_config: &aster::agents::SessionConfig,
status: TauriRuntimeStatus,
status: AgentRuntimeStatus,
) {
match agent
.upsert_runtime_status_item(
@@ -329,7 +368,7 @@ pub(super) async fn emit_runtime_status_with_projection(
.await
{
Ok(agent_event) => {
for event in lime_agent::convert_agent_event(agent_event) {
for event in project_runtime_event(agent_event) {
emit_projected_runtime_item_event(
app,
event_name,
@@ -347,7 +386,7 @@ pub(super) async fn emit_runtime_status_with_projection(
}
}
let runtime_event = TauriAgentEvent::RuntimeStatus { status };
let runtime_event = RuntimeAgentEvent::RuntimeStatus { status };
if let Err(error) = app.emit(event_name, &runtime_event) {
tracing::warn!("[AsterAgent] 发送 runtime_status 失败: {}", error);
}
@@ -363,7 +402,7 @@ pub(super) async fn complete_runtime_status_projection(
) {
match agent.complete_runtime_status_item(session_config).await {
Ok(Some(agent_event)) => {
for event in lime_agent::convert_agent_event(agent_event) {
for event in project_runtime_event(agent_event) {
emit_projected_runtime_item_event(
app,
event_name,
@@ -428,9 +467,9 @@ pub(super) async fn stream_reply_once<F>(
cancel_token: CancellationToken,
request_tool_policy: &RequestToolPolicy,
mut on_event: F,
) -> Result<(), ReplyAttemptError>
) -> Result<StreamReplyExecution, ReplyAttemptError>
where
F: FnMut(&TauriAgentEvent),
F: FnMut(&RuntimeAgentEvent),
{
stream_message_reply_with_policy(
agent,
@@ -453,7 +492,6 @@ where
},
)
.await
.map(|_| ())
}
pub(super) fn build_runtime_user_message(
File diff suppressed because it is too large Load Diff
@@ -47,11 +47,11 @@ pub(super) fn extract_provider_continuation_from_metadata(
}
pub(super) fn extract_provider_continuation_from_message(
message: &TauriMessage,
message: &AgentMessage,
capability: ProviderContinuationCapability,
) -> Option<ProviderContinuationState> {
for content in &message.content {
if let TauriMessageContent::ToolResponse {
if let AgentMessageContent::ToolResponse {
metadata: Some(metadata),
..
} = content
@@ -11,6 +11,28 @@ fn extract_harness_object(
Some(object)
}
fn extract_harness_preferences_object(
request_metadata: Option<&serde_json::Value>,
) -> Option<&serde_json::Map<String, serde_json::Value>> {
extract_harness_object(request_metadata)?
.get("preferences")
.and_then(serde_json::Value::as_object)
}
fn legacy_harness_bool_preference_keys(key: &str) -> Option<&'static [&'static str]> {
match key {
"web_search_enabled" | "webSearchEnabled" => Some(&["web_search", "webSearch"]),
"thinking_enabled" | "thinkingEnabled" => {
Some(&["thinking", "thinking_enabled", "thinkingEnabled"])
}
"task_mode_enabled" | "taskModeEnabled" => Some(&["task", "task_mode", "taskMode"]),
"subagent_mode_enabled" | "subagentModeEnabled" => {
Some(&["subagent", "subagent_mode", "subagentMode"])
}
_ => None,
}
}
pub(in crate::commands::aster_agent_cmd) fn extract_harness_string(
request_metadata: Option<&serde_json::Value>,
keys: &[&str],
@@ -29,8 +51,19 @@ pub(in crate::commands::aster_agent_cmd) fn extract_harness_bool(
keys: &[&str],
) -> Option<bool> {
let harness = extract_harness_object(request_metadata)?;
keys.iter()
let explicit = keys
.iter()
.filter_map(|key| harness.get(*key))
.find_map(serde_json::Value::as_bool);
if explicit.is_some() {
return explicit;
}
let preferences = extract_harness_preferences_object(request_metadata)?;
keys.iter()
.filter_map(|key| legacy_harness_bool_preference_keys(key))
.flat_map(|nested_keys| nested_keys.iter().copied())
.filter_map(|key| preferences.get(key))
.find_map(serde_json::Value::as_bool)
}
@@ -73,6 +106,9 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields(
("creationMode", "creation_mode"),
("chat_mode", "chat_mode"),
("chatMode", "chat_mode"),
("turn_purpose", "turn_purpose"),
("turnPurpose", "turn_purpose"),
("purpose", "turn_purpose"),
("session_mode", "session_mode"),
("sessionMode", "session_mode"),
("gate_key", "gate_key"),
@@ -93,12 +129,6 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields(
("selectedTeamSummary", "selected_team_summary"),
("selected_team_roles", "selected_team_roles"),
("selectedTeamRoles", "selected_team_roles"),
("turn_team_decision", "turn_team_decision"),
("turnTeamDecision", "turn_team_decision"),
("turn_team_reason", "turn_team_reason"),
("turnTeamReason", "turn_team_reason"),
("turn_team_blueprint", "turn_team_blueprint"),
("turnTeamBlueprint", "turn_team_blueprint"),
("browser_requirement", "browser_requirement"),
("browserRequirement", "browser_requirement"),
("browser_requirement_reason", "browser_requirement_reason"),
@@ -113,6 +143,32 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields(
target.insert(target_key.to_string(), value.clone());
}
}
for (target_key, preference_keys) in [
("web_search_enabled", &["web_search", "webSearch"][..]),
(
"thinking_enabled",
&["thinking", "thinking_enabled", "thinkingEnabled"][..],
),
("task_mode_enabled", &["task", "task_mode", "taskMode"][..]),
(
"subagent_mode_enabled",
&["subagent", "subagent_mode", "subagentMode"][..],
),
] {
if target.contains_key(target_key) {
continue;
}
let value = extract_harness_preferences_object(request_metadata).and_then(|preferences| {
preference_keys
.iter()
.filter_map(|key| preferences.get(*key))
.find_map(serde_json::Value::as_bool)
});
if let Some(value) = value {
target.insert(target_key.to_string(), serde_json::json!(value));
}
}
}
pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base(
@@ -1,4 +1,10 @@
use super::*;
use lime_agent::artifact_protocol::{
extract_artifact_protocol_paths_from_metadata, extract_artifact_protocol_paths_from_value,
normalize_artifact_protocol_path,
};
use lime_agent::filesystem_event_protocol::extract_filesystem_event_location_hints_from_metadata;
use lime_agent::AgentEvent as RuntimeAgentEvent;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(in crate::commands::aster_agent_cmd) struct SocialRunArtifactDescriptor {
@@ -25,13 +31,13 @@ pub(in crate::commands::aster_agent_cmd) struct ChatRunObservation {
impl ChatRunObservation {
pub(in crate::commands::aster_agent_cmd) fn record_event(
&mut self,
event: &TauriAgentEvent,
event: &RuntimeAgentEvent,
workspace_root: &str,
request_metadata: Option<&serde_json::Value>,
provider_continuation_capability: ProviderContinuationCapability,
) {
match event {
TauriAgentEvent::ToolStart {
RuntimeAgentEvent::ToolStart {
tool_name,
arguments,
..
@@ -44,7 +50,7 @@ impl ChatRunObservation {
self.record_artifact_path(path, request_metadata);
}
}
TauriAgentEvent::ToolEnd { result, .. } => {
RuntimeAgentEvent::ToolEnd { result, .. } => {
if let Some(metadata) = &result.metadata {
if let Some(provider_continuation) = extract_provider_continuation_from_metadata(
metadata,
@@ -59,7 +65,7 @@ impl ChatRunObservation {
}
}
}
TauriAgentEvent::Message { message } => {
RuntimeAgentEvent::Message { message } => {
if let Some(provider_continuation) = extract_provider_continuation_from_message(
message,
provider_continuation_capability,
@@ -67,7 +73,7 @@ impl ChatRunObservation {
self.record_provider_continuation(provider_continuation);
}
}
TauriAgentEvent::ArtifactSnapshot { artifact } => {
RuntimeAgentEvent::ArtifactSnapshot { artifact } => {
if let Some(path) =
normalize_metadata_path(artifact.file_path.as_str(), workspace_root)
{
@@ -166,62 +172,86 @@ pub(in crate::commands::aster_agent_cmd) fn extract_artifact_path_from_tool_star
}
let args = parse_tool_arguments(arguments)?;
let object = args.as_object()?;
for key in ["path", "file_path", "filePath", "output_path", "outputPath"] {
let Some(raw_path) = object.get(key).and_then(serde_json::Value::as_str) else {
continue;
};
if normalized_tool_name.contains("write")
|| normalized_tool_name.contains("create")
|| normalized_tool_name.contains("output")
{
return normalize_metadata_path(raw_path, workspace_root);
}
if normalized_tool_name.contains("write")
|| normalized_tool_name.contains("create")
|| normalized_tool_name.contains("output")
{
return extract_artifact_protocol_paths_from_value(&args)
.into_iter()
.find_map(|path| normalize_metadata_path(path.as_str(), workspace_root));
}
None
}
fn push_metadata_path(target: &mut Vec<String>, value: &serde_json::Value, workspace_root: &str) {
fn push_normalized_metadata_path(target: &mut Vec<String>, path: &str, workspace_root: &str) {
if let Some(normalized) = normalize_metadata_path(path, workspace_root) {
if !target.iter().any(|item| item == &normalized) {
target.push(normalized);
}
}
}
fn push_compat_metadata_paths(
target: &mut Vec<String>,
value: &serde_json::Value,
workspace_root: &str,
) {
match value {
serde_json::Value::String(path) => {
if let Some(normalized) = normalize_metadata_path(path, workspace_root) {
if !target.iter().any(|item| item == &normalized) {
target.push(normalized);
}
}
let Some(normalized_path) = normalize_artifact_protocol_path(path) else {
return;
};
push_normalized_metadata_path(target, normalized_path.as_str(), workspace_root);
}
serde_json::Value::Array(items) => {
for item in items {
push_metadata_path(target, item, workspace_root);
push_compat_metadata_paths(target, item, workspace_root);
}
}
_ => {}
}
}
fn is_probable_artifact_location_hint(path: &str) -> bool {
let normalized = path.trim().replace('\\', "/").to_lowercase();
if normalized.is_empty() || normalized.ends_with('/') {
return false;
}
let file_name = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
if !file_name.contains('.') {
return false;
}
!file_name.ends_with(".log") && !file_name.ends_with(".txt") && !file_name.ends_with(".jsonl")
}
fn extract_artifact_paths_from_tool_result_metadata(
metadata: &HashMap<String, serde_json::Value>,
workspace_root: &str,
) -> Vec<String> {
let mut paths = Vec::new();
for key in [
"artifact_paths",
"artifact_path",
"path",
"absolute_path",
"output_file",
"file_path",
"output_path",
"article_path",
"cover_meta_path",
"publish_path",
] {
let mut paths = extract_artifact_protocol_paths_from_metadata(metadata)
.into_iter()
.filter_map(|path| normalize_metadata_path(path.as_str(), workspace_root))
.collect::<Vec<_>>();
for key in ["article_path", "cover_meta_path", "publish_path"] {
if let Some(value) = metadata.get(key) {
push_metadata_path(&mut paths, value, workspace_root);
push_compat_metadata_paths(&mut paths, value, workspace_root);
}
}
if paths.is_empty() {
// `output_file` / `cwd` 这类字段只是文件事件位置线索,不是 artifact 事实源。
// 只有完全没有显式 artifact 路径时,才允许做一次保守兜底。
for hint in extract_filesystem_event_location_hints_from_metadata(metadata) {
if is_probable_artifact_location_hint(hint.as_str()) {
push_normalized_metadata_path(&mut paths, hint.as_str(), workspace_root);
}
}
}
paths
}
@@ -1,355 +1,180 @@
use super::*;
use aster::session::TurnContextOverride;
use lime_agent::AgentEvent as RuntimeAgentEvent;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RuntimePreparedTeamRole {
pub(crate) id: String,
pub(crate) label: String,
pub(crate) summary: Option<String>,
pub(crate) profile_id: Option<String>,
pub(crate) role_key: Option<String>,
pub(crate) skill_ids: Vec<String>,
}
const ARTIFACT_DOCUMENT_REPAIRED_WARNING_CODE: &str = "artifact_document_repaired";
const ARTIFACT_DOCUMENT_FAILED_WARNING_CODE: &str = "artifact_document_failed";
const ARTIFACT_DOCUMENT_PERSIST_FAILED_WARNING_CODE: &str = "artifact_document_persist_failed";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RuntimePreparedTeamSessionCandidate {
pub(crate) blueprint_role_id: String,
pub(crate) session_id: String,
pub(crate) status_kind: SubagentRuntimeStatusKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RuntimePreparedTeamAction {
Spawn(RuntimePreparedTeamRole),
Resume {
role: RuntimePreparedTeamRole,
session_id: String,
},
}
fn normalize_runtime_team_role_string(value: Option<&serde_json::Value>) -> Option<String> {
normalize_optional_text(
value
.and_then(serde_json::Value::as_str)
.map(ToString::to_string),
)
}
fn normalize_runtime_team_role_id_fragment(value: &str) -> Option<String> {
let normalized = value.split_whitespace().collect::<Vec<_>>().join("-");
let normalized = normalized.trim_matches('-').to_string();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
fn normalize_runtime_team_skill_ids(value: Option<&serde_json::Value>) -> Vec<String> {
let Some(items) = value.and_then(serde_json::Value::as_array) else {
return Vec::new();
};
let mut seen = HashSet::new();
let mut skill_ids = Vec::new();
for item in items {
let Some(skill_id) = normalize_runtime_team_role_string(Some(item)) else {
continue;
};
if seen.insert(skill_id.clone()) {
skill_ids.push(skill_id);
}
}
skill_ids
}
fn parse_runtime_prepared_team_role(
role_value: &serde_json::Value,
index: usize,
) -> Option<RuntimePreparedTeamRole> {
let role = role_value.as_object()?;
let profile_id = normalize_runtime_team_role_string(role.get("profile_id"))
.or_else(|| normalize_runtime_team_role_string(role.get("profileId")));
let role_key = normalize_runtime_team_role_string(role.get("role_key"))
.or_else(|| normalize_runtime_team_role_string(role.get("roleKey")));
let label = normalize_runtime_team_role_string(role.get("label"))
.or_else(|| profile_id.clone())
.or_else(|| role_key.clone())
.unwrap_or_else(|| format!("角色 {}", index + 1));
let id = normalize_runtime_team_role_string(role.get("id"))
.or_else(|| {
profile_id
.as_deref()
.and_then(normalize_runtime_team_role_id_fragment)
.map(|fragment| format!("profile-{fragment}"))
})
.or_else(|| {
role_key
.as_deref()
.and_then(normalize_runtime_team_role_id_fragment)
.map(|fragment| format!("role-{fragment}"))
})
.or_else(|| {
normalize_runtime_team_role_id_fragment(&label)
.map(|fragment| format!("lane-{fragment}"))
})
.unwrap_or_else(|| format!("runtime-team-role-{}", index + 1));
Some(RuntimePreparedTeamRole {
id,
label,
summary: normalize_runtime_team_role_string(role.get("summary")),
profile_id,
role_key,
skill_ids: normalize_runtime_team_skill_ids(
role.get("skill_ids").or_else(|| role.get("skillIds")),
),
})
}
pub(crate) fn parse_runtime_prepared_team_roles(
request_metadata: Option<&serde_json::Value>,
) -> Vec<RuntimePreparedTeamRole> {
if extract_harness_string(
request_metadata,
&["turn_team_decision", "turnTeamDecision"],
)
.as_deref()
!= Some("team_prepared")
{
return Vec::new();
}
let Some(blueprint) = extract_harness_nested_object(
request_metadata,
&["turn_team_blueprint", "turnTeamBlueprint"],
) else {
return Vec::new();
};
let Some(role_values) = blueprint.get("roles").and_then(serde_json::Value::as_array) else {
return Vec::new();
};
let mut seen = HashSet::new();
let mut roles = Vec::new();
for (index, role_value) in role_values.iter().enumerate() {
let Some(role) = parse_runtime_prepared_team_role(role_value, index) else {
continue;
};
if seen.insert(role.id.clone()) {
roles.push(role);
}
}
roles
}
fn runtime_prepared_team_session_rank(status_kind: SubagentRuntimeStatusKind) -> u8 {
match status_kind {
SubagentRuntimeStatusKind::Running => 7,
SubagentRuntimeStatusKind::Queued => 6,
SubagentRuntimeStatusKind::Idle => 5,
SubagentRuntimeStatusKind::Completed => 4,
SubagentRuntimeStatusKind::Failed => 3,
SubagentRuntimeStatusKind::Aborted => 2,
SubagentRuntimeStatusKind::Closed => 1,
SubagentRuntimeStatusKind::NotFound => 0,
}
}
pub(crate) fn plan_runtime_prepared_team_actions(
roles: &[RuntimePreparedTeamRole],
existing_candidates: &[RuntimePreparedTeamSessionCandidate],
) -> Vec<RuntimePreparedTeamAction> {
let mut existing_by_role_id: HashMap<&str, &RuntimePreparedTeamSessionCandidate> =
HashMap::new();
for candidate in existing_candidates {
let replace = existing_by_role_id
.get(candidate.blueprint_role_id.as_str())
.map(|current| {
runtime_prepared_team_session_rank(candidate.status_kind)
> runtime_prepared_team_session_rank(current.status_kind)
})
.unwrap_or(true);
if replace {
existing_by_role_id.insert(candidate.blueprint_role_id.as_str(), candidate);
}
}
roles
.iter()
.filter_map(|role| match existing_by_role_id.get(role.id.as_str()) {
Some(candidate) if candidate.status_kind == SubagentRuntimeStatusKind::Closed => {
Some(RuntimePreparedTeamAction::Resume {
role: role.clone(),
session_id: candidate.session_id.clone(),
})
}
Some(candidate) if candidate.status_kind != SubagentRuntimeStatusKind::NotFound => None,
_ => Some(RuntimePreparedTeamAction::Spawn(role.clone())),
})
.collect()
}
pub(crate) fn build_runtime_prepared_team_spawn_message(
role: &RuntimePreparedTeamRole,
user_message: &str,
) -> String {
let mut sections = vec![format!("你是当前协作团队中的「{}」角色。", role.label)];
if let Some(summary) = role.summary.as_deref() {
sections.push(format!("你负责:{summary}"));
}
sections
.push("先用 1-2 句说明你会接手哪一部分,再开始处理,不要只回一句笼统状态。".to_string());
sections.push(
"请直接在当前子会话输出可交付的过程与结果,优先给事实、结论、风险和下一步,不要把具体产出留给父会话代写。".to_string(),
);
sections.push(format!("当前用户任务:\n{}", user_message.trim()));
sections.push(
"只处理当前角色范围内的工作;如果依赖其他角色,请明确写出需要谁补充什么,再继续推进。"
.to_string(),
);
sections.join("\n\n")
}
async fn collect_runtime_prepared_team_candidates(
parent_session_id: &str,
) -> Result<Vec<RuntimePreparedTeamSessionCandidate>, String> {
let child_sessions =
list_child_subagent_sessions(parent_session_id, "读取 runtime team child sessions 失败")
.await?;
let mut candidates = Vec::new();
for child_session in child_sessions {
let Some(customization) = SubagentCustomizationState::from_session(&child_session) else {
continue;
};
let Some(blueprint_role_id) = normalize_optional_text(customization.blueprint_role_id)
else {
continue;
};
let status_kind = load_subagent_runtime_status(&child_session.id)
.await
.map(|status| status.kind)
.unwrap_or(SubagentRuntimeStatusKind::NotFound);
candidates.push(RuntimePreparedTeamSessionCandidate {
blueprint_role_id,
session_id: child_session.id,
status_kind,
});
}
Ok(candidates)
}
async fn maybe_prepare_runtime_team_sessions(
fn emit_runtime_side_event(
app: &AppHandle,
state: &AsterAgentState,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderServiceState,
logs: &LogState,
config_manager: &GlobalConfigManagerState,
mcp_manager: &McpManagerState,
automation_state: &AutomationServiceState,
request: &AsterChatRequest,
) -> Result<(), String> {
let roles = parse_runtime_prepared_team_roles(request.metadata.as_ref());
if roles.is_empty() {
return Ok(());
}
let existing_candidates = collect_runtime_prepared_team_candidates(&request.session_id).await?;
let actions = plan_runtime_prepared_team_actions(&roles, &existing_candidates);
if actions.is_empty() {
tracing::info!(
"[AsterAgent][RuntimeTeam] session={} 所有 blueprint 角色已就绪,无需新增预拉起",
request.session_id
);
return Ok(());
}
tracing::info!(
"[AsterAgent][RuntimeTeam] session={} 开始按 blueprint 预拉起角色: total_roles={}, pending_actions={}",
request.session_id,
roles.len(),
actions.len()
);
let runtime = SubagentControlRuntime::new(
app.clone(),
state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
);
for action in actions {
match action {
RuntimePreparedTeamAction::Spawn(role) => {
tracing::info!(
"[AsterAgent][RuntimeTeam] session={} 自动预拉起角色: role_id={}, label={}",
request.session_id,
role.id,
role.label
);
agent_runtime_spawn_subagent_internal(
&runtime,
AgentRuntimeSpawnSubagentRequest {
parent_session_id: request.session_id.clone(),
message: build_runtime_prepared_team_spawn_message(&role, &request.message),
agent_type: Some(role.label.clone()),
model: None,
reasoning_effort: None,
fork_context: false,
blueprint_role_id: Some(role.id.clone()),
blueprint_role_label: Some(role.label.clone()),
profile_id: role.profile_id.clone(),
profile_name: None,
role_key: role.role_key.clone(),
skill_ids: role.skill_ids.clone(),
skill_directories: Vec::new(),
team_preset_id: None,
theme: None,
system_overlay: None,
output_contract: None,
},
)
.await
.map_err(|error| {
format!(
"自动预拉起 team 角色失败: role_id={}, label={}, error={error}",
role.id, role.label
)
})?;
}
RuntimePreparedTeamAction::Resume { role, session_id } => {
tracing::info!(
"[AsterAgent][RuntimeTeam] session={} 恢复已关闭角色 lane: role_id={}, label={}, child_session={}",
request.session_id,
role.id,
role.label,
session_id
);
agent_runtime_resume_subagent_internal(
&runtime,
AgentRuntimeResumeSubagentRequest { id: session_id.clone() },
)
.await
.map_err(|error| {
format!(
"恢复已关闭 team 角色失败: role_id={}, label={}, child_session={}, error={error}",
role.id, role.label, session_id
)
})?;
}
event_name: &str,
timeline_recorder: &Arc<Mutex<AgentTimelineRecorder>>,
workspace_root: &str,
event: RuntimeAgentEvent,
) {
{
let mut recorder = match timeline_recorder.lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
};
if let Err(error) = recorder.record_runtime_event(app, event_name, &event, workspace_root) {
tracing::warn!(
"[AsterAgent] 记录 Artifact 运行时事件失败(已降级继续): {}",
error
);
}
}
Ok(())
if let Err(error) = app.emit(event_name, &event) {
tracing::warn!("[AsterAgent] 发送 Artifact 运行时事件失败: {}", error);
}
}
fn summarize_artifact_document_issues(issues: &[String]) -> String {
let parts = issues
.iter()
.map(|issue| issue.trim())
.filter(|issue| !issue.is_empty())
.take(3)
.collect::<Vec<_>>();
if parts.is_empty() {
"结构已按最小可用方案落盘。".to_string()
} else {
parts.join(";")
}
}
fn merge_turn_context_with_artifact_output_schema(
turn_context: Option<TurnContextOverride>,
request_metadata: Option<&serde_json::Value>,
) -> Option<TurnContextOverride> {
crate::services::artifact_output_schema_service::merge_turn_context_with_artifact_output_schema(
turn_context,
request_metadata,
)
}
fn should_skip_artifact_document_autopersist(
run_observation: &Arc<Mutex<ChatRunObservation>>,
final_text_output: &str,
) -> bool {
if final_text_output.trim().is_empty() {
return true;
}
let observation = match run_observation.lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
};
// 只允许根据运行期 artifact observation 决定是否跳过 autopersist,
// 不再从最终文本中的 `<write_file>` 片段反推 artifact 状态。
!observation.artifact_paths.is_empty()
}
fn maybe_persist_artifact_document_after_stream(
app: &AppHandle,
event_name: &str,
timeline_recorder: &Arc<Mutex<AgentTimelineRecorder>>,
run_observation: &Arc<Mutex<ChatRunObservation>>,
workspace_root: &str,
workspace_id: &str,
thread_id: &str,
turn_id: &str,
request_metadata: Option<&serde_json::Value>,
final_text_output: &str,
) {
if !crate::services::artifact_document_service::should_attempt_artifact_document_autopersist(
request_metadata,
) {
return;
}
if should_skip_artifact_document_autopersist(run_observation, final_text_output) {
return;
}
let persist_params =
crate::services::artifact_document_service::ArtifactDocumentPersistParams {
workspace_root: PathBuf::from(workspace_root),
workspace_id: Some(workspace_id.to_string()),
thread_id: thread_id.to_string(),
turn_id: turn_id.to_string(),
request_metadata: request_metadata.cloned(),
};
match crate::services::artifact_document_service::persist_artifact_document_from_text(
final_text_output,
&persist_params,
) {
Ok(persisted) => {
{
let mut observation = match run_observation.lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
};
observation.record_artifact_path(persisted.relative_path.clone(), request_metadata);
}
emit_runtime_side_event(
app,
event_name,
timeline_recorder,
workspace_root,
RuntimeAgentEvent::ArtifactSnapshot {
artifact: lime_agent::AgentArtifactSignal {
artifact_id: persisted.artifact_id.clone(),
file_path: persisted.relative_path.clone(),
content: Some(persisted.serialized_document.clone()),
metadata: Some(
persisted
.snapshot_metadata
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect(),
),
},
},
);
if persisted.repaired || persisted.status == "failed" {
let (code, prefix) = if persisted.status == "failed" {
(
ARTIFACT_DOCUMENT_FAILED_WARNING_CODE,
"ArtifactDocument 未通过完整校验,已以失败态文档落盘",
)
} else {
(
ARTIFACT_DOCUMENT_REPAIRED_WARNING_CODE,
"ArtifactDocument 已自动修复后落盘",
)
};
let detail = summarize_artifact_document_issues(&persisted.issues);
emit_runtime_side_event(
app,
event_name,
timeline_recorder,
workspace_root,
RuntimeAgentEvent::Warning {
code: Some(code.to_string()),
message: format!("{prefix}: {detail}"),
},
);
}
}
Err(error) => {
emit_runtime_side_event(
app,
event_name,
timeline_recorder,
workspace_root,
RuntimeAgentEvent::Warning {
code: Some(ARTIFACT_DOCUMENT_PERSIST_FAILED_WARNING_CODE.to_string()),
message: format!("ArtifactDocument 自动落盘失败,已保留消息区结果:{error}"),
},
);
}
}
}
async fn execute_aster_chat_request(
@@ -361,7 +186,7 @@ async fn execute_aster_chat_request(
config_manager: &GlobalConfigManagerState,
mcp_manager: &McpManagerState,
automation_state: &AutomationServiceState,
request: AsterChatRequest,
mut request: AsterChatRequest,
) -> Result<(), String> {
tracing::info!(
"[AsterAgent] 发送流式消息: session={}, event={}",
@@ -388,6 +213,12 @@ async fn execute_aster_chat_request(
}
ensure_tool_search_tool_registered(state).await?;
if let Some(resolved_provider_config) =
resolve_runtime_request_provider_config(app, db, api_key_provider_service, &request).await?
{
request.provider_config = Some(resolved_provider_config);
}
// 直接使用前端传递的 session_id
// LimeSessionStore 会在 add_message 时自动创建不存在的 session
// 同时 get_session 也会自动创建不存在的 session
@@ -463,7 +294,7 @@ async fn execute_aster_chat_request(
logs.write()
.await
.add("warn", &format!("[AsterAgent] {}", warning_message));
let warning_event = TauriAgentEvent::Warning {
let warning_event = RuntimeAgentEvent::Warning {
code: Some(WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE.to_string()),
message: warning_message,
};
@@ -645,10 +476,19 @@ async fn execute_aster_chat_request(
prompt_with_request_policy.clone(),
);
let prompt_with_elicitation = merge_system_prompt_with_elicitation_context(
let prompt_with_artifact = merge_system_prompt_with_artifact_context(
prompt_with_request_policy,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::Artifact,
prompt_with_artifact.clone(),
);
let prompt_with_elicitation = merge_system_prompt_with_elicitation_context(
prompt_with_artifact,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::Elicitation,
prompt_with_elicitation.clone(),
@@ -844,7 +684,7 @@ async fn execute_aster_chat_request(
warning_message
);
if notify_user {
let warning_event = TauriAgentEvent::Warning {
let warning_event = RuntimeAgentEvent::Warning {
code: Some(WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE.to_string()),
message: warning_message,
};
@@ -858,6 +698,8 @@ async fn execute_aster_chat_request(
let tracker = ExecutionTracker::new(db.clone());
let cancel_token = state.create_cancel_token(session_id).await;
let auto_continue_metadata = auto_continue_config.clone();
request.metadata = crate::services::artifact_request_metadata_service::
normalize_request_metadata_with_artifact_defaults(request.metadata.take());
let request_metadata = request.metadata.clone();
sync_browser_assist_runtime_hint(session_id, request_metadata.as_ref()).await;
let model_skill_tool_enabled = should_enable_model_skill_tool(request_metadata.as_ref());
@@ -950,51 +792,30 @@ async fn execute_aster_chat_request(
let mut session_config_builder = SessionConfigBuilder::new(session_id)
.thread_id(turn_state.thread_id.clone())
.turn_id(turn_state.turn_id.clone());
if let Some(turn_context) = turn_input_envelope.turn_context_override() {
if let Some(turn_context) = merge_turn_context_with_artifact_output_schema(
turn_input_envelope.turn_context_override(),
request_metadata.as_ref(),
) {
session_config_builder = session_config_builder.turn_context(turn_context);
}
session_config_builder.build()
};
let runtime_turn_initialized = {
{
let guard = agent_arc.read().await;
let agent = guard.as_ref().ok_or("Agent not initialized")?;
match agent
if let Err(error) = agent
.ensure_runtime_turn_initialized(
&runtime_status_session_config,
Some(request.message.clone()),
)
.await
{
Ok(_) => true,
Err(error) => {
tracing::warn!(
"[AsterAgent] 初始化 runtime turn 失败,后续降级继续: {}",
error
);
false
}
tracing::warn!(
"[AsterAgent] 初始化 runtime turn 失败,后续降级继续: {}",
error
);
}
};
if runtime_turn_initialized {
maybe_prepare_runtime_team_sessions(
app,
state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
&request,
)
.await?;
} else {
tracing::warn!(
"[AsterAgent][RuntimeTeam] 跳过当前 task 的 team 预拉起,因为父会话 runtime turn 尚未就绪: session={}",
session_id
);
}
// 获取 Agent Arc 并保持 guard 在整个流处理期间存活
@@ -1025,6 +846,7 @@ async fn execute_aster_chat_request(
let resolved_thread_id_for_session = turn_state.thread_id.clone();
let resolved_turn_id_for_session = turn_state.turn_id.clone();
let turn_input_envelope_for_session = turn_input_envelope.clone();
let request_metadata_for_session = request_metadata.clone();
let build_session_config = || {
let mut session_config_builder = SessionConfigBuilder::new(session_id)
@@ -1033,7 +855,10 @@ async fn execute_aster_chat_request(
if let Some(prompt) = turn_input_envelope_for_session.system_prompt() {
session_config_builder = session_config_builder.system_prompt(prompt.to_string());
}
if let Some(turn_context) = turn_input_envelope_for_session.turn_context_override() {
if let Some(turn_context) = merge_turn_context_with_artifact_output_schema(
turn_input_envelope_for_session.turn_context_override(),
request_metadata_for_session.as_ref(),
) {
session_config_builder = session_config_builder.turn_context(turn_context);
}
session_config_builder = session_config_builder
@@ -1108,7 +933,21 @@ async fn execute_aster_chat_request(
.await;
let run_result: Result<(), String> = match primary_result {
Ok(()) => Ok(()),
Ok(execution) => {
maybe_persist_artifact_document_after_stream(
&app,
&request.event_name,
&timeline_recorder,
&run_observation,
workspace_root.as_str(),
workspace_id.as_str(),
turn_state.thread_id.as_str(),
turn_state.turn_id.as_str(),
request_metadata.as_ref(),
execution.text_output.as_str(),
);
Ok(())
}
Err(primary_error)
if effective_strategy == AsterExecutionStrategy::CodeOrchestrated
&& should_fallback_to_react_from_code_orchestrated(&primary_error) =>
@@ -1184,6 +1023,20 @@ async fn execute_aster_chat_request(
},
)
.await
.map(|execution| {
maybe_persist_artifact_document_after_stream(
&app,
&request.event_name,
&timeline_recorder,
&run_observation,
workspace_root.as_str(),
workspace_id.as_str(),
turn_state.thread_id.as_str(),
turn_state.turn_id.as_str(),
request_metadata.as_ref(),
execution.text_output.as_str(),
);
})
.map_err(|fallback_err| fallback_err.message)
}
Err(primary_error) => Err(primary_error.message),
@@ -1252,7 +1105,7 @@ async fn execute_aster_chat_request(
tracing::warn!("[AsterAgent] 完成 turn 时间线失败(已降级继续): {}", error);
}
}
let done_event = TauriAgentEvent::FinalDone { usage: None };
let done_event = RuntimeAgentEvent::FinalDone { usage: None };
if let Err(e) = app.emit(&request.event_name, &done_event) {
tracing::error!("[AsterAgent] 发送完成事件失败: {}", e);
}
@@ -1280,7 +1133,7 @@ async fn execute_aster_chat_request(
);
}
}
let error_event = TauriAgentEvent::Error { message: e.clone() };
let error_event = RuntimeAgentEvent::Error { message: e.clone() };
if let Err(emit_err) = app.emit(&request.event_name, &error_event) {
tracing::error!("[AsterAgent] 发送错误事件失败: {}", emit_err);
}
@@ -1406,9 +1259,7 @@ pub(crate) async fn compact_runtime_session_internal(
.ensure_runtime_turn_initialized(&session_config, Some("压缩上下文".to_string()))
.await
.map_err(|error| format!("初始化压缩 turn 失败: {error}"))?;
for event in
lime_agent::event_converter::convert_agent_event(AgentEvent::TurnStarted { turn })
{
for event in lime_agent::project_runtime_event(AgentEvent::TurnStarted { turn }) {
{
let mut recorder = match timeline_recorder.lock() {
Ok(guard) => guard,
@@ -1431,7 +1282,7 @@ pub(crate) async fn compact_runtime_session_internal(
.clone()
.unwrap_or_else(|| session_id.clone());
let compaction_item_id = format!("context_compaction:{compaction_turn_id}");
let start_event = TauriAgentEvent::ContextCompactionStarted {
let start_event = RuntimeAgentEvent::ContextCompactionStarted {
item_id: compaction_item_id.clone(),
trigger: "manual".to_string(),
detail: Some("系统正在将较早消息整理为摘要,以释放上下文窗口。".to_string()),
@@ -1468,7 +1319,7 @@ pub(crate) async fn compact_runtime_session_internal(
.await?;
update_compaction_session_metrics(&session_config, &usage).await?;
let completed_event = TauriAgentEvent::ContextCompactionCompleted {
let completed_event = RuntimeAgentEvent::ContextCompactionCompleted {
item_id: compaction_item_id,
trigger: "manual".to_string(),
detail: Some("较早消息已替换为摘要,后续回复会基于压缩后的上下文继续。".to_string()),
@@ -1506,7 +1357,7 @@ pub(crate) async fn compact_runtime_session_internal(
error
);
}
let done_event = TauriAgentEvent::FinalDone { usage: None };
let done_event = RuntimeAgentEvent::FinalDone { usage: None };
if let Err(error) = app.emit(&event_name, &done_event) {
tracing::error!("[AsterAgent] 发送压缩完成事件失败: {}", error);
}
@@ -1523,7 +1374,7 @@ pub(crate) async fn compact_runtime_session_internal(
timeline_error
);
}
let error_event = TauriAgentEvent::Error {
let error_event = RuntimeAgentEvent::Error {
message: error.clone(),
};
if let Err(emit_error) = app.emit(&event_name, &error_event) {
@@ -1635,7 +1486,7 @@ fn build_provider_runtime_status_metadata(
fn build_provider_waiting_runtime_status(
snapshot: &ProviderRuntimeGovernorSnapshot,
is_team_member: bool,
) -> TauriRuntimeStatus {
) -> AgentRuntimeStatus {
let target_label = if is_team_member {
"这位协作成员"
} else {
@@ -1658,7 +1509,7 @@ fn build_provider_waiting_runtime_status(
));
}
TauriRuntimeStatus {
AgentRuntimeStatus {
phase: "routing".to_string(),
title: "当前服务较忙,稍后开始处理".to_string(),
detail: snapshot
@@ -1673,14 +1524,14 @@ fn build_provider_waiting_runtime_status(
fn build_provider_running_runtime_status(
snapshot: &ProviderRuntimeGovernorSnapshot,
is_team_member: bool,
) -> TauriRuntimeStatus {
) -> AgentRuntimeStatus {
let detail = if is_team_member {
"已轮到这位协作成员,系统会按更稳妥的节奏继续处理。".to_string()
} else {
"已轮到这条请求,系统会按更稳妥的节奏开始处理。".to_string()
};
TauriRuntimeStatus {
AgentRuntimeStatus {
phase: "routing".to_string(),
title: if is_team_member {
"协作成员开始处理".to_string()
@@ -1737,7 +1588,7 @@ fn build_team_runtime_status_metadata(
metadata
}
fn build_team_waiting_runtime_status(snapshot: &TeamRuntimeGovernorSnapshot) -> TauriRuntimeStatus {
fn build_team_waiting_runtime_status(snapshot: &TeamRuntimeGovernorSnapshot) -> AgentRuntimeStatus {
let mut checkpoints = vec![format!(
"当前已有 {}/{} 位协作成员在处理",
snapshot.team_active_count, snapshot.team_parallel_budget
@@ -1752,7 +1603,7 @@ fn build_team_waiting_runtime_status(snapshot: &TeamRuntimeGovernorSnapshot) ->
checkpoints.push("当前服务较忙,已切换为更稳妥的顺序处理".to_string());
}
TauriRuntimeStatus {
AgentRuntimeStatus {
phase: "routing".to_string(),
title: "等待执行窗口".to_string(),
detail: snapshot
@@ -1764,7 +1615,7 @@ fn build_team_waiting_runtime_status(snapshot: &TeamRuntimeGovernorSnapshot) ->
}
}
fn build_team_running_runtime_status(snapshot: &TeamRuntimeGovernorSnapshot) -> TauriRuntimeStatus {
fn build_team_running_runtime_status(snapshot: &TeamRuntimeGovernorSnapshot) -> AgentRuntimeStatus {
let mut checkpoints = vec![format!(
"当前并发预算 {}/{}",
snapshot.team_active_count, snapshot.team_parallel_budget
@@ -1773,7 +1624,7 @@ fn build_team_running_runtime_status(snapshot: &TeamRuntimeGovernorSnapshot) ->
checkpoints.push("当前服务使用稳妥处理模式".to_string());
}
TauriRuntimeStatus {
AgentRuntimeStatus {
phase: "routing".to_string(),
title: "开始处理".to_string(),
detail: "已获得可用执行窗口,这位协作成员正在接手当前任务。".to_string(),
@@ -1782,11 +1633,11 @@ fn build_team_running_runtime_status(snapshot: &TeamRuntimeGovernorSnapshot) ->
}
}
fn emit_transient_runtime_status(app: &AppHandle, event_name: &str, status: TauriRuntimeStatus) {
fn emit_transient_runtime_status(app: &AppHandle, event_name: &str, status: AgentRuntimeStatus) {
if event_name.trim().is_empty() {
return;
}
let event = TauriAgentEvent::RuntimeStatus { status };
let event = RuntimeAgentEvent::RuntimeStatus { status };
if let Err(error) = app.emit(event_name, &event) {
tracing::warn!(
"[AsterAgent] 发送 team runtime 状态失败: event_name={}, error={}",
@@ -2143,4 +1994,38 @@ mod tests {
.await
.expect("清理测试会话失败");
}
#[test]
fn should_skip_artifact_document_autopersist_when_output_is_empty() {
let observation = Arc::new(Mutex::new(ChatRunObservation::default()));
assert!(should_skip_artifact_document_autopersist(
&observation,
" \n "
));
}
#[test]
fn should_skip_artifact_document_autopersist_when_runtime_observation_has_artifacts() {
let observation = Arc::new(Mutex::new(ChatRunObservation::default()));
observation
.lock()
.expect("lock observation")
.record_artifact_path("social-posts/demo.md".to_string(), None);
assert!(should_skip_artifact_document_autopersist(
&observation,
"普通正文输出"
));
}
#[test]
fn should_not_skip_artifact_document_autopersist_based_on_write_file_text_only() {
let observation = Arc::new(Mutex::new(ChatRunObservation::default()));
assert!(!should_skip_artifact_document_autopersist(
&observation,
"<write_file path=\"social-posts/demo.md\">内容</write_file>"
));
}
}
@@ -1,5 +1,6 @@
use super::*;
use lime_agent::restore_aster_runtime_queued_turns;
use lime_agent::AgentEvent as RuntimeAgentEvent;
const SUBAGENT_RUNTIME_EVENT_PREFIX: &str = "agent_subagent_stream";
const SUBAGENT_STATUS_EVENT_PREFIX: &str = "agent_subagent_status";
@@ -325,17 +326,17 @@ fn parse_subagent_runtime_event_session_id(event_name: &str) -> Option<&str> {
.and_then(|rest| rest.strip_prefix(':'))
}
fn should_emit_subagent_status_for_runtime_event(event: &TauriAgentEvent) -> bool {
fn should_emit_subagent_status_for_runtime_event(event: &RuntimeAgentEvent) -> bool {
matches!(
event,
TauriAgentEvent::ThreadStarted { .. }
| TauriAgentEvent::TurnStarted { .. }
| TauriAgentEvent::TurnCompleted { .. }
| TauriAgentEvent::TurnFailed { .. }
| TauriAgentEvent::QueueAdded { .. }
| TauriAgentEvent::QueueRemoved { .. }
| TauriAgentEvent::QueueStarted { .. }
| TauriAgentEvent::QueueCleared { .. }
RuntimeAgentEvent::ThreadStarted { .. }
| RuntimeAgentEvent::TurnStarted { .. }
| RuntimeAgentEvent::TurnCompleted { .. }
| RuntimeAgentEvent::TurnFailed { .. }
| RuntimeAgentEvent::QueueAdded { .. }
| RuntimeAgentEvent::QueueRemoved { .. }
| RuntimeAgentEvent::QueueStarted { .. }
| RuntimeAgentEvent::QueueCleared { .. }
)
}
@@ -379,7 +380,7 @@ pub(crate) async fn emit_subagent_status_changed_events(app: &AppHandle, session
pub(crate) async fn maybe_emit_subagent_status_for_runtime_event(
app: &AppHandle,
event_name: &str,
event: &TauriAgentEvent,
event: &RuntimeAgentEvent,
) {
let Some(session_id) = parse_subagent_runtime_event_session_id(event_name) else {
return;
@@ -678,6 +679,9 @@ pub(crate) async fn agent_runtime_spawn_subagent_internal(
event_name: build_subagent_runtime_event_name(&child_session_id),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
project_id: None,
workspace_id,
web_search: None,
@@ -751,6 +755,9 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal(
event_name: build_subagent_runtime_event_name(&session_id),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
project_id: None,
workspace_id,
web_search: None,
+262 -171
View File
@@ -1,9 +1,10 @@
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use crate::commands::aster_agent_cmd::action_runtime::build_runtime_action_scope;
use crate::commands::aster_agent_cmd::dto::AgentRuntimeActionScope;
use async_trait::async_trait;
use lime_agent::AgentEvent as RuntimeAgentEvent;
use lime_agent::request_tool_policy::resolve_request_tool_policy;
use regex::Regex;
use std::ffi::OsString;
@@ -530,6 +531,9 @@ mod tests {
"turn_config": {
"execution_strategy": "auto",
"web_search": true,
"provider_preference": "custom-provider",
"model_preference": "gpt-5.3-codex",
"thinking_enabled": true,
"system_prompt": "runtime prompt",
"provider_config": {
"provider_id": "custom-provider",
@@ -554,6 +558,12 @@ mod tests {
Some(AsterExecutionStrategy::Auto)
);
assert_eq!(mapped.web_search, Some(true));
assert_eq!(
mapped.provider_preference.as_deref(),
Some("custom-provider")
);
assert_eq!(mapped.model_preference.as_deref(), Some("gpt-5.3-codex"));
assert_eq!(mapped.thinking_enabled, Some(true));
assert_eq!(mapped.system_prompt.as_deref(), Some("runtime prompt"));
assert_eq!(
mapped
@@ -750,6 +760,17 @@ mod tests {
assert_eq!(path.as_deref(), Some("social-posts/demo.md"));
}
#[test]
fn test_extract_artifact_path_from_tool_start_reads_nested_artifact_protocol_path() {
let path = extract_artifact_path_from_tool_start(
"write_file",
Some(r##"{"payload":{"artifact_paths":["social-posts\\nested.md"]}}"##),
"/tmp/workspace",
);
assert_eq!(path.as_deref(), Some("social-posts/nested.md"));
}
#[test]
fn test_resolve_social_run_artifact_descriptor_matches_social_draft() {
let descriptor = resolve_social_run_artifact_descriptor(
@@ -773,6 +794,9 @@ mod tests {
event_name: "event-1".to_string(),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
project_id: Some("project-1".to_string()),
workspace_id: "workspace-1".to_string(),
web_search: Some(false),
@@ -839,15 +863,228 @@ mod tests {
);
}
#[test]
fn test_extract_harness_bool_reads_nested_preferences() {
let metadata = serde_json::json!({
"harness": {
"preferences": {
"web_search": true,
"thinking": true,
"task": false,
"subagent": true
}
}
});
assert_eq!(
extract_harness_bool(Some(&metadata), &["web_search_enabled", "webSearchEnabled"]),
Some(true)
);
assert_eq!(
extract_harness_bool(Some(&metadata), &["thinking_enabled", "thinkingEnabled"]),
Some(true)
);
assert_eq!(
extract_harness_bool(Some(&metadata), &["task_mode_enabled", "taskModeEnabled"]),
Some(false)
);
assert_eq!(
extract_harness_bool(
Some(&metadata),
&["subagent_mode_enabled", "subagentModeEnabled"]
),
Some(true)
);
}
#[test]
fn test_build_chat_run_metadata_base_flattens_nested_preferences() {
let metadata = build_chat_run_metadata_base(
&AsterChatRequest {
message: "hello".to_string(),
session_id: "session-1".to_string(),
event_name: "event-1".to_string(),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
project_id: Some("project-1".to_string()),
workspace_id: "workspace-1".to_string(),
web_search: Some(false),
search_mode: None,
execution_strategy: Some(AsterExecutionStrategy::React),
auto_continue: None,
system_prompt: None,
metadata: Some(serde_json::json!({
"harness": {
"theme": "general",
"preferences": {
"thinking": true,
"task": false,
"subagent": true
}
}
})),
turn_id: None,
queue_if_busy: None,
queued_turn_id: None,
},
"workspace-1",
AsterExecutionStrategy::React,
&RequestToolPolicy {
search_mode: RequestToolPolicyMode::Disabled,
effective_web_search: false,
required_tools: vec![],
allowed_tools: vec![],
disallowed_tools: vec![],
},
false,
None,
);
assert_eq!(
metadata
.get("thinking_enabled")
.and_then(serde_json::Value::as_bool),
Some(true)
);
assert_eq!(
metadata
.get("task_mode_enabled")
.and_then(serde_json::Value::as_bool),
Some(false)
);
assert_eq!(
metadata
.get("subagent_mode_enabled")
.and_then(serde_json::Value::as_bool),
Some(true)
);
}
#[test]
fn test_chat_run_observation_records_nested_artifact_protocol_paths_from_tool_result() {
let mut observation = ChatRunObservation::default();
observation.record_event(
&RuntimeAgentEvent::ToolEnd {
tool_id: "tool-1".to_string(),
result: lime_agent::AgentToolResult {
success: true,
output: "done".to_string(),
error: None,
images: None,
metadata: Some(HashMap::from([(
"payload".to_string(),
serde_json::json!({
"artifact_paths": [" /tmp/workspace/social-posts\\final.md "]
}),
)])),
},
},
"/tmp/workspace",
Some(&serde_json::json!({
"harness": {
"theme": "social-media",
"gate_key": "write_mode"
}
})),
ProviderContinuationCapability::HistoryReplayOnly,
);
assert_eq!(
observation.artifact_paths,
vec!["social-posts/final.md".to_string()]
);
assert_eq!(
observation
.primary_social_artifact
.as_ref()
.map(|artifact| artifact.source_file_name.as_str()),
Some("social-posts/final.md")
);
}
#[test]
fn test_chat_run_observation_ignores_output_file_log_hint_without_explicit_artifact_path() {
let mut observation = ChatRunObservation::default();
observation.record_event(
&RuntimeAgentEvent::ToolEnd {
tool_id: "tool-1".to_string(),
result: lime_agent::AgentToolResult {
success: true,
output: "done".to_string(),
error: None,
images: None,
metadata: Some(HashMap::from([(
"output_file".to_string(),
serde_json::json!("/tmp/workspace/tasks/task.log"),
)])),
},
},
"/tmp/workspace",
Some(&serde_json::json!({
"harness": {
"theme": "social-media",
"gate_key": "write_mode"
}
})),
ProviderContinuationCapability::HistoryReplayOnly,
);
assert!(observation.artifact_paths.is_empty());
assert!(observation.primary_social_artifact.is_none());
}
#[test]
fn test_chat_run_observation_falls_back_to_probable_output_file_artifact_hint() {
let mut observation = ChatRunObservation::default();
observation.record_event(
&RuntimeAgentEvent::ToolEnd {
tool_id: "tool-1".to_string(),
result: lime_agent::AgentToolResult {
success: true,
output: "done".to_string(),
error: None,
images: None,
metadata: Some(HashMap::from([(
"output_file".to_string(),
serde_json::json!("/tmp/workspace/social-posts/final.md"),
)])),
},
},
"/tmp/workspace",
Some(&serde_json::json!({
"harness": {
"theme": "social-media",
"gate_key": "write_mode"
}
})),
ProviderContinuationCapability::HistoryReplayOnly,
);
assert_eq!(
observation.artifact_paths,
vec!["social-posts/final.md".to_string()]
);
assert_eq!(
observation
.primary_social_artifact
.as_ref()
.map(|artifact| artifact.source_file_name.as_str()),
Some("social-posts/final.md")
);
}
#[test]
fn test_chat_run_observation_records_previous_response_id_from_message_event() {
let mut observation = ChatRunObservation::default();
observation.record_event(
&TauriAgentEvent::Message {
message: TauriMessage {
&RuntimeAgentEvent::Message {
message: lime_agent::AgentMessage {
id: Some("resp-1".to_string()),
role: "assistant".to_string(),
content: vec![TauriMessageContent::Text {
content: vec![lime_agent::AgentMessageContent::Text {
text: "hello".to_string(),
}],
timestamp: 0,
@@ -868,11 +1105,11 @@ mod tests {
fn test_chat_run_observation_records_provider_session_token_from_message_event() {
let mut observation = ChatRunObservation::default();
observation.record_event(
&TauriAgentEvent::Message {
message: TauriMessage {
&RuntimeAgentEvent::Message {
message: lime_agent::AgentMessage {
id: Some("conv-1".to_string()),
role: "assistant".to_string(),
content: vec![TauriMessageContent::Text {
content: vec![lime_agent::AgentMessageContent::Text {
text: "hello".to_string(),
}],
timestamp: 0,
@@ -1308,58 +1545,30 @@ mod tests {
}
#[test]
fn test_build_team_preference_system_prompt_renders_turn_team_contract() {
fn test_build_team_preference_system_prompt_emphasizes_parent_coordination() {
let prompt = build_team_preference_system_prompt(Some(&serde_json::json!({
"harness": {
"subagent_mode_enabled": true,
"turn_team_decision": "team_prepared",
"turn_team_reason": "runtime_team_prepared",
"turn_team_blueprint": {
"label": "当前调试 Team",
"description": "先分析,再实现,最后验证。",
"roles": [
{
"id": "runtime-explorer",
"label": "分析",
"summary": "负责定位问题。",
"profile_id": "code-explorer",
"role_key": "explorer",
"skill_ids": ["repo-exploration"]
},
{
"label": "执行",
"summary": "负责提交修复。"
}
]
}
"selected_team_label": "当前调试 Team",
"selected_team_roles": [
{
"id": "runtime-explorer",
"label": "分析",
"summary": "负责定位问题。",
"profile_id": "code-explorer",
"role_key": "explorer",
"skill_ids": ["repo-exploration"]
}
]
}
})))
.expect("team prompt should exist");
assert!(prompt.contains("发送前已经准备好协作分工"));
assert!(prompt.contains("当前调试 Team"));
assert!(prompt.contains("先分析,再实现,最后验证。"));
assert!(prompt.contains("分析:负责定位问题。"));
assert!(prompt.contains("id: runtime-explorer"));
assert!(prompt.contains("blueprintRoleId"));
assert!(prompt.contains("主对话要像项目助理一样汇总目标"));
assert!(prompt.contains("不要等主 agent 完整处理结束后再补做 team"));
}
#[test]
fn test_build_team_preference_system_prompt_renders_single_agent_turn_decision() {
let prompt = build_team_preference_system_prompt(Some(&serde_json::json!({
"harness": {
"subagent_mode_enabled": true,
"turn_team_decision": "single_agent",
"turn_team_reason": "single_agent_direct",
}
})))
.expect("team prompt should exist");
assert!(prompt.contains("当前任务没有在 GUI 中提前准备 Team"));
assert!(prompt.contains("主助手直接处理更合适"));
assert!(prompt.contains("不要为了形式化 team 而推迟主任务"));
assert!(prompt.contains("主对话需要承担协调职责"));
assert!(prompt.contains("主动汇总关键进展、风险和下一步"));
}
#[test]
@@ -1442,126 +1651,6 @@ mod tests {
assert!(prompt.contains("输出问题定位、证据与影响面。"));
}
#[test]
fn test_parse_runtime_prepared_team_roles_from_metadata() {
let roles = parse_runtime_prepared_team_roles(Some(&serde_json::json!({
"harness": {
"turn_team_decision": "team_prepared",
"turn_team_blueprint": {
"roles": [
{
"id": "runtime-explorer",
"label": "分析",
"summary": "负责定位问题。",
"profile_id": "code-explorer",
"role_key": "explorer",
"skill_ids": ["repo-exploration", "repo-exploration", " "]
},
{
"label": "执行 角色",
"summary": "负责提交修复。"
},
{
"id": "runtime-explorer",
"label": "重复分析"
}
]
}
}
})));
assert_eq!(roles.len(), 2);
assert_eq!(roles[0].id, "runtime-explorer");
assert_eq!(roles[0].label, "分析");
assert_eq!(roles[0].profile_id.as_deref(), Some("code-explorer"));
assert_eq!(roles[0].role_key.as_deref(), Some("explorer"));
assert_eq!(roles[0].skill_ids, vec!["repo-exploration".to_string()]);
assert_eq!(roles[1].id, "lane-执行-角色");
assert_eq!(roles[1].label, "执行 角色");
assert_eq!(roles[1].summary.as_deref(), Some("负责提交修复。"));
}
#[test]
fn test_plan_runtime_prepared_team_actions_skips_existing_and_resumes_closed_lane() {
let roles = vec![
RuntimePreparedTeamRole {
id: "runtime-explorer".to_string(),
label: "分析".to_string(),
summary: Some("负责定位问题。".to_string()),
profile_id: Some("code-explorer".to_string()),
role_key: Some("explorer".to_string()),
skill_ids: vec!["repo-exploration".to_string()],
},
RuntimePreparedTeamRole {
id: "runtime-executor".to_string(),
label: "执行".to_string(),
summary: Some("负责提交修复。".to_string()),
profile_id: Some("code-executor".to_string()),
role_key: Some("executor".to_string()),
skill_ids: vec![],
},
RuntimePreparedTeamRole {
id: "runtime-verifier".to_string(),
label: "验证".to_string(),
summary: Some("负责回归验证。".to_string()),
profile_id: Some("code-verifier".to_string()),
role_key: Some("verifier".to_string()),
skill_ids: vec![],
},
];
let existing_candidates = vec![
RuntimePreparedTeamSessionCandidate {
blueprint_role_id: "runtime-explorer".to_string(),
session_id: "child-explorer".to_string(),
status_kind: SubagentRuntimeStatusKind::Running,
},
RuntimePreparedTeamSessionCandidate {
blueprint_role_id: "runtime-executor".to_string(),
session_id: "child-executor-old".to_string(),
status_kind: SubagentRuntimeStatusKind::Closed,
},
RuntimePreparedTeamSessionCandidate {
blueprint_role_id: "runtime-executor".to_string(),
session_id: "child-executor-stale".to_string(),
status_kind: SubagentRuntimeStatusKind::NotFound,
},
];
let actions = plan_runtime_prepared_team_actions(&roles, &existing_candidates);
assert_eq!(actions.len(), 2);
assert!(matches!(
&actions[0],
RuntimePreparedTeamAction::Resume { role, session_id }
if role.id == "runtime-executor" && session_id == "child-executor-old"
));
assert!(matches!(
&actions[1],
RuntimePreparedTeamAction::Spawn(role) if role.id == "runtime-verifier"
));
}
#[test]
fn test_build_runtime_prepared_team_spawn_message_emphasizes_lane_output() {
let message = build_runtime_prepared_team_spawn_message(
&RuntimePreparedTeamRole {
id: "runtime-explorer".to_string(),
label: "分析".to_string(),
summary: Some("负责定位问题。".to_string()),
profile_id: Some("code-explorer".to_string()),
role_key: Some("explorer".to_string()),
skill_ids: vec!["repo-exploration".to_string()],
},
"修正 team runtime 的执行顺序",
);
assert!(message.contains("当前子会话输出"));
assert!(message.contains("修正 team runtime 的执行顺序"));
assert!(message.contains("负责定位问题。"));
assert!(message.contains("不要把具体产出留给父会话代写"));
}
#[test]
fn test_normalize_shell_command_params_accepts_cmd_alias() {
let input = serde_json::json!({
@@ -1867,15 +1956,16 @@ mod tests {
model: None,
working_dir: None,
workspace_id: None,
messages: vec![TauriMessage {
messages: vec![lime_agent::AgentMessage {
id: None,
role: "assistant".to_string(),
content: vec![TauriMessageContent::Text {
content: vec![lime_agent::AgentMessageContent::Text {
text: "子代理最终结论".to_string(),
}],
timestamp: 0,
}],
execution_strategy: None,
execution_runtime: None,
turns: vec![],
items: vec![],
todo_items: vec![],
@@ -1902,6 +1992,7 @@ mod tests {
workspace_id: None,
messages: vec![],
execution_strategy: None,
execution_runtime: None,
turns: vec![lime_core::database::dao::agent_timeline::AgentThreadTurn {
id: "turn-1".to_string(),
thread_id: "thread-2".to_string(),
@@ -6,6 +6,8 @@ mod browser_tools;
mod creation_tools;
#[path = "tool_runtime/search_bridge.rs"]
mod search_bridge;
#[path = "tool_runtime/site_tools.rs"]
mod site_tools;
#[path = "tool_runtime/social_tools.rs"]
pub(crate) mod social_tools;
#[path = "tool_runtime/subagent_tools.rs"]
@@ -73,8 +75,10 @@ fn sync_workspace_mode_native_tool_surface(
) {
if surface.browser_assist {
browser_tools::register_browser_mcp_tools_to_registry(registry);
site_tools::register_site_tools_to_registry(registry, db.clone());
} else {
browser_tools::unregister_browser_mcp_tools_from_registry(registry);
site_tools::unregister_site_tools_from_registry(registry);
}
if surface.creator {
File diff suppressed because it is too large Load Diff
@@ -199,16 +199,16 @@ pub(crate) fn collect_subagent_task_compat_warnings(input: &SubAgentTaskToolInpu
warnings
}
fn extract_tauri_message_text(message: &TauriMessage) -> Option<String> {
fn extract_runtime_message_text(message: &AgentMessage) -> Option<String> {
let parts = message
.content
.iter()
.filter_map(|content| match content {
TauriMessageContent::Text { text } => {
AgentMessageContent::Text { text } => {
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
TauriMessageContent::ToolResponse {
AgentMessageContent::ToolResponse {
output, success, ..
} if *success => {
let trimmed = output.trim();
@@ -231,7 +231,7 @@ pub(crate) fn extract_runtime_subagent_result_text(detail: &SessionDetail) -> Op
.iter()
.rev()
.find(|message| message.role == "assistant")
.and_then(extract_tauri_message_text)
.and_then(extract_runtime_message_text)
.or_else(|| {
detail.items.iter().rev().find_map(|item| {
match &item.payload {
+22 -26
View File
@@ -6,6 +6,7 @@ use crate::database::dao::agent_run::{AgentRun, AgentRunDao, AgentRunStatus};
use crate::database::DbConnection;
use crate::services::execution_tracker_service::ExecutionTracker;
use chrono::Utc;
use lime_agent::artifact_protocol::extract_artifact_protocol_paths_from_value;
use serde::Serialize;
use serde_json::Value;
use tauri::State;
@@ -318,32 +319,7 @@ fn derive_run_artifact_paths(run: &AgentRun) -> Vec<String> {
parsed_metadata
.as_ref()
.map(|value| {
let mut paths = value
.get("artifact_paths")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|path| !path.is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
for key in ["artifact_path", "source_file_name"] {
if let Some(path) = metadata_string(value, &[key]) {
let normalized = path.to_string();
if !paths.iter().any(|existing| existing == &normalized) {
paths.push(normalized);
}
}
}
paths
})
.map(extract_artifact_protocol_paths_from_value)
.unwrap_or_default()
}
@@ -661,6 +637,26 @@ mod tests {
);
}
#[test]
fn derive_run_artifact_paths_should_recurse_nested_artifact_protocol_metadata() {
let run = sample_run_with_metadata(Some(serde_json::json!({
"payload": {
"artifact_paths": ["social-posts\\nested.md"]
},
"result": {
"absolute_path": " /tmp\\social-posts\\final.md "
}
})));
assert_eq!(
derive_run_artifact_paths(&run),
vec![
"social-posts/nested.md".to_string(),
"/tmp/social-posts/final.md".to_string(),
]
);
}
#[test]
fn derive_recent_terminal_items_should_keep_multiple_terminal_runs() {
let mut latest_error_run = sample_run_with_metadata(Some(serde_json::json!({
+1
View File
@@ -55,6 +55,7 @@ pub mod route_cmd;
pub mod screenshot_cmd;
pub mod security_perf_cmd;
pub mod session_files_cmd;
pub mod site_capability_cmd;
pub mod skill_cmd;
pub mod skill_error;
pub mod skill_exec_cmd;
+16
View File
@@ -13,6 +13,12 @@ use crate::screenshot::{capture, shortcut};
use tauri::{AppHandle, Emitter, Manager, State};
use tracing::{debug, error, info};
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScreenshotShortcutRuntimeStatus {
pub shortcut_registered: bool,
pub registered_shortcut: Option<String>,
}
/// 获取实验室功能配置
///
/// 从应用状态中获取当前的实验室功能配置
@@ -31,6 +37,16 @@ pub async fn get_experimental_config(
Ok(config.experimental.clone())
}
/// 获取截图快捷键运行时状态
#[tauri::command]
pub async fn get_screenshot_shortcut_runtime_status(
) -> Result<ScreenshotShortcutRuntimeStatus, String> {
Ok(ScreenshotShortcutRuntimeStatus {
shortcut_registered: shortcut::is_registered(),
registered_shortcut: shortcut::get_current(),
})
}
/// 保存实验室功能配置
///
/// 将实验室功能配置保存到应用状态和配置文件
@@ -0,0 +1,89 @@
use crate::database::DbConnection;
use crate::services::site_adapter_registry::{
apply_site_adapter_catalog_bootstrap, clear_site_adapter_catalog_cache,
get_site_adapter_catalog_status, SiteAdapterCatalogStatus,
};
use crate::services::site_capability_service::{
get_site_adapter, list_site_adapters, run_site_adapter, run_site_adapter_with_optional_save,
save_existing_site_result_to_project, search_site_adapters, RunSiteAdapterRequest,
SaveSiteAdapterResultRequest, SavedSiteAdapterContent, SiteAdapterDefinition,
SiteAdapterRunResult,
};
use serde::Deserialize;
use serde_json::Value;
use tauri::State;
#[derive(Debug, Deserialize)]
pub struct SiteAdapterNameRequest {
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct SiteAdapterSearchRequest {
pub query: String,
}
#[derive(Debug, Deserialize)]
pub struct SiteAdapterCatalogBootstrapRequest {
pub payload: Value,
}
#[tauri::command]
pub fn site_list_adapters() -> Result<Vec<SiteAdapterDefinition>, String> {
Ok(list_site_adapters())
}
#[tauri::command]
pub fn site_search_adapters(
request: SiteAdapterSearchRequest,
) -> Result<Vec<SiteAdapterDefinition>, String> {
Ok(search_site_adapters(&request.query))
}
#[tauri::command]
pub fn site_get_adapter_info(
request: SiteAdapterNameRequest,
) -> Result<SiteAdapterDefinition, String> {
get_site_adapter(&request.name).ok_or_else(|| "未找到对应的站点适配器".to_string())
}
#[tauri::command]
pub fn site_get_adapter_catalog_status() -> Result<SiteAdapterCatalogStatus, String> {
get_site_adapter_catalog_status()
}
#[tauri::command]
pub fn site_apply_adapter_catalog_bootstrap(
request: SiteAdapterCatalogBootstrapRequest,
) -> Result<SiteAdapterCatalogStatus, String> {
apply_site_adapter_catalog_bootstrap(&request.payload)
}
#[tauri::command]
pub fn site_clear_adapter_catalog_cache() -> Result<SiteAdapterCatalogStatus, String> {
clear_site_adapter_catalog_cache()
}
#[tauri::command]
pub async fn site_run_adapter(
db: State<'_, DbConnection>,
request: RunSiteAdapterRequest,
) -> Result<SiteAdapterRunResult, String> {
Ok(run_site_adapter_with_optional_save(db.inner(), request).await)
}
#[tauri::command]
pub async fn site_debug_run_adapter(
db: State<'_, DbConnection>,
request: RunSiteAdapterRequest,
) -> Result<SiteAdapterRunResult, String> {
Ok(run_site_adapter(db.inner(), request).await)
}
#[tauri::command]
pub fn site_save_adapter_result(
db: State<'_, DbConnection>,
request: SaveSiteAdapterResultRequest,
) -> Result<SavedSiteAdapterContent, String> {
save_existing_site_result_to_project(db.inner(), request)
}
+10
View File
@@ -2152,6 +2152,7 @@ async fn execute_extension_backend_action(
target: None,
text: None,
url: action_arg_string(&args, &["url"]),
payload: None,
wait_for_page_info: action_arg_bool(&args, "wait_for_page_info", true),
timeout_ms: Some(normalize_action_timeout(
action_arg_u64(&args, "timeout_ms").or(timeout_ms),
@@ -2165,6 +2166,7 @@ async fn execute_extension_backend_action(
target: None,
text: None,
url: None,
payload: None,
wait_for_page_info: action_arg_bool(&args, "wait_for_page_info", true),
timeout_ms: Some(normalize_action_timeout(timeout_ms)),
})
@@ -2176,6 +2178,7 @@ async fn execute_extension_backend_action(
target: None,
text: None,
url: None,
payload: None,
wait_for_page_info: action_arg_bool(&args, "wait_for_page_info", true),
timeout_ms: Some(normalize_action_timeout(timeout_ms)),
})
@@ -2187,6 +2190,7 @@ async fn execute_extension_backend_action(
target: None,
text: None,
url: None,
payload: None,
wait_for_page_info: action_arg_bool(&args, "wait_for_page_info", true),
timeout_ms: Some(normalize_action_timeout(timeout_ms)),
})
@@ -2201,6 +2205,7 @@ async fn execute_extension_backend_action(
target: None,
text: None,
url: None,
payload: None,
wait_for_page_info: true,
timeout_ms: Some(normalize_action_timeout(timeout_ms)),
})
@@ -2215,6 +2220,7 @@ async fn execute_extension_backend_action(
target: None,
text: None,
url: None,
payload: None,
wait_for_page_info: true,
timeout_ms: Some(normalize_action_timeout(timeout_ms)),
})
@@ -2244,6 +2250,7 @@ async fn execute_extension_backend_action(
target: action_arg_string(&args, &["ref_id", "target"]),
text: action_arg_string(&args, &["value", "text"]),
url: None,
payload: None,
wait_for_page_info: action_arg_bool(&args, "wait_for_page_info", false),
timeout_ms: Some(normalize_action_timeout(timeout_ms)),
})
@@ -2276,6 +2283,7 @@ async fn execute_extension_backend_action(
target: action_arg_string(&args, &["ref_id", "target"]),
text: text_payload,
url: action_arg_string(&args, &["url"]),
payload: None,
wait_for_page_info: action_arg_bool(
&args,
"wait_for_page_info",
@@ -2306,6 +2314,7 @@ async fn execute_extension_backend_action(
target: None,
text: None,
url: None,
payload: None,
wait_for_page_info: false,
timeout_ms: Some(normalize_action_timeout(timeout_ms)),
})
@@ -2319,6 +2328,7 @@ async fn execute_extension_backend_action(
target: action_arg_string(&args, &["target", "ref_id"]),
text: action_arg_string(&args, &["text", "value"]),
url: action_arg_string(&args, &["url"]),
payload: None,
wait_for_page_info: action_arg_bool(
&args,
"wait_for_page_info",
+29
View File
@@ -17,6 +17,7 @@ mod providers;
mod runtime_queries;
mod skills;
mod tray;
mod voice;
mod workspace;
use crate::dev_bridge::DevBridgeState;
@@ -97,6 +98,10 @@ pub async fn handle_command(
return Ok(result);
}
if let Some(result) = voice::try_handle(state, cmd, args.as_ref()).await? {
return Ok(result);
}
if let Some(result) = browser::try_handle(state, cmd, args.as_ref()).await? {
return Ok(result);
}
@@ -283,4 +288,28 @@ mod tests {
assert_eq!(list.len(), 1);
assert_eq!(list[0].id, created.id);
}
#[tokio::test]
async fn claw_solution_list_bridge_query_available() {
let state = make_test_state();
let list_value = handle_command(&state, "claw_solution_list", None)
.await
.unwrap();
let list = list_value.as_array().unwrap();
assert!(!list.is_empty());
assert!(list[0]["id"].is_string());
assert!(list[0]["readiness"].is_string());
}
#[tokio::test]
async fn voice_shortcut_status_bridge_query_available() {
let state = make_test_state();
let status_value = handle_command(&state, "get_voice_shortcut_runtime_status", None)
.await
.unwrap();
assert!(status_value["shortcut_registered"].is_boolean());
assert!(status_value["translate_shortcut_registered"].is_boolean());
}
}
@@ -7,6 +7,7 @@ mod bridge;
mod cdp;
mod runtime;
mod sessions;
mod site;
type DynError = Box<dyn std::error::Error>;
@@ -41,5 +42,9 @@ pub(super) async fn try_handle(
return Ok(Some(result));
}
if let Some(result) = site::try_handle(state, cmd, args).await? {
return Ok(Some(result));
}
sessions::try_handle(state, cmd, args).await
}
@@ -0,0 +1,64 @@
use super::super::get_db;
use super::{parse_request, DynError};
use crate::dev_bridge::DevBridgeState;
use crate::services::site_adapter_registry::{
apply_site_adapter_catalog_bootstrap, clear_site_adapter_catalog_cache,
get_site_adapter_catalog_status,
};
use crate::services::site_capability_service::{
get_site_adapter, list_site_adapters, run_site_adapter, run_site_adapter_with_optional_save,
save_existing_site_result_to_project, search_site_adapters,
};
use serde_json::Value as JsonValue;
pub(super) async fn try_handle(
state: &DevBridgeState,
cmd: &str,
args: Option<&JsonValue>,
) -> Result<Option<JsonValue>, DynError> {
let result = match cmd {
"site_list_adapters" => serde_json::to_value(list_site_adapters())?,
"site_search_adapters" => {
let request: crate::commands::site_capability_cmd::SiteAdapterSearchRequest =
parse_request(args)?;
serde_json::to_value(search_site_adapters(&request.query))?
}
"site_get_adapter_info" => {
let request: crate::commands::site_capability_cmd::SiteAdapterNameRequest =
parse_request(args)?;
let adapter = get_site_adapter(&request.name)
.ok_or_else(|| "未找到对应的站点适配器".to_string())?;
serde_json::to_value(adapter)?
}
"site_get_adapter_catalog_status" => {
serde_json::to_value(get_site_adapter_catalog_status()?)?
}
"site_apply_adapter_catalog_bootstrap" => {
let request: crate::commands::site_capability_cmd::SiteAdapterCatalogBootstrapRequest =
parse_request(args)?;
serde_json::to_value(apply_site_adapter_catalog_bootstrap(&request.payload)?)?
}
"site_clear_adapter_catalog_cache" => {
serde_json::to_value(clear_site_adapter_catalog_cache()?)?
}
"site_run_adapter" | "site_debug_run_adapter" => {
let request: crate::services::site_capability_service::RunSiteAdapterRequest =
parse_request(args)?;
let db = get_db(state)?.clone();
if cmd == "site_run_adapter" {
serde_json::to_value(run_site_adapter_with_optional_save(&db, request).await)?
} else {
serde_json::to_value(run_site_adapter(&db, request).await)?
}
}
"site_save_adapter_result" => {
let request: crate::services::site_capability_service::SaveSiteAdapterResultRequest =
parse_request(args)?;
let db = get_db(state)?.clone();
serde_json::to_value(save_existing_site_result_to_project(&db, request)?)?
}
_ => return Ok(None),
};
Ok(Some(result))
}
@@ -30,7 +30,7 @@ pub(super) async fn try_handle(
cmd: &str,
args: Option<&JsonValue>,
) -> Result<Option<JsonValue>, DynError> {
if let Some(result) = queries::try_handle(state, cmd).await? {
if let Some(result) = queries::try_handle(state, cmd, args).await? {
return Ok(Some(result));
}
@@ -1,12 +1,51 @@
use super::super::{args_or_default, get_db, get_string_arg, parse_optional_nested_arg};
use super::{openclaw_context, DynError};
use crate::dev_bridge::DevBridgeState;
use crate::services::claw_solution_service::{ClawSolutionContext, ClawSolutionService};
use serde_json::Value as JsonValue;
pub(super) async fn try_handle(
state: &DevBridgeState,
cmd: &str,
args: Option<&JsonValue>,
) -> Result<Option<JsonValue>, DynError> {
let result = match cmd {
"claw_solution_list" => {
let db = get_db(state)?;
serde_json::to_value(ClawSolutionService::default().list(db).await?)?
}
"claw_solution_detail" => {
let args = args_or_default(args);
let solution_id = get_string_arg(&args, "solutionId", "solution_id")?;
let db = get_db(state)?;
serde_json::to_value(
ClawSolutionService::default()
.detail(db, &solution_id)
.await?,
)?
}
"claw_solution_check_readiness" => {
let args = args_or_default(args);
let solution_id = get_string_arg(&args, "solutionId", "solution_id")?;
let _: Option<ClawSolutionContext> = parse_optional_nested_arg(&args, "context")?;
let db = get_db(state)?;
serde_json::to_value(
ClawSolutionService::default()
.check_readiness(db, &solution_id)
.await?,
)?
}
"claw_solution_prepare" => {
let args = args_or_default(args);
let solution_id = get_string_arg(&args, "solutionId", "solution_id")?;
let context: Option<ClawSolutionContext> = parse_optional_nested_arg(&args, "context")?;
let db = get_db(state)?;
serde_json::to_value(
ClawSolutionService::default()
.prepare(db, &solution_id, context)
.await?,
)?
}
"openclaw_check_installed" => {
let (_app_handle, service) = openclaw_context(state)?;
let service = service.lock().await;
@@ -0,0 +1,189 @@
use super::{args_or_default, get_string_arg, parse_nested_arg, require_app_handle};
use crate::commands::asr_cmd::AddAsrCredentialRequest;
use crate::config::AsrCredentialEntry;
use crate::dev_bridge::DevBridgeState;
use crate::voice::commands::{RecordingStatus, StopRecordingResult, VoiceShortcutRuntimeStatus};
use crate::voice::recording_service::RecordingServiceState;
use lime_core::config::{VoiceInputConfig, VoiceInstruction};
use serde_json::Value as JsonValue;
use tauri::Manager;
type DynError = Box<dyn std::error::Error>;
fn get_optional_string_arg(args: &JsonValue, primary: &str, secondary: &str) -> Option<String> {
args.get(primary)
.or_else(|| args.get(secondary))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn get_required_u32_arg(args: &JsonValue, primary: &str, secondary: &str) -> Result<u32, DynError> {
args.get(primary)
.or_else(|| args.get(secondary))
.and_then(|value| value.as_u64())
.map(|value| value as u32)
.ok_or_else(|| format!("缺少参数: {primary}/{secondary}").into())
}
pub(super) async fn try_handle(
state: &DevBridgeState,
cmd: &str,
args: Option<&JsonValue>,
) -> Result<Option<JsonValue>, DynError> {
let result = match cmd {
"get_asr_credentials" => {
serde_json::to_value(crate::commands::asr_cmd::get_asr_credentials().await?)?
}
"add_asr_credential" => {
let args = args_or_default(args);
let entry: AddAsrCredentialRequest = parse_nested_arg(&args, "entry")?;
serde_json::to_value(crate::commands::asr_cmd::add_asr_credential(entry).await?)?
}
"update_asr_credential" => {
let args = args_or_default(args);
let entry: AsrCredentialEntry = parse_nested_arg(&args, "entry")?;
crate::commands::asr_cmd::update_asr_credential(entry).await?;
JsonValue::Null
}
"delete_asr_credential" => {
let args = args_or_default(args);
let id = get_string_arg(&args, "id", "id")?;
crate::commands::asr_cmd::delete_asr_credential(id).await?;
JsonValue::Null
}
"set_default_asr_credential" => {
let args = args_or_default(args);
let id = get_string_arg(&args, "id", "id")?;
crate::commands::asr_cmd::set_default_asr_credential(id).await?;
JsonValue::Null
}
"test_asr_credential" => {
let args = args_or_default(args);
let id = get_string_arg(&args, "id", "id")?;
serde_json::to_value(crate::commands::asr_cmd::test_asr_credential(id).await?)?
}
"get_voice_input_config" => {
serde_json::to_value(crate::voice::commands::get_voice_input_config().await?)?
}
"get_voice_shortcut_runtime_status" => {
let status: VoiceShortcutRuntimeStatus =
crate::voice::commands::get_voice_shortcut_runtime_status().await?;
serde_json::to_value(status)?
}
"save_voice_input_config" => {
let args = args_or_default(args);
let voice_config: VoiceInputConfig = parse_nested_arg(&args, "voiceConfig")?;
let app_handle = require_app_handle(state)?;
crate::voice::commands::save_voice_input_config(app_handle, voice_config).await?;
JsonValue::Null
}
"get_voice_instructions" => {
serde_json::to_value(crate::voice::commands::get_voice_instructions().await?)?
}
"save_voice_instruction" => {
let args = args_or_default(args);
let instruction: VoiceInstruction = parse_nested_arg(&args, "instruction")?;
crate::voice::commands::save_voice_instruction(instruction).await?;
JsonValue::Null
}
"delete_voice_instruction" => {
let args = args_or_default(args);
let id = get_string_arg(&args, "id", "id")?;
crate::voice::commands::delete_voice_instruction(id).await?;
JsonValue::Null
}
"open_voice_window" => {
let app_handle = require_app_handle(state)?;
crate::voice::commands::open_voice_window(app_handle).await?;
JsonValue::Null
}
"close_voice_window" => {
let app_handle = require_app_handle(state)?;
crate::voice::commands::close_voice_window(app_handle).await?;
JsonValue::Null
}
"list_audio_devices" => {
serde_json::to_value(crate::voice::recording_service::list_audio_devices()?)?
}
"transcribe_audio" => {
let args = args_or_default(args);
let audio_data = args
.get("audioData")
.or_else(|| args.get("audio_data"))
.cloned()
.ok_or_else(|| "缺少参数: audioData/audio_data".to_string())?;
let audio_data: Vec<u8> = serde_json::from_value(audio_data)?;
let sample_rate = get_required_u32_arg(&args, "sampleRate", "sample_rate")?;
let credential_id = get_optional_string_arg(&args, "credentialId", "credential_id");
serde_json::to_value(
crate::voice::commands::transcribe_audio(audio_data, sample_rate, credential_id)
.await?,
)?
}
"polish_voice_text" => {
let args = args_or_default(args);
let text = get_string_arg(&args, "text", "text")?;
let instruction_id = get_optional_string_arg(&args, "instructionId", "instruction_id");
serde_json::to_value(
crate::voice::commands::polish_voice_text(text, instruction_id).await?,
)?
}
"output_voice_text" => {
let args = args_or_default(args);
let text = get_string_arg(&args, "text", "text")?;
let mode = get_optional_string_arg(&args, "mode", "mode");
crate::voice::commands::output_voice_text(text, mode).await?;
JsonValue::Null
}
"start_recording" => {
let args = args_or_default(args);
let device_id = get_optional_string_arg(&args, "deviceId", "device_id");
let app_handle = require_app_handle(state)?;
let recording_service = app_handle.state::<RecordingServiceState>();
let mut service = recording_service.0.lock();
service.start(device_id)?;
JsonValue::Null
}
"stop_recording" => {
let app_handle = require_app_handle(state)?;
let recording_service = app_handle.state::<RecordingServiceState>();
let mut service = recording_service.0.lock();
let audio = service.stop()?;
serde_json::to_value(StopRecordingResult {
audio_data: audio.to_pcm16le_bytes(),
sample_rate: audio.sample_rate,
duration: audio.duration_secs,
})?
}
"cancel_recording" => {
let app_handle = require_app_handle(state)?;
let recording_service = app_handle.state::<RecordingServiceState>();
if let Some(mut service) = recording_service.0.try_lock() {
service.cancel();
}
JsonValue::Null
}
"get_recording_status" => {
let app_handle = require_app_handle(state)?;
let recording_service = app_handle.state::<RecordingServiceState>();
let service = recording_service.0.lock();
serde_json::to_value(RecordingStatus {
is_recording: service.is_recording(),
volume: service.get_volume(),
duration: service.get_duration(),
})?
}
"open_input_with_text" => {
let args = args_or_default(args);
let text = get_string_arg(&args, "text", "text")?;
let app_handle = require_app_handle(state)?;
crate::commands::screenshot_cmd::open_input_with_text(app_handle, text)?;
JsonValue::Null
}
_ => return Ok(None),
};
Ok(Some(result))
}
+21
View File
@@ -290,6 +290,11 @@ fn handle_shortcut_triggered(app: &AppHandle) {
mod tests {
use super::*;
fn reset_runtime_state() {
IS_REGISTERED.store(false, Ordering::SeqCst);
*get_current_shortcut().write() = None;
}
#[test]
fn test_validate_valid_shortcuts() {
// 有效的快捷键格式
@@ -319,4 +324,20 @@ mod tests {
assert!(validate("+++").is_err());
assert!(validate("Ctrl++").is_err());
}
#[test]
fn test_runtime_status_accessors_reflect_state() {
reset_runtime_state();
assert!(!is_registered());
assert_eq!(get_current(), None);
IS_REGISTERED.store(true, Ordering::SeqCst);
*get_current_shortcut().write() = Some("CommandOrControl+Shift+4".to_string());
assert!(is_registered());
assert_eq!(get_current().as_deref(), Some("CommandOrControl+Shift+4"));
reset_runtime_state();
}
}
@@ -1,5 +1,5 @@
use chrono::Utc;
use lime_agent::TauriAgentEvent;
use lime_agent::AgentEvent as RuntimeAgentEvent;
use lime_core::database::dao::agent_timeline::{
AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn,
AgentThreadTurnStatus, AgentTimelineDao,
@@ -9,7 +9,7 @@ use serde_json::Value;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
fn emit_event(app: &AppHandle, event_name: &str, event: &TauriAgentEvent) {
fn emit_event(app: &AppHandle, event_name: &str, event: &RuntimeAgentEvent) {
if let Err(error) = app.emit(event_name, event) {
tracing::error!("[AgentTimeline] 发送事件失败: {}", error);
}
@@ -107,12 +107,12 @@ impl AgentTimelineRecorder {
&mut self,
app: &AppHandle,
event_name: &str,
event: &TauriAgentEvent,
event: &RuntimeAgentEvent,
_workspace_root: &str,
) -> Result<(), String> {
match event {
TauriAgentEvent::ThreadStarted { .. } => {}
TauriAgentEvent::TurnStarted { turn } => {
RuntimeAgentEvent::ThreadStarted { .. } => {}
RuntimeAgentEvent::TurnStarted { turn } => {
self.thread_id = turn.thread_id.clone();
self.turn_id = turn.id.clone();
self.turn = turn.clone();
@@ -121,33 +121,34 @@ impl AgentTimelineRecorder {
AgentTimelineDao::upsert_turn(&conn, &self.turn)
.map_err(|e| format!("同步 turn 启动态失败: {e}"))?;
}
TauriAgentEvent::ItemStarted { item } => {
RuntimeAgentEvent::ItemStarted { item } => {
self.persist_runtime_item(
app,
event_name,
item.clone(),
TauriAgentEvent::ItemStarted { item: item.clone() },
RuntimeAgentEvent::ItemStarted { item: item.clone() },
)?;
}
TauriAgentEvent::ItemUpdated { item } => {
RuntimeAgentEvent::ItemUpdated { item } => {
self.persist_runtime_item(
app,
event_name,
item.clone(),
TauriAgentEvent::ItemUpdated { item: item.clone() },
RuntimeAgentEvent::ItemUpdated { item: item.clone() },
)?;
}
TauriAgentEvent::ItemCompleted { item } => {
RuntimeAgentEvent::ItemCompleted { item } => {
self.persist_runtime_item(
app,
event_name,
item.clone(),
TauriAgentEvent::ItemCompleted { item: item.clone() },
RuntimeAgentEvent::ItemCompleted { item: item.clone() },
)?;
}
TauriAgentEvent::RuntimeStatus { .. } => {}
TauriAgentEvent::ToolEnd { .. } => {}
TauriAgentEvent::ArtifactSnapshot { artifact } => {
RuntimeAgentEvent::RuntimeStatus { .. } => {}
RuntimeAgentEvent::TurnContext { .. } => {}
RuntimeAgentEvent::ToolEnd { .. } => {}
RuntimeAgentEvent::ArtifactSnapshot { artifact } => {
let metadata_value = artifact
.metadata
.as_ref()
@@ -170,8 +171,8 @@ impl AgentTimelineRecorder {
);
self.persist_and_emit_item(app, event_name, item)?;
}
TauriAgentEvent::ActionRequired { .. } => {}
TauriAgentEvent::ContextCompactionStarted {
RuntimeAgentEvent::ActionRequired { .. } => {}
RuntimeAgentEvent::ContextCompactionStarted {
item_id,
trigger,
detail,
@@ -188,7 +189,7 @@ impl AgentTimelineRecorder {
);
self.persist_and_emit_item(app, event_name, item)?;
}
TauriAgentEvent::ContextCompactionCompleted {
RuntimeAgentEvent::ContextCompactionCompleted {
item_id,
trigger,
detail,
@@ -205,7 +206,7 @@ impl AgentTimelineRecorder {
);
self.persist_and_emit_item(app, event_name, item)?;
}
TauriAgentEvent::Warning { code, message } => {
RuntimeAgentEvent::Warning { code, message } => {
let item = self.build_item(
format!("warning:{}:{}", self.turn_id, self.sequence_counter + 1),
AgentThreadItemStatus::Completed,
@@ -217,7 +218,7 @@ impl AgentTimelineRecorder {
);
self.persist_and_emit_item(app, event_name, item)?;
}
TauriAgentEvent::Error { message } => {
RuntimeAgentEvent::Error { message } => {
let item = self.build_item(
format!("error:{}", self.turn_id),
AgentThreadItemStatus::Failed,
@@ -260,7 +261,7 @@ impl AgentTimelineRecorder {
emit_event(
app,
event_name,
&TauriAgentEvent::TurnCompleted {
&RuntimeAgentEvent::TurnCompleted {
turn: self.turn.clone(),
},
);
@@ -305,7 +306,7 @@ impl AgentTimelineRecorder {
emit_event(
app,
event_name,
&TauriAgentEvent::TurnFailed {
&RuntimeAgentEvent::TurnFailed {
turn: self.turn.clone(),
},
);
@@ -391,13 +392,13 @@ impl AgentTimelineRecorder {
.insert(item.id.clone(), item.status.clone());
let event = match (&previous_status, &item.status) {
(None, AgentThreadItemStatus::InProgress) => {
TauriAgentEvent::ItemStarted { item: item.clone() }
RuntimeAgentEvent::ItemStarted { item: item.clone() }
}
(None, _) => TauriAgentEvent::ItemCompleted { item: item.clone() },
(None, _) => RuntimeAgentEvent::ItemCompleted { item: item.clone() },
(_, AgentThreadItemStatus::Completed | AgentThreadItemStatus::Failed) => {
TauriAgentEvent::ItemCompleted { item: item.clone() }
RuntimeAgentEvent::ItemCompleted { item: item.clone() }
}
_ => TauriAgentEvent::ItemUpdated { item: item.clone() },
_ => RuntimeAgentEvent::ItemUpdated { item: item.clone() },
};
emit_event(app, event_name, &event);
Ok(())
@@ -408,7 +409,7 @@ impl AgentTimelineRecorder {
app: &AppHandle,
event_name: &str,
item: AgentThreadItem,
event: TauriAgentEvent,
event: RuntimeAgentEvent,
) -> Result<(), String> {
self.sync_runtime_item_state(&item);
{
@@ -0,0 +1,369 @@
//! Artifact 版本差异服务
//!
//! 负责比较两个 ArtifactDocument 快照,生成可直接给 Workbench
//! 消费的 block 级 diff 摘要。
use serde_json::{Map, Value};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum ArtifactBlockChangeType {
Added,
Removed,
Updated,
Moved,
}
impl ArtifactBlockChangeType {
fn as_str(self) -> &'static str {
match self {
Self::Added => "added",
Self::Removed => "removed",
Self::Updated => "updated",
Self::Moved => "moved",
}
}
}
pub fn build_artifact_version_diff(
base_document: Option<&Value>,
base_version_id: Option<&str>,
base_version_no: Option<usize>,
target_document: &Value,
target_version_id: &str,
target_version_no: usize,
) -> Option<Map<String, Value>> {
let base_record = base_document.and_then(Value::as_object)?;
let target_record = target_document.as_object()?;
let base_blocks = extract_blocks(base_record);
let target_blocks = extract_blocks(target_record);
if base_blocks.is_empty() && target_blocks.is_empty() {
return None;
}
let mut changed_blocks = Vec::new();
let mut counts = HashMap::from([
(ArtifactBlockChangeType::Added, 0usize),
(ArtifactBlockChangeType::Removed, 0usize),
(ArtifactBlockChangeType::Updated, 0usize),
(ArtifactBlockChangeType::Moved, 0usize),
]);
let target_by_id = target_blocks
.iter()
.map(|block| (block.id.as_str(), block))
.collect::<HashMap<_, _>>();
let base_by_id = base_blocks
.iter()
.map(|block| (block.id.as_str(), block))
.collect::<HashMap<_, _>>();
for target_block in &target_blocks {
let Some(base_block) = base_by_id.get(target_block.id.as_str()) else {
changed_blocks.push(build_changed_block_record(
ArtifactBlockChangeType::Added,
None,
Some(target_block),
));
*counts.entry(ArtifactBlockChangeType::Added).or_default() += 1;
continue;
};
if base_block.block_type != target_block.block_type
|| base_block.preview_text != target_block.preview_text
{
changed_blocks.push(build_changed_block_record(
ArtifactBlockChangeType::Updated,
Some(base_block),
Some(target_block),
));
*counts.entry(ArtifactBlockChangeType::Updated).or_default() += 1;
continue;
}
if base_block.index != target_block.index {
changed_blocks.push(build_changed_block_record(
ArtifactBlockChangeType::Moved,
Some(base_block),
Some(target_block),
));
*counts.entry(ArtifactBlockChangeType::Moved).or_default() += 1;
}
}
for base_block in &base_blocks {
if target_by_id.contains_key(base_block.id.as_str()) {
continue;
}
changed_blocks.push(build_changed_block_record(
ArtifactBlockChangeType::Removed,
Some(base_block),
None,
));
*counts.entry(ArtifactBlockChangeType::Removed).or_default() += 1;
}
if changed_blocks.is_empty() {
return None;
}
let mut diff = Map::new();
if let Some(version_id) = normalize_text(base_version_id) {
diff.insert("baseVersionId".to_string(), Value::String(version_id));
}
if let Some(version_no) = base_version_no {
diff.insert("baseVersionNo".to_string(), Value::from(version_no as u64));
}
diff.insert(
"targetVersionId".to_string(),
Value::String(target_version_id.to_string()),
);
diff.insert(
"targetVersionNo".to_string(),
Value::from(target_version_no as u64),
);
diff.insert(
"addedCount".to_string(),
Value::from(*counts.get(&ArtifactBlockChangeType::Added).unwrap_or(&0) as u64),
);
diff.insert(
"removedCount".to_string(),
Value::from(*counts.get(&ArtifactBlockChangeType::Removed).unwrap_or(&0) as u64),
);
diff.insert(
"updatedCount".to_string(),
Value::from(*counts.get(&ArtifactBlockChangeType::Updated).unwrap_or(&0) as u64),
);
diff.insert(
"movedCount".to_string(),
Value::from(*counts.get(&ArtifactBlockChangeType::Moved).unwrap_or(&0) as u64),
);
diff.insert(
"changedBlocks".to_string(),
Value::Array(changed_blocks.into_iter().map(Value::Object).collect()),
);
Some(diff)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ArtifactBlockSnapshot {
id: String,
index: usize,
block_type: String,
preview_text: String,
}
fn extract_blocks(record: &Map<String, Value>) -> Vec<ArtifactBlockSnapshot> {
let Some(blocks) = record.get("blocks").and_then(Value::as_array) else {
return Vec::new();
};
blocks
.iter()
.enumerate()
.filter_map(|(index, block)| {
let record = block.as_object()?;
let id = normalize_text(record.get("id").and_then(Value::as_str))?;
let block_type = normalize_text(record.get("type").and_then(Value::as_str))
.unwrap_or_else(|| "rich_text".to_string());
Some(ArtifactBlockSnapshot {
id,
index,
block_type,
preview_text: build_block_preview(record),
})
})
.collect()
}
fn build_block_preview(record: &Map<String, Value>) -> String {
let block_type = normalize_text(record.get("type").and_then(Value::as_str))
.unwrap_or_else(|| "rich_text".to_string());
let preview = match block_type.as_str() {
"hero_summary" => normalize_text(record.get("summary").and_then(Value::as_str)),
"section_header" => normalize_text(record.get("title").and_then(Value::as_str))
.or_else(|| normalize_text(record.get("description").and_then(Value::as_str))),
"callout" => normalize_text(record.get("title").and_then(Value::as_str))
.or_else(|| normalize_text(record.get("content").and_then(Value::as_str)))
.or_else(|| normalize_text(record.get("text").and_then(Value::as_str))),
"rich_text" => normalize_text(record.get("markdown").and_then(Value::as_str))
.or_else(|| normalize_text(record.get("text").and_then(Value::as_str)))
.or_else(|| normalize_text(record.get("content").and_then(Value::as_str))),
"key_points" | "checklist" | "metric_grid" => record
.get("items")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.take(3)
.filter_map(value_to_preview_text)
.collect::<Vec<_>>()
.join(";")
})
.filter(|value| !value.trim().is_empty()),
"table" => record
.get("rows")
.and_then(Value::as_array)
.map(|rows| format!("{} 行表格", rows.len())),
"quote" => normalize_text(record.get("text").and_then(Value::as_str))
.or_else(|| normalize_text(record.get("content").and_then(Value::as_str))),
_ => value_to_preview_text(&Value::Object(record.clone())),
};
preview
.map(|value| truncate_text(value.as_str(), 160))
.unwrap_or_else(|| block_type)
}
fn build_changed_block_record(
change_type: ArtifactBlockChangeType,
before: Option<&ArtifactBlockSnapshot>,
after: Option<&ArtifactBlockSnapshot>,
) -> Map<String, Value> {
let mut record = Map::new();
record.insert(
"changeType".to_string(),
Value::String(change_type.as_str().to_string()),
);
let block_id = after
.map(|block| block.id.clone())
.or_else(|| before.map(|block| block.id.clone()))
.unwrap_or_else(|| "unknown-block".to_string());
record.insert("blockId".to_string(), Value::String(block_id));
if let Some(before) = before {
record.insert(
"beforeType".to_string(),
Value::String(before.block_type.clone()),
);
record.insert("beforeIndex".to_string(), Value::from(before.index as u64));
if !before.preview_text.is_empty() {
record.insert(
"beforeText".to_string(),
Value::String(before.preview_text.clone()),
);
}
}
if let Some(after) = after {
record.insert(
"afterType".to_string(),
Value::String(after.block_type.clone()),
);
record.insert("afterIndex".to_string(), Value::from(after.index as u64));
if !after.preview_text.is_empty() {
record.insert(
"afterText".to_string(),
Value::String(after.preview_text.clone()),
);
}
}
record.insert(
"summary".to_string(),
Value::String(build_change_summary(change_type, before, after)),
);
record
}
fn build_change_summary(
change_type: ArtifactBlockChangeType,
before: Option<&ArtifactBlockSnapshot>,
after: Option<&ArtifactBlockSnapshot>,
) -> String {
match change_type {
ArtifactBlockChangeType::Added => format!(
"新增 {} block",
after
.map(|block| block.block_type.as_str())
.unwrap_or("artifact")
),
ArtifactBlockChangeType::Removed => format!(
"删除 {} block",
before
.map(|block| block.block_type.as_str())
.unwrap_or("artifact")
),
ArtifactBlockChangeType::Updated => "更新 block 内容".to_string(),
ArtifactBlockChangeType::Moved => {
let from = before.map(|block| block.index + 1).unwrap_or(0);
let to = after.map(|block| block.index + 1).unwrap_or(0);
format!("block 位置从 #{from} 调整到 #{to}")
}
}
}
fn value_to_preview_text(value: &Value) -> Option<String> {
match value {
Value::String(text) => normalize_text(Some(text)),
Value::Array(items) => {
let joined = items
.iter()
.filter_map(value_to_preview_text)
.collect::<Vec<_>>()
.join(";");
normalize_text(Some(joined.as_str()))
}
Value::Object(record) => normalize_text(
record
.get("text")
.and_then(Value::as_str)
.or_else(|| record.get("label").and_then(Value::as_str))
.or_else(|| record.get("title").and_then(Value::as_str))
.or_else(|| record.get("content").and_then(Value::as_str))
.or_else(|| record.get("summary").and_then(Value::as_str)),
),
_ => None,
}
}
fn normalize_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|text| !text.is_empty())
.map(ToString::to_string)
}
fn truncate_text(value: &str, max_chars: usize) -> String {
value.trim().chars().take(max_chars).collect::<String>()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn should_build_block_level_diff_summary() {
let previous = json!({
"blocks": [
{ "id": "hero-1", "type": "hero_summary", "summary": "旧摘要" },
{ "id": "body-1", "type": "rich_text", "markdown": "旧正文" }
]
});
let current = json!({
"blocks": [
{ "id": "hero-1", "type": "hero_summary", "summary": "新摘要" },
{ "id": "appendix-1", "type": "rich_text", "markdown": "新增附录" },
{ "id": "body-1", "type": "rich_text", "markdown": "旧正文" }
]
});
let diff = build_artifact_version_diff(
Some(&previous),
Some("artifact:v1"),
Some(1),
&current,
"artifact:v2",
2,
)
.expect("diff");
assert_eq!(diff.get("updatedCount").and_then(Value::as_u64), Some(1));
assert_eq!(diff.get("addedCount").and_then(Value::as_u64), Some(1));
assert_eq!(diff.get("movedCount").and_then(Value::as_u64), Some(1));
assert_eq!(
diff.get("changedBlocks")
.and_then(Value::as_array)
.map(|items| items.len()),
Some(3)
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,429 @@
//! Artifact Workbench 提示词装配服务
//!
//! 统一根据 turn metadata 组装 Artifact 交付策略、来源策略与阶段合同,
//! 避免前端或 runtime_turn 直接散落拼接规则。
use serde_json::Value;
const ARTIFACT_DELIVERY_POLICY_PROMPT_MARKER: &str = "【Artifact 交付策略】";
const ARTIFACT_SOURCE_POLICY_PROMPT_MARKER: &str = "【Artifact 来源策略】";
const ARTIFACT_STAGE1_PROMPT_MARKER: &str = "【Artifact Stage 1 合同】";
const ARTIFACT_STAGE2_PROMPT_MARKER: &str = "【Artifact Stage 2 合同】";
const ARTIFACT_REWRITE_PROMPT_MARKER: &str = "【Artifact Rewrite 合同】";
const ARTIFACT_SCHEMA_HINT_PROMPT_MARKER: &str = "【Artifact 输出 Schema 提示】";
const ARTIFACT_DOCUMENT_SCHEMA_VERSION: &str = "artifact_document.v1";
const ARTIFACT_ALLOWED_BLOCKS: &[&str] = &[
"section_header",
"hero_summary",
"key_points",
"rich_text",
"callout",
"table",
"checklist",
"metric_grid",
"quote",
"citation_list",
"image",
"code_block",
"divider",
];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct ArtifactPromptContext {
mode: Option<String>,
kind: Option<String>,
stage: Option<String>,
source_policy: Option<String>,
workbench_surface: Option<String>,
request_id: Option<String>,
target_block_id: Option<String>,
rewrite_instruction: Option<String>,
}
fn normalize_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn extract_artifact_object(
request_metadata: Option<&Value>,
) -> Option<&serde_json::Map<String, Value>> {
let metadata = request_metadata?;
let object = metadata.as_object()?;
if let Some(artifact) = object.get("artifact").and_then(Value::as_object) {
return Some(artifact);
}
Some(object)
}
fn extract_artifact_string(request_metadata: Option<&Value>, keys: &[&str]) -> Option<String> {
let artifact = extract_artifact_object(request_metadata)?;
keys.iter()
.filter_map(|key| artifact.get(*key))
.find_map(Value::as_str)
.and_then(|value| normalize_text(Some(value)))
}
fn build_artifact_prompt_context(
request_metadata: Option<&Value>,
) -> Option<ArtifactPromptContext> {
let context = ArtifactPromptContext {
mode: extract_artifact_string(request_metadata, &["artifact_mode", "artifactMode"]),
kind: extract_artifact_string(request_metadata, &["artifact_kind", "artifactKind"]),
stage: extract_artifact_string(request_metadata, &["artifact_stage", "artifactStage"]),
source_policy: extract_artifact_string(
request_metadata,
&["source_policy", "sourcePolicy"],
),
workbench_surface: extract_artifact_string(
request_metadata,
&["workbench_surface", "workbenchSurface"],
),
request_id: extract_artifact_string(
request_metadata,
&["artifact_request_id", "artifactRequestId"],
),
target_block_id: extract_artifact_string(
request_metadata,
&["artifact_target_block_id", "artifactTargetBlockId"],
),
rewrite_instruction: extract_artifact_string(
request_metadata,
&["artifact_rewrite_instruction", "artifactRewriteInstruction"],
),
};
let has_meaningful_fields = [
context.mode.as_ref(),
context.kind.as_ref(),
context.stage.as_ref(),
context.source_policy.as_ref(),
context.workbench_surface.as_ref(),
context.request_id.as_ref(),
context.target_block_id.as_ref(),
context.rewrite_instruction.as_ref(),
]
.iter()
.any(|value| value.is_some());
if has_meaningful_fields {
Some(context)
} else {
None
}
}
fn merge_prompt_section(
base_prompt: Option<String>,
section_prompt: Option<String>,
marker: &str,
) -> Option<String> {
match (base_prompt, section_prompt) {
(Some(base), Some(section)) => {
if base.contains(marker) {
Some(base)
} else if base.trim().is_empty() {
Some(section)
} else {
Some(format!("{base}\n\n{section}"))
}
}
(Some(base), None) => Some(base),
(None, Some(section)) => Some(section),
(None, None) => None,
}
}
fn build_artifact_delivery_prompt(context: &ArtifactPromptContext) -> Option<String> {
if matches!(context.mode.as_deref(), Some("none")) {
return None;
}
let mut lines = vec![ARTIFACT_DELIVERY_POLICY_PROMPT_MARKER.to_string()];
match context.mode.as_deref() {
Some("rewrite") => {
lines
.push("- 当前回合是对现有 Artifact 的定向改写,不是一次普通聊天回复。".to_string());
}
_ => {
lines.push("- 当前回合需要进入 Artifact Workbench 正式交付链。".to_string());
}
}
if let Some(kind) = context.kind.as_deref() {
lines.push(format!("- 目标交付物类型:{kind}。"));
}
if let Some(surface) = context.workbench_surface.as_deref() {
lines.push(format!("- 主要承载面:{surface}。"));
}
if let Some(request_id) = context.request_id.as_deref() {
lines.push(format!("- 本轮 artifact request id:{request_id}。"));
}
lines.push("执行要求:".to_string());
lines.push(
"1. 消息区只保留进度、结论、缺口与下一步,不要把整篇正式产物再贴回消息区。".to_string(),
);
lines.push("2. 只要需要正式交付物,优先通过文件写入或 write_file 工具把结果落到工作区,以便右侧 Artifact Workbench 实时预览。".to_string());
lines.push(
"3. 正式交付物优先使用 ArtifactDocument v1 JSON,而不是自由 Markdown 长文。".to_string(),
);
lines.push("4. 如果信息不足以完成正式交付,先明确缺口,再产出最稳妥的结构化草稿。".to_string());
Some(lines.join("\n"))
}
fn build_artifact_source_policy_prompt(context: &ArtifactPromptContext) -> Option<String> {
let source_policy = context.source_policy.as_deref()?;
let policy_text = match source_policy {
"required" => "本轮来源为强约束。关键结论、比较和事实判断必须绑定 sources,不要编造来源。",
"preferred" => "本轮来源为软约束。有真实来源时应尽量保留 sources,没有就明确说明来源不足。",
"none" => "本轮来源不是硬门槛,但仍然禁止伪造来源或把推断冒充成已验证事实。",
_ => return None,
};
Some(format!(
"{ARTIFACT_SOURCE_POLICY_PROMPT_MARKER}\n\
- source_policy:{source_policy}\n\
- {policy_text}"
))
}
fn build_artifact_stage_prompt(context: &ArtifactPromptContext) -> Option<String> {
let stage = context
.stage
.as_deref()
.or(context.mode.as_deref())
.unwrap_or("stage2");
match stage {
"stage1" => Some(format!(
"{ARTIFACT_STAGE1_PROMPT_MARKER}\n\
执行目标:\n\
1. 判断是否需要正式 Artifact。\n\
2. 锁定 kind、标题、source policy、section outline 与 block plan。\n\
3. 标出当前缺口、假设与风险。\n\
禁止项:\n\
1. 不要直接写完整正文。\n\
2. 不要输出 HTML / CSS / 视觉样式说明。\n\
3. 不要在消息区和交付区重复粘贴同一份长文。\
"
)),
"rewrite" => {
let mut lines = vec![ARTIFACT_REWRITE_PROMPT_MARKER.to_string()];
lines.push(
"- 当前回合只改写指定 Artifact 范围,除非明确要求,不要重写整份文档。".to_string(),
);
if let Some(target_block_id) = context.target_block_id.as_deref() {
lines.push(format!("- 目标 block:{target_block_id}。"));
}
if let Some(instruction) = context.rewrite_instruction.as_deref() {
lines.push(format!("- 改写指令:{instruction}"));
}
lines.push("执行要求:".to_string());
lines.push(
"1. 默认输出 `artifact_rewrite_patch`;只有兼容旧链路时才回退到 `artifact_ops`。"
.to_string(),
);
lines.push(
"2. 如果提供了目标 block,则不要改写其他 block,也不要借机重排整个文档结构。"
.to_string(),
);
lines.push("3. 保留原有结构与来源绑定,优先最小改动。".to_string());
lines.push("4. 若局部改写无法满足要求,再明确说明需要扩大的范围。".to_string());
Some(lines.join("\n"))
}
_ => Some(format!(
"{ARTIFACT_STAGE2_PROMPT_MARKER}\n\
执行目标:\n\
1. 输出正式结构化交付物草稿。\n\
2. 初次生成可输出 `artifact_document_draft`;对已有文档做增量补充时优先输出 `artifact_ops`。\n\
3. 交付物必须满足 ArtifactDocument v1。\n\
4. block 类型只能来自白名单,不要自由发明新 block。\n\
5. 若已知 sources,应挂到 sources[] 并让 block.sourceIds 指向已有来源。"
)),
}
}
fn build_artifact_schema_hint_prompt(context: &ArtifactPromptContext) -> Option<String> {
if matches!(context.mode.as_deref(), Some("none"))
|| matches!(context.stage.as_deref(), Some("stage1"))
{
return None;
}
let is_rewrite = matches!(
context.stage.as_deref().or(context.mode.as_deref()),
Some("rewrite")
);
let output_contract = if is_rewrite {
"本轮优先输出 `artifact_rewrite_patch`;兼容情况下也可输出 `artifact_ops`,不要返回整篇 `artifact_document_draft`。"
} else {
"本轮可以输出 `artifact_document_draft`,也可以在已有文档上输出 `artifact_ops`。"
};
let shape_hint = if is_rewrite {
"`artifact_rewrite_patch` 顶层字段优先包含:type、artifactId、targetBlockId、block\n- 可选补充 `source / sources / summary / status`\n- 若需兼容旧链路,也可回退到 `artifact_ops`\n- 若存在 target block,schema 与运行时都会限制改写范围,只允许命中该 block"
} else {
"顶层字段优先包含:artifactId、kind、title、status、language、summary、blocks、sources、metadata\n- 若使用 `artifact_ops`,顶层字段包含:type、artifactId、ops"
};
let example = if is_rewrite {
"{\n \
\"type\": \"artifact_rewrite_patch\",\n \
\"artifactId\": \"artifact-demo\",\n \
\"targetBlockId\": \"body-1\",\n \
\"block\": { \"id\": \"body-1\", \"type\": \"rich_text\", \"markdown\": \"改写后的正文\" },\n \
\"summary\": \"把正文改成更适合董事会的措辞\"\n\
}"
} else {
"{\n \
\"type\": \"artifact_document_draft\",\n \
\"document\": {\n \
\"schemaVersion\": \"artifact_document.v1\",\n \
\"kind\": \"report\",\n \
\"title\": \"示例标题\",\n \
\"status\": \"ready\",\n \
\"language\": \"zh-CN\",\n \
\"summary\": \"一句话摘要\",\n \
\"blocks\": [\n \
{ \"id\": \"hero-1\", \"type\": \"hero_summary\", \"summary\": \"核心结论\" },\n \
{ \"id\": \"body-1\", \"type\": \"rich_text\", \"markdown\": \"正文内容\" }\n \
],\n \
\"sources\": [],\n \
\"metadata\": {}\n \
}\n\
}"
};
Some(format!(
"{ARTIFACT_SCHEMA_HINT_PROMPT_MARKER}\n\
{output_contract}\n\
本轮正式交付物优先满足以下结构:\n\
- schemaVersion 必须为 `{ARTIFACT_DOCUMENT_SCHEMA_VERSION}`\n\
- {shape_hint}\n\
- blocks 至少 1 个,允许的 block 类型只有:{allowed_blocks}\n\
- rich_text 可以承载 markdown 或编辑器 JSON,但文档顶层不能退化成整篇无结构长文\n\n\
最小可用示例(你的输出必须是这种 JSON 结构,不要包裹在 markdown code fence 中):\n\
{example}",
allowed_blocks = ARTIFACT_ALLOWED_BLOCKS.join(", ")
))
}
pub fn merge_system_prompt_with_artifact_context(
base_prompt: Option<String>,
request_metadata: Option<&Value>,
) -> Option<String> {
let Some(context) = build_artifact_prompt_context(request_metadata) else {
return base_prompt;
};
let with_delivery = merge_prompt_section(
base_prompt,
build_artifact_delivery_prompt(&context),
ARTIFACT_DELIVERY_POLICY_PROMPT_MARKER,
);
let with_sources = merge_prompt_section(
with_delivery,
build_artifact_source_policy_prompt(&context),
ARTIFACT_SOURCE_POLICY_PROMPT_MARKER,
);
let with_stage = merge_prompt_section(
with_sources,
build_artifact_stage_prompt(&context),
if matches!(context.stage.as_deref(), Some("stage1")) {
ARTIFACT_STAGE1_PROMPT_MARKER
} else if matches!(
context.stage.as_deref().or(context.mode.as_deref()),
Some("rewrite")
) {
ARTIFACT_REWRITE_PROMPT_MARKER
} else {
ARTIFACT_STAGE2_PROMPT_MARKER
},
);
merge_prompt_section(
with_stage,
build_artifact_schema_hint_prompt(&context),
ARTIFACT_SCHEMA_HINT_PROMPT_MARKER,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_ignore_empty_artifact_metadata() {
let merged = merge_system_prompt_with_artifact_context(None, None);
assert!(merged.is_none());
}
#[test]
fn should_build_draft_prompt_sections() {
let metadata = serde_json::json!({
"artifact": {
"artifact_mode": "draft",
"artifact_kind": "report",
"artifact_stage": "stage2",
"source_policy": "required",
"workbench_surface": "right_panel"
}
});
let merged =
merge_system_prompt_with_artifact_context(None, Some(&metadata)).unwrap_or_default();
assert!(merged.contains(ARTIFACT_DELIVERY_POLICY_PROMPT_MARKER));
assert!(merged.contains(ARTIFACT_SOURCE_POLICY_PROMPT_MARKER));
assert!(merged.contains(ARTIFACT_STAGE2_PROMPT_MARKER));
assert!(merged.contains(ARTIFACT_SCHEMA_HINT_PROMPT_MARKER));
assert!(merged.contains("ArtifactDocument v1"));
assert!(merged.contains("artifact_ops"));
}
#[test]
fn should_build_rewrite_prompt() {
let metadata = serde_json::json!({
"artifact": {
"artifact_mode": "rewrite",
"artifact_stage": "rewrite",
"artifact_target_block_id": "block-3",
"artifact_rewrite_instruction": "把语言改得更适合董事会"
}
});
let merged =
merge_system_prompt_with_artifact_context(None, Some(&metadata)).unwrap_or_default();
assert!(merged.contains(ARTIFACT_REWRITE_PROMPT_MARKER));
assert!(merged.contains("block-3"));
assert!(merged.contains("更适合董事会"));
assert!(merged.contains("artifact_rewrite_patch"));
assert!(merged.contains("artifact_ops"));
}
#[test]
fn should_not_duplicate_existing_marker() {
let metadata = serde_json::json!({
"artifact": {
"artifact_mode": "draft",
"artifact_kind": "analysis"
}
});
let base = Some(format!(
"已有内容\n\n{ARTIFACT_DELIVERY_POLICY_PROMPT_MARKER}\n已有 Artifact 段"
));
let merged = merge_system_prompt_with_artifact_context(base.clone(), Some(&metadata));
let merged_text = merged.unwrap_or_default();
assert_eq!(
merged_text
.matches(ARTIFACT_DELIVERY_POLICY_PROMPT_MARKER)
.count(),
1
);
}
}
@@ -0,0 +1,345 @@
//! Artifact request metadata 归一化服务
//!
//! 统一根据 request metadata 中的 harness / artifact 上下文补全 Artifact
//! 默认值,避免前端继续预计算 draft kind / source policy / request id。
use serde_json::{Map, Value};
const ARTIFACT_MEANINGFUL_KEYS: &[&str] = &[
"artifact_mode",
"artifactMode",
"artifact_kind",
"artifactKind",
"artifact_stage",
"artifactStage",
"source_policy",
"sourcePolicy",
"workbench_surface",
"workbenchSurface",
"artifact_request_id",
"artifactRequestId",
"artifact_target_block_id",
"artifactTargetBlockId",
"artifact_rewrite_instruction",
"artifactRewriteInstruction",
];
fn normalize_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn root_object(request_metadata: Option<&Value>) -> Option<&Map<String, Value>> {
request_metadata?.as_object()
}
fn extract_harness_object(request_metadata: Option<&Value>) -> Option<&Map<String, Value>> {
let object = root_object(request_metadata)?;
if let Some(harness) = object.get("harness").and_then(Value::as_object) {
return Some(harness);
}
Some(object)
}
fn extract_harness_string(request_metadata: Option<&Value>, keys: &[&str]) -> Option<String> {
let harness = extract_harness_object(request_metadata)?;
keys.iter()
.filter_map(|key| harness.get(*key))
.find_map(Value::as_str)
.and_then(|value| normalize_text(Some(value)))
}
fn is_flat_artifact_metadata_key(key: &str) -> bool {
matches!(
key,
"artifact_mode"
| "artifactMode"
| "artifact_kind"
| "artifactKind"
| "artifact_stage"
| "artifactStage"
| "source_policy"
| "sourcePolicy"
| "workbench_surface"
| "workbenchSurface"
| "artifact_request_id"
| "artifactRequestId"
| "artifact_target_block_id"
| "artifactTargetBlockId"
| "artifact_rewrite_instruction"
| "artifactRewriteInstruction"
)
}
fn extract_existing_artifact_object(
request_metadata: Option<&Value>,
) -> Option<Map<String, Value>> {
let object = root_object(request_metadata)?;
if let Some(artifact) = object.get("artifact").and_then(Value::as_object) {
return Some(artifact.clone());
}
let artifact = object
.iter()
.filter(|(key, _)| is_flat_artifact_metadata_key(key))
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<Map<_, _>>();
if artifact.is_empty() {
None
} else {
Some(artifact)
}
}
fn extract_artifact_string(artifact: &Map<String, Value>, keys: &[&str]) -> Option<String> {
keys.iter()
.filter_map(|key| artifact.get(*key))
.find_map(Value::as_str)
.and_then(|value| normalize_text(Some(value)))
}
fn infer_artifact_kind(theme: &str) -> Option<&'static str> {
match theme.trim().to_ascii_lowercase().as_str() {
"document" => Some("report"),
"knowledge" => Some("analysis"),
"planning" => Some("plan"),
"general" => Some("brief"),
_ => None,
}
}
fn infer_source_policy(kind: Option<&str>) -> Option<&'static str> {
match kind {
Some("report") | Some("analysis") | Some("comparison") => Some("required"),
Some("roadmap") | Some("prd") | Some("brief") | Some("plan") => Some("preferred"),
_ => None,
}
}
fn should_enable_artifact_draft(request_metadata: Option<&Value>) -> bool {
if extract_harness_string(request_metadata, &["session_mode", "sessionMode"]).as_deref()
!= Some("theme_workbench")
{
return false;
}
if extract_harness_string(
request_metadata,
&["turn_purpose", "turnPurpose", "purpose"],
)
.is_some()
{
return false;
}
extract_harness_string(
request_metadata,
&["theme", "harness_theme", "harnessTheme"],
)
.as_deref()
.and_then(infer_artifact_kind)
.is_some()
}
fn is_meaningful_artifact_value(value: Option<&Value>) -> bool {
value
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty())
}
pub fn normalize_request_metadata_with_artifact_defaults(
request_metadata: Option<Value>,
) -> Option<Value> {
let request_metadata = request_metadata?;
let Some(root) = request_metadata.as_object() else {
return Some(request_metadata);
};
let request_metadata_ref = Some(&request_metadata);
let inferred_kind = extract_harness_string(
request_metadata_ref,
&["theme", "harness_theme", "harnessTheme"],
)
.as_deref()
.and_then(infer_artifact_kind)
.map(str::to_string);
let should_enable_draft = should_enable_artifact_draft(request_metadata_ref);
let content_id = extract_harness_string(request_metadata_ref, &["content_id", "contentId"]);
let mut artifact = extract_existing_artifact_object(request_metadata_ref).unwrap_or_default();
let explicit_artifact_kind =
extract_artifact_string(&artifact, &["artifact_kind", "artifactKind"]);
let artifact_mode = extract_artifact_string(&artifact, &["artifact_mode", "artifactMode"])
.or_else(|| should_enable_draft.then(|| "draft".to_string()));
let artifact_kind = explicit_artifact_kind
.clone()
.or_else(|| should_enable_draft.then(|| inferred_kind.clone()).flatten());
let artifact_stage = extract_artifact_string(&artifact, &["artifact_stage", "artifactStage"])
.or_else(|| should_enable_draft.then(|| "stage2".to_string()));
let source_policy = extract_artifact_string(&artifact, &["source_policy", "sourcePolicy"])
.or_else(|| {
if should_enable_draft || explicit_artifact_kind.is_some() {
infer_source_policy(artifact_kind.as_deref()).map(str::to_string)
} else {
None
}
});
let workbench_surface =
extract_artifact_string(&artifact, &["workbench_surface", "workbenchSurface"])
.or_else(|| should_enable_draft.then(|| "right_panel".to_string()));
if let Some(value) = artifact_mode.as_ref() {
artifact.insert("artifact_mode".to_string(), Value::String(value.clone()));
}
if let Some(value) = artifact_kind.as_ref() {
artifact.insert("artifact_kind".to_string(), Value::String(value.clone()));
}
if let Some(value) = artifact_stage.as_ref() {
artifact.insert("artifact_stage".to_string(), Value::String(value.clone()));
}
if let Some(value) = source_policy.as_ref() {
artifact.insert("source_policy".to_string(), Value::String(value.clone()));
}
if let Some(value) = workbench_surface.as_ref() {
artifact.insert(
"workbench_surface".to_string(),
Value::String(value.clone()),
);
}
if extract_artifact_string(&artifact, &["artifact_request_id", "artifactRequestId"]).is_none()
&& artifact_mode.as_deref() == Some("draft")
{
if let Some(content_id) = content_id {
artifact.insert(
"artifact_request_id".to_string(),
Value::String(format!("artifact:{content_id}")),
);
}
}
let mut normalized = root.clone();
let has_meaningful_artifact_metadata = ARTIFACT_MEANINGFUL_KEYS
.iter()
.any(|key| is_meaningful_artifact_value(artifact.get(*key)));
if has_meaningful_artifact_metadata {
normalized.insert("artifact".to_string(), Value::Object(artifact));
} else {
normalized.remove("artifact");
}
Some(Value::Object(normalized))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn should_infer_theme_workbench_artifact_defaults_from_harness() {
let metadata = json!({
"harness": {
"theme": "document",
"session_mode": "theme_workbench",
"content_id": "content-1"
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata))
.expect("normalized metadata");
assert_eq!(
normalized
.pointer("/artifact/artifact_mode")
.and_then(Value::as_str),
Some("draft")
);
assert_eq!(
normalized
.pointer("/artifact/artifact_kind")
.and_then(Value::as_str),
Some("report")
);
assert_eq!(
normalized
.pointer("/artifact/artifact_stage")
.and_then(Value::as_str),
Some("stage2")
);
assert_eq!(
normalized
.pointer("/artifact/source_policy")
.and_then(Value::as_str),
Some("required")
);
assert_eq!(
normalized
.pointer("/artifact/workbench_surface")
.and_then(Value::as_str),
Some("right_panel")
);
assert_eq!(
normalized
.pointer("/artifact/artifact_request_id")
.and_then(Value::as_str),
Some("artifact:content-1")
);
}
#[test]
fn should_skip_auto_draft_when_turn_purpose_is_present() {
let metadata = json!({
"harness": {
"theme": "document",
"session_mode": "theme_workbench",
"turn_purpose": "content_review",
"content_id": "content-1"
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata))
.expect("normalized metadata");
assert!(normalized.get("artifact").is_none());
}
#[test]
fn should_preserve_explicit_artifact_and_fill_missing_source_policy() {
let metadata = json!({
"harness": {
"theme": "knowledge",
"session_mode": "default"
},
"artifact": {
"artifact_kind": "analysis"
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata))
.expect("normalized metadata");
assert_eq!(
normalized
.pointer("/artifact/artifact_kind")
.and_then(Value::as_str),
Some("analysis")
);
assert_eq!(
normalized
.pointer("/artifact/source_policy")
.and_then(Value::as_str),
Some("required")
);
assert_eq!(
normalized
.pointer("/artifact/artifact_mode")
.and_then(Value::as_str),
None
);
}
}
+9
View File
@@ -5,6 +5,13 @@
// 保留在主 crate 的 Tauri 相关服务
pub mod agent_timeline_service;
pub mod artifact_diff_service;
pub mod artifact_document_service;
pub mod artifact_document_validator;
pub mod artifact_ops_service;
pub mod artifact_output_schema_service;
pub mod artifact_prompt_service;
pub mod artifact_request_metadata_service;
pub mod auto_memory_service;
pub mod automation_service;
pub mod browser_environment_service;
@@ -23,6 +30,8 @@ pub mod memory_source_resolver_service;
pub mod novel_service;
pub mod openclaw_service;
pub mod runtime_agents_template_service;
pub mod site_adapter_registry;
pub mod site_capability_service;
pub mod sysinfo_service;
pub mod thread_reliability_projection_service;
pub mod update_check_service;
@@ -0,0 +1,982 @@
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
const BUNDLED_ADAPTER_RELATIVE_DIR: &str = "resources/site-adapters/bundled";
const SERVER_SYNCED_ADAPTER_RELATIVE_DIR: &str = "site-adapters/server-synced";
const BUNDLED_INDEX_FALLBACK: &str =
include_str!("../../resources/site-adapters/bundled/index.json");
const DEFAULT_REGISTRY_VERSION: u32 = 1;
static TEMPLATE_TOKEN_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"\{\{\s*([a-zA-Z0-9_]+)(?:\s*\|\s*(urlencode))?\s*\}\}")
.expect("site adapter entry template regex should compile")
});
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SiteAdapterArgType {
String,
Integer,
}
impl SiteAdapterArgType {
pub fn schema_type(self) -> &'static str {
match self {
Self::String => "string",
Self::Integer => "integer",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SiteAdapterSourceKind {
Bundled,
ServerSynced,
}
impl SiteAdapterSourceKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Bundled => "bundled",
Self::ServerSynced => "server_synced",
}
}
}
#[derive(Debug, Clone)]
pub struct SiteAdapterArgSpec {
pub name: String,
pub description: String,
pub required: bool,
pub arg_type: SiteAdapterArgType,
pub example: Option<Value>,
}
#[derive(Debug, Clone)]
pub enum SiteAdapterEntrySpec {
FixedUrl { url: String },
UrlTemplate { template: String },
Builder { id: String },
}
#[derive(Debug, Clone)]
pub struct SiteAdapterSpec {
pub name: String,
pub domain: String,
pub description: String,
pub read_only: bool,
pub capabilities: Vec<String>,
pub args: Vec<SiteAdapterArgSpec>,
pub example: String,
pub auth_hint: Option<String>,
pub entry: SiteAdapterEntrySpec,
pub script: String,
pub source_kind: SiteAdapterSourceKind,
pub source_version: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct SiteAdapterRegistryDocument {
#[serde(default = "default_registry_version")]
registry_version: u32,
#[serde(default, alias = "catalogVersion", alias = "version")]
catalog_version: Option<String>,
#[serde(default, alias = "tenantId")]
tenant_id: Option<String>,
#[serde(default, alias = "syncedAt")]
synced_at: Option<String>,
adapters: Vec<SiteAdapterManifestEntry>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct SiteAdapterManifestEntry {
name: String,
domain: String,
description: String,
#[serde(default = "default_read_only", alias = "readOnly")]
read_only: bool,
#[serde(default)]
capabilities: Vec<String>,
#[serde(default)]
args: Vec<SiteAdapterArgManifest>,
example: String,
#[serde(default, alias = "authHint")]
auth_hint: Option<String>,
entry: SiteAdapterEntryManifest,
#[serde(alias = "scriptFile")]
script_file: String,
#[serde(default, alias = "sourceVersion")]
source_version: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct SiteAdapterArgManifest {
name: String,
description: String,
required: bool,
arg_type: SiteAdapterArgTypeManifest,
#[serde(default)]
example: Option<Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
enum SiteAdapterArgTypeManifest {
String,
Integer,
}
impl From<SiteAdapterArgTypeManifest> for SiteAdapterArgType {
fn from(value: SiteAdapterArgTypeManifest) -> Self {
match value {
SiteAdapterArgTypeManifest::String => Self::String,
SiteAdapterArgTypeManifest::Integer => Self::Integer,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum SiteAdapterEntryManifest {
FixedUrl { url: String },
UrlTemplate { template: String },
Builder { id: String },
}
impl From<SiteAdapterEntryManifest> for SiteAdapterEntrySpec {
fn from(value: SiteAdapterEntryManifest) -> Self {
match value {
SiteAdapterEntryManifest::FixedUrl { url } => Self::FixedUrl { url },
SiteAdapterEntryManifest::UrlTemplate { template } => Self::UrlTemplate { template },
SiteAdapterEntryManifest::Builder { id } => Self::Builder { id },
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct SiteAdapterCatalogStatus {
pub exists: bool,
pub source_kind: String,
pub registry_version: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub catalog_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub synced_at: Option<String>,
pub adapter_count: usize,
}
#[derive(Debug, Deserialize)]
struct SiteAdapterCatalogBootstrapDocument {
#[serde(default = "default_registry_version")]
registry_version: u32,
#[serde(default, alias = "catalogVersion", alias = "version")]
catalog_version: Option<String>,
#[serde(default, alias = "tenantId")]
tenant_id: Option<String>,
#[serde(default, alias = "syncedAt")]
synced_at: Option<String>,
adapters: Vec<SiteAdapterCatalogBootstrapEntry>,
}
#[derive(Debug, Deserialize)]
struct SiteAdapterCatalogBootstrapEntry {
name: String,
domain: String,
description: String,
#[serde(default = "default_read_only", alias = "readOnly")]
read_only: bool,
#[serde(default)]
capabilities: Vec<String>,
#[serde(default)]
args: Vec<SiteAdapterArgManifest>,
example: String,
#[serde(default, alias = "authHint")]
auth_hint: Option<String>,
entry: SiteAdapterEntryManifest,
#[serde(default, alias = "sourceVersion")]
source_version: Option<String>,
script: String,
}
pub fn normalize_site_adapter_name(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
pub fn load_site_adapter_specs() -> Result<Vec<SiteAdapterSpec>, String> {
let mut merged = BTreeMap::new();
for spec in load_bundled_site_adapters()? {
merged.insert(normalize_site_adapter_name(&spec.name), spec);
}
for spec in load_server_synced_site_adapters()? {
merged.insert(normalize_site_adapter_name(&spec.name), spec);
}
Ok(merged.into_values().collect())
}
pub fn find_site_adapter_spec(name: &str) -> Result<Option<SiteAdapterSpec>, String> {
let normalized = normalize_site_adapter_name(name);
let adapters = load_site_adapter_specs()?;
Ok(adapters
.into_iter()
.find(|spec| normalize_site_adapter_name(&spec.name) == normalized))
}
pub fn get_site_adapter_catalog_status() -> Result<SiteAdapterCatalogStatus, String> {
get_site_adapter_catalog_status_from_dir(resolve_server_synced_adapter_dir())
}
pub fn apply_site_adapter_catalog_bootstrap(
payload: &Value,
) -> Result<SiteAdapterCatalogStatus, String> {
let Some(dir) = resolve_server_synced_adapter_dir() else {
return Err("无法解析站点适配器缓存目录".to_string());
};
apply_site_adapter_catalog_bootstrap_to_dir(&dir, payload)
}
pub fn clear_site_adapter_catalog_cache() -> Result<SiteAdapterCatalogStatus, String> {
clear_site_adapter_catalog_cache_at_dir(resolve_server_synced_adapter_dir())
}
pub fn build_entry_url(
spec: &SiteAdapterSpec,
args: &Map<String, Value>,
) -> Result<String, String> {
match &spec.entry {
SiteAdapterEntrySpec::FixedUrl { url } => Ok(url.clone()),
SiteAdapterEntrySpec::UrlTemplate { template } => render_entry_template(template, args),
SiteAdapterEntrySpec::Builder { id } => build_entry_url_with_builder(id, args),
}
}
fn load_bundled_site_adapters() -> Result<Vec<SiteAdapterSpec>, String> {
if let Some(dir) = resolve_bundled_adapter_dir() {
return load_site_adapters_from_dir(&dir, SiteAdapterSourceKind::Bundled);
}
load_site_adapters_from_embedded_index(SiteAdapterSourceKind::Bundled)
}
fn load_server_synced_site_adapters() -> Result<Vec<SiteAdapterSpec>, String> {
let Some(dir) = resolve_server_synced_adapter_dir() else {
return Ok(Vec::new());
};
if !dir.exists() {
return Ok(Vec::new());
}
load_site_adapters_from_dir(&dir, SiteAdapterSourceKind::ServerSynced)
}
fn get_site_adapter_catalog_status_from_dir(
dir: Option<PathBuf>,
) -> Result<SiteAdapterCatalogStatus, String> {
let directory = dir.as_ref().map(|value| value.display().to_string());
let Some(dir) = dir else {
return Ok(SiteAdapterCatalogStatus {
exists: false,
source_kind: SiteAdapterSourceKind::ServerSynced.as_str().to_string(),
registry_version: default_registry_version(),
directory,
catalog_version: None,
tenant_id: None,
synced_at: None,
adapter_count: 0,
});
};
let index_path = dir.join("index.json");
if !index_path.exists() {
return Ok(SiteAdapterCatalogStatus {
exists: false,
source_kind: SiteAdapterSourceKind::ServerSynced.as_str().to_string(),
registry_version: default_registry_version(),
directory,
catalog_version: None,
tenant_id: None,
synced_at: None,
adapter_count: 0,
});
}
let content = fs::read_to_string(&index_path)
.map_err(|error| format!("读取站点适配器索引失败 {}: {error}", index_path.display()))?;
let document: SiteAdapterRegistryDocument = serde_json::from_str(&content)
.map_err(|error| format!("解析站点适配器索引失败: {error}"))?;
Ok(SiteAdapterCatalogStatus {
exists: true,
source_kind: SiteAdapterSourceKind::ServerSynced.as_str().to_string(),
registry_version: document.registry_version,
directory,
catalog_version: document.catalog_version,
tenant_id: document.tenant_id,
synced_at: document.synced_at,
adapter_count: document.adapters.len(),
})
}
fn apply_site_adapter_catalog_bootstrap_to_dir(
dir: &Path,
payload: &Value,
) -> Result<SiteAdapterCatalogStatus, String> {
let catalog_value = extract_site_adapter_catalog_from_bootstrap_payload(payload)
.ok_or_else(|| "payload 中未找到 siteAdapterCatalog".to_string())?;
let document = parse_site_adapter_catalog_bootstrap_document(catalog_value)?;
write_server_synced_catalog_to_dir(dir, document)?;
get_site_adapter_catalog_status_from_dir(Some(dir.to_path_buf()))
}
fn clear_site_adapter_catalog_cache_at_dir(
dir: Option<PathBuf>,
) -> Result<SiteAdapterCatalogStatus, String> {
let directory = dir.as_ref().map(|value| value.display().to_string());
let Some(dir) = dir else {
return Ok(SiteAdapterCatalogStatus {
exists: false,
source_kind: SiteAdapterSourceKind::ServerSynced.as_str().to_string(),
registry_version: default_registry_version(),
directory,
catalog_version: None,
tenant_id: None,
synced_at: None,
adapter_count: 0,
});
};
if dir.exists() {
fs::remove_dir_all(&dir)
.map_err(|error| format!("清理站点适配器缓存失败 {}: {error}", dir.display()))?;
}
get_site_adapter_catalog_status_from_dir(Some(dir))
}
fn load_site_adapters_from_dir(
dir: &Path,
source_kind: SiteAdapterSourceKind,
) -> Result<Vec<SiteAdapterSpec>, String> {
let index_path = dir.join("index.json");
let content = fs::read_to_string(&index_path)
.map_err(|error| format!("读取站点适配器索引失败 {}: {error}", index_path.display()))?;
load_site_adapters_from_str(&content, Some(dir), source_kind)
}
fn load_site_adapters_from_embedded_index(
source_kind: SiteAdapterSourceKind,
) -> Result<Vec<SiteAdapterSpec>, String> {
load_site_adapters_from_str(BUNDLED_INDEX_FALLBACK, None, source_kind)
}
fn load_site_adapters_from_str(
content: &str,
dir: Option<&Path>,
source_kind: SiteAdapterSourceKind,
) -> Result<Vec<SiteAdapterSpec>, String> {
let document: SiteAdapterRegistryDocument = serde_json::from_str(content)
.map_err(|error| format!("解析站点适配器索引失败: {error}"))?;
document
.adapters
.into_iter()
.map(|entry| manifest_entry_to_spec(entry, dir, source_kind))
.collect()
}
fn manifest_entry_to_spec(
entry: SiteAdapterManifestEntry,
dir: Option<&Path>,
source_kind: SiteAdapterSourceKind,
) -> Result<SiteAdapterSpec, String> {
let script = if let Some(base_dir) = dir {
let script_path = base_dir.join(&entry.script_file);
fs::read_to_string(&script_path)
.map_err(|error| format!("读取站点适配器脚本失败 {}: {error}", script_path.display()))?
} else {
load_embedded_bundled_script(&entry.script_file)?.to_string()
};
Ok(SiteAdapterSpec {
name: entry.name,
domain: entry.domain,
description: entry.description,
read_only: entry.read_only,
capabilities: entry.capabilities,
args: entry
.args
.into_iter()
.map(|arg| SiteAdapterArgSpec {
name: arg.name,
description: arg.description,
required: arg.required,
arg_type: arg.arg_type.into(),
example: arg.example,
})
.collect(),
example: entry.example,
auth_hint: entry.auth_hint,
entry: entry.entry.into(),
script,
source_kind,
source_version: entry.source_version,
})
}
fn extract_site_adapter_catalog_from_bootstrap_payload<'a>(
payload: &'a Value,
) -> Option<&'a Value> {
if looks_like_site_adapter_catalog_document(payload) {
return Some(payload);
}
let record = payload.as_object()?;
for key in [
"siteAdapterCatalog",
"site_adapter_catalog",
"bootstrap",
"data",
] {
if let Some(nested) = record.get(key) {
if let Some(found) = extract_site_adapter_catalog_from_bootstrap_payload(nested) {
return Some(found);
}
}
}
None
}
fn looks_like_site_adapter_catalog_document(payload: &Value) -> bool {
payload
.as_object()
.and_then(|record| record.get("adapters"))
.is_some_and(Value::is_array)
}
fn parse_site_adapter_catalog_bootstrap_document(
payload: &Value,
) -> Result<SiteAdapterCatalogBootstrapDocument, String> {
serde_json::from_value::<SiteAdapterCatalogBootstrapDocument>(payload.clone())
.map_err(|error| format!("解析站点适配器 bootstrap payload 失败: {error}"))
}
fn write_server_synced_catalog_to_dir(
dir: &Path,
payload: SiteAdapterCatalogBootstrapDocument,
) -> Result<(), String> {
if dir.exists() {
fs::remove_dir_all(dir)
.map_err(|error| format!("清理旧站点适配器目录失败 {}: {error}", dir.display()))?;
}
fs::create_dir_all(dir.join("scripts"))
.map_err(|error| format!("创建站点适配器目录失败 {}: {error}", dir.display()))?;
let mut seen_names = BTreeSet::new();
let mut adapters = Vec::with_capacity(payload.adapters.len());
for entry in payload.adapters {
let normalized_name = normalize_site_adapter_name(&entry.name);
if normalized_name.is_empty() {
return Err("站点适配器 name 不能为空".to_string());
}
if !seen_names.insert(normalized_name) {
return Err(format!("站点适配器重复: {}", entry.name));
}
let script = normalize_required_text(&entry.script, "script")?;
let script_file = build_server_synced_script_file(&entry.name);
let script_path = dir.join(&script_file);
if let Some(parent) = script_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!("创建站点适配器脚本目录失败 {}: {error}", parent.display())
})?;
}
fs::write(&script_path, script).map_err(|error| {
format!("写入站点适配器脚本失败 {}: {error}", script_path.display())
})?;
adapters.push(SiteAdapterManifestEntry {
name: normalize_required_text(&entry.name, "name")?,
domain: normalize_required_text(&entry.domain, "domain")?,
description: normalize_required_text(&entry.description, "description")?,
read_only: entry.read_only,
capabilities: entry.capabilities,
args: entry.args,
example: normalize_required_text(&entry.example, "example")?,
auth_hint: normalize_optional_text(entry.auth_hint),
entry: entry.entry,
script_file,
source_version: normalize_optional_text(entry.source_version),
});
}
let document = SiteAdapterRegistryDocument {
registry_version: if payload.registry_version == 0 {
default_registry_version()
} else {
payload.registry_version
},
catalog_version: normalize_optional_text(payload.catalog_version),
tenant_id: normalize_optional_text(payload.tenant_id),
synced_at: normalize_optional_text(payload.synced_at),
adapters,
};
let index_path = dir.join("index.json");
let content = serde_json::to_string_pretty(&document)
.map_err(|error| format!("序列化站点适配器索引失败: {error}"))?;
fs::write(&index_path, content)
.map_err(|error| format!("写入站点适配器索引失败 {}: {error}", index_path.display()))
}
fn load_embedded_bundled_script(script_file: &str) -> Result<&'static str, String> {
match script_file {
"scripts/36kr-newsflash.js" => Ok(include_str!(
"../../resources/site-adapters/bundled/scripts/36kr-newsflash.js"
)),
"scripts/bilibili-search.js" => Ok(include_str!(
"../../resources/site-adapters/bundled/scripts/bilibili-search.js"
)),
"scripts/github-issues.js" => Ok(include_str!(
"../../resources/site-adapters/bundled/scripts/github-issues.js"
)),
"scripts/github-search.js" => Ok(include_str!(
"../../resources/site-adapters/bundled/scripts/github-search.js"
)),
"scripts/zhihu-hot.js" => Ok(include_str!(
"../../resources/site-adapters/bundled/scripts/zhihu-hot.js"
)),
"scripts/zhihu-search.js" => Ok(include_str!(
"../../resources/site-adapters/bundled/scripts/zhihu-search.js"
)),
_ => Err(format!("未注册的 bundled 适配器脚本: {script_file}")),
}
}
fn resolve_bundled_adapter_dir() -> Option<PathBuf> {
let dev_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/site-adapters/bundled");
if dev_dir.exists() {
return Some(dev_dir);
}
resolve_packaged_resource_root()
.map(|root| root.join(BUNDLED_ADAPTER_RELATIVE_DIR))
.filter(|dir| dir.exists())
}
fn resolve_server_synced_adapter_dir() -> Option<PathBuf> {
lime_core::app_paths::preferred_data_dir()
.ok()
.map(|root| root.join(SERVER_SYNCED_ADAPTER_RELATIVE_DIR))
}
fn resolve_packaged_resource_root() -> Option<PathBuf> {
let mut path = std::env::current_exe().ok()?;
path.pop();
#[cfg(target_os = "macos")]
{
path.pop();
path.push("Resources");
}
Some(path)
}
fn build_server_synced_script_file(adapter_name: &str) -> String {
let file_name = sanitize_path_segment(adapter_name);
format!("scripts/{file_name}.js")
}
fn sanitize_path_segment(value: &str) -> String {
let mut sanitized = String::with_capacity(value.len());
let mut last_was_dash = false;
for ch in value.chars() {
let normalized = if ch.is_ascii_alphanumeric() {
Some(ch.to_ascii_lowercase())
} else if matches!(ch, '/' | '\\' | '-' | '_' | ' ') {
Some('-')
} else {
None
};
let Some(next_char) = normalized else {
continue;
};
if next_char == '-' {
if last_was_dash {
continue;
}
last_was_dash = true;
sanitized.push(next_char);
continue;
}
last_was_dash = false;
sanitized.push(next_char);
}
let trimmed = sanitized.trim_matches('-');
if trimmed.is_empty() {
"adapter".to_string()
} else {
trimmed.to_string()
}
}
fn normalize_required_text(value: &str, field: &str) -> Result<String, String> {
let normalized = value.trim();
if normalized.is_empty() {
return Err(format!("站点适配器字段 {field} 不能为空"));
}
Ok(normalized.to_string())
}
fn normalize_optional_text(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let normalized = item.trim();
if normalized.is_empty() {
None
} else {
Some(normalized.to_string())
}
})
}
fn render_entry_template(template: &str, args: &Map<String, Value>) -> Result<String, String> {
let mut missing_arg = None::<String>;
let rendered = TEMPLATE_TOKEN_REGEX.replace_all(template, |captures: &regex::Captures<'_>| {
let arg_name = captures
.get(1)
.map(|value| value.as_str())
.unwrap_or_default();
let Some(raw_value) = args.get(arg_name) else {
missing_arg = Some(arg_name.to_string());
return String::new();
};
let string_value = match value_to_url_token(raw_value) {
Some(value) => value,
None => {
missing_arg = Some(arg_name.to_string());
return String::new();
}
};
match captures.get(2).map(|value| value.as_str()) {
Some("urlencode") => urlencoding::encode(&string_value).into_owned(),
_ => string_value,
}
});
if let Some(arg_name) = missing_arg {
return Err(format!("缺少入口 URL 所需参数: {arg_name}"));
}
Ok(rendered.into_owned())
}
fn build_entry_url_with_builder(id: &str, args: &Map<String, Value>) -> Result<String, String> {
match id {
"github_issues" => build_github_issues_url(args),
_ => Err(format!("不支持的入口构造器: {id}")),
}
}
fn build_github_issues_url(args: &Map<String, Value>) -> Result<String, String> {
let repo = get_required_string_arg(args, "repo")?;
let query = get_optional_string_arg(args, "query");
let state = get_optional_string_arg(args, "state");
let mut query_parts = Vec::new();
if let Some(value) = query {
query_parts.push(value);
}
if let Some(value) = state {
match value.as_str() {
"open" | "closed" => query_parts.push(format!("state:{value}")),
"all" => {}
_ => return Err("state 仅支持 open / closed / all".to_string()),
}
}
if query_parts.is_empty() {
Ok(format!("https://github.com/{repo}/issues"))
} else {
Ok(format!(
"https://github.com/{repo}/issues?q={}",
urlencoding::encode(&query_parts.join(" "))
))
}
}
fn get_required_string_arg(args: &Map<String, Value>, key: &str) -> Result<String, String> {
get_optional_string_arg(args, key).ok_or_else(|| format!("参数 {key} 不能为空"))
}
fn get_optional_string_arg(args: &Map<String, Value>, key: &str) -> Option<String> {
args.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn value_to_url_token(value: &Value) -> Option<String> {
value
.as_str()
.map(str::trim)
.filter(|raw| !raw.is_empty())
.map(ToString::to_string)
.or_else(|| value.as_i64().map(|raw| raw.to_string()))
.or_else(|| value.as_u64().map(|raw| raw.to_string()))
}
fn default_read_only() -> bool {
true
}
fn default_registry_version() -> u32 {
DEFAULT_REGISTRY_VERSION
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn should_load_bundled_registry_from_resources() {
let adapters = load_bundled_site_adapters().expect("bundled adapters should load");
assert!(adapters
.iter()
.any(|adapter| adapter.name == "github/search"));
let github = adapters
.iter()
.find(|adapter| adapter.name == "github/search")
.expect("github/search should exist");
assert_eq!(github.source_kind, SiteAdapterSourceKind::Bundled);
assert_eq!(github.source_version.as_deref(), Some("2026-03-25"));
assert!(github.script.contains("a.v-align-middle"));
}
#[test]
fn should_render_url_template_with_urlencode() {
let spec = SiteAdapterSpec {
name: "github/search".to_string(),
domain: "github.com".to_string(),
description: String::new(),
read_only: true,
capabilities: Vec::new(),
args: Vec::new(),
example: String::new(),
auth_hint: None,
entry: SiteAdapterEntrySpec::UrlTemplate {
template: "https://github.com/search?q={{query|urlencode}}".to_string(),
},
script: String::new(),
source_kind: SiteAdapterSourceKind::Bundled,
source_version: None,
};
let mut args = Map::new();
args.insert(
"query".to_string(),
Value::String("model context protocol".to_string()),
);
let url = build_entry_url(&spec, &args).expect("template url should render");
assert_eq!(
url,
"https://github.com/search?q=model%20context%20protocol"
);
}
#[test]
fn should_allow_server_synced_adapters_to_override_bundled_name() {
let temp_dir = tempdir().expect("temp dir should exist");
let dir = temp_dir.path();
fs::create_dir_all(dir.join("scripts")).expect("scripts dir should exist");
fs::write(
dir.join("index.json"),
r#"
{
"adapters": [
{
"name": "github/search",
"domain": "github.com",
"description": "server synced",
"read_only": true,
"capabilities": ["search"],
"args": [],
"example": "github/search {}",
"entry": {
"kind": "fixed_url",
"url": "https://github.com/search"
},
"script_file": "scripts/github-search.js",
"source_version": "sync-1"
}
]
}
"#,
)
.expect("index should write");
fs::write(
dir.join("scripts/github-search.js"),
"async () => ({ ok: true })",
)
.expect("script should write");
let adapters = load_site_adapters_from_dir(dir, SiteAdapterSourceKind::ServerSynced)
.expect("server synced adapters should load");
assert_eq!(adapters.len(), 1);
assert_eq!(adapters[0].name, "github/search");
assert_eq!(adapters[0].source_kind, SiteAdapterSourceKind::ServerSynced);
assert_eq!(adapters[0].source_version.as_deref(), Some("sync-1"));
}
#[test]
fn should_extract_site_adapter_catalog_from_nested_bootstrap_payload() {
let payload = serde_json::json!({
"data": {
"bootstrap": {
"siteAdapterCatalog": {
"catalogVersion": "tenant-sync-1",
"adapters": []
}
}
}
});
let extracted = extract_site_adapter_catalog_from_bootstrap_payload(&payload)
.expect("nested catalog should extract");
assert_eq!(
extracted["catalogVersion"],
Value::String("tenant-sync-1".to_string())
);
}
#[test]
fn should_persist_server_synced_bootstrap_catalog_with_inline_scripts() {
let temp_dir = tempdir().expect("temp dir should exist");
let payload = serde_json::json!({
"bootstrap": {
"siteAdapterCatalog": {
"catalogVersion": "tenant-sync-1",
"tenantId": "tenant-demo",
"syncedAt": "2026-03-25T10:00:00.000Z",
"adapters": [
{
"name": "github/search",
"domain": "github.com",
"description": "server synced github search",
"read_only": true,
"capabilities": ["search"],
"args": [],
"example": "github/search {\"query\":\"lime\"}",
"entry": {
"kind": "fixed_url",
"url": "https://github.com/search"
},
"script": "async () => ({ items: [] })",
"sourceVersion": "tenant-sync-1"
}
]
}
}
});
let status = apply_site_adapter_catalog_bootstrap_to_dir(temp_dir.path(), &payload)
.expect("bootstrap catalog should persist");
assert_eq!(
status,
SiteAdapterCatalogStatus {
exists: true,
source_kind: "server_synced".to_string(),
registry_version: 1,
directory: Some(temp_dir.path().display().to_string()),
catalog_version: Some("tenant-sync-1".to_string()),
tenant_id: Some("tenant-demo".to_string()),
synced_at: Some("2026-03-25T10:00:00.000Z".to_string()),
adapter_count: 1,
}
);
let index_content = fs::read_to_string(temp_dir.path().join("index.json"))
.expect("index.json should exist");
assert!(index_content.contains("\"catalog_version\": \"tenant-sync-1\""));
assert!(index_content.contains("\"script_file\": \"scripts/github-search.js\""));
let script_content = fs::read_to_string(temp_dir.path().join("scripts/github-search.js"))
.expect("script file should exist");
assert_eq!(script_content, "async () => ({ items: [] })");
let adapters =
load_site_adapters_from_dir(temp_dir.path(), SiteAdapterSourceKind::ServerSynced)
.expect("persisted adapters should load");
assert_eq!(adapters.len(), 1);
assert_eq!(adapters[0].name, "github/search");
assert_eq!(adapters[0].source_version.as_deref(), Some("tenant-sync-1"));
}
#[test]
fn should_clear_server_synced_catalog_cache() {
let temp_dir = tempdir().expect("temp dir should exist");
let payload = serde_json::json!({
"siteAdapterCatalog": {
"catalogVersion": "tenant-sync-1",
"adapters": [
{
"name": "zhihu/hot",
"domain": "www.zhihu.com",
"description": "server synced zhihu hot",
"read_only": true,
"capabilities": ["hot"],
"args": [],
"example": "zhihu/hot {}",
"entry": {
"kind": "fixed_url",
"url": "https://www.zhihu.com/hot"
},
"script": "async () => ({ items: [] })"
}
]
}
});
apply_site_adapter_catalog_bootstrap_to_dir(temp_dir.path(), &payload)
.expect("bootstrap catalog should persist");
let status = clear_site_adapter_catalog_cache_at_dir(Some(temp_dir.path().to_path_buf()))
.expect("cache should clear");
assert!(!status.exists);
assert_eq!(status.adapter_count, 0);
assert!(!temp_dir.path().exists());
}
#[test]
fn should_build_github_issues_url_with_state_filter() {
let mut args = Map::new();
args.insert(
"repo".to_string(),
Value::String("rust-lang/rust".to_string()),
);
args.insert(
"query".to_string(),
Value::String("borrow checker".to_string()),
);
args.insert("state".to_string(), Value::String("open".to_string()));
let url = build_entry_url_with_builder("github_issues", &args)
.expect("github issues url should build");
assert!(url.contains("rust-lang/rust/issues"));
assert!(url.contains("borrow%20checker"));
assert!(url.contains("state%3Aopen"));
}
}
File diff suppressed because it is too large Load Diff
@@ -183,6 +183,7 @@ mod tests {
workspace_id: None,
messages: Vec::new(),
execution_strategy: None,
execution_runtime: None,
turns: Vec::new(),
items: Vec::new(),
todo_items: Vec::new(),
+1
View File
@@ -202,6 +202,7 @@ mod tests {
success: true,
output: Some("Hello, world!".to_string()),
error: None,
artifact_paths: vec![],
steps_completed: vec![StepResult {
step_id: "step-1".to_string(),
step_name: "Process".to_string(),
+11 -9
View File
@@ -4,9 +4,10 @@
//! 本模块只保留 Tauri emitter 与错误码映射。
use lime_agent::{
artifact_protocol::extend_unique_artifact_protocol_paths,
execute_skill_prompt as execute_agent_skill_prompt,
execute_skill_workflow as execute_agent_skill_workflow, AsterAgentState, SkillEventEmitter,
SkillExecutionError, SkillWorkflowExecution, TauriAgentEvent,
execute_skill_workflow as execute_agent_skill_workflow, AgentEvent as RuntimeAgentEvent,
AsterAgentState, SkillEventEmitter, SkillExecutionError, SkillWorkflowExecution,
};
use lime_skills::{ExecutionCallback, LoadedSkillDefinition};
use std::sync::{Arc, Mutex};
@@ -89,7 +90,7 @@ impl ExecutionCallback for TauriExecutionCallbackAdapter<'_> {
fn create_skill_event_emitter(app_handle: &AppHandle) -> SkillEventEmitter {
let app_handle = app_handle.clone();
Arc::new(move |event_name: String, event: TauriAgentEvent| {
Arc::new(move |event_name: String, event: RuntimeAgentEvent| {
if let Err(error) = app_handle.emit(&event_name, &event) {
tracing::error!("[execute_skill_workflow] 发送事件失败: {}", error);
}
@@ -98,7 +99,7 @@ fn create_skill_event_emitter(app_handle: &AppHandle) -> SkillEventEmitter {
fn emit_skill_final_done(app_handle: &AppHandle, execution_id: &str) {
let event_name = format!("skill-exec-{execution_id}");
if let Err(error) = app_handle.emit(&event_name, TauriAgentEvent::FinalDone { usage: None }) {
if let Err(error) = app_handle.emit(&event_name, RuntimeAgentEvent::FinalDone { usage: None }) {
tracing::error!("[execute_skill] 发送完成事件失败: {}", error);
}
}
@@ -274,20 +275,21 @@ pub async fn execute_skill_prompt(
return Ok(result);
}
let final_output = finalize_skill_output(
let finalized = finalize_skill_output(
app_handle,
&skill.skill_name,
user_input,
execution_id,
result.output.as_deref().unwrap_or(""),
);
result.output = Some(final_output.clone());
extend_unique_artifact_protocol_paths(&mut result.artifact_paths, &finalized.artifact_paths);
result.output = Some(finalized.final_output.clone());
if let Some(step_result) = result.steps_completed.get_mut(0) {
step_result.output = Some(final_output.clone());
step_result.output = Some(finalized.final_output.clone());
}
callback_adapter.on_step_complete("main", &final_output);
callback_adapter.on_complete(true, Some(&final_output), None);
callback_adapter.on_step_complete("main", &finalized.final_output);
callback_adapter.on_complete(true, Some(&finalized.final_output), None);
emit_skill_final_done(app_handle, execution_id);
Ok(result)
}
+1 -1
View File
@@ -24,7 +24,7 @@ pub use runtime::{
build_skill_run_finish_decision, build_skill_run_start_metadata, prepare_skill_execution,
PreparedSkillExecution, SkillProviderSelection,
};
pub use social_post::{collect_social_artifact_paths_from_output, infer_theme_workbench_gate_key};
pub use social_post::infer_theme_workbench_gate_key;
// Tauri 实现(留在主 crate)
pub use default_skills::ensure_default_local_skills;
pub use execution_callback::TauriExecutionCallback;
+48 -4
View File
@@ -17,9 +17,7 @@ use std::path::Path;
use super::execution::SkillExecutionResult;
use super::execution_callback::TauriExecutionCallback;
use super::social_post::{
collect_social_artifact_paths_from_output, infer_theme_workbench_gate_key,
};
use super::social_post::infer_theme_workbench_gate_key;
#[derive(Debug, Clone)]
pub struct SkillProviderSelection {
@@ -335,7 +333,7 @@ pub fn build_skill_run_finish_decision(
provider_override,
model_override,
provider_selection,
collect_social_artifact_paths_from_output(execution.output.as_deref()),
execution.artifact_paths.clone(),
)),
},
Ok(execution) => RunFinishDecision {
@@ -366,3 +364,49 @@ pub fn build_skill_run_finish_decision(
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skills::StepResult;
#[test]
fn test_build_skill_run_finish_decision_uses_execution_artifact_paths() {
let result = Ok(SkillExecutionResult {
success: true,
output: Some("纯文本输出,不含 write_file block".to_string()),
error: None,
artifact_paths: vec![
"social-posts/demo.md".to_string(),
"social-posts/demo.cover.json".to_string(),
"social-posts/demo.publish-pack.json".to_string(),
],
steps_completed: vec![StepResult {
step_id: "main".to_string(),
step_name: "social_post_with_cover".to_string(),
success: true,
output: Some("done".to_string()),
error: None,
}],
});
let decision = build_skill_run_finish_decision(
SOCIAL_POST_WITH_COVER_SKILL_NAME,
"exec-1",
None,
None,
None,
&result,
);
let metadata = decision.metadata.expect("metadata should exist");
assert_eq!(
metadata["artifact_paths"],
serde_json::json!([
"social-posts/demo.md",
"social-posts/demo.cover.json",
"social-posts/demo.publish-pack.json"
])
);
}
}
+118 -94
View File
@@ -1,6 +1,6 @@
use crate::agent::TauriAgentEvent;
use crate::agent::AgentEvent as RuntimeAgentEvent;
use chrono::Utc;
use lime_agent::event_converter::{TauriArtifactSnapshot, TauriToolResult};
use lime_agent::{build_write_tool_artifact_events, AgentToolResult};
use tauri::{AppHandle, Emitter};
const SOCIAL_POST_WITH_COVER_SKILL_NAME: &str = "social_post_with_cover";
@@ -11,6 +11,12 @@ 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)]
pub struct FinalizedSkillOutput {
pub final_output: String,
pub artifact_paths: Vec<String>,
}
#[derive(Debug, Clone)]
struct SocialSkillOutputEnvelope {
final_output: String,
@@ -50,13 +56,17 @@ pub fn finalize_skill_output(
user_input: &str,
execution_id: &str,
raw_output: &str,
) -> String {
) -> FinalizedSkillOutput {
let Some(social_output) =
normalize_social_post_output(skill_name, user_input, execution_id, raw_output)
else {
return raw_output.to_string();
return FinalizedSkillOutput {
final_output: raw_output.to_string(),
artifact_paths: Vec::new(),
};
};
let artifact_paths = build_social_artifact_paths(&social_output.file_path);
emit_social_write_file_events(
app_handle,
execution_id,
@@ -72,21 +82,10 @@ pub fn finalize_skill_output(
emit_social_write_file_events(app_handle, execution_id, &artifact_path, &artifact_content);
}
social_output.final_output
}
pub 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]
FinalizedSkillOutput {
final_output: social_output.final_output,
artifact_paths,
}
}
fn normalize_social_post_output(
@@ -243,6 +242,11 @@ fn derive_social_auxiliary_paths(article_path: &str) -> (String, String) {
)
}
fn build_social_artifact_paths(article_path: &str) -> Vec<String> {
let (cover_meta_path, publish_pack_path) = derive_social_auxiliary_paths(article_path);
vec![article_path.to_string(), cover_meta_path, publish_pack_path]
}
fn summarize_social_content(content: &str) -> String {
let compact = content
.lines()
@@ -375,6 +379,28 @@ fn build_social_tool_event_id(execution_id: &str, file_path: &str) -> String {
format!("social-write-{execution_id}-{hash:08x}")
}
fn build_social_write_tool_events(
execution_id: &str,
file_path: &str,
file_content: &str,
) -> Vec<RuntimeAgentEvent> {
let tool_id = build_social_tool_event_id(execution_id, file_path);
build_write_tool_artifact_events(
&format!("skill-exec-{execution_id}"),
SOCIAL_POST_WRITE_TOOL_NAME,
&tool_id,
file_path,
file_content,
AgentToolResult {
success: true,
output: format!("写入社媒文稿: {file_path}"),
error: None,
images: None,
metadata: None,
},
)
}
fn emit_social_write_file_events(
app_handle: &AppHandle,
execution_id: &str,
@@ -382,78 +408,10 @@ fn emit_social_write_file_events(
file_content: &str,
) {
let event_name = format!("skill-exec-{execution_id}");
let tool_id = build_social_tool_event_id(execution_id, file_path);
let artifact_id = format!("{tool_id}:artifact");
let arguments = serde_json::json!({
"path": file_path,
"content": file_content,
})
.to_string();
let preview_text = file_content.trim().chars().take(480).collect::<String>();
let latest_chunk = file_content
.trim()
.chars()
.rev()
.take(240)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<String>();
let mut artifact_metadata = std::collections::HashMap::from([
("complete".to_string(), serde_json::json!(true)),
("writePhase".to_string(), serde_json::json!("persisted")),
("isPartial".to_string(), serde_json::json!(false)),
(
"lastUpdateSource".to_string(),
serde_json::json!("tool_result"),
),
]);
if !preview_text.is_empty() {
artifact_metadata.insert("previewText".to_string(), serde_json::json!(preview_text));
}
if !latest_chunk.is_empty() {
artifact_metadata.insert("latestChunk".to_string(), serde_json::json!(latest_chunk));
}
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 artifact_snapshot = TauriAgentEvent::ArtifactSnapshot {
artifact: TauriArtifactSnapshot {
artifact_id: artifact_id.clone(),
file_path: file_path.to_string(),
content: Some(file_content.to_string()),
metadata: Some(artifact_metadata.clone()),
},
};
if let Err(err) = app_handle.emit(&event_name, &artifact_snapshot) {
tracing::warn!("[execute_skill] 发送社媒产物快照事件失败: {}", err);
}
let mut tool_end_metadata = artifact_metadata;
tool_end_metadata.insert("artifact_streamed".to_string(), serde_json::json!(true));
tool_end_metadata.insert("artifact_id".to_string(), serde_json::json!(artifact_id));
tool_end_metadata.insert("artifact_path".to_string(), serde_json::json!(file_path));
tool_end_metadata.insert("path".to_string(), serde_json::json!(file_path));
tool_end_metadata.insert("file_path".to_string(), serde_json::json!(file_path));
let tool_end = TauriAgentEvent::ToolEnd {
tool_id,
result: TauriToolResult {
success: true,
output: format!("写入社媒文稿: {file_path}"),
error: None,
images: None,
metadata: Some(tool_end_metadata),
},
};
if let Err(err) = app_handle.emit(&event_name, &tool_end) {
tracing::warn!("[execute_skill] 发送社媒写入工具完成事件失败: {}", err);
for event in build_social_write_tool_events(execution_id, file_path, file_content) {
if let Err(err) = app_handle.emit(&event_name, &event) {
tracing::warn!("[execute_skill] 发送社媒写入事件失败: {}", err);
}
}
}
@@ -540,15 +498,81 @@ mod tests {
}
#[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));
fn test_build_social_artifact_paths_should_expand_auxiliary_files() {
let paths = build_social_artifact_paths("social-posts/demo.md");
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_write_tool_events_reuses_unified_artifact_emitter() {
let events =
build_social_write_tool_events("exec123", "social-posts/demo.md", "# 标题\n\n正文");
assert_eq!(events.len(), 4);
match &events[0] {
RuntimeAgentEvent::ArtifactSnapshot { artifact } => {
assert_eq!(artifact.file_path, "social-posts/demo.md");
assert_eq!(
artifact
.metadata
.as_ref()
.and_then(|metadata| metadata.get("writePhase"))
.and_then(serde_json::Value::as_str),
Some("streaming")
);
}
other => panic!("expected ArtifactSnapshot, got {other:?}"),
}
match &events[1] {
RuntimeAgentEvent::ToolStart { tool_name, .. } => {
assert_eq!(tool_name, SOCIAL_POST_WRITE_TOOL_NAME);
}
other => panic!("expected ToolStart, got {other:?}"),
}
match &events[2] {
RuntimeAgentEvent::ArtifactSnapshot { artifact } => {
assert_eq!(artifact.file_path, "social-posts/demo.md");
assert_eq!(
artifact
.metadata
.as_ref()
.and_then(|metadata| metadata.get("writePhase"))
.and_then(serde_json::Value::as_str),
Some("completed")
);
}
other => panic!("expected ArtifactSnapshot, got {other:?}"),
}
match &events[3] {
RuntimeAgentEvent::ToolEnd { result, .. } => {
assert_eq!(
result
.metadata
.as_ref()
.and_then(|metadata| metadata.get("artifact_streamed"))
.and_then(serde_json::Value::as_bool),
Some(true)
);
assert_eq!(
result
.metadata
.as_ref()
.and_then(|metadata| metadata.get("file_path"))
.and_then(serde_json::Value::as_str),
Some("social-posts/demo.md")
);
}
other => panic!("expected ToolEnd, got {other:?}"),
}
}
#[test]
fn test_build_social_post_slug_fallback_to_post() {
assert_eq!(build_social_post_slug(""), "post");
+19
View File
@@ -21,6 +21,14 @@ fn normalize_shortcut(value: Option<String>) -> Option<String> {
})
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct VoiceShortcutRuntimeStatus {
pub shortcut_registered: bool,
pub registered_shortcut: Option<String>,
pub translate_shortcut_registered: bool,
pub registered_translate_shortcut: Option<String>,
}
/// 获取所有可用的麦克风设备
#[command]
pub async fn list_audio_devices() -> Result<Vec<AudioDeviceInfo>, String> {
@@ -33,6 +41,17 @@ pub async fn get_voice_input_config() -> Result<VoiceInputConfig, String> {
config::load_voice_config()
}
/// 获取语音快捷键运行时状态
#[command]
pub async fn get_voice_shortcut_runtime_status() -> Result<VoiceShortcutRuntimeStatus, String> {
Ok(VoiceShortcutRuntimeStatus {
shortcut_registered: super::shortcut::is_registered(),
registered_shortcut: super::shortcut::get_current(),
translate_shortcut_registered: super::shortcut::is_translate_registered(),
registered_translate_shortcut: super::shortcut::get_current_translate(),
})
}
/// 保存语音输入配置
#[command]
pub async fn save_voice_input_config(
+62
View File
@@ -166,6 +166,11 @@ pub fn is_registered() -> bool {
IS_REGISTERED.load(Ordering::SeqCst)
}
/// 获取当前已注册的主语音快捷键
pub fn get_current() -> Option<String> {
get_current_shortcut().read().clone()
}
/// 注册翻译模式快捷键
pub fn register_translate(
app: &AppHandle,
@@ -274,6 +279,16 @@ pub fn unregister_translate(app: &AppHandle) -> Result<(), String> {
Ok(())
}
/// 检查翻译快捷键是否已注册
pub fn is_translate_registered() -> bool {
IS_TRANSLATE_REGISTERED.load(Ordering::SeqCst)
}
/// 获取当前已注册的翻译快捷键
pub fn get_current_translate() -> Option<String> {
get_translate_shortcut().read().clone()
}
/// 更新翻译快捷键
pub fn update_translate(
app: &AppHandle,
@@ -311,3 +326,50 @@ pub fn update_translate(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn reset_runtime_state() {
IS_REGISTERED.store(false, Ordering::SeqCst);
IS_TRANSLATE_REGISTERED.store(false, Ordering::SeqCst);
*get_current_shortcut().write() = None;
*get_translate_shortcut().write() = None;
}
#[test]
fn test_main_shortcut_runtime_accessors_reflect_state() {
reset_runtime_state();
assert!(!is_registered());
assert_eq!(get_current(), None);
IS_REGISTERED.store(true, Ordering::SeqCst);
*get_current_shortcut().write() = Some("CommandOrControl+Shift+V".to_string());
assert!(is_registered());
assert_eq!(get_current().as_deref(), Some("CommandOrControl+Shift+V"));
reset_runtime_state();
}
#[test]
fn test_translate_shortcut_runtime_accessors_reflect_state() {
reset_runtime_state();
assert!(!is_translate_registered());
assert_eq!(get_current_translate(), None);
IS_TRANSLATE_REGISTERED.store(true, Ordering::SeqCst);
*get_translate_shortcut().write() = Some("CommandOrControl+Shift+T".to_string());
assert!(is_translate_registered());
assert_eq!(
get_current_translate().as_deref(),
Some("CommandOrControl+Shift+T")
);
reset_runtime_state();
}
}