mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v1.6.0
This commit is contained in:
@@ -8,6 +8,9 @@ fn build_agent_status(
|
||||
initialized,
|
||||
provider_configured: provider_config.is_some(),
|
||||
provider_name: provider_config.as_ref().map(|c| c.provider_name.clone()),
|
||||
provider_selector: provider_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.provider_selector.clone()),
|
||||
model_name: provider_config.as_ref().map(|c| c.model_name.clone()),
|
||||
credential_uuid: provider_config.and_then(|c| c.credential_uuid),
|
||||
}
|
||||
@@ -76,6 +79,7 @@ pub async fn aster_agent_configure_provider(
|
||||
initialized: true,
|
||||
provider_configured: true,
|
||||
provider_name: Some(config.provider_name),
|
||||
provider_selector: config.provider_selector,
|
||||
model_name: Some(config.model_name),
|
||||
credential_uuid: None,
|
||||
})
|
||||
@@ -111,6 +115,7 @@ pub async fn aster_agent_configure_from_pool(
|
||||
initialized: true,
|
||||
provider_configured: true,
|
||||
provider_name: Some(aster_config.provider_name),
|
||||
provider_selector: aster_config.provider_selector,
|
||||
model_name: Some(aster_config.model_name),
|
||||
credential_uuid: Some(aster_config.credential_uuid),
|
||||
})
|
||||
@@ -144,6 +149,7 @@ pub async fn aster_agent_reset(
|
||||
initialized: state.is_initialized().await,
|
||||
provider_configured: false,
|
||||
provider_name: None,
|
||||
provider_selector: None,
|
||||
model_name: None,
|
||||
credential_uuid: None,
|
||||
})
|
||||
|
||||
@@ -7,6 +7,8 @@ pub struct AsterAgentStatus {
|
||||
pub initialized: bool,
|
||||
pub provider_configured: bool,
|
||||
pub provider_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub provider_selector: Option<String>,
|
||||
pub model_name: Option<String>,
|
||||
/// 凭证 UUID(来自凭证池)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -486,6 +486,99 @@ fn truncate_prompt_text(value: String, max_chars: usize) -> String {
|
||||
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
|
||||
}
|
||||
|
||||
fn build_prompt_file_path(root: Option<&str>, path: &str) -> String {
|
||||
let normalized_path = path.trim().trim_start_matches(|ch| ch == '/' || ch == '\\');
|
||||
if normalized_path.is_empty() {
|
||||
return path.trim().to_string();
|
||||
}
|
||||
|
||||
let Some(root) = root
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.trim_end_matches(|ch| ch == '/' || ch == '\\'))
|
||||
else {
|
||||
return normalized_path.to_string();
|
||||
};
|
||||
|
||||
if root.is_empty() {
|
||||
normalized_path.to_string()
|
||||
} else {
|
||||
format!("{root}/{normalized_path}")
|
||||
}
|
||||
}
|
||||
|
||||
fn build_markdown_bundle_translation_followup(
|
||||
execution: &ServiceSkillLaunchPreloadExecution,
|
||||
) -> Vec<String> {
|
||||
if !execution.result.ok {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let request_args = match execution.request.args.as_object() {
|
||||
Some(args) => args,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
let Some(target_language) =
|
||||
extract_object_string(request_args, &["target_language", "targetLanguage"])
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let Some(saved_content) = execution.result.saved_content.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(markdown_relative_path) = saved_content
|
||||
.markdown_relative_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let export_kind = execution
|
||||
.result
|
||||
.data
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|data| extract_object_string(data, &["export_kind", "exportKind"]));
|
||||
if export_kind.as_deref() != Some("markdown_bundle") {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let markdown_path = build_prompt_file_path(
|
||||
saved_content.project_root_path.as_deref(),
|
||||
markdown_relative_path,
|
||||
);
|
||||
let mut lines = vec![
|
||||
format!("- 当前请求要求把已导出的 Markdown 正文翻译成{target_language}。"),
|
||||
format!("- 已保存的 Markdown 文件路径:{markdown_path}。"),
|
||||
"- 从这一刻起,本回合只允许新增 Read / Write / Edit 这类本地文件工具来处理已保存的 Markdown;不要再调用 lime_site_run,也不要转去 WebSearch、research、webReader、WebFetch 或其他通用网页工具。".to_string(),
|
||||
format!("- 必须先用 Read 读取 {markdown_path},再把正文翻译成{target_language}。"),
|
||||
"- 翻译时必须保留 Markdown 结构、标题层级、列表、表格、引用、frontmatter(如果存在)、链接目标和相对图片路径。".to_string(),
|
||||
"- 代码块、内联代码、URL、图片路径、文件路径、命令行、API 名称默认保持原文,不要翻译。".to_string(),
|
||||
format!(
|
||||
"- 完成翻译后必须用 Write 覆写同一路径 {markdown_path},不要另存为第二份摘要、HTML 或新的 artifact。"
|
||||
),
|
||||
format!(
|
||||
"- 最终答复必须明确说明:已将导出的 Markdown 正文翻译成{target_language}并回写到 {markdown_path}。"
|
||||
),
|
||||
];
|
||||
|
||||
if let Some(image_dir) = saved_content
|
||||
.images_relative_dir
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
lines.push(format!(
|
||||
"- 已保存的图片目录:{image_dir}。回写正文时不要破坏这些相对图片引用。"
|
||||
));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn build_service_skill_launch_preload_prompt(
|
||||
execution: &ServiceSkillLaunchPreloadExecution,
|
||||
) -> String {
|
||||
@@ -537,9 +630,9 @@ fn build_service_skill_launch_preload_prompt(
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "未提供".to_string());
|
||||
let execution_requirement = if execution.result.ok {
|
||||
"- 站点技能已经在系统侧预执行成功。请直接基于下面的结构化结果完成答复,不要再次调用 lime_site_run,也不要回退到底层浏览器兼容工具。".to_string()
|
||||
"- 站点技能已经在系统侧预执行成功。请直接基于下面的结构化结果完成答复,不要再次调用 lime_site_run,也不要回退到 mcp 浏览器工具、webReader、WebFetch、WebSearch、research 或其他通用网页阅读/检索工具。".to_string()
|
||||
} else {
|
||||
"- 站点技能已经在系统侧预执行,但执行失败。请直接根据失败结果向用户说明缺少的浏览器上下文、登录态或权限,不要伪造采集成功,也不要再次尝试调用 lime_site_run / mcp__lime-browser__* / browser_*。".to_string()
|
||||
"- 站点技能已经在系统侧预执行,但执行失败。请直接根据失败结果向用户说明缺少的浏览器上下文、登录态或权限,不要伪造采集成功,也不要再次尝试调用 lime_site_run、mcp__lime-browser__*、browser_*、webReader、WebFetch、WebSearch、research 或其他通用网页阅读/检索工具。".to_string()
|
||||
};
|
||||
let failure_contract = match execution.result.error_code.as_deref() {
|
||||
Some("attached_session_required") => {
|
||||
@@ -550,8 +643,7 @@ fn build_service_skill_launch_preload_prompt(
|
||||
}
|
||||
_ => "- 如果用户追问失败原因,优先引用 error_code / error_message / report_hint / auth_hint,而不是自行编造执行细节。".to_string(),
|
||||
};
|
||||
|
||||
[
|
||||
let mut lines = vec![
|
||||
SERVICE_SKILL_LAUNCH_PRELOAD_PROMPT_MARKER.to_string(),
|
||||
execution_requirement,
|
||||
failure_contract,
|
||||
@@ -559,9 +651,13 @@ fn build_service_skill_launch_preload_prompt(
|
||||
format!("- 当前适配器说明:{adapter_description}"),
|
||||
format!("- 已预执行请求(JSON):{request_json}。"),
|
||||
format!("- 已预执行结果(JSON):{result_json}。"),
|
||||
"- 除非用户明确要求“重跑一次 / 换关键词 / 换筛选条件 / 重新抓取”,否则本回合不要再次调用任何站点执行工具。".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
];
|
||||
lines.extend(build_markdown_bundle_translation_followup(execution));
|
||||
lines.push(
|
||||
"- 除非用户明确要求“重跑一次 / 换关键词 / 换筛选条件 / 重新抓取”,否则本回合不要再次调用任何站点执行工具。"
|
||||
.to_string(),
|
||||
);
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn merge_system_prompt_with_service_skill_launch_preload(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::service_skill_launch::build_service_skill_preload_tool_projection;
|
||||
use super::*;
|
||||
use aster::session::TurnContextOverride;
|
||||
use lime_agent::AgentEvent as RuntimeAgentEvent;
|
||||
@@ -37,6 +38,47 @@ fn emit_runtime_side_event(
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_service_skill_preload_runtime_events(
|
||||
app: &AppHandle,
|
||||
event_name: &str,
|
||||
timeline_recorder: &Arc<Mutex<AgentTimelineRecorder>>,
|
||||
workspace_root: &str,
|
||||
execution: &ServiceSkillLaunchPreloadExecution,
|
||||
) {
|
||||
let projection = match build_service_skill_preload_tool_projection(execution) {
|
||||
Ok(projection) => projection,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 构造站点技能预执行投影事件失败,已降级跳过可视过程: {}",
|
||||
error
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
emit_runtime_side_event(
|
||||
app,
|
||||
event_name,
|
||||
timeline_recorder,
|
||||
workspace_root,
|
||||
RuntimeAgentEvent::ToolStart {
|
||||
tool_name: projection.tool_name.clone(),
|
||||
tool_id: projection.tool_id.clone(),
|
||||
arguments: Some(projection.arguments),
|
||||
},
|
||||
);
|
||||
emit_runtime_side_event(
|
||||
app,
|
||||
event_name,
|
||||
timeline_recorder,
|
||||
workspace_root,
|
||||
RuntimeAgentEvent::ToolEnd {
|
||||
tool_id: projection.tool_id,
|
||||
result: projection.result,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn build_artifact_document_warning_message(
|
||||
status: &str,
|
||||
fallback_used: bool,
|
||||
@@ -1304,6 +1346,15 @@ async fn execute_aster_chat_request(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let Some(preload) = service_skill_preload.as_ref() {
|
||||
emit_service_skill_preload_runtime_events(
|
||||
app,
|
||||
&request.event_name,
|
||||
&timeline_recorder,
|
||||
workspace_root.as_str(),
|
||||
preload,
|
||||
);
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -3,6 +3,8 @@ use crate::services::site_capability_service::{
|
||||
get_site_adapter, run_site_adapter_with_optional_save, RunSiteAdapterRequest,
|
||||
SiteAdapterDefinition, SiteAdapterRunResult,
|
||||
};
|
||||
use lime_agent::AgentToolResult;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const SERVICE_SKILL_LAUNCH_BROWSER_DENY_PATTERNS: &[&str] = &[
|
||||
"mcp__lime-browser__*",
|
||||
@@ -30,6 +32,14 @@ pub(crate) struct ServiceSkillLaunchPreloadExecution {
|
||||
pub(crate) result: SiteAdapterRunResult,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ServiceSkillPreloadToolProjection {
|
||||
pub(crate) tool_name: String,
|
||||
pub(crate) tool_id: String,
|
||||
pub(crate) arguments: String,
|
||||
pub(crate) result: AgentToolResult,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct ServiceSceneLaunchOemRuntimeContext {
|
||||
pub(crate) scene_base_url: Option<String>,
|
||||
@@ -71,6 +81,150 @@ fn normalized_optional_object(
|
||||
value.and_then(serde_json::Value::as_object)
|
||||
}
|
||||
|
||||
fn sanitize_service_skill_preload_id_segment(value: &str) -> String {
|
||||
let sanitized = value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
sanitized
|
||||
.split('-')
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
fn build_service_skill_preload_tool_id(execution: &ServiceSkillLaunchPreloadExecution) -> String {
|
||||
let adapter = sanitize_service_skill_preload_id_segment(&execution.request.adapter_name);
|
||||
let target = execution
|
||||
.result
|
||||
.target_id
|
||||
.as_deref()
|
||||
.map(sanitize_service_skill_preload_id_segment)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
format!("service-skill-preload:{adapter}:{target}")
|
||||
}
|
||||
|
||||
fn build_service_skill_preload_output(execution: &ServiceSkillLaunchPreloadExecution) -> String {
|
||||
let skill_title = execution
|
||||
.request
|
||||
.skill_title
|
||||
.as_deref()
|
||||
.unwrap_or(execution.request.adapter_name.as_str());
|
||||
let mut lines = Vec::new();
|
||||
|
||||
if execution.result.ok {
|
||||
lines.push(format!("已完成站点技能预执行:{skill_title}。"));
|
||||
} else {
|
||||
lines.push(format!("站点技能预执行失败:{skill_title}。"));
|
||||
}
|
||||
|
||||
lines.push(format!("- 适配器:{}", execution.request.adapter_name));
|
||||
lines.push(format!("- 入口页面:{}", execution.result.entry_url));
|
||||
|
||||
if let Some(source_url) = execution.result.source_url.as_deref() {
|
||||
lines.push(format!("- 当前页面:{source_url}"));
|
||||
}
|
||||
|
||||
if let Some(saved_content) = execution.result.saved_content.as_ref() {
|
||||
lines.push(format!("- 已保存内容:{}", saved_content.title));
|
||||
if let Some(path) = saved_content.markdown_relative_path.as_deref() {
|
||||
lines.push(format!("- Markdown 文件:{path}"));
|
||||
}
|
||||
if let Some(image_count) = saved_content.image_count {
|
||||
let image_dir = saved_content
|
||||
.images_relative_dir
|
||||
.as_deref()
|
||||
.map(|value| format!(" · {value}"))
|
||||
.unwrap_or_default();
|
||||
lines.push(format!("- 图片资源:{image_count} 张{image_dir}"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(project_id) = execution.result.saved_project_id.as_deref() {
|
||||
lines.push(format!("- 保存项目:{project_id}"));
|
||||
}
|
||||
|
||||
if let Some(error_code) = execution.result.error_code.as_deref() {
|
||||
lines.push(format!("- 错误码:{error_code}"));
|
||||
}
|
||||
if let Some(error_message) = execution.result.error_message.as_deref() {
|
||||
lines.push(format!("- 错误说明:{error_message}"));
|
||||
}
|
||||
if let Some(report_hint) = execution.result.report_hint.as_deref() {
|
||||
lines.push(format!("- 处理建议:{report_hint}"));
|
||||
}
|
||||
if let Some(auth_hint) = execution.result.auth_hint.as_deref() {
|
||||
lines.push(format!("- 登录提示:{auth_hint}"));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_service_skill_preload_tool_projection(
|
||||
execution: &ServiceSkillLaunchPreloadExecution,
|
||||
) -> Result<ServiceSkillPreloadToolProjection, String> {
|
||||
let arguments = serde_json::json!({
|
||||
"adapter_name": execution.request.adapter_name.clone(),
|
||||
"args": execution.request.args.clone(),
|
||||
"profile_key": execution.request.profile_key.clone(),
|
||||
"target_id": execution.request.target_id.clone(),
|
||||
"content_id": execution.request.content_id.clone(),
|
||||
"project_id": execution.request.project_id.clone(),
|
||||
"save_title": execution.request.save_title.clone(),
|
||||
"require_attached_session": execution.request.require_attached_session,
|
||||
"skill_title": execution.request.skill_title.clone(),
|
||||
"execution_origin": "preload",
|
||||
});
|
||||
let mut metadata = HashMap::from([
|
||||
("tool_family".to_string(), serde_json::json!("site")),
|
||||
("execution_origin".to_string(), serde_json::json!("preload")),
|
||||
("preload".to_string(), serde_json::json!(true)),
|
||||
(
|
||||
"adapter_name".to_string(),
|
||||
serde_json::json!(execution.request.adapter_name.clone()),
|
||||
),
|
||||
(
|
||||
"skill_title".to_string(),
|
||||
serde_json::json!(execution.request.skill_title.clone()),
|
||||
),
|
||||
(
|
||||
"result".to_string(),
|
||||
serde_json::to_value(&execution.result).unwrap_or_default(),
|
||||
),
|
||||
]);
|
||||
if let Some(adapter) = execution.adapter.as_ref() {
|
||||
metadata.insert(
|
||||
"adapter_source_kind".to_string(),
|
||||
serde_json::json!(adapter.source_kind),
|
||||
);
|
||||
metadata.insert(
|
||||
"adapter_source_version".to_string(),
|
||||
serde_json::json!(adapter.source_version),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(ServiceSkillPreloadToolProjection {
|
||||
tool_name: "lime_site_run".to_string(),
|
||||
tool_id: build_service_skill_preload_tool_id(execution),
|
||||
arguments: serde_json::to_string(&arguments)
|
||||
.map_err(|error| format!("序列化站点技能预执行参数失败: {error}"))?,
|
||||
result: AgentToolResult {
|
||||
success: execution.result.ok,
|
||||
output: build_service_skill_preload_output(execution),
|
||||
error: execution.result.error_message.clone(),
|
||||
images: None,
|
||||
metadata: Some(metadata),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
|
||||
let Some(root) = value.as_object_mut() else {
|
||||
return;
|
||||
|
||||
@@ -5,8 +5,9 @@ mod tests {
|
||||
build_runtime_action_scope, build_runtime_action_session_config,
|
||||
};
|
||||
use crate::commands::aster_agent_cmd::dto::AgentRuntimeActionScope;
|
||||
use crate::commands::aster_agent_cmd::service_skill_launch::build_service_skill_preload_tool_projection;
|
||||
use crate::services::site_capability_service::{
|
||||
RunSiteAdapterRequest, SiteAdapterDefinition, SiteAdapterRunResult,
|
||||
RunSiteAdapterRequest, SavedSiteAdapterContent, SiteAdapterDefinition, SiteAdapterRunResult,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
@@ -1192,7 +1193,10 @@ mod tests {
|
||||
serde_json::json!({"type": "object"}),
|
||||
)));
|
||||
|
||||
prune_resource_search_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata));
|
||||
prune_resource_search_skill_launch_detour_tools_from_registry(
|
||||
&mut registry,
|
||||
Some(&metadata),
|
||||
);
|
||||
|
||||
assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME));
|
||||
assert!(!registry.contains("Read"));
|
||||
@@ -2160,8 +2164,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prune_url_parse_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools(
|
||||
) {
|
||||
fn test_prune_url_parse_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools()
|
||||
{
|
||||
let metadata = serde_json::json!({
|
||||
"harness": {
|
||||
"url_parse_skill_launch": {
|
||||
@@ -4025,9 +4029,7 @@ mod tests {
|
||||
)
|
||||
.expect("should contain merged prompt");
|
||||
|
||||
assert!(merged.contains(
|
||||
super::image_skill_launch::IMAGE_SKILL_LAUNCH_PROMPT_MARKER
|
||||
));
|
||||
assert!(merged.contains(super::image_skill_launch::IMAGE_SKILL_LAUNCH_PROMPT_MARKER));
|
||||
assert!(merged.contains("第一优先工具调用必须是 Skill"));
|
||||
assert!(merged.contains("skill=\"image_generate\""));
|
||||
assert!(merged.contains("Skill.args 的 JSON"));
|
||||
@@ -5080,7 +5082,8 @@ mod tests {
|
||||
assert!(merged.contains("不要先走 ToolSearch / WebSearch / Read / Glob / Grep"));
|
||||
assert!(merged.contains("目标是复用 Lime 现有 A2UI 协议输出一份真实可渲染的表单"));
|
||||
assert!(merged.contains("最终结果必须输出一个 ```a2ui 代码块"));
|
||||
assert!(merged.contains("字段类型只允许使用 simple form 已支持的 choice / text / slider / checkbox"));
|
||||
assert!(merged
|
||||
.contains("字段类型只允许使用 simple form 已支持的 choice / text / slider / checkbox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5097,8 +5100,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let prepared = prepare_form_skill_launch_request_metadata(Some(&metadata))
|
||||
.expect("prepared metadata");
|
||||
let prepared =
|
||||
prepare_form_skill_launch_request_metadata(Some(&metadata)).expect("prepared metadata");
|
||||
|
||||
let harness = prepared
|
||||
.get("harness")
|
||||
@@ -5395,10 +5398,101 @@ mod tests {
|
||||
assert!(merged.contains(SERVICE_SKILL_LAUNCH_PRELOAD_PROMPT_MARKER));
|
||||
assert!(merged.contains("系统侧预执行成功"));
|
||||
assert!(merged.contains("不要再次调用 lime_site_run"));
|
||||
assert!(merged.contains("不要回退到"));
|
||||
assert!(merged.contains("WebSearch"));
|
||||
assert!(merged.contains("microsoft/autogen"));
|
||||
assert!(merged.contains("\"require_attached_session\":true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_system_prompt_with_service_skill_launch_preload_adds_markdown_bundle_translation_contract(
|
||||
) {
|
||||
let execution = ServiceSkillLaunchPreloadExecution {
|
||||
request: RunSiteAdapterRequest {
|
||||
adapter_name: "x/article-export".to_string(),
|
||||
args: serde_json::json!({
|
||||
"url": "https://x.com/GoogleCloudTech/article/2033953579824758855",
|
||||
"target_language": "中文"
|
||||
}),
|
||||
profile_key: Some("attached-x".to_string()),
|
||||
target_id: Some("tab-x".to_string()),
|
||||
timeout_ms: None,
|
||||
content_id: Some("content-1".to_string()),
|
||||
project_id: Some("project-1".to_string()),
|
||||
save_title: Some("Google Cloud Tech 文章转存".to_string()),
|
||||
require_attached_session: Some(true),
|
||||
skill_title: Some("X 文章转存".to_string()),
|
||||
},
|
||||
adapter: Some(SiteAdapterDefinition {
|
||||
name: "x/article-export".to_string(),
|
||||
domain: "x.com".to_string(),
|
||||
description: "导出 X 文章为 Markdown。".to_string(),
|
||||
read_only: true,
|
||||
capabilities: vec!["export".to_string()],
|
||||
input_schema: serde_json::json!({}),
|
||||
example_args: serde_json::json!({
|
||||
"url": "https://x.com/example/article/1"
|
||||
}),
|
||||
example: "x/article-export {\"url\":\"https://x.com/example/article/1\"}"
|
||||
.to_string(),
|
||||
auth_hint: None,
|
||||
source_kind: Some("server_synced".to_string()),
|
||||
source_version: Some("2026-04-07".to_string()),
|
||||
}),
|
||||
result: SiteAdapterRunResult {
|
||||
ok: true,
|
||||
adapter: "x/article-export".to_string(),
|
||||
domain: "x.com".to_string(),
|
||||
profile_key: "attached-x".to_string(),
|
||||
session_id: Some("session-1".to_string()),
|
||||
target_id: Some("tab-x".to_string()),
|
||||
entry_url: "https://x.com/GoogleCloudTech/article/2033953579824758855".to_string(),
|
||||
source_url: Some(
|
||||
"https://x.com/GoogleCloudTech/article/2033953579824758855".to_string(),
|
||||
),
|
||||
data: Some(serde_json::json!({
|
||||
"export_kind": "markdown_bundle",
|
||||
"title": "Google Cloud Tech",
|
||||
"markdown": "# Example\n\n\n\nHello world"
|
||||
})),
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
auth_hint: None,
|
||||
report_hint: None,
|
||||
saved_content: Some(SavedSiteAdapterContent {
|
||||
content_id: "content-1".to_string(),
|
||||
project_id: "project-1".to_string(),
|
||||
title: "Google Cloud Tech 文章转存".to_string(),
|
||||
project_root_path: Some("/tmp/project".to_string()),
|
||||
bundle_relative_dir: Some("saved/x-article-export".to_string()),
|
||||
markdown_relative_path: Some("saved/x-article-export/index.md".to_string()),
|
||||
images_relative_dir: Some("saved/x-article-export/images".to_string()),
|
||||
meta_relative_path: Some("saved/x-article-export/meta.json".to_string()),
|
||||
image_count: Some(1),
|
||||
}),
|
||||
saved_project_id: Some("project-1".to_string()),
|
||||
saved_by: Some("context_project".to_string()),
|
||||
save_skipped_project_id: None,
|
||||
save_skipped_by: None,
|
||||
save_error_message: None,
|
||||
},
|
||||
};
|
||||
|
||||
let merged = merge_system_prompt_with_service_skill_launch_preload(
|
||||
Some("你是助手".to_string()),
|
||||
Some(&execution),
|
||||
)
|
||||
.expect("should contain preload prompt");
|
||||
|
||||
assert!(merged.contains("Markdown 正文翻译成中文"));
|
||||
assert!(merged.contains("/tmp/project/saved/x-article-export/index.md"));
|
||||
assert!(merged.contains("只允许新增 Read / Write / Edit"));
|
||||
assert!(merged.contains("必须先用 Read 读取"));
|
||||
assert!(merged.contains("必须用 Write 覆写同一路径"));
|
||||
assert!(merged.contains("代码块、内联代码、URL、图片路径"));
|
||||
assert!(merged.contains("不要破坏这些相对图片引用"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_system_prompt_with_service_skill_launch_preload_handles_missing_context_failure()
|
||||
{
|
||||
@@ -5450,9 +5544,126 @@ mod tests {
|
||||
assert!(merged.contains("attached_session_required"));
|
||||
assert!(merged.contains("先连接并附着到目标站点页面"));
|
||||
assert!(merged.contains("不要再次尝试调用 lime_site_run"));
|
||||
assert!(merged.contains("webReader"));
|
||||
assert!(merged.contains("请先连接并停留在 github.com。"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_service_skill_preload_tool_projection_emits_site_metadata() {
|
||||
let execution = ServiceSkillLaunchPreloadExecution {
|
||||
request: RunSiteAdapterRequest {
|
||||
adapter_name: "x/article-export".to_string(),
|
||||
args: serde_json::json!({
|
||||
"url": "https://x.com/GoogleCloudTech/article/2033953579824758855"
|
||||
}),
|
||||
profile_key: Some("attached-x".to_string()),
|
||||
target_id: Some("tab-x".to_string()),
|
||||
timeout_ms: None,
|
||||
content_id: Some("content-1".to_string()),
|
||||
project_id: Some("project-1".to_string()),
|
||||
save_title: Some("Google Cloud Tech 文章转存".to_string()),
|
||||
require_attached_session: Some(true),
|
||||
skill_title: Some("X 文章转存".to_string()),
|
||||
},
|
||||
adapter: Some(SiteAdapterDefinition {
|
||||
name: "x/article-export".to_string(),
|
||||
domain: "x.com".to_string(),
|
||||
description: "导出 X 文章为 Markdown。".to_string(),
|
||||
read_only: true,
|
||||
capabilities: vec!["export".to_string()],
|
||||
input_schema: serde_json::json!({}),
|
||||
example_args: serde_json::json!({
|
||||
"url": "https://x.com/example/article/1"
|
||||
}),
|
||||
example: "x/article-export {\"url\":\"https://x.com/example/article/1\"}"
|
||||
.to_string(),
|
||||
auth_hint: None,
|
||||
source_kind: Some("server_synced".to_string()),
|
||||
source_version: Some("2026-04-07".to_string()),
|
||||
}),
|
||||
result: SiteAdapterRunResult {
|
||||
ok: true,
|
||||
adapter: "x/article-export".to_string(),
|
||||
domain: "x.com".to_string(),
|
||||
profile_key: "attached-x".to_string(),
|
||||
session_id: Some("session-1".to_string()),
|
||||
target_id: Some("tab-x".to_string()),
|
||||
entry_url: "https://x.com/GoogleCloudTech/article/2033953579824758855".to_string(),
|
||||
source_url: Some(
|
||||
"https://x.com/GoogleCloudTech/article/2033953579824758855".to_string(),
|
||||
),
|
||||
data: Some(serde_json::json!({
|
||||
"title": "Google Cloud Tech"
|
||||
})),
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
auth_hint: None,
|
||||
report_hint: None,
|
||||
saved_content: Some(SavedSiteAdapterContent {
|
||||
content_id: "content-1".to_string(),
|
||||
project_id: "project-1".to_string(),
|
||||
title: "Google Cloud Tech 文章转存".to_string(),
|
||||
project_root_path: Some("/tmp/project".to_string()),
|
||||
bundle_relative_dir: Some("saved/x-article-export".to_string()),
|
||||
markdown_relative_path: Some("saved/x-article-export/article.md".to_string()),
|
||||
images_relative_dir: Some("saved/x-article-export/images".to_string()),
|
||||
meta_relative_path: Some("saved/x-article-export/meta.json".to_string()),
|
||||
image_count: Some(2),
|
||||
}),
|
||||
saved_project_id: Some("project-1".to_string()),
|
||||
saved_by: Some("context_project".to_string()),
|
||||
save_skipped_project_id: None,
|
||||
save_skipped_by: None,
|
||||
save_error_message: None,
|
||||
},
|
||||
};
|
||||
|
||||
let projection = build_service_skill_preload_tool_projection(&execution)
|
||||
.expect("should build preload projection");
|
||||
|
||||
assert_eq!(projection.tool_name, "lime_site_run");
|
||||
assert!(projection.tool_id.starts_with("service-skill-preload:"));
|
||||
assert!(projection
|
||||
.arguments
|
||||
.contains("\"execution_origin\":\"preload\""));
|
||||
assert!(projection
|
||||
.arguments
|
||||
.contains("\"skill_title\":\"X 文章转存\""));
|
||||
assert!(projection.result.success);
|
||||
assert!(projection.result.output.contains("已完成站点技能预执行"));
|
||||
assert!(projection.result.output.contains("图片资源:2 张"));
|
||||
|
||||
let metadata = projection.result.metadata.expect("metadata should exist");
|
||||
assert_eq!(
|
||||
metadata.get("tool_family"),
|
||||
Some(&serde_json::json!("site"))
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.get("execution_origin"),
|
||||
Some(&serde_json::json!("preload"))
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.get("adapter_source_kind"),
|
||||
Some(&serde_json::json!("server_synced"))
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.get("adapter_source_version"),
|
||||
Some(&serde_json::json!("2026-04-07"))
|
||||
);
|
||||
let result = metadata
|
||||
.get("result")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.expect("result metadata should exist");
|
||||
let saved_content = result
|
||||
.get("saved_content")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.expect("saved content should exist");
|
||||
assert_eq!(
|
||||
saved_content.get("markdown_relative_path"),
|
||||
Some(&serde_json::json!("saved/x-article-export/article.md"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_fallback_to_react_from_code_orchestrated_when_no_event_emitted() {
|
||||
let error = ReplyAttemptError {
|
||||
@@ -6305,6 +6516,24 @@ mod tests {
|
||||
assert!(exact > partial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_search_parse_select_query_supports_multiple_names() {
|
||||
let parsed = ToolSearchBridgeTool::parse_select_query("select:Read, mcp__docs__search");
|
||||
assert_eq!(
|
||||
parsed,
|
||||
Some(vec!["Read".to_string(), "mcp__docs__search".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_search_select_match_rank_supports_native_aliases() {
|
||||
let requested = vec!["read_file".to_string(), "Write".to_string()];
|
||||
let read_rank = ToolSearchBridgeTool::select_match_rank(&requested, "Read");
|
||||
let write_rank = ToolSearchBridgeTool::select_match_rank(&requested, "Write");
|
||||
assert_eq!(read_rank, Some(100_000));
|
||||
assert_eq!(write_rank, Some(99_999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_search_extension_tool_status_marks_default_visible_and_loaded_tools() {
|
||||
let configs = vec![builtin_extension_config(
|
||||
|
||||
@@ -71,6 +71,33 @@ impl ToolSearchBridgeTool {
|
||||
lime_core::tool_calling::score_tool_match(name, description, tags, query)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_select_query(query: &str) -> Option<Vec<String>> {
|
||||
let prefix = "select:";
|
||||
let actual_prefix = query.get(..prefix.len())?;
|
||||
if !actual_prefix.eq_ignore_ascii_case(prefix) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
query[prefix.len()..]
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn select_match_rank(requested: &[String], tool_name: &str) -> Option<i32> {
|
||||
requested
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(index, requested_name)| {
|
||||
lime_core::tool_calling::tool_search_exact_match(tool_name, requested_name)
|
||||
.then_some(100_000 - index as i32)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn extension_tool_status(
|
||||
extension_configs: &[ExtensionConfig],
|
||||
visible_extension_tools: &HashSet<String>,
|
||||
@@ -96,14 +123,17 @@ impl Tool for ToolSearchBridgeTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"统一搜索当前会话工具面:包含原生 registry 工具与 extension/MCP 工具。对 deferred 工具会返回加载提示。"
|
||||
"统一搜索当前会话工具面:包含原生 registry 工具与 extension/MCP 工具。支持 select:<tool_name>[,<tool_name>] 直接选择,对 deferred 工具会返回加载提示。"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "工具名称/描述关键词" },
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "用于搜索工具的关键词;如已知精确工具名,可使用 select:<tool_name>[,<tool_name>] 直接选择。"
|
||||
},
|
||||
"caller": { "type": "string", "description": "调用方,例如 assistant/code_execution" },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": 100 },
|
||||
"include_deferred": { "type": "boolean", "description": "是否包含延迟加载工具" },
|
||||
@@ -125,12 +155,13 @@ impl Tool for ToolSearchBridgeTool {
|
||||
params: serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let query = params
|
||||
let raw_query = params
|
||||
.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
.to_string();
|
||||
let query = raw_query.to_ascii_lowercase();
|
||||
let caller = params
|
||||
.get("caller")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -150,6 +181,7 @@ impl Tool for ToolSearchBridgeTool {
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v.clamp(1, 100) as usize)
|
||||
.unwrap_or(10);
|
||||
let select_requested = Self::parse_select_query(&raw_query);
|
||||
|
||||
let registry = self.registry.read().await;
|
||||
let definitions = registry.get_definitions();
|
||||
@@ -174,8 +206,11 @@ impl Tool for ToolSearchBridgeTool {
|
||||
let allowed_callers = metadata.allowed_callers.unwrap_or_default();
|
||||
let tags = metadata.tags.unwrap_or_default();
|
||||
let input_examples = metadata.input_examples;
|
||||
let score =
|
||||
Self::score_match(&definition.name, &definition.description, &tags, &query);
|
||||
let score = if let Some(requested) = select_requested.as_ref() {
|
||||
Self::select_match_rank(requested, &definition.name).unwrap_or(0)
|
||||
} else {
|
||||
Self::score_match(&definition.name, &definition.description, &tags, &query)
|
||||
};
|
||||
if score <= 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -235,7 +270,11 @@ impl Tool for ToolSearchBridgeTool {
|
||||
|
||||
let tool_name = tool.name.to_string();
|
||||
let description = tool.description.as_deref().unwrap_or("").to_string();
|
||||
let score = Self::score_match(&tool_name, &description, &[], &query);
|
||||
let score = if let Some(requested) = select_requested.as_ref() {
|
||||
Self::select_match_rank(requested, &tool_name).unwrap_or(0)
|
||||
} else {
|
||||
Self::score_match(&tool_name, &description, &[], &query)
|
||||
};
|
||||
if score <= 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -298,7 +337,7 @@ impl Tool for ToolSearchBridgeTool {
|
||||
.map(|(_, item)| item)
|
||||
.collect::<Vec<_>>();
|
||||
let text = serde_json::to_string_pretty(&serde_json::json!({
|
||||
"query": query,
|
||||
"query": raw_query,
|
||||
"caller": caller,
|
||||
"count": result.len(),
|
||||
"tools": result
|
||||
|
||||
@@ -90,6 +90,7 @@ pub async fn import_document_to_session(
|
||||
session_id,
|
||||
file_name.to_string(),
|
||||
content.clone(),
|
||||
None,
|
||||
)?;
|
||||
|
||||
Ok((content, file_name.to_string()))
|
||||
|
||||
@@ -78,6 +78,7 @@ pub async fn upload_image_to_session(
|
||||
session_id.clone(),
|
||||
file_name.to_string(),
|
||||
base64_content,
|
||||
None,
|
||||
)?;
|
||||
|
||||
// 返回文件访问路径
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use crate::session_files::{
|
||||
SessionDetail, SessionFile, SessionFileStorage, SessionMeta, SessionSummary,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::sync::Mutex;
|
||||
use tauri::State;
|
||||
|
||||
@@ -96,9 +97,10 @@ pub fn session_files_save_file(
|
||||
session_id: String,
|
||||
file_name: String,
|
||||
content: String,
|
||||
metadata: Option<Value>,
|
||||
) -> Result<SessionFile, String> {
|
||||
let storage = state.0.lock().map_err(|e| format!("锁定失败: {e}"))?;
|
||||
storage.save_file(&session_id, &file_name, &content)
|
||||
storage.save_file_with_metadata(&session_id, &file_name, &content, metadata)
|
||||
}
|
||||
|
||||
/// 读取会话文件
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::models::skill_model::{
|
||||
use chrono::Utc;
|
||||
use lime_core::app_paths;
|
||||
use lime_services::skill_service::SkillService;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -170,6 +170,102 @@ struct SkillScaffoldFrontmatter<'a> {
|
||||
description: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateSkillScaffoldRequest {
|
||||
pub target: String,
|
||||
pub directory: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub when_to_use: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub inputs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub outputs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub steps: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub fallback_strategy: Vec<String>,
|
||||
}
|
||||
|
||||
struct SkillScaffoldSections {
|
||||
when_to_use: Vec<String>,
|
||||
inputs: Vec<String>,
|
||||
outputs: Vec<String>,
|
||||
steps: Vec<String>,
|
||||
fallback_strategy: Vec<String>,
|
||||
}
|
||||
|
||||
fn normalize_scaffold_items(items: &[String], fallback: &[&str]) -> Vec<String> {
|
||||
let normalized: Vec<String> = items
|
||||
.iter()
|
||||
.map(|item| item.trim())
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
|
||||
if normalized.is_empty() {
|
||||
return fallback.iter().map(|item| (*item).to_string()).collect();
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
fn build_skill_scaffold_sections(request: &CreateSkillScaffoldRequest) -> SkillScaffoldSections {
|
||||
SkillScaffoldSections {
|
||||
when_to_use: normalize_scaffold_items(
|
||||
&request.when_to_use,
|
||||
&[
|
||||
"当你需要重复完成这类任务时使用。",
|
||||
"适合把一次成功结果沉淀成稳定可复用的工作流。",
|
||||
],
|
||||
),
|
||||
inputs: normalize_scaffold_items(
|
||||
&request.inputs,
|
||||
&[
|
||||
"用户目标、主题与成功标准。",
|
||||
"受众、风格、篇幅、平台或交付格式等约束。",
|
||||
"如有参考资料、示例或素材,请一并提供。",
|
||||
],
|
||||
),
|
||||
outputs: normalize_scaffold_items(
|
||||
&request.outputs,
|
||||
&[
|
||||
"交付一份可直接使用的完整结果。",
|
||||
"保留清晰的结构层级、重点信息与必要说明。",
|
||||
],
|
||||
),
|
||||
steps: normalize_scaffold_items(
|
||||
&request.steps,
|
||||
&[
|
||||
"先确认目标、边界与交付格式。",
|
||||
"提炼可复用的结构骨架,再补齐关键信息。",
|
||||
"输出可直接交付的首版结果,并为后续迭代留好锚点。",
|
||||
],
|
||||
),
|
||||
fallback_strategy: normalize_scaffold_items(
|
||||
&request.fallback_strategy,
|
||||
&[
|
||||
"信息不足时,先补问最关键的约束,不要自行假设事实。",
|
||||
"原结果不可直接复用时,先提炼最小骨架,再继续展开。",
|
||||
],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_bullet_list(items: &[String]) -> String {
|
||||
items.iter().map(|item| format!("- {item}\n")).collect()
|
||||
}
|
||||
|
||||
fn render_ordered_list(items: &[String]) -> String {
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| format!("{}. {item}\n", index + 1))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resolve_skill_scaffold_root(
|
||||
app_type: &AppType,
|
||||
target: SkillScaffoldTarget,
|
||||
@@ -184,30 +280,37 @@ fn resolve_skill_scaffold_root(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_skill_scaffold_content(name: &str, description: &str) -> Result<String, String> {
|
||||
fn build_skill_scaffold_content(request: &CreateSkillScaffoldRequest) -> Result<String, String> {
|
||||
let name = request.name.trim();
|
||||
let description = request.description.trim();
|
||||
let sections = build_skill_scaffold_sections(request);
|
||||
let frontmatter = serde_yaml::to_string(&SkillScaffoldFrontmatter { name, description })
|
||||
.map_err(|e| format!("Failed to build skill frontmatter: {e}"))?;
|
||||
let frontmatter = frontmatter.strip_prefix("---\n").unwrap_or(&frontmatter);
|
||||
|
||||
Ok(format!(
|
||||
"---\n{frontmatter}---\n\n# {name}\n\n## 何时使用\n- 描述该 Skill 的适用场景。\n\n## 输入\n- 说明用户需要提供的上下文、约束和素材。\n\n## 执行要求\n1. 先明确目标、边界和输出格式。\n2. 如需引用资料,请将文件放到 `references/` 目录。\n3. 如需脚本或素材,请分别放到 `scripts/` 与 `assets/` 目录。\n\n## 输出\n- 说明最终交付物及验收标准。\n"
|
||||
"---\n{frontmatter}---\n\n# {name}\n\n## 何时使用\n{when_to_use}\n## 输入\n{inputs}\n## 执行步骤\n{steps}\n## 输出\n{outputs}\n## 失败回退\n{fallback_strategy}\n## 维护提示\n- 如需引用资料,请将文件放到 `references/` 目录。\n- 如需脚本或素材,请分别放到 `scripts/` 与 `assets/` 目录。\n- 如需长期沉淀模板或示例,优先放到相邻目录,不要把所有细节都塞进主文件。\n",
|
||||
when_to_use = render_bullet_list(§ions.when_to_use),
|
||||
inputs = render_bullet_list(§ions.inputs),
|
||||
steps = render_ordered_list(§ions.steps),
|
||||
outputs = render_bullet_list(§ions.outputs),
|
||||
fallback_strategy = render_bullet_list(§ions.fallback_strategy),
|
||||
))
|
||||
}
|
||||
|
||||
fn create_skill_scaffold_in_root(
|
||||
skills_root: &Path,
|
||||
directory: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
request: &CreateSkillScaffoldRequest,
|
||||
) -> Result<SkillPackageInspection, String> {
|
||||
let directory = request.directory.trim();
|
||||
validate_skill_directory(directory)?;
|
||||
|
||||
let name = name.trim();
|
||||
let name = request.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Skill name is required".to_string());
|
||||
}
|
||||
|
||||
let description = description.trim();
|
||||
let description = request.description.trim();
|
||||
if description.is_empty() {
|
||||
return Err("Skill description is required".to_string());
|
||||
}
|
||||
@@ -231,7 +334,7 @@ fn create_skill_scaffold_in_root(
|
||||
)
|
||||
})?;
|
||||
|
||||
let skill_md_content = build_skill_scaffold_content(name, description)?;
|
||||
let skill_md_content = build_skill_scaffold_content(request)?;
|
||||
let skill_md_path = skill_dir.join("SKILL.md");
|
||||
if let Err(error) = fs::write(&skill_md_path, skill_md_content) {
|
||||
let _ = fs::remove_dir_all(&skill_dir);
|
||||
@@ -431,15 +534,12 @@ pub fn inspect_local_skill_for_app(
|
||||
#[tauri::command]
|
||||
pub fn create_skill_scaffold_for_app(
|
||||
app: String,
|
||||
target: String,
|
||||
directory: String,
|
||||
name: String,
|
||||
description: String,
|
||||
request: CreateSkillScaffoldRequest,
|
||||
) -> Result<SkillPackageInspection, String> {
|
||||
let app_type: AppType = app.parse().map_err(|e: String| e)?;
|
||||
let target = SkillScaffoldTarget::parse(&target)?;
|
||||
let target = SkillScaffoldTarget::parse(&request.target)?;
|
||||
let skills_root = resolve_skill_scaffold_root(&app_type, target)?;
|
||||
let inspection = create_skill_scaffold_in_root(&skills_root, &directory, &name, &description)?;
|
||||
let inspection = create_skill_scaffold_in_root(&skills_root, &request)?;
|
||||
|
||||
if matches!(app_type, AppType::Lime) {
|
||||
AsterAgentState::reload_lime_skills();
|
||||
@@ -968,9 +1068,17 @@ content"#,
|
||||
|
||||
let inspection = create_skill_scaffold_in_root(
|
||||
&skills_dir,
|
||||
"draft-skill",
|
||||
"Draft Skill",
|
||||
"Create a new draft",
|
||||
&CreateSkillScaffoldRequest {
|
||||
target: "project".to_string(),
|
||||
directory: "draft-skill".to_string(),
|
||||
name: "Draft Skill".to_string(),
|
||||
description: "Create a new draft".to_string(),
|
||||
when_to_use: vec!["当你需要复用草稿输出时使用。".to_string()],
|
||||
inputs: vec!["目标与主题:草稿输出".to_string()],
|
||||
outputs: vec!["交付一份可直接复用的草稿。".to_string()],
|
||||
steps: vec!["先确认目标,再复用结构。".to_string()],
|
||||
fallback_strategy: vec!["信息不足时先补问。".to_string()],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -979,6 +1087,9 @@ content"#,
|
||||
assert!(inspection.standard_compliance.is_standard);
|
||||
assert!(inspection.content.contains("name: Draft Skill"));
|
||||
assert!(inspection.content.contains("# Draft Skill"));
|
||||
assert!(inspection.content.contains("## 失败回退"));
|
||||
assert!(inspection.content.contains("当你需要复用草稿输出时使用。"));
|
||||
assert!(inspection.content.contains("1. 先确认目标,再复用结构。"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -989,9 +1100,17 @@ content"#,
|
||||
|
||||
let err = create_skill_scaffold_in_root(
|
||||
&skills_dir,
|
||||
"draft-skill",
|
||||
"Draft Skill",
|
||||
"Create a new draft",
|
||||
&CreateSkillScaffoldRequest {
|
||||
target: "project".to_string(),
|
||||
directory: "draft-skill".to_string(),
|
||||
name: "Draft Skill".to_string(),
|
||||
description: "Create a new draft".to_string(),
|
||||
when_to_use: Vec::new(),
|
||||
inputs: Vec::new(),
|
||||
outputs: Vec::new(),
|
||||
steps: Vec::new(),
|
||||
fallback_strategy: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
|
||||
@@ -531,6 +531,27 @@ mod tests {
|
||||
assert_eq!(active_list_after_restore.as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn site_adapter_launch_readiness_command_is_bridged() {
|
||||
let state = make_test_state();
|
||||
|
||||
let value = handle_command(
|
||||
&state,
|
||||
"site_get_adapter_launch_readiness",
|
||||
Some(serde_json::json!({
|
||||
"request": {
|
||||
"adapter_name": "x/article-export"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(value["adapter"], "x/article-export");
|
||||
assert_eq!(value["domain"], "x.com");
|
||||
assert_eq!(value["status"], "requires_browser_runtime");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_execution_catalog_commands_are_bridged() {
|
||||
let state = make_test_state();
|
||||
|
||||
@@ -9,9 +9,9 @@ use crate::services::site_adapter_registry::{
|
||||
get_site_adapter_catalog_status,
|
||||
};
|
||||
use crate::services::site_capability_service::{
|
||||
get_site_adapter, list_site_adapters, recommend_site_adapters, run_site_adapter,
|
||||
run_site_adapter_with_optional_save, save_existing_site_result_to_project,
|
||||
search_site_adapters,
|
||||
get_site_adapter, get_site_adapter_launch_readiness, list_site_adapters,
|
||||
recommend_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;
|
||||
|
||||
@@ -40,6 +40,12 @@ pub(super) async fn try_handle(
|
||||
.ok_or_else(|| "未找到对应的站点适配器".to_string())?;
|
||||
serde_json::to_value(adapter)?
|
||||
}
|
||||
"site_get_adapter_launch_readiness" => {
|
||||
let request: crate::services::site_capability_service::SiteAdapterLaunchReadinessRequest =
|
||||
parse_request(args)?;
|
||||
let db = get_db(state)?.clone();
|
||||
serde_json::to_value(get_site_adapter_launch_readiness(&db, request).await?)?
|
||||
}
|
||||
"site_get_adapter_catalog_status" => {
|
||||
serde_json::to_value(get_site_adapter_catalog_status()?)?
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{args_or_default, get_string_arg, require_app_handle};
|
||||
use super::{args_or_default, get_string_arg, parse_nested_arg, require_app_handle};
|
||||
use crate::dev_bridge::DevBridgeState;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tauri::Manager;
|
||||
@@ -106,18 +106,12 @@ pub(super) async fn try_handle(
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("lime")
|
||||
.to_string();
|
||||
let target = get_string_arg(&args, "target", "target")?;
|
||||
let directory = get_string_arg(&args, "directory", "directory")?;
|
||||
let name = get_string_arg(&args, "name", "name")?;
|
||||
let description = get_string_arg(&args, "description", "description")?;
|
||||
let inspection = crate::commands::skill_cmd::create_skill_scaffold_for_app(
|
||||
app,
|
||||
target,
|
||||
directory,
|
||||
name,
|
||||
description,
|
||||
)
|
||||
.map_err(|e| format!("创建 Skill 脚手架失败: {e}"))?;
|
||||
let request = parse_nested_arg::<crate::commands::skill_cmd::CreateSkillScaffoldRequest>(
|
||||
&args, "request",
|
||||
)?;
|
||||
let inspection =
|
||||
crate::commands::skill_cmd::create_skill_scaffold_for_app(app, request)
|
||||
.map_err(|e| format!("创建 Skill 脚手架失败: {e}"))?;
|
||||
serde_json::to_value(inspection)?
|
||||
}
|
||||
"import_local_skill_for_app" => {
|
||||
|
||||
@@ -1066,12 +1066,13 @@ mod tests {
|
||||
.find(|adapter| adapter.name == "x/article-export")
|
||||
.expect("x/article-export should exist");
|
||||
assert_eq!(x_article.source_kind, SiteAdapterSourceKind::Bundled);
|
||||
assert_eq!(x_article.source_version.as_deref(), Some("2026-04-07"));
|
||||
assert_eq!(x_article.source_version.as_deref(), Some("2026-04-08"));
|
||||
assert!(matches!(
|
||||
x_article.entry,
|
||||
SiteAdapterEntrySpec::Builder { ref id } if id == "x_article_export"
|
||||
));
|
||||
assert!(x_article.script.contains("markdown_bundle"));
|
||||
assert!(x_article.script.contains("currentSrc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -32,6 +32,8 @@ const DEFAULT_PROFILE_KEY: &str = "default";
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 20_000;
|
||||
const MIN_ADAPTER_EVALUATE_TIMEOUT_MS: u64 = 30_000;
|
||||
const MAX_TIMEOUT_MS: u64 = 120_000;
|
||||
const EXISTING_SESSION_TRANSIENT_RETRY_LIMIT: usize = 2;
|
||||
const EXISTING_SESSION_TRANSIENT_RETRY_DELAY_MS: u64 = 400;
|
||||
const EXPLICIT_PROJECT_SAVE_SOURCE: &str = "explicit_project";
|
||||
const EXPLICIT_CONTENT_SAVE_SOURCE: &str = "explicit_content";
|
||||
const SITE_EXPORTS_ROOT_DIR: &str = "exports";
|
||||
@@ -2173,6 +2175,25 @@ fn select_existing_session_navigation_seed(
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn select_existing_session_retry_target(
|
||||
tabs: &[ExistingSessionTabRecord],
|
||||
domain: &str,
|
||||
current_target_id: Option<&str>,
|
||||
) -> Option<ExistingSessionTabRecord> {
|
||||
current_target_id
|
||||
.and_then(|target_id| {
|
||||
tabs.iter()
|
||||
.find(|tab| tab.id == target_id && tab_matches_domain(tab, domain))
|
||||
.cloned()
|
||||
})
|
||||
.or_else(|| select_existing_session_target(tabs, domain))
|
||||
.or_else(|| {
|
||||
current_target_id
|
||||
.and_then(|target_id| tabs.iter().find(|tab| tab.id == target_id).cloned())
|
||||
})
|
||||
.or_else(|| select_existing_session_navigation_seed(tabs))
|
||||
}
|
||||
|
||||
fn build_recommendation_entry_url(spec: &SiteAdapterSpec) -> String {
|
||||
let example_args = match build_example_args(&spec.args) {
|
||||
Value::Object(map) => map,
|
||||
@@ -2525,7 +2546,7 @@ async fn run_existing_session_adapter(
|
||||
.map(|current_url| url_matches_expected_entry(current_url, &entry_url))
|
||||
.unwrap_or(false);
|
||||
let mut bridged_target_id = selected_target.as_ref().map(|target| target.id.clone());
|
||||
let latest_source_url = if should_skip_navigation {
|
||||
let mut latest_source_url = if should_skip_navigation {
|
||||
selected_target
|
||||
.as_ref()
|
||||
.and_then(|target| target.url.clone())
|
||||
@@ -2583,7 +2604,8 @@ async fn run_existing_session_adapter(
|
||||
})
|
||||
};
|
||||
|
||||
let adapter_output = match execute_bridge_adapter_command(ChromeBridgeCommandRequest {
|
||||
let adapter_timeout_ms = normalize_adapter_evaluate_timeout_ms(timeout_ms);
|
||||
let adapter_request = ChromeBridgeCommandRequest {
|
||||
profile_key: Some(profile_key.clone()),
|
||||
command: "run_adapter".to_string(),
|
||||
target: bridged_target_id.clone(),
|
||||
@@ -2591,14 +2613,47 @@ async fn run_existing_session_adapter(
|
||||
url: None,
|
||||
payload: Some(json!({
|
||||
"adapter_name": spec.name,
|
||||
"args": Value::Object(args),
|
||||
"args": Value::Object(args.clone()),
|
||||
})),
|
||||
wait_for_page_info: false,
|
||||
timeout_ms: Some(normalize_adapter_evaluate_timeout_ms(timeout_ms)),
|
||||
})
|
||||
.await
|
||||
{
|
||||
timeout_ms: Some(adapter_timeout_ms),
|
||||
};
|
||||
|
||||
let adapter_output = match execute_bridge_adapter_command(adapter_request).await {
|
||||
Ok(result) => result.data.unwrap_or(Value::Null),
|
||||
Err(error)
|
||||
if looks_like_existing_session_transient_runtime_error(&error)
|
||||
&& bridged_target_id.is_some() =>
|
||||
{
|
||||
match retry_existing_session_adapter_after_transient_error(
|
||||
spec,
|
||||
&profile_key,
|
||||
adapter_timeout_ms,
|
||||
&args,
|
||||
bridged_target_id.clone(),
|
||||
latest_source_url.clone(),
|
||||
&error,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((output, retry_target_id, retry_source_url)) => {
|
||||
bridged_target_id = retry_target_id;
|
||||
latest_source_url = retry_source_url;
|
||||
output
|
||||
}
|
||||
Err(retry_error) => {
|
||||
return build_error_result(
|
||||
spec,
|
||||
profile_key,
|
||||
None,
|
||||
bridged_target_id,
|
||||
entry_url,
|
||||
"adapter_runtime_error",
|
||||
&retry_error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return build_error_result(
|
||||
spec,
|
||||
@@ -2625,6 +2680,69 @@ async fn run_existing_session_adapter(
|
||||
)
|
||||
}
|
||||
|
||||
async fn retry_existing_session_adapter_after_transient_error(
|
||||
spec: &SiteAdapterSpec,
|
||||
profile_key: &str,
|
||||
adapter_timeout_ms: u64,
|
||||
args: &Map<String, Value>,
|
||||
mut target_id: Option<String>,
|
||||
mut source_url: Option<String>,
|
||||
initial_error: &str,
|
||||
) -> Result<(Value, Option<String>, Option<String>), String> {
|
||||
let mut last_error = initial_error.to_string();
|
||||
|
||||
for attempt in 0..EXISTING_SESSION_TRANSIENT_RETRY_LIMIT {
|
||||
let delay_ms = EXISTING_SESSION_TRANSIENT_RETRY_DELAY_MS * (attempt as u64 + 1);
|
||||
tracing::warn!(
|
||||
"[site_capability] existing_session 运行适配器命中瞬态错误,准备重试: adapter={}, profile_key={}, target_id={:?}, attempt={}, delay_ms={}, error={}",
|
||||
spec.name,
|
||||
profile_key,
|
||||
target_id,
|
||||
attempt + 1,
|
||||
delay_ms,
|
||||
last_error
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
|
||||
if let Ok(tabs) = load_existing_session_tabs(profile_key).await {
|
||||
if let Some(refreshed_target) =
|
||||
select_existing_session_retry_target(&tabs, &spec.domain, target_id.as_deref())
|
||||
{
|
||||
source_url = refreshed_target.url.clone().or(source_url);
|
||||
target_id = Some(refreshed_target.id);
|
||||
}
|
||||
}
|
||||
|
||||
let retry_request = ChromeBridgeCommandRequest {
|
||||
profile_key: Some(profile_key.to_string()),
|
||||
command: "run_adapter".to_string(),
|
||||
target: target_id.clone(),
|
||||
text: None,
|
||||
url: None,
|
||||
payload: Some(json!({
|
||||
"adapter_name": spec.name,
|
||||
"args": Value::Object(args.clone()),
|
||||
})),
|
||||
wait_for_page_info: false,
|
||||
timeout_ms: Some(adapter_timeout_ms),
|
||||
};
|
||||
|
||||
match execute_bridge_adapter_command(retry_request).await {
|
||||
Ok(result) => {
|
||||
return Ok((result.data.unwrap_or(Value::Null), target_id, source_url));
|
||||
}
|
||||
Err(error) => {
|
||||
last_error = error;
|
||||
if !looks_like_existing_session_transient_runtime_error(&last_error) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error)
|
||||
}
|
||||
|
||||
async fn run_managed_cdp_adapter(
|
||||
db: &DbConnection,
|
||||
spec: &SiteAdapterSpec,
|
||||
@@ -3367,6 +3485,16 @@ fn looks_like_navigation_timeout_error(error: &str) -> bool {
|
||||
&& (normalized.contains("timeout") || error.contains("超时"))
|
||||
}
|
||||
|
||||
fn looks_like_existing_session_transient_runtime_error(error: &str) -> bool {
|
||||
let normalized = error.to_ascii_lowercase();
|
||||
normalized.contains("frame with id")
|
||||
|| normalized.contains("frame was removed")
|
||||
|| normalized.contains("receiving end does not exist")
|
||||
|| normalized.contains("could not establish connection")
|
||||
|| normalized.contains("message port closed")
|
||||
|| normalized.contains("extension context invalidated")
|
||||
}
|
||||
|
||||
fn build_site_adapter_report_hint(error_code: &str) -> Option<String> {
|
||||
match error_code {
|
||||
"attached_session_required" => Some(
|
||||
@@ -4037,6 +4165,42 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_detect_existing_session_transient_runtime_error() {
|
||||
assert!(looks_like_existing_session_transient_runtime_error(
|
||||
"Frame with ID 0 was removed."
|
||||
));
|
||||
assert!(looks_like_existing_session_transient_runtime_error(
|
||||
"Could not establish connection. Receiving end does not exist."
|
||||
));
|
||||
assert!(!looks_like_existing_session_transient_runtime_error(
|
||||
"当前扩展内未注册站点适配器: x/article-export"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_prefer_matching_domain_when_retrying_existing_session_target() {
|
||||
let tabs = vec![
|
||||
ExistingSessionTabRecord {
|
||||
id: "tab-old".to_string(),
|
||||
index: 3,
|
||||
url: Some("https://example.com/dashboard".to_string()),
|
||||
active: false,
|
||||
},
|
||||
ExistingSessionTabRecord {
|
||||
id: "tab-x".to_string(),
|
||||
index: 1,
|
||||
url: Some("https://x.com/GoogleCloudTech/article/2033953579824758855".to_string()),
|
||||
active: true,
|
||||
},
|
||||
];
|
||||
|
||||
let selected = select_existing_session_retry_target(&tabs, "x.com", Some("tab-old"))
|
||||
.expect("应优先改选到匹配目标域名的标签页");
|
||||
|
||||
assert_eq!(selected.id, "tab-x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_site_unreachable_report_hint() {
|
||||
let hint = build_site_adapter_report_hint("site_unreachable")
|
||||
@@ -4320,10 +4484,7 @@ mod tests {
|
||||
.get("siteAdapterBundleMetaPath")
|
||||
.and_then(Value::as_str)
|
||||
.expect("应记录 meta 相对路径");
|
||||
assert_eq!(
|
||||
saved_content.meta_relative_path.as_deref(),
|
||||
Some(meta_path)
|
||||
);
|
||||
assert_eq!(saved_content.meta_relative_path.as_deref(), Some(meta_path));
|
||||
assert_eq!(
|
||||
saved_content.bundle_relative_dir.as_deref(),
|
||||
Some(bundle_dir)
|
||||
|
||||
Reference in New Issue
Block a user