release: v1.6.0

This commit is contained in:
coso
2026-04-09 00:08:21 +08:00
parent a057d76e6a
commit db3bfae4ff
864 changed files with 307221 additions and 1689 deletions
@@ -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;
+239 -10
View File
@@ -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![封面图](images/cover.png)\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,
)?;
// 返回文件访问路径
+3 -1
View File
@@ -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)
}
/// 读取会话文件
+140 -21
View File
@@ -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(&sections.when_to_use),
inputs = render_bullet_list(&sections.inputs),
steps = render_ordered_list(&sections.steps),
outputs = render_bullet_list(&sections.outputs),
fallback_strategy = render_bullet_list(&sections.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();
+21
View File
@@ -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()?)?
}
+7 -13
View File
@@ -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]
+172 -11
View File
@@ -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)