release: v1.4.0

This commit is contained in:
coso
2026-04-06 22:50:50 +08:00
parent acc573818d
commit 7c06f84e16
467 changed files with 23371 additions and 15151 deletions
+26 -2
View File
@@ -11,9 +11,11 @@ pub const LIME_CREATE_BROADCAST_TASK_TOOL_NAME: &str = "lime_create_broadcast_ge
pub const LIME_CREATE_COVER_TASK_TOOL_NAME: &str = "lime_create_cover_generation_task";
pub const LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME: &str =
"lime_create_modal_resource_search_task";
pub const LIME_SEARCH_WEB_IMAGES_TOOL_NAME: &str = "lime_search_web_images";
pub const LIME_CREATE_IMAGE_TASK_TOOL_NAME: &str = "lime_create_image_generation_task";
pub const LIME_CREATE_URL_PARSE_TASK_TOOL_NAME: &str = "lime_create_url_parse_task";
pub const LIME_CREATE_TYPESETTING_TASK_TOOL_NAME: &str = "lime_create_typesetting_task";
pub const LIME_RUN_SERVICE_SKILL_TOOL_NAME: &str = "lime_run_service_skill";
pub const LIME_SITE_LIST_TOOL_NAME: &str = "lime_site_list";
pub const LIME_SITE_RECOMMEND_TOOL_NAME: &str = "lime_site_recommend";
pub const LIME_SITE_SEARCH_TOOL_NAME: &str = "lime_site_search";
@@ -564,6 +566,15 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_SEARCH_WEB_IMAGES_TOOL_NAME,
profiles: WORKBENCH_PROFILES,
capabilities: SEARCH_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_CREATE_IMAGE_TASK_TOOL_NAME,
profiles: WORKBENCH_PROFILES,
@@ -591,6 +602,15 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_RUN_SERVICE_SKILL_TOOL_NAME,
profiles: WORKBENCH_PROFILES,
capabilities: EXECUTION_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_SITE_LIST_TOOL_NAME,
profiles: BROWSER_PROFILES,
@@ -1085,10 +1105,12 @@ mod tests {
#[test]
fn test_workbench_tool_names_only_returns_workbench_increment() {
let names = workbench_tool_names().into_iter().collect::<BTreeSet<_>>();
assert_eq!(names.len(), 9);
assert_eq!(names.len(), 11);
assert!(names.contains(SOCIAL_IMAGE_TOOL_NAME));
assert!(names.contains(LIME_CREATE_VIDEO_TASK_TOOL_NAME));
assert!(names.contains(LIME_CREATE_TRANSCRIPTION_TASK_TOOL_NAME));
assert!(names.contains(LIME_RUN_SERVICE_SKILL_TOOL_NAME));
assert!(names.contains(LIME_SEARCH_WEB_IMAGES_TOOL_NAME));
assert!(!names.contains(TOOL_SEARCH_TOOL_NAME));
assert!(!names.contains(BROWSER_RUNTIME_TOOL_PREFIX));
}
@@ -1099,7 +1121,7 @@ mod tests {
let names = workspace_default_allowed_tool_names(
WorkspaceToolSurface::workbench_with_browser_assist(),
);
assert_eq!(names.len(), 43);
assert_eq!(names.len(), 45);
assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME));
assert!(names.contains(&TOOL_SEARCH_TOOL_NAME));
assert!(names.contains(&LIST_MCP_RESOURCES_TOOL_NAME));
@@ -1108,6 +1130,8 @@ mod tests {
assert!(names.contains(&"TeamCreate"));
assert!(names.contains(&"TeamDelete"));
assert!(names.contains(&LIME_CREATE_TRANSCRIPTION_TASK_TOOL_NAME));
assert!(names.contains(&LIME_RUN_SERVICE_SKILL_TOOL_NAME));
assert!(names.contains(&LIME_SEARCH_WEB_IMAGES_TOOL_NAME));
assert!(names.contains(&LIME_SITE_RECOMMEND_TOOL_NAME));
assert!(names.contains(&LIME_SITE_RUN_TOOL_NAME));
assert!(!names
+18 -3
View File
@@ -980,6 +980,9 @@ mod tests {
#[test]
fn test_build_tool_inventory_workbench_with_browser_surface_keeps_small_default_allowlist() {
let expected_catalog = tool_catalog_entries_for_surface(
WorkspaceToolSurface::workbench_with_browser_assist(),
);
let inventory = build_tool_inventory(AgentToolInventoryBuildInput {
surface: WorkspaceToolSurface::workbench_with_browser_assist(),
caller: "assistant".to_string(),
@@ -1001,9 +1004,21 @@ mod tests {
.map(ToString::to_string)
.collect::<Vec<_>>();
assert_eq!(inventory.counts.catalog_total, 56);
assert_eq!(inventory.counts.catalog_current_total, 55);
assert_eq!(inventory.counts.catalog_compat_total, 1);
assert_eq!(inventory.counts.catalog_total, expected_catalog.len());
assert_eq!(
inventory.counts.catalog_current_total,
expected_catalog
.iter()
.filter(|entry| entry.lifecycle == ToolLifecycle::Current)
.count()
);
assert_eq!(
inventory.counts.catalog_compat_total,
expected_catalog
.iter()
.filter(|entry| entry.lifecycle == ToolLifecycle::Compat)
.count()
);
assert_eq!(inventory.default_allowed_tools, expected_default_allowed);
assert_eq!(
inventory.counts.default_allowed_total,
+3 -3
View File
@@ -1235,8 +1235,8 @@ pub fn run() {
// Execution run commands
commands::execution_run_cmd::execution_run_list,
commands::execution_run_cmd::execution_run_get,
commands::execution_run_cmd::execution_run_get_theme_workbench_state,
commands::execution_run_cmd::execution_run_list_theme_workbench_history,
commands::execution_run_cmd::execution_run_get_general_workbench_state,
commands::execution_run_cmd::execution_run_list_general_workbench_history,
// Ecommerce Review Reply commands
commands::ecommerce_review_reply_cmd::execute_ecommerce_review_reply,
// Provider Pool commands
@@ -1726,7 +1726,7 @@ pub fn run() {
// Content commands
commands::content_cmd::content_create,
commands::content_cmd::content_get,
commands::content_cmd::content_get_theme_workbench_document_state,
commands::content_cmd::content_get_general_workbench_document_state,
commands::content_cmd::content_list,
commands::content_cmd::content_update,
commands::content_cmd::content_delete,
@@ -0,0 +1,186 @@
use super::*;
const ANALYSIS_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_ANALYSIS_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_analysis_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["analysis_skill_launch", "analysisSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_analysis_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_analysis_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(ANALYSIS_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_analysis_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["analysis_skill_launch", "analysisSkillLaunch"],
)?;
let kind =
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "analysis_request".to_string());
if kind != "analysis_request" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "analysis".to_string());
let analysis_request = launch
.get("analysis_request")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(analysis_request, &["raw_text", "rawText"]);
let prompt = extract_object_string(analysis_request, &["prompt"])
.unwrap_or_else(|| "请分析当前对话中最相关的内容".to_string());
let content = extract_object_string(analysis_request, &["content"]);
let focus = extract_object_string(analysis_request, &["focus"]);
let style = extract_object_string(analysis_request, &["style"]);
let output_format = extract_object_string(analysis_request, &["output_format", "outputFormat"]);
let project_id = extract_object_string(analysis_request, &["project_id", "projectId"]);
let content_id = extract_object_string(analysis_request, &["content_id", "contentId"]);
let entry_source = extract_object_string(analysis_request, &["entry_source", "entrySource"])
.unwrap_or_else(|| "at_analysis_command".to_string());
let args_payload = serde_json::json!({
"user_input": raw_text.clone().unwrap_or_else(|| prompt.clone()),
"analysis_request": serde_json::Value::Object(analysis_request.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let request_json = truncate_prompt_text(
serde_json::to_string(analysis_request).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_explicit_content = content
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
ANALYSIS_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自分析技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速判断要分析什么,再立刻把任务交给 Skill 工具;不要直接跳过 Skill 在聊天区作答。"
.to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- 这条命令属于 prompt skill 主链,不要创建 task file,也不要回退成普通聊天分析。".to_string(),
"- 若用户明确给了正文、文件路径或范围,优先分析这些材料;若未明确给材料,则分析当前对话中与请求最相关的内容。".to_string(),
"- 分析结果必须区分原文事实、你的判断与待确认项,不要把推断写成已确认事实。".to_string(),
format!("- 当前分析请求上下文(JSON):{request_json}"),
format!("- 当前入口来源:{entry_source}。"),
format!("- 当前分析目标:{prompt}"),
];
if let Some(value) = content.as_deref() {
lines.push(format!("- 当前显式正文:{value}。"));
}
if let Some(value) = focus.as_deref() {
lines.push(format!("- 当前分析重点:{value}。"));
}
if let Some(value) = style.as_deref() {
lines.push(format!("- 当前风格偏好:{value}。"));
}
if let Some(value) = output_format.as_deref() {
lines.push(format!("- 当前输出格式偏好:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if has_explicit_content {
lines
.push("- 当前任务已经显式进入分析技能主链,不要再追问用户“是否开始分析”。".to_string());
} else {
lines.push(
"- 当前没有显式正文时,优先尝试分析当前对话上下文;只有在上下文也不足以完成时,才最多追问 1 个关键问题。"
.to_string(),
);
}
Some(lines.join("\n"))
}
@@ -0,0 +1,205 @@
use super::*;
const BROADCAST_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_BROADCAST_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_broadcast_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["broadcast_skill_launch", "broadcastSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_broadcast_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_broadcast_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(BROADCAST_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_broadcast_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["broadcast_skill_launch", "broadcastSkillLaunch"],
)?;
let kind =
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "broadcast_task".to_string());
if kind != "broadcast_task" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "broadcast_generate".to_string());
let broadcast_task = launch
.get("broadcast_task")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(broadcast_task, &["raw_text", "rawText"]);
let prompt = extract_object_string(broadcast_task, &["prompt"]);
let content = extract_object_string(broadcast_task, &["content"]);
let title = extract_object_string(broadcast_task, &["title"]);
let audience = extract_object_string(broadcast_task, &["audience"]);
let tone = extract_object_string(broadcast_task, &["tone"]);
let duration_hint_minutes = broadcast_task
.get("duration_hint_minutes")
.or_else(|| broadcast_task.get("durationHintMinutes"))
.and_then(serde_json::Value::as_u64);
let session_id = extract_object_string(broadcast_task, &["session_id", "sessionId"]);
let project_id = extract_object_string(broadcast_task, &["project_id", "projectId"]);
let content_id = extract_object_string(broadcast_task, &["content_id", "contentId"]);
let entry_source = extract_object_string(broadcast_task, &["entry_source", "entrySource"])
.unwrap_or_else(|| "at_broadcast_command".to_string());
let args_payload = serde_json::json!({
"user_input": raw_text
.clone()
.or(prompt.clone())
.or(content.clone())
.unwrap_or_else(|| "请根据当前要求执行播报整理任务".to_string()),
"broadcast_task": serde_json::Value::Object(broadcast_task.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let task_json = truncate_prompt_text(
serde_json::to_string(broadcast_task).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let content_present = content
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
BROADCAST_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自播报技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速归纳用户目标,然后立刻把任务交给 Skill 工具;不要停留在泛泛解释。".to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- Skill 执行后,优先沿 broadcast_generate skill 的 Bash / task file 主链提交异步任务;只有 Skill 明确不可用时,才允许直接回退到 lime_create_broadcast_generation_task。".to_string(),
"- 不要伪造“播报已完成”;在 task file 真正返回结果前,只能汇报任务已提交、排队或执行中。".to_string(),
format!("- 当前播报任务上下文(JSON):{task_json}"),
format!("- 当前入口来源:{entry_source}。"),
];
if let Some(value) = prompt.as_deref() {
lines.push(format!("- 当前播报目标:{value}"));
}
if let Some(value) = title.as_deref() {
lines.push(format!("- 当前标题:{value}。"));
}
if let Some(value) = audience.as_deref() {
lines.push(format!("- 当前目标听众:{value}。"));
}
if let Some(value) = tone.as_deref() {
lines.push(format!("- 当前语气风格:{value}。"));
}
if let Some(value) = duration_hint_minutes {
lines.push(format!("- 当前建议时长:{value} 分钟。"));
}
if let Some(value) = content.as_deref() {
lines.push(format!(
"- 当前待整理正文摘要:{}",
truncate_prompt_text(value.to_string(), 400)
));
}
if let Some(value) = session_id.as_deref() {
lines.push(format!("- 当前 session_id:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if content_present {
lines.push(
"- 当前任务已经显式进入播报技能主链,不要再要求用户额外确认“是否开始整理播报文本”。"
.to_string(),
);
} else {
lines.push(
"- 当前还缺少明确待整理原文。你最多只能追问 1 个关键问题,请用户补充正文;在正文补齐前不要创建任务,也不要伪造结果。"
.to_string(),
);
}
Some(lines.join("\n"))
}
@@ -249,6 +249,6 @@ pub(crate) fn should_enable_model_skill_tool(request_metadata: Option<&serde_jso
matches!(
extract_harness_string(request_metadata, &["session_mode", "sessionMode"]).as_deref(),
Some("theme_workbench")
Some("general_workbench") | Some("theme_workbench")
)
}
@@ -0,0 +1,203 @@
use super::*;
const DEEP_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_DEEP_SEARCH_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_deep_search_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["deep_search_skill_launch", "deepSearchSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_deep_search_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_deep_search_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(DEEP_SEARCH_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_deep_search_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["deep_search_skill_launch", "deepSearchSkillLaunch"],
)?;
let kind = extract_object_string(launch, &["kind"])
.unwrap_or_else(|| "deep_search_request".to_string());
if kind != "deep_search_request" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "research".to_string());
let deep_search_request = launch
.get("deep_search_request")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(deep_search_request, &["raw_text", "rawText"]);
let prompt = extract_object_string(deep_search_request, &["prompt"]);
let query = extract_object_string(deep_search_request, &["query"]);
let site = extract_object_string(deep_search_request, &["site"]);
let time_range = extract_object_string(deep_search_request, &["time_range", "timeRange"]);
let depth = extract_object_string(deep_search_request, &["depth"]);
let focus = extract_object_string(deep_search_request, &["focus"]);
let output_format =
extract_object_string(deep_search_request, &["output_format", "outputFormat"]);
let project_id = extract_object_string(deep_search_request, &["project_id", "projectId"]);
let content_id = extract_object_string(deep_search_request, &["content_id", "contentId"]);
let entry_source = extract_object_string(deep_search_request, &["entry_source", "entrySource"])
.unwrap_or_else(|| "at_deep_search_command".to_string());
let args_payload = serde_json::json!({
"user_input": raw_text
.clone()
.or(prompt.clone())
.or(query.clone())
.unwrap_or_else(|| "请根据当前要求执行深度搜索任务".to_string()),
"deep_search_request": serde_json::Value::Object(deep_search_request.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let request_json = truncate_prompt_text(
serde_json::to_string(deep_search_request).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_query = query
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
DEEP_SEARCH_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自深搜技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速归纳用户的深搜目标,然后立刻把任务交给 Skill 工具;不要先直接给出记忆性结论。"
.to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- 这条命令属于 prompt skill 主链,不要创建 task file,也不要退回普通聊天、普通 @搜索 或一次浅搜。".to_string(),
"- research skill 内部必须真正执行联网检索,不要只凭已有记忆直接回答。".to_string(),
"- 深搜至少执行 2 轮以上扩搜,主动使用不同关键词组合、来源或时间切片;不能只搜一次就直接收尾。".to_string(),
"- 如果用户要求最新、近期、今天或时间敏感信息,检索词里必须补年份或时间范围,并在最终回答中标注时间口径。".to_string(),
"- 最终输出必须显式区分“已确认事实”“基于来源的推断”“待确认项”,若来源之间存在冲突,也要明确标出来。".to_string(),
"- Skill 执行后,再基于检索结果整理结论、来源聚类与后续建议;在真实检索完成前,不要伪造“已经深搜完毕”的细节。".to_string(),
format!("- 当前深搜请求上下文(JSON):{request_json}"),
format!("- 当前入口来源:{entry_source}。"),
];
if let Some(value) = prompt.as_deref() {
lines.push(format!("- 当前深搜目标:{value}"));
}
if let Some(value) = query.as_deref() {
lines.push(format!("- 当前核心查询:{value}。"));
}
if let Some(value) = site.as_deref() {
lines.push(format!("- 当前目标站点/来源:{value}。"));
}
if let Some(value) = time_range.as_deref() {
lines.push(format!("- 当前时间范围:{value}。"));
}
if let Some(value) = depth.as_deref() {
lines.push(format!("- 当前调研深度:{value}。"));
}
if let Some(value) = focus.as_deref() {
lines.push(format!("- 当前关注重点:{value}。"));
}
if let Some(value) = output_format.as_deref() {
lines.push(format!("- 当前输出格式偏好:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if has_query {
lines
.push("- 当前任务已经显式进入深搜技能主链,不要再追问用户“是否开始深搜”。".to_string());
} else {
lines.push(
"- 当前还缺少明确深搜主题。你最多只能追问 1 个关键问题,请用户补充最关键的检索对象;在主题补齐前不要伪造检索结果。"
.to_string(),
);
}
Some(lines.join("\n"))
}
@@ -98,7 +98,7 @@ pub struct AsterChatRequest {
/// 前端传入的 System Prompt(可选,优先级低于项目上下文)
#[serde(default, alias = "systemPrompt")]
pub system_prompt: Option<String>,
/// 请求级元数据(可选,用于 harness / 主题工作台状态对齐)
/// 请求级元数据(可选,用于 harness / 工作区编排状态对齐)
#[serde(default)]
pub metadata: Option<serde_json::Value>,
/// 回合 ID(可选,由前端提供时透传到 Aster runtime)
+52 -1
View File
@@ -258,22 +258,33 @@ fn normalize_optional_text(value: Option<String>) -> Option<String> {
}
pub(crate) mod action_runtime;
mod analysis_skill_launch;
mod broadcast_skill_launch;
mod browser_assist;
pub(crate) mod command_api;
mod cover_skill_launch;
mod deep_search_skill_launch;
mod dto;
mod image_skill_launch;
mod mcp_bridge;
mod pdf_read_skill_launch;
mod prompt_context;
mod reply_runtime;
mod report_skill_launch;
mod request_model_resolution;
mod research_skill_launch;
mod resource_search_skill_launch;
mod run_metadata;
mod runtime_turn;
mod service_skill_launch;
mod session_runtime;
mod site_search_skill_launch;
mod subagent_runtime;
mod summary_skill_launch;
pub(crate) mod tool_runtime;
mod transcription_skill_launch;
mod translation_skill_launch;
mod typesetting_skill_launch;
mod url_parse_skill_launch;
mod video_skill_launch;
#[cfg(test)]
@@ -294,6 +305,13 @@ pub(crate) use action_runtime::{
build_action_resume_runtime_status, build_runtime_action_user_data,
validate_elicitation_submission,
};
pub(crate) use analysis_skill_launch::{
merge_system_prompt_with_analysis_skill_launch, prepare_analysis_skill_launch_request_metadata,
};
pub(crate) use broadcast_skill_launch::{
merge_system_prompt_with_broadcast_skill_launch,
prepare_broadcast_skill_launch_request_metadata,
};
pub(crate) use browser_assist::{
append_browser_assist_session_permissions, apply_browser_requirement_to_request_tool_policy,
default_web_search_enabled_for_chat_mode, extract_browser_task_requirement,
@@ -320,6 +338,10 @@ pub(crate) use command_api::{
aster_agent_configure_provider, aster_agent_init, aster_agent_reset, aster_agent_status,
};
pub(crate) use cover_skill_launch::merge_system_prompt_with_cover_skill_launch;
pub(crate) use deep_search_skill_launch::{
merge_system_prompt_with_deep_search_skill_launch,
prepare_deep_search_skill_launch_request_metadata,
};
#[allow(unused_imports)]
pub(crate) use dto::{
build_incidents, build_last_outcome, build_pending_requests, AgentRuntimeActionType,
@@ -343,6 +365,9 @@ pub(crate) use image_skill_launch::{
merge_system_prompt_with_image_skill_launch, prepare_image_skill_launch_request_metadata,
};
pub(crate) use mcp_bridge::{ensure_lime_mcp_servers_running, inject_mcp_extensions};
pub(crate) use pdf_read_skill_launch::{
merge_system_prompt_with_pdf_read_skill_launch, prepare_pdf_read_skill_launch_request_metadata,
};
#[cfg(test)]
pub(crate) use prompt_context::build_team_preference_system_prompt;
pub(crate) use prompt_context::{
@@ -358,7 +383,17 @@ use reply_runtime::{
emit_runtime_status_with_projection, ensure_code_execution_extension_enabled,
should_fallback_to_react_from_code_orchestrated, stream_reply_once,
};
pub(crate) use report_skill_launch::{
merge_system_prompt_with_report_skill_launch, prepare_report_skill_launch_request_metadata,
};
use request_model_resolution::resolve_runtime_request_provider_config;
pub(crate) use research_skill_launch::{
merge_system_prompt_with_research_skill_launch, prepare_research_skill_launch_request_metadata,
};
pub(crate) use resource_search_skill_launch::{
merge_system_prompt_with_resource_search_skill_launch,
prepare_resource_search_skill_launch_request_metadata,
};
use run_metadata::{
build_chat_run_finish_metadata, build_chat_run_metadata_base, extract_harness_array,
extract_harness_bool, extract_harness_nested_object, extract_harness_string,
@@ -378,7 +413,8 @@ pub(crate) use runtime_turn::{
pub(crate) use service_skill_launch::build_service_skill_launch_run_request;
pub(crate) use service_skill_launch::{
append_service_skill_launch_session_permissions, preload_service_skill_launch_execution,
should_lock_service_skill_launch_to_site_tools, ServiceSkillLaunchPreloadExecution,
prepare_service_scene_launch_request_metadata, should_lock_service_skill_launch_to_site_tools,
ServiceSkillLaunchPreloadExecution,
};
pub(crate) use session_runtime::{
delete_runtime_session_internal, persist_session_provider_routing,
@@ -387,6 +423,10 @@ pub(crate) use session_runtime::{
resolve_session_recent_runtime_context, SessionRecentHarnessContext,
SessionRecentRuntimeContext,
};
pub(crate) use site_search_skill_launch::{
merge_system_prompt_with_site_search_skill_launch,
prepare_site_search_skill_launch_request_metadata,
};
#[allow(unused_imports)]
pub(crate) use subagent_runtime::{
agent_runtime_close_subagent_internal, agent_runtime_resume_subagent_internal,
@@ -394,6 +434,9 @@ pub(crate) use subagent_runtime::{
agent_runtime_wait_subagents_internal, emit_subagent_status_changed_events,
maybe_emit_subagent_status_for_runtime_event, SubagentControlRuntime,
};
pub(crate) use summary_skill_launch::{
merge_system_prompt_with_summary_skill_launch, prepare_summary_skill_launch_request_metadata,
};
#[allow(unused_imports)]
pub(crate) use tool_runtime::social_generate_cover_image_cmd;
pub(crate) use tool_runtime::{apply_workspace_sandbox_permissions, ImageInput};
@@ -410,6 +453,14 @@ pub(crate) use tool_runtime::{
ensure_runtime_support_tools_registered, ensure_social_image_tool_registered,
};
pub(crate) use transcription_skill_launch::merge_system_prompt_with_transcription_skill_launch;
pub(crate) use translation_skill_launch::{
merge_system_prompt_with_translation_skill_launch,
prepare_translation_skill_launch_request_metadata,
};
pub(crate) use typesetting_skill_launch::{
merge_system_prompt_with_typesetting_skill_launch,
prepare_typesetting_skill_launch_request_metadata,
};
pub(crate) use url_parse_skill_launch::merge_system_prompt_with_url_parse_skill_launch;
pub(crate) use video_skill_launch::merge_system_prompt_with_video_skill_launch;
@@ -0,0 +1,196 @@
use super::*;
const PDF_READ_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_PDF_READ_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_pdf_read_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["pdf_read_skill_launch", "pdfReadSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_pdf_read_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_pdf_read_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(PDF_READ_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_pdf_read_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["pdf_read_skill_launch", "pdfReadSkillLaunch"],
)?;
let kind =
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "pdf_read_request".to_string());
if kind != "pdf_read_request" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "pdf_read".to_string());
let pdf_read_request = launch
.get("pdf_read_request")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(pdf_read_request, &["raw_text", "rawText"]);
let prompt = extract_object_string(pdf_read_request, &["prompt"])
.unwrap_or_else(|| "请阅读这份 PDF 并提炼关键信息".to_string());
let source_path = extract_object_string(pdf_read_request, &["source_path", "sourcePath"]);
let source_url = extract_object_string(pdf_read_request, &["source_url", "sourceUrl"]);
let focus = extract_object_string(pdf_read_request, &["focus"]);
let output_format = extract_object_string(pdf_read_request, &["output_format", "outputFormat"]);
let project_id = extract_object_string(pdf_read_request, &["project_id", "projectId"]);
let content_id = extract_object_string(pdf_read_request, &["content_id", "contentId"]);
let entry_source = extract_object_string(pdf_read_request, &["entry_source", "entrySource"])
.unwrap_or_else(|| "at_pdf_read_command".to_string());
let args_payload = serde_json::json!({
"user_input": raw_text.clone().unwrap_or_else(|| prompt.clone()),
"pdf_read_request": serde_json::Value::Object(pdf_read_request.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let request_json = truncate_prompt_text(
serde_json::to_string(pdf_read_request).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_source_path = source_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let has_source_url = source_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
PDF_READ_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自读 PDF 技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先确认 PDF 来源,再立刻把任务交给 Skill 工具;不要在未实际读取文件前直接总结 PDF 内容。"
.to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- 这条命令属于 prompt skill 主链,不要创建 task file,也不要退回普通聊天凭空回答。".to_string(),
"- 若拿到本地或工作区 PDF 路径,优先最小化使用 `list_directory / read_file` 读取目标 PDF,并保留真实 tool timeline。".to_string(),
"- 若路径是相对路径,可先用 `list_directory` 确认位置,再调用 `read_file`;不要假装文件已经读取成功。".to_string(),
"- 结果必须按“文档信息 / 核心要点 / 关键证据 / 待确认项”组织,且所有结论都要能回溯到实际读到的 PDF 内容。".to_string(),
format!("- 当前读 PDF 请求上下文(JSON):{request_json}"),
format!("- 当前入口来源:{entry_source}。"),
format!("- 当前解读目标:{prompt}"),
];
if let Some(value) = source_path.as_deref() {
lines.push(format!("- 当前 PDF 本地路径:{value}。"));
}
if let Some(value) = source_url.as_deref() {
lines.push(format!("- 当前 PDF 链接:{value}。"));
}
if let Some(value) = focus.as_deref() {
lines.push(format!("- 当前关注重点:{value}。"));
}
if let Some(value) = output_format.as_deref() {
lines.push(format!("- 当前输出格式偏好:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if has_source_path {
lines.push(
"- 当前任务已经显式提供 PDF 路径,不要再追问用户“是否开始读取 PDF”。".to_string(),
);
} else if has_source_url {
lines.push(
"- 当前只有 PDF URL。现有链路不保证能稳定直接读取远程 PDF;你最多只能追问 1 个关键问题,请用户提供本地路径或先把 PDF 导入工作区。".to_string(),
);
} else {
lines.push(
"- 当前缺少明确 PDF 来源。你最多只能追问 1 个关键问题,请用户补充本地路径或工作区内 PDF 文件位置;在来源补齐前不要伪造已读结果。".to_string(),
);
}
Some(lines.join("\n"))
}
@@ -253,6 +253,10 @@ fn build_service_skill_launch_run_example(
fn build_service_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
if let Some(prompt) = build_service_scene_launch_system_prompt(request_metadata) {
return Some(prompt);
}
let launch = extract_harness_nested_object(
request_metadata,
&["service_skill_launch", "serviceSkillLaunch"],
@@ -361,6 +365,94 @@ fn build_service_skill_launch_system_prompt(
Some(lines.join("\n"))
}
fn build_service_scene_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let context =
super::service_skill_launch::extract_service_scene_launch_context(request_metadata)?;
let tool_input = if let Some(user_input) = context
.user_input
.clone()
.filter(|value| !value.trim().is_empty())
{
serde_json::json!({ "input": user_input }).to_string()
} else {
"{}".to_string()
};
let mut lines = vec![
SERVICE_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自服务型场景启动,不要把它当成普通聊天或纯文本分析。".to_string(),
"- 先快速确认当前 slash 场景目标,然后立刻调用服务型技能运行工具;不要停留在泛泛解释。".to_string(),
"- 第一优先工具调用必须是 lime_run_service_skill。".to_string(),
"- lime_run_service_skill 会自动读取当前回合绑定的 serviceSkillId 与 OEM 运行时上下文,通常不需要手动补鉴权字段。".to_string(),
format!("- 推荐第一工具调用参数 JSON:{tool_input}。"),
"- 如果用户没有追加补充要求,直接传 {} 也可以;不要把 scene metadata 里的 session_token、tenant_id、scene_base_url 重新抄进工具参数。".to_string(),
"- 调用 lime_run_service_skill 后,若返回 queued/running,可以继续等待一次或基于当前状态向用户汇报“已提交云端,正在处理中”;不要伪造已完成结果。".to_string(),
"- 如果工具返回缺少 OEM 配置、缺少 Session Token 或授权失败,不要伪造成功结果;直接说明当前云端会话不可用,并引导用户先完成登录或注入会话。".to_string(),
format!("- 当前服务型技能 ID:{}。", context.service_skill_id),
format!(
"- 当前服务型技能标题:{}。",
context
.skill_title
.clone()
.unwrap_or_else(|| "未提供".to_string())
),
format!(
"- 当前 scene_key:{}。",
context
.scene_key
.clone()
.unwrap_or_else(|| "未提供".to_string())
),
format!(
"- 当前 command_prefix:{}。",
context
.command_prefix
.clone()
.unwrap_or_else(|| "未提供".to_string())
),
format!(
"- 当前入口来源:{}。",
context
.entry_source
.clone()
.unwrap_or_else(|| "slash_scene_command".to_string())
),
];
if let Some(value) = context.user_input.as_deref() {
lines.push(format!("- 当前补充要求:{value}"));
} else if let Some(value) = context.raw_text.as_deref() {
lines.push(format!("- 当前原始指令:{value}"));
}
if let Some(value) = context.project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = context.content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if let Some(value) = context.skill_summary.as_deref() {
lines.push(format!("- 当前技能说明:{value}"));
}
if let Some(value) = context.oem_runtime.scene_base_url.as_deref() {
lines.push(format!("- 当前 scene_base_url:{value}。"));
} else {
lines.push(
"- 当前缺少 scene_base_url。若工具返回缺少 OEM 配置,直接向用户说明未完成云端接线。"
.to_string(),
);
}
if context.oem_runtime.session_token.is_some() {
lines
.push("- 当前回合已绑定 OEM Session Token,可直接调用服务型技能运行工具。".to_string());
} else {
lines.push("- 当前回合尚未绑定 OEM Session Token。若工具返回授权失败,不要重试伪造结果,直接要求用户先登录 OEM 云端。".to_string());
}
Some(lines.join("\n"))
}
pub(crate) fn merge_system_prompt_with_service_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
@@ -0,0 +1,201 @@
use super::*;
const REPORT_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_REPORT_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_report_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["report_skill_launch", "reportSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_report_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_report_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(REPORT_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_report_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["report_skill_launch", "reportSkillLaunch"],
)?;
let kind =
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "report_request".to_string());
if kind != "report_request" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "report_generate".to_string());
let report_request = launch
.get("report_request")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(report_request, &["raw_text", "rawText"]);
let prompt = extract_object_string(report_request, &["prompt"]);
let query = extract_object_string(report_request, &["query"]);
let site = extract_object_string(report_request, &["site"]);
let time_range = extract_object_string(report_request, &["time_range", "timeRange"]);
let depth = extract_object_string(report_request, &["depth"]);
let focus = extract_object_string(report_request, &["focus"]);
let output_format = extract_object_string(report_request, &["output_format", "outputFormat"]);
let project_id = extract_object_string(report_request, &["project_id", "projectId"]);
let content_id = extract_object_string(report_request, &["content_id", "contentId"]);
let entry_source = extract_object_string(report_request, &["entry_source", "entrySource"])
.unwrap_or_else(|| "at_report_command".to_string());
let args_payload = serde_json::json!({
"user_input": raw_text
.clone()
.or(prompt.clone())
.or(query.clone())
.unwrap_or_else(|| "请根据当前要求执行研报任务".to_string()),
"report_request": serde_json::Value::Object(report_request.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let request_json = truncate_prompt_text(
serde_json::to_string(report_request).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_query = query
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
REPORT_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自研报技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速归纳用户的研报目标,然后立刻把任务交给 Skill 工具;不要先写空泛长文。"
.to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- 这条命令属于 prompt skill 主链,不要创建媒体 task file,也不要退回普通聊天写长文。".to_string(),
"- report_generate skill 内部必须先执行真实联网检索,再产出研究报告。".to_string(),
"- 如果用户要求最新、近期、今天或时间敏感信息,检索词里必须补年份或时间范围,并在结果中标注时间口径。".to_string(),
"- 最终输出必须清楚区分核心结论、关键证据、风险/待确认项与建议动作,不要把推断写成已确认事实。".to_string(),
format!("- 当前研报请求上下文(JSON):{request_json}"),
format!("- 当前入口来源:{entry_source}。"),
];
if let Some(value) = prompt.as_deref() {
lines.push(format!("- 当前研报目标:{value}"));
}
if let Some(value) = query.as_deref() {
lines.push(format!("- 当前核心主题:{value}。"));
}
if let Some(value) = site.as_deref() {
lines.push(format!("- 当前目标站点/来源:{value}。"));
}
if let Some(value) = time_range.as_deref() {
lines.push(format!("- 当前时间范围:{value}。"));
}
if let Some(value) = depth.as_deref() {
lines.push(format!("- 当前调研深度:{value}。"));
}
if let Some(value) = focus.as_deref() {
lines.push(format!("- 当前关注重点:{value}。"));
}
if let Some(value) = output_format.as_deref() {
lines.push(format!("- 当前输出格式偏好:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if has_query {
lines.push(
"- 当前任务已经显式进入研报技能主链,不要再追问用户“是否开始做研报”。".to_string(),
);
} else {
lines.push(
"- 当前还缺少明确研报主题。你最多只能追问 1 个关键问题,请用户补充最关键的研究对象;在主题补齐前不要伪造检索或研报结果。"
.to_string(),
);
}
Some(lines.join("\n"))
}
@@ -13,6 +13,7 @@ struct ProviderResolutionContext {
registry_provider_ids: Vec<String>,
alias_key: String,
custom_models: Vec<String>,
is_custom_provider: bool,
}
fn normalize_identifier(value: &str) -> String {
@@ -224,6 +225,8 @@ fn build_provider_resolution_context(
provider_selector: &str,
) -> Result<ProviderResolutionContext, String> {
let provider_selector = normalize_identifier(provider_selector);
let is_custom_provider =
lime_core::models::provider_type::is_custom_provider_id(&provider_selector);
let mut compatibility_provider_key = provider_selector.clone();
let mut registry_provider_ids = vec![
provider_selector.clone(),
@@ -231,7 +234,7 @@ fn build_provider_resolution_context(
];
let mut custom_models = Vec::new();
if lime_core::models::provider_type::is_custom_provider_id(&provider_selector) {
if is_custom_provider {
if let Some(provider_with_keys) = api_key_provider_service
.0
.get_provider(db, &provider_selector)?
@@ -251,6 +254,7 @@ fn build_provider_resolution_context(
alias_key: provider_alias_config_key(&provider_selector),
compatibility_provider_key,
custom_models,
is_custom_provider,
provider_selector,
registry_provider_ids,
})
@@ -422,6 +426,129 @@ fn resolve_base_model_on_thinking_off(
.unwrap_or_else(|| current_model_id.to_string())
}
fn normalize_model_lineage_key(model_id: &str) -> String {
let normalized = normalize_identifier(model_id);
let primary = normalized
.split('/')
.find(|part| !part.is_empty())
.unwrap_or(normalized.as_str());
let mut lineage = String::new();
for ch in primary.chars() {
if ch.is_ascii_alphabetic() {
lineage.push(ch);
continue;
}
if !lineage.is_empty() {
break;
}
}
if !lineage.is_empty() {
return lineage;
}
primary
.split(|ch| ['.', '_', '-'].contains(&ch))
.find(|part| !part.is_empty())
.unwrap_or(primary)
.to_string()
}
fn is_likely_non_chat_model(model: &EnhancedModelMetadata) -> bool {
let text = [
normalize_identifier(&model.id),
normalize_identifier(&model.display_name),
model
.family
.as_deref()
.map(normalize_identifier)
.unwrap_or_default(),
model
.description
.as_deref()
.map(normalize_identifier)
.unwrap_or_default(),
]
.join(" ");
is_likely_image_generation_model(model)
|| text_contains_any(
&text,
&[
"embedding",
"embed",
"rerank",
"tts",
"stt",
"transcribe",
"transcription",
"speech",
"audio",
"moderation",
],
)
}
fn resolve_catalog_fallback_model_id(
current_model_id: &str,
models: &[EnhancedModelMetadata],
prefer_reasoning: bool,
prefer_vision: bool,
) -> String {
if let Some(current_model) = find_model_meta(current_model_id, models) {
return current_model.id.clone();
}
let current_base_key = normalize_base_model_key(current_model_id);
let current_lineage_key = normalize_model_lineage_key(current_model_id);
let mut candidates = models
.iter()
.filter(|candidate| !is_likely_non_chat_model(candidate))
.collect::<Vec<_>>();
candidates.sort_by(|left, right| {
let left_same_base = normalize_base_model_key(&left.id) == current_base_key;
let right_same_base = normalize_base_model_key(&right.id) == current_base_key;
let left_same_lineage = !current_lineage_key.is_empty()
&& normalize_model_lineage_key(&left.id) == current_lineage_key;
let right_same_lineage = !current_lineage_key.is_empty()
&& normalize_model_lineage_key(&right.id) == current_lineage_key;
let left_reasoning_match =
model_has_reasoning_capability(Some(left), &left.id) == prefer_reasoning;
let right_reasoning_match =
model_has_reasoning_capability(Some(right), &right.id) == prefer_reasoning;
let left_vision_match = left.capabilities.vision == prefer_vision;
let right_vision_match = right.capabilities.vision == prefer_vision;
left_same_base
.cmp(&right_same_base)
.reverse()
.then(left_same_lineage.cmp(&right_same_lineage).reverse())
.then(left_reasoning_match.cmp(&right_reasoning_match).reverse())
.then(left_vision_match.cmp(&right_vision_match).reverse())
.then(
capability_score(left)
.cmp(&capability_score(right))
.reverse(),
)
.then(left.is_latest.cmp(&right.is_latest).reverse())
.then(
tier_weight(&left.tier)
.cmp(&tier_weight(&right.tier))
.reverse(),
)
.then(compare_release_date_desc(left, right).cmp(&0))
.then(left.id.cmp(&right.id))
});
candidates
.into_iter()
.next()
.map(|candidate| candidate.id.clone())
.unwrap_or_else(|| current_model_id.to_string())
}
fn is_likely_image_generation_model(model: &EnhancedModelMetadata) -> bool {
let text = [
normalize_identifier(&model.id),
@@ -738,6 +865,28 @@ pub(super) async fn resolve_runtime_request_provider_config(
} else {
resolve_base_model_on_thinking_off(&model_preference, &catalog)
};
let should_fallback_unknown_session_model =
matches!(model_preference_source, RequestPreferenceSource::Session)
&& !context.is_custom_provider
&& find_model_meta(&resolved_model, &catalog).is_none();
if should_fallback_unknown_session_model {
let fallback_model = resolve_catalog_fallback_model_id(
&resolved_model,
&catalog,
thinking_enabled,
has_images,
);
if fallback_model != resolved_model {
tracing::info!(
"[AsterAgent] 会话持久化模型已失效,自动回落到当前可用模型: session={}, provider={}, stale_model={}, fallback_model={}",
request.session_id,
context.provider_selector,
resolved_model,
fallback_model
);
resolved_model = fallback_model;
}
}
resolved_model =
resolve_provider_model_compatibility(&context.compatibility_provider_key, &resolved_model);
if has_images {
@@ -926,6 +1075,53 @@ mod tests {
);
}
#[test]
fn catalog_fallback_prefers_latest_same_lineage_chat_model_for_unknown_session_model() {
let models = vec![
build_model(
"embedding-3",
Some("embedding"),
false,
false,
true,
ModelTier::Pro,
Some("2026-01-05"),
),
build_model(
"glm-4v",
Some("glm"),
false,
true,
true,
ModelTier::Pro,
Some("2026-01-04"),
),
build_model(
"glm-4.6",
Some("glm"),
false,
false,
false,
ModelTier::Pro,
Some("2026-01-03"),
),
build_model(
"glm-4.7",
Some("glm"),
false,
false,
true,
ModelTier::Pro,
Some("2026-01-04"),
),
];
assert_eq!(
resolve_catalog_fallback_model_id("glm-5.1", &models, false, false),
"glm-4.7"
);
}
#[test]
fn model_preference_falls_back_to_session_model_when_provider_matches() {
let resolved = resolve_model_preference_with_session_fallback(
@@ -0,0 +1,200 @@
use super::*;
const RESEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_RESEARCH_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_research_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["research_skill_launch", "researchSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_research_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_research_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(RESEARCH_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_research_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["research_skill_launch", "researchSkillLaunch"],
)?;
let kind =
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "research_request".to_string());
if kind != "research_request" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "research".to_string());
let research_request = launch
.get("research_request")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(research_request, &["raw_text", "rawText"]);
let prompt = extract_object_string(research_request, &["prompt"]);
let query = extract_object_string(research_request, &["query"]);
let site = extract_object_string(research_request, &["site"]);
let time_range = extract_object_string(research_request, &["time_range", "timeRange"]);
let depth = extract_object_string(research_request, &["depth"]);
let focus = extract_object_string(research_request, &["focus"]);
let output_format = extract_object_string(research_request, &["output_format", "outputFormat"]);
let project_id = extract_object_string(research_request, &["project_id", "projectId"]);
let content_id = extract_object_string(research_request, &["content_id", "contentId"]);
let entry_source = extract_object_string(research_request, &["entry_source", "entrySource"])
.unwrap_or_else(|| "at_search_command".to_string());
let args_payload = serde_json::json!({
"user_input": raw_text
.clone()
.or(prompt.clone())
.or(query.clone())
.unwrap_or_else(|| "请根据当前要求执行联网搜索任务".to_string()),
"research_request": serde_json::Value::Object(research_request.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let request_json = truncate_prompt_text(
serde_json::to_string(research_request).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_query = query
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
RESEARCH_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自搜索技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速归纳用户的搜索目标,然后立刻把任务交给 Skill 工具;不要先直接给出记忆性结论。"
.to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- 这条命令属于 prompt skill 主链,不要创建媒体 task file,也不要回退成普通聊天搜索。".to_string(),
"- research skill 内部必须真正执行联网检索,不要只凭已有记忆直接回答。".to_string(),
"- 如果用户要求最新、近期、今天或时间敏感信息,检索词里必须补年份或时间范围,并在最终回答中标注时间口径。".to_string(),
"- Skill 执行后,再基于检索结果整理结论、来源与建议;在真实检索完成前,不要伪造“已经搜索完毕”的细节。".to_string(),
format!("- 当前搜索请求上下文(JSON):{request_json}"),
format!("- 当前入口来源:{entry_source}。"),
];
if let Some(value) = prompt.as_deref() {
lines.push(format!("- 当前搜索目标:{value}"));
}
if let Some(value) = query.as_deref() {
lines.push(format!("- 当前核心查询:{value}。"));
}
if let Some(value) = site.as_deref() {
lines.push(format!("- 当前目标站点/来源:{value}。"));
}
if let Some(value) = time_range.as_deref() {
lines.push(format!("- 当前时间范围:{value}。"));
}
if let Some(value) = depth.as_deref() {
lines.push(format!("- 当前调研深度:{value}。"));
}
if let Some(value) = focus.as_deref() {
lines.push(format!("- 当前关注重点:{value}。"));
}
if let Some(value) = output_format.as_deref() {
lines.push(format!("- 当前输出格式偏好:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if has_query {
lines
.push("- 当前任务已经显式进入搜索技能主链,不要再追问用户“是否开始搜索”。".to_string());
} else {
lines.push(
"- 当前还缺少明确搜索主题。你最多只能追问 1 个关键问题,请用户补充最关键的检索对象;在主题补齐前不要伪造检索结果。"
.to_string(),
);
}
Some(lines.join("\n"))
}
@@ -0,0 +1,241 @@
use super::*;
const RESOURCE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str =
"<<LIME_RESOURCE_SEARCH_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_resource_search_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["resource_search_skill_launch", "resourceSearchSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_resource_search_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_resource_search_skill_launch_system_prompt(request_metadata)
else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(RESOURCE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_resource_search_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["resource_search_skill_launch", "resourceSearchSkillLaunch"],
)?;
let kind = extract_object_string(launch, &["kind"])
.unwrap_or_else(|| "resource_search_task".to_string());
if kind != "resource_search_task" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "modal_resource_search".to_string());
let resource_search_task = launch
.get("resource_search_task")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(resource_search_task, &["raw_text", "rawText"]);
let prompt = extract_object_string(resource_search_task, &["prompt"]);
let title = extract_object_string(resource_search_task, &["title"]);
let resource_type =
extract_object_string(resource_search_task, &["resource_type", "resourceType"]);
let query = extract_object_string(resource_search_task, &["query"]);
let usage = extract_object_string(resource_search_task, &["usage"]);
let session_id = extract_object_string(resource_search_task, &["session_id", "sessionId"]);
let project_id = extract_object_string(resource_search_task, &["project_id", "projectId"]);
let content_id = extract_object_string(resource_search_task, &["content_id", "contentId"]);
let count = resource_search_task
.get("count")
.and_then(serde_json::Value::as_u64);
let filters = resource_search_task
.get("filters")
.and_then(serde_json::Value::as_object)
.cloned();
let entry_source =
extract_object_string(resource_search_task, &["entry_source", "entrySource"])
.unwrap_or_else(|| "at_resource_search_command".to_string());
let args_payload = serde_json::json!({
"user_input": raw_text
.clone()
.or(prompt.clone())
.or(query.clone())
.unwrap_or_else(|| "请根据当前要求执行素材检索任务".to_string()),
"resource_search_task": serde_json::Value::Object(resource_search_task.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let task_json = truncate_prompt_text(
serde_json::to_string(resource_search_task).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_resource_type = resource_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let has_query = query
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
RESOURCE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自素材检索技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速归纳用户的素材目标,然后立刻把任务交给 Skill 工具;不要停留在泛泛解释。".to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
format!("- 当前素材检索任务上下文(JSON):{task_json}"),
format!("- 当前入口来源:{entry_source}。"),
];
if let Some(value) = prompt.as_deref() {
lines.push(format!("- 当前检索目标:{value}"));
}
if let Some(value) = title.as_deref() {
lines.push(format!("- 当前任务标题:{value}。"));
}
if let Some(value) = resource_type.as_deref() {
lines.push(format!("- 当前资源类型:{value}。"));
}
if let Some(value) = query.as_deref() {
lines.push(format!("- 当前检索关键词:{value}。"));
}
if let Some(value) = usage.as_deref() {
lines.push(format!("- 当前使用场景:{value}。"));
}
if let Some(value) = count {
lines.push(format!("- 当前候选数量:{value}。"));
}
if let Some(value) = session_id.as_deref() {
lines.push(format!("- 当前 session_id:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if let Some(value) = filters.as_ref() {
lines.push(format!(
"- 当前过滤条件(JSON):{}",
truncate_prompt_text(
serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()),
1_000,
)
));
}
if matches!(resource_type.as_deref(), Some("image")) && has_query {
lines.push(
"- 当前是图片素材检索。Skill 内必须优先调用 lime_search_web_images 直接搜图,不要先走 ToolSearch / WebSearch / Grep 等长链工具搜索。"
.to_string(),
);
lines.push(
"- 若 lime_search_web_images 返回候选,直接汇总候选摘要与来源,不要伪造“任务已创建”。只有 Pexels API Key 未配置、无结果,或用户明确要求异步追踪时,才回退 Bash / lime_create_modal_resource_search_task。"
.to_string(),
);
} else {
lines.push(
"- Skill 执行后,优先沿 modal_resource_search skill 的 Bash / task file 主链提交异步任务;只有 Skill 明确不可用时,才允许直接回退到 lime_create_modal_resource_search_task。"
.to_string(),
);
lines.push(
"- 不要伪造“素材已检索完成”;在 task file 真正返回候选前,只能汇报任务已提交、排队或执行中。"
.to_string(),
);
}
if has_resource_type && has_query {
lines.push(
"- 当前任务已经显式进入素材检索技能主链,不要再要求用户额外确认“是否开始检索素材”。"
.to_string(),
);
} else {
lines.push(
"- 当前还缺少明确资源类型或检索关键词。你最多只能追问 1 个关键问题,请用户补充最关键缺口;在信息补齐前不要创建任务,也不要伪造结果。"
.to_string(),
);
}
Some(lines.join("\n"))
}
@@ -463,6 +463,19 @@ async fn execute_aster_chat_request(
request.metadata.as_ref(),
request.images.as_deref(),
);
request.metadata = prepare_broadcast_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata =
prepare_resource_search_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_research_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_report_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_deep_search_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_site_search_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_pdf_read_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_summary_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_translation_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_analysis_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_typesetting_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_service_scene_launch_request_metadata(request.metadata.as_ref());
let runtime_config = config_manager.config();
apply_web_search_runtime_env(&runtime_config);
let auto_continue_config = request
@@ -726,9 +739,100 @@ async fn execute_aster_chat_request(
prompt_with_video_skill_launch.clone(),
);
let prompt_with_broadcast_skill_launch = merge_system_prompt_with_broadcast_skill_launch(
prompt_with_video_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::BroadcastSkillLaunch,
prompt_with_broadcast_skill_launch.clone(),
);
let prompt_with_resource_search_skill_launch =
merge_system_prompt_with_resource_search_skill_launch(
prompt_with_broadcast_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::ResourceSearchSkillLaunch,
prompt_with_resource_search_skill_launch.clone(),
);
let prompt_with_research_skill_launch = merge_system_prompt_with_research_skill_launch(
prompt_with_resource_search_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::ResearchSkillLaunch,
prompt_with_research_skill_launch.clone(),
);
let prompt_with_report_skill_launch = merge_system_prompt_with_report_skill_launch(
prompt_with_research_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::ReportSkillLaunch,
prompt_with_report_skill_launch.clone(),
);
let prompt_with_deep_search_skill_launch = merge_system_prompt_with_deep_search_skill_launch(
prompt_with_report_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::DeepSearchSkillLaunch,
prompt_with_deep_search_skill_launch.clone(),
);
let prompt_with_site_search_skill_launch = merge_system_prompt_with_site_search_skill_launch(
prompt_with_deep_search_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::SiteSearchSkillLaunch,
prompt_with_site_search_skill_launch.clone(),
);
let prompt_with_pdf_read_skill_launch = merge_system_prompt_with_pdf_read_skill_launch(
prompt_with_site_search_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::PdfReadSkillLaunch,
prompt_with_pdf_read_skill_launch.clone(),
);
let prompt_with_summary_skill_launch = merge_system_prompt_with_summary_skill_launch(
prompt_with_pdf_read_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::SummarySkillLaunch,
prompt_with_summary_skill_launch.clone(),
);
let prompt_with_translation_skill_launch = merge_system_prompt_with_translation_skill_launch(
prompt_with_summary_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::TranslationSkillLaunch,
prompt_with_translation_skill_launch.clone(),
);
let prompt_with_analysis_skill_launch = merge_system_prompt_with_analysis_skill_launch(
prompt_with_translation_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::AnalysisSkillLaunch,
prompt_with_analysis_skill_launch.clone(),
);
let prompt_with_transcription_skill_launch =
merge_system_prompt_with_transcription_skill_launch(
prompt_with_video_skill_launch,
prompt_with_analysis_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
@@ -745,10 +849,19 @@ async fn execute_aster_chat_request(
prompt_with_url_parse_skill_launch.clone(),
);
let prompt_with_service_skill_launch = merge_system_prompt_with_service_skill_launch(
let prompt_with_typesetting_skill_launch = merge_system_prompt_with_typesetting_skill_launch(
prompt_with_url_parse_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::TypesettingSkillLaunch,
prompt_with_typesetting_skill_launch.clone(),
);
let prompt_with_service_skill_launch = merge_system_prompt_with_service_skill_launch(
prompt_with_typesetting_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::ServiceSkillLaunch,
prompt_with_service_skill_launch.clone(),
@@ -2432,7 +2545,7 @@ mod tests {
metadata: Some(json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench",
"session_mode": "general_workbench",
"content_id": "content-1"
}
})),
@@ -2603,7 +2716,7 @@ mod tests {
metadata: Some(json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench"
"session_mode": "general_workbench"
}
})),
turn_id: None,
@@ -2614,7 +2727,7 @@ mod tests {
normalize_runtime_turn_request_metadata(
&mut request,
Some("general"),
Some("theme_workbench"),
Some("general_workbench"),
None,
None,
Some("content-from-session"),
@@ -2631,7 +2744,7 @@ mod tests {
normalized_metadata
.pointer("/harness/session_mode")
.and_then(Value::as_str),
Some("theme_workbench")
Some("general_workbench")
);
assert_eq!(
normalized_metadata
@@ -2651,7 +2764,7 @@ mod tests {
fn normalize_runtime_turn_request_metadata_should_backfill_theme_and_session_mode_from_session_runtime(
) {
let mut request = AsterChatRequest {
message: "继续推进当前主题工作台".to_string(),
message: "继续推进当前工作区编排".to_string(),
session_id: "session-artifact-theme-fallback".to_string(),
event_name: "agent_stream".to_string(),
images: None,
@@ -2681,7 +2794,7 @@ mod tests {
normalize_runtime_turn_request_metadata(
&mut request,
Some("general"),
Some("theme_workbench"),
Some("general_workbench"),
None,
None,
Some("content-from-session"),
@@ -2698,7 +2811,7 @@ mod tests {
normalized_metadata
.pointer("/harness/session_mode")
.and_then(Value::as_str),
Some("theme_workbench")
Some("general_workbench")
);
assert_eq!(
normalized_metadata
@@ -2732,7 +2845,7 @@ mod tests {
metadata: Some(json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench",
"session_mode": "general_workbench",
"content_id": "content-social-1"
}
})),
@@ -2744,7 +2857,7 @@ mod tests {
normalize_runtime_turn_request_metadata(
&mut request,
Some("general"),
Some("theme_workbench"),
Some("general_workbench"),
Some("write_mode"),
Some("社媒初稿"),
Some("content-social-1"),
@@ -30,6 +30,29 @@ pub(crate) struct ServiceSkillLaunchPreloadExecution {
pub(crate) result: SiteAdapterRunResult,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ServiceSceneLaunchOemRuntimeContext {
pub(crate) scene_base_url: Option<String>,
pub(crate) tenant_id: Option<String>,
pub(crate) session_token: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ServiceSceneLaunchContext {
pub(crate) raw_text: Option<String>,
pub(crate) user_input: Option<String>,
pub(crate) scene_key: Option<String>,
pub(crate) command_prefix: Option<String>,
pub(crate) service_skill_id: String,
pub(crate) service_skill_key: Option<String>,
pub(crate) skill_title: Option<String>,
pub(crate) skill_summary: Option<String>,
pub(crate) project_id: Option<String>,
pub(crate) content_id: Option<String>,
pub(crate) entry_source: Option<String>,
pub(crate) oem_runtime: ServiceSceneLaunchOemRuntimeContext,
}
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
@@ -48,6 +71,38 @@ fn normalized_optional_object(
value.and_then(serde_json::Value::as_object)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
pub(crate) fn extract_service_skill_launch_site_adapter_context(
request_metadata: Option<&serde_json::Value>,
) -> Option<ServiceSkillLaunchSiteAdapterContext> {
@@ -90,6 +145,71 @@ pub(crate) fn extract_service_skill_launch_site_adapter_context(
})
}
pub(crate) fn extract_service_scene_launch_context(
request_metadata: Option<&serde_json::Value>,
) -> Option<ServiceSceneLaunchContext> {
let launch = extract_harness_nested_object(
request_metadata,
&["service_scene_launch", "serviceSceneLaunch"],
)?;
let kind =
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "cloud_scene".to_string());
if kind != "cloud_scene" {
return None;
}
let service_scene_run = launch
.get("service_scene_run")
.or_else(|| launch.get("serviceSceneRun"))
.and_then(serde_json::Value::as_object)?;
let service_skill_id = extract_object_string(
service_scene_run,
&["skill_id", "skillId", "linked_skill_id", "linkedSkillId"],
)?;
let oem_runtime = service_scene_run
.get("oem_runtime")
.or_else(|| service_scene_run.get("oemRuntime"))
.and_then(serde_json::Value::as_object);
Some(ServiceSceneLaunchContext {
raw_text: extract_object_string(service_scene_run, &["raw_text", "rawText"]),
user_input: extract_object_string(service_scene_run, &["user_input", "userInput"]),
scene_key: extract_object_string(service_scene_run, &["scene_key", "sceneKey"]),
command_prefix: extract_object_string(
service_scene_run,
&["command_prefix", "commandPrefix"],
),
service_skill_id,
service_skill_key: extract_object_string(service_scene_run, &["skill_key", "skillKey"]),
skill_title: extract_object_string(service_scene_run, &["skill_title", "skillTitle"]),
skill_summary: extract_object_string(service_scene_run, &["skill_summary", "skillSummary"]),
project_id: extract_object_string(service_scene_run, &["project_id", "projectId"]),
content_id: extract_object_string(service_scene_run, &["content_id", "contentId"]),
entry_source: extract_object_string(service_scene_run, &["entry_source", "entrySource"]),
oem_runtime: ServiceSceneLaunchOemRuntimeContext {
scene_base_url: oem_runtime.and_then(|value| {
extract_object_string(value, &["scene_base_url", "sceneBaseUrl"])
}),
tenant_id: oem_runtime
.and_then(|value| extract_object_string(value, &["tenant_id", "tenantId"])),
session_token: oem_runtime
.and_then(|value| extract_object_string(value, &["session_token", "sessionToken"])),
},
})
}
pub(crate) fn prepare_service_scene_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["service_scene_launch", "serviceSceneLaunch"],
);
Some(metadata)
}
pub(crate) fn should_lock_service_skill_launch_to_site_tools(
request_metadata: Option<&serde_json::Value>,
) -> bool {
@@ -0,0 +1,196 @@
use super::*;
const SITE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_SITE_SEARCH_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_site_search_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["site_search_skill_launch", "siteSearchSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_site_search_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_site_search_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(SITE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_site_search_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["site_search_skill_launch", "siteSearchSkillLaunch"],
)?;
let kind = extract_object_string(launch, &["kind"])
.unwrap_or_else(|| "site_search_request".to_string());
if kind != "site_search_request" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "site_search".to_string());
let site_search_request = launch
.get("site_search_request")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(site_search_request, &["raw_text", "rawText"]);
let prompt = extract_object_string(site_search_request, &["prompt"]);
let site = extract_object_string(site_search_request, &["site"]);
let query = extract_object_string(site_search_request, &["query"]);
let project_id = extract_object_string(site_search_request, &["project_id", "projectId"]);
let content_id = extract_object_string(site_search_request, &["content_id", "contentId"]);
let limit = site_search_request
.get("limit")
.and_then(serde_json::Value::as_u64);
let entry_source = extract_object_string(site_search_request, &["entry_source", "entrySource"])
.unwrap_or_else(|| "at_site_search_command".to_string());
let args_payload = serde_json::json!({
"user_input": raw_text
.clone()
.or(prompt.clone())
.or(query.clone())
.unwrap_or_else(|| "请根据当前要求执行站点检索任务".to_string()),
"site_search_request": serde_json::Value::Object(site_search_request.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let request_json = truncate_prompt_text(
serde_json::to_string(site_search_request).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_site = site
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let has_query = query
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
SITE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自站点搜索技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速归纳用户要查哪个站点、查什么,然后立刻把任务交给 Skill 工具。".to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- 这条命令属于 prompt skill 主链,不要创建 task file,也不要退回普通 research / WebSearch。".to_string(),
"- site_search skill 内部应优先沿 lime_site_info / lime_site_run / lime_site_search 主链执行,不要先改用 WebSearch、research、webReader 或底层浏览器工具替代。".to_string(),
"- 若用户已明确指定站点,应优先在该站点的 adapter 范围内求解;只有 adapter 名不明确时,才允许先用 lime_site_search 缩小范围。".to_string(),
"- Skill 执行后,再基于真实站点结果整理摘要;在真实检索完成前,不要伪造“已完成站点搜索”的结果。".to_string(),
format!("- 当前站点搜索请求上下文(JSON):{request_json}"),
format!("- 当前入口来源:{entry_source}。"),
];
if let Some(value) = prompt.as_deref() {
lines.push(format!("- 当前站点搜索目标:{value}"));
}
if let Some(value) = site.as_deref() {
lines.push(format!("- 当前目标站点:{value}。"));
}
if let Some(value) = query.as_deref() {
lines.push(format!("- 当前检索关键词:{value}。"));
}
if let Some(value) = limit {
lines.push(format!("- 当前结果数量上限:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if has_site && has_query {
lines.push(
"- 当前任务已经显式进入站点搜索技能主链,不要再追问用户“是否开始站点搜索”。"
.to_string(),
);
} else {
lines.push(
"- 当前还缺少明确站点或检索关键词。你最多只能追问 1 个关键问题,请用户补最关键的缺口;在信息补齐前不要伪造结果。"
.to_string(),
);
}
Some(lines.join("\n"))
}
@@ -0,0 +1,195 @@
use super::*;
const SUMMARY_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_SUMMARY_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_summary_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["summary_skill_launch", "summarySkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_summary_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_summary_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(SUMMARY_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_summary_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["summary_skill_launch", "summarySkillLaunch"],
)?;
let kind =
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "summary_request".to_string());
if kind != "summary_request" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "summary".to_string());
let summary_request = launch
.get("summary_request")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(summary_request, &["raw_text", "rawText"]);
let prompt = extract_object_string(summary_request, &["prompt"])
.unwrap_or_else(|| "请总结当前对话中的关键信息".to_string());
let content = extract_object_string(summary_request, &["content"]);
let focus = extract_object_string(summary_request, &["focus"]);
let length = extract_object_string(summary_request, &["length"]);
let style = extract_object_string(summary_request, &["style"]);
let output_format = extract_object_string(summary_request, &["output_format", "outputFormat"]);
let project_id = extract_object_string(summary_request, &["project_id", "projectId"]);
let content_id = extract_object_string(summary_request, &["content_id", "contentId"]);
let entry_source = extract_object_string(summary_request, &["entry_source", "entrySource"])
.unwrap_or_else(|| "at_summary_command".to_string());
let args_payload = serde_json::json!({
"user_input": raw_text.clone().unwrap_or_else(|| prompt.clone()),
"summary_request": serde_json::Value::Object(summary_request.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let request_json = truncate_prompt_text(
serde_json::to_string(summary_request).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_explicit_material = content
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
|| raw_text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
SUMMARY_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自总结技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速判断要总结什么,再立刻把任务交给 Skill 工具;不要直接跳过 Skill 在聊天区作答。"
.to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- 这条命令属于 prompt skill 主链,不要创建 task file,也不要回退成普通聊天总结。".to_string(),
"- 若用户明确给了正文、文件路径或范围,优先总结这些材料;若未明确给材料,则总结当前对话中与请求最相关的内容。".to_string(),
"- 结果必须忠于原文,不要补写原文没有的新事实;遇到信息缺失或歧义时,要单独标注待确认项。".to_string(),
format!("- 当前总结请求上下文(JSON):{request_json}"),
format!("- 当前入口来源:{entry_source}。"),
format!("- 当前总结目标:{prompt}"),
];
if let Some(value) = content.as_deref() {
lines.push(format!("- 当前显式正文:{value}。"));
}
if let Some(value) = focus.as_deref() {
lines.push(format!("- 当前关注重点:{value}。"));
}
if let Some(value) = length.as_deref() {
lines.push(format!("- 当前摘要长度偏好:{value}。"));
}
if let Some(value) = style.as_deref() {
lines.push(format!("- 当前风格偏好:{value}。"));
}
if let Some(value) = output_format.as_deref() {
lines.push(format!("- 当前输出格式偏好:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if has_explicit_material {
lines
.push("- 当前任务已经显式进入总结技能主链,不要再追问用户“是否开始总结”。".to_string());
} else {
lines.push(
"- 当前没有显式正文时,优先尝试总结当前对话上下文;只有在上下文也不足以完成时,才最多追问 1 个关键问题。"
.to_string(),
);
}
Some(lines.join("\n"))
}
+752 -6
View File
@@ -1,7 +1,6 @@
#[cfg(test)]
mod tests {
use super::*;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use crate::commands::aster_agent_cmd::action_runtime::{
build_runtime_action_scope, build_runtime_action_session_config,
};
@@ -10,6 +9,7 @@ mod tests {
RunSiteAdapterRequest, SiteAdapterDefinition, SiteAdapterRunResult,
};
use async_trait::async_trait;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use lime_agent::request_tool_policy::resolve_request_tool_policy;
use lime_agent::AgentEvent as RuntimeAgentEvent;
use regex::Regex;
@@ -377,11 +377,11 @@ mod tests {
}
#[test]
fn test_should_enable_model_skill_tool_allows_theme_workbench() {
fn test_should_enable_model_skill_tool_allows_general_workbench() {
let metadata = serde_json::json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench"
"session_mode": "general_workbench"
}
});
@@ -393,7 +393,7 @@ mod tests {
let metadata = serde_json::json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench",
"session_mode": "general_workbench",
"allow_model_skills": false
}
});
@@ -1920,14 +1920,14 @@ mod tests {
fast_mode_enabled: false,
continuation_length: 1,
sensitivity: 55,
source: Some("theme_workbench_document_auto_continue".to_string()),
source: Some("general_workbench_document_auto_continue".to_string()),
};
let merged =
merge_system_prompt_with_auto_continue(Some("你是助手".to_string()), Some(&config))
.expect("should contain merged prompt");
assert!(merged.contains(AUTO_CONTINUE_PROMPT_MARKER));
assert!(merged.contains("续写长度"));
assert!(merged.contains("theme_workbench_document_auto_continue"));
assert!(merged.contains("general_workbench_document_auto_continue"));
}
#[test]
@@ -2072,6 +2072,45 @@ mod tests {
assert!(merged.contains("不要再让用户额外确认"));
}
#[test]
fn test_merge_system_prompt_with_service_scene_launch_appends_runtime_tool_contract() {
let metadata = serde_json::json!({
"harness": {
"service_scene_launch": {
"kind": "cloud_scene",
"service_scene_run": {
"skill_id": "skill-scene-1",
"skill_title": "趋势赛题日报",
"skill_summary": "拉取热点赛题并整理成日报摘要。",
"scene_key": "daily-trend-brief",
"command_prefix": "/daily-trend-brief",
"user_input": "帮我输出今天的小红书趋势赛题",
"entry_source": "slash_scene_command",
"project_id": "project-1",
"content_id": "content-1",
"oem_runtime": {
"scene_base_url": "https://example.com/scene-api",
"session_token": "session-token-demo"
}
}
}
}
});
let merged = merge_system_prompt_with_service_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains(SERVICE_SKILL_LAUNCH_PROMPT_MARKER));
assert!(merged.contains("第一优先工具调用必须是 lime_run_service_skill"));
assert!(merged.contains("不要把 scene metadata 里的 session_token"));
assert!(merged.contains("当前服务型技能 ID:skill-scene-1"));
assert!(merged.contains("当前 scene_key:daily-trend-brief"));
assert!(merged.contains("当前回合已绑定 OEM Session Token"));
}
#[test]
fn test_merge_system_prompt_with_image_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
@@ -2214,6 +2253,626 @@ mod tests {
assert!(merged.contains("当前任务已经显式进入转写技能主链"));
}
#[test]
fn test_merge_system_prompt_with_broadcast_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"broadcast_skill_launch": {
"skill_name": "broadcast_generate",
"kind": "broadcast_task",
"broadcast_task": {
"prompt": "整理成 5 分钟创始人口播",
"raw_text": "@播报 标题: 创始人周报 听众: 创业者 语气: 口语化 时长: 5分钟 把下面文章整理成播报文本",
"content": "今天我们重点讨论 AI Agent 产品化的三个观察。",
"title": "创始人周报",
"audience": "创业者",
"tone": "口语化",
"duration_hint_minutes": 5
}
}
}
});
let merged = merge_system_prompt_with_broadcast_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_BROADCAST_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("第一优先工具调用必须是 Skill"));
assert!(merged.contains("skill=\"broadcast_generate\""));
assert!(merged.contains("Skill.args 的 JSON"));
assert!(merged.contains("\"broadcast_task\":"));
assert!(merged.contains("不要伪造“播报已完成”"));
assert!(merged.contains("当前任务已经显式进入播报技能主链"));
}
#[test]
fn test_merge_system_prompt_with_resource_search_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"resource_search_skill_launch": {
"skill_name": "modal_resource_search",
"kind": "resource_search_task",
"resource_search_task": {
"prompt": "咖啡馆木桌背景 公众号头图",
"raw_text": "@素材 类型:图片 关键词:咖啡馆木桌背景 用途:公众号头图 数量:8",
"resource_type": "image",
"query": "咖啡馆木桌背景",
"usage": "公众号头图",
"count": 8
}
}
}
});
let merged = merge_system_prompt_with_resource_search_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_RESOURCE_SEARCH_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("第一优先工具调用必须是 Skill"));
assert!(merged.contains("skill=\"modal_resource_search\""));
assert!(merged.contains("Skill.args 的 JSON"));
assert!(merged.contains("\"resource_search_task\":"));
assert!(merged.contains("lime_search_web_images"));
assert!(merged.contains("不要先走 ToolSearch / WebSearch / Grep"));
assert!(merged.contains("当前任务已经显式进入素材检索技能主链"));
}
#[test]
fn test_merge_system_prompt_with_research_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"research_skill_launch": {
"skill_name": "research",
"kind": "research_request",
"research_request": {
"prompt": "AI Agent 融资 36Kr 近30天 融资额与产品发布",
"raw_text": "@搜索 关键词:AI Agent 融资 站点:36Kr 时间:近30天 深度:深度 重点:融资额与产品发布 输出:要点",
"query": "AI Agent 融资",
"site": "36Kr",
"time_range": "近30天",
"depth": "deep",
"focus": "融资额与产品发布",
"output_format": "要点"
}
}
}
});
let merged = merge_system_prompt_with_research_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_RESEARCH_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("第一优先工具调用必须是 Skill"));
assert!(merged.contains("skill=\"research\""));
assert!(merged.contains("Skill.args 的 JSON"));
assert!(merged.contains("\"research_request\":"));
assert!(merged.contains("research skill 内部必须真正执行联网检索"));
assert!(merged.contains("当前任务已经显式进入搜索技能主链"));
}
#[test]
fn test_merge_system_prompt_with_deep_search_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"deep_search_skill_launch": {
"skill_name": "research",
"kind": "deep_search_request",
"deep_search_request": {
"prompt": "AI Agent 融资 36Kr 近30天 融资额与产品发布",
"raw_text": "@深搜 关键词:AI Agent 融资 站点:36Kr 时间:近30天 重点:融资额与产品发布 输出:对比表",
"query": "AI Agent 融资",
"site": "36Kr",
"time_range": "近30天",
"depth": "deep",
"focus": "融资额与产品发布",
"output_format": "对比表"
}
}
}
});
let merged = merge_system_prompt_with_deep_search_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_DEEP_SEARCH_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("skill=\"research\""));
assert!(merged.contains("\"deep_search_request\":"));
assert!(merged.contains("深搜至少执行 2 轮以上扩搜"));
assert!(merged.contains("已确认事实"));
assert!(merged.contains("当前任务已经显式进入深搜技能主链"));
}
#[test]
fn test_merge_system_prompt_with_report_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"report_skill_launch": {
"skill_name": "report_generate",
"kind": "report_request",
"report_request": {
"prompt": "AI Agent 融资 36Kr 近30天 融资额与代表产品 投资人研报",
"raw_text": "@研报 关键词:AI Agent 融资 站点:36Kr 时间:近30天 重点:融资额与代表产品 输出:投资人研报",
"query": "AI Agent 融资",
"site": "36Kr",
"time_range": "近30天",
"depth": "deep",
"focus": "融资额与代表产品",
"output_format": "投资人研报"
}
}
}
});
let merged = merge_system_prompt_with_report_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_REPORT_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("skill=\"report_generate\""));
assert!(merged.contains("\"report_request\":"));
assert!(merged.contains("report_generate skill 内部必须先执行真实联网检索"));
assert!(merged.contains("核心结论、关键证据、风险/待确认项与建议动作"));
assert!(merged.contains("当前任务已经显式进入研报技能主链"));
}
#[test]
fn test_merge_system_prompt_with_site_search_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"site_search_skill_launch": {
"skill_name": "site_search",
"kind": "site_search_request",
"site_search_request": {
"prompt": "openai agents sdk issue",
"raw_text": "@站点搜索 站点:GitHub 关键词:openai agents sdk issue 数量:8",
"site": "GitHub",
"query": "openai agents sdk issue",
"limit": 8
}
}
}
});
let merged = merge_system_prompt_with_site_search_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_SITE_SEARCH_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("第一优先工具调用必须是 Skill"));
assert!(merged.contains("skill=\"site_search\""));
assert!(merged.contains("Skill.args 的 JSON"));
assert!(merged.contains("\"site_search_request\":"));
assert!(merged.contains("不要先改用 WebSearch、research"));
assert!(merged.contains("当前任务已经显式进入站点搜索技能主链"));
}
#[test]
fn test_merge_system_prompt_with_pdf_read_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"pdf_read_skill_launch": {
"skill_name": "pdf_read",
"kind": "pdf_read_request",
"pdf_read_request": {
"prompt": "提炼三点结论并标注关键证据",
"raw_text": "@读PDF /tmp/agent-report.pdf 提炼三点结论并标注关键证据",
"source_path": "/tmp/agent-report.pdf",
"focus": "融资数据",
"output_format": "投资人摘要"
}
}
}
});
let merged = merge_system_prompt_with_pdf_read_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_PDF_READ_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("skill=\"pdf_read\""));
assert!(merged.contains("\"pdf_read_request\":"));
assert!(merged.contains("list_directory / read_file"));
assert!(merged.contains("当前任务已经显式提供 PDF 路径"));
}
#[test]
fn test_merge_system_prompt_with_summary_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"summary_skill_launch": {
"skill_name": "summary",
"kind": "summary_request",
"summary_request": {
"prompt": "请总结这篇长文的三点要点",
"raw_text": "@总结 内容:这是一篇关于 AI Agent 融资的长文 重点:融资额与发布时间 长度:简短 风格:投资人简报 输出:三点要点",
"content": "这是一篇关于 AI Agent 融资的长文",
"focus": "融资额与发布时间",
"length": "short",
"style": "投资人简报",
"output_format": "三点要点"
}
}
}
});
let merged = merge_system_prompt_with_summary_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_SUMMARY_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("skill=\"summary\""));
assert!(merged.contains("\"summary_request\":"));
assert!(merged.contains("结果必须忠于原文"));
assert!(merged.contains("当前任务已经显式进入总结技能主链"));
}
#[test]
fn test_merge_system_prompt_with_translation_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"translation_skill_launch": {
"skill_name": "translation",
"kind": "translation_request",
"translation_request": {
"prompt": "将 hello world 翻译成中文",
"raw_text": "@翻译 内容:hello world 原语言:英语 目标语言:中文 风格:产品文案 输出:只输出译文",
"content": "hello world",
"source_language": "英语",
"target_language": "中文",
"style": "产品文案",
"output_format": "只输出译文"
}
}
}
});
let merged = merge_system_prompt_with_translation_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_TRANSLATION_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("skill=\"translation\""));
assert!(merged.contains("\"translation_request\":"));
assert!(merged.contains("译文必须忠于原文"));
assert!(merged.contains("当前任务已经显式进入翻译技能主链"));
}
#[test]
fn test_merge_system_prompt_with_analysis_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"analysis_skill_launch": {
"skill_name": "analysis",
"kind": "analysis_request",
"analysis_request": {
"prompt": "判断 OpenAI 新模型发布的商业影响",
"raw_text": "@分析 内容:OpenAI 发布新模型 重点:商业影响 风格:投资备忘 输出:三点判断",
"content": "OpenAI 发布新模型",
"focus": "商业影响",
"style": "投资备忘",
"output_format": "三点判断"
}
}
}
});
let merged = merge_system_prompt_with_analysis_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_ANALYSIS_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("skill=\"analysis\""));
assert!(merged.contains("\"analysis_request\":"));
assert!(merged.contains("分析结果必须区分原文事实、你的判断与待确认项"));
assert!(merged.contains("当前任务已经显式进入分析技能主链"));
}
#[test]
fn test_prepare_broadcast_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"broadcast_skill_launch": {
"skill_name": "broadcast_generate",
"kind": "broadcast_task",
"broadcast_task": {
"content": "待整理原文"
}
}
}
});
let prepared = prepare_broadcast_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_resource_search_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"resource_search_skill_launch": {
"skill_name": "modal_resource_search",
"kind": "resource_search_task",
"resource_search_task": {
"resource_type": "image",
"query": "咖啡馆木桌背景"
}
}
}
});
let prepared = prepare_resource_search_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_site_search_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"site_search_skill_launch": {
"skill_name": "site_search",
"kind": "site_search_request",
"site_search_request": {
"site": "GitHub",
"query": "openai agents sdk issue"
}
}
}
});
let prepared = prepare_site_search_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_pdf_read_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"pdf_read_skill_launch": {
"skill_name": "pdf_read",
"kind": "pdf_read_request",
"pdf_read_request": {
"source_path": "/tmp/agent-report.pdf"
}
}
}
});
let prepared = prepare_pdf_read_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_research_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"research_skill_launch": {
"skill_name": "research",
"kind": "research_request",
"research_request": {
"query": "AI Agent 融资"
}
}
}
});
let prepared = prepare_research_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_deep_search_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"deep_search_skill_launch": {
"skill_name": "research",
"kind": "deep_search_request",
"deep_search_request": {
"query": "AI Agent 融资"
}
}
}
});
let prepared = prepare_deep_search_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_report_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"report_skill_launch": {
"skill_name": "report_generate",
"kind": "report_request",
"report_request": {
"query": "AI Agent 融资"
}
}
}
});
let prepared = prepare_report_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_summary_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"summary_skill_launch": {
"skill_name": "summary",
"kind": "summary_request",
"summary_request": {
"prompt": "请总结当前对话"
}
}
}
});
let prepared = prepare_summary_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_translation_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"translation_skill_launch": {
"skill_name": "translation",
"kind": "translation_request",
"translation_request": {
"prompt": "请把当前对话翻译成英文"
}
}
}
});
let prepared = prepare_translation_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_analysis_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"analysis_skill_launch": {
"skill_name": "analysis",
"kind": "analysis_request",
"analysis_request": {
"prompt": "请分析当前对话"
}
}
}
});
let prepared = prepare_analysis_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_merge_system_prompt_with_url_parse_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
@@ -2247,6 +2906,66 @@ mod tests {
assert!(merged.contains("当前任务已经显式进入链接解析技能主链"));
}
#[test]
fn test_merge_system_prompt_with_typesetting_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"typesetting_skill_launch": {
"skill_name": "typesetting",
"kind": "typesetting_task",
"typesetting_task": {
"prompt": "整理成更适合小红书阅读的短句节奏",
"raw_text": "@排版 平台:小红书 帮我把下面文案整理成短句节奏",
"content": "平台:小红书 帮我把下面文案整理成短句节奏",
"target_platform": "小红书"
}
}
}
});
let merged = merge_system_prompt_with_typesetting_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_TYPESETTING_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("第一优先工具调用必须是 Skill"));
assert!(merged.contains("skill=\"typesetting\""));
assert!(merged.contains("Skill.args 的 JSON"));
assert!(merged.contains("\"typesetting_task\":"));
assert!(merged.contains("不要伪造“排版已完成”"));
assert!(merged.contains("当前任务已经显式进入排版技能主链"));
}
#[test]
fn test_prepare_typesetting_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"typesetting_skill_launch": {
"skill_name": "typesetting",
"kind": "typesetting_task",
"typesetting_task": {
"content": "待排版正文"
}
}
}
});
let prepared = prepare_typesetting_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_image_skill_launch_request_metadata_materializes_input_refs() {
let temp_dir = TempDir::new().expect("temp dir");
@@ -2352,6 +3071,33 @@ mod tests {
);
}
#[test]
fn test_prepare_service_scene_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"service_scene_launch": {
"kind": "cloud_scene",
"service_scene_run": {
"skill_id": "skill-scene-1",
"scene_key": "daily-trend-brief"
}
}
}
});
let prepared = prepare_service_scene_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_merge_system_prompt_with_service_skill_launch_skips_duplicate_marker() {
let metadata = serde_json::json!({
@@ -10,8 +10,12 @@ mod lime_cli_runtime;
mod mcp_resource_tools;
#[path = "tool_runtime/media_cli_bridge.rs"]
pub(crate) mod media_cli_bridge;
#[path = "tool_runtime/resource_search_tools.rs"]
mod resource_search_tools;
#[path = "tool_runtime/search_bridge.rs"]
mod search_bridge;
#[path = "tool_runtime/service_skill_tools.rs"]
mod service_skill_tools;
#[path = "tool_runtime/site_tools.rs"]
mod site_tools;
#[path = "tool_runtime/social_tools.rs"]
@@ -31,6 +35,8 @@ pub(crate) use mcp_resource_tools::{ListMcpResourcesBridgeTool, ReadMcpResourceB
pub(crate) use search_bridge::ensure_tool_search_tool_registered;
#[allow(unused_imports)]
pub(crate) use search_bridge::ToolSearchBridgeTool;
#[allow(unused_imports)]
pub(crate) use service_skill_tools::LimeRunServiceSkillTool;
pub(crate) use social_tools::ensure_social_image_tool_registered;
pub(crate) use social_tools::social_generate_cover_image_cmd;
#[allow(unused_imports)]
@@ -92,6 +98,11 @@ fn sync_workspace_mode_native_tool_surface(
if surface.workbench {
social_tools::register_social_image_tool_to_registry(registry, config_manager);
resource_search_tools::register_resource_search_tools_to_registry(
registry,
app_handle.clone(),
);
service_skill_tools::register_service_skill_tools_to_registry(registry);
creation_tools::register_creation_task_tools_to_registry(
registry,
db,
@@ -101,6 +112,7 @@ fn sync_workspace_mode_native_tool_surface(
} else {
let workbench_tools = workbench_tool_names();
unregister_named_tools(registry, &workbench_tools);
service_skill_tools::unregister_service_skill_tools_from_registry(registry);
}
}
@@ -0,0 +1,226 @@
use super::*;
use crate::agent_tools::catalog::LIME_SEARCH_WEB_IMAGES_TOOL_NAME;
use crate::app::AppState;
use crate::commands::image_search_cmd::{
get_pexels_api_key_from_app_state, search_web_images_with_pexels_api_key, WebImageSearchRequest,
};
use tauri::Manager;
const DEFAULT_WEB_IMAGE_SEARCH_COUNT: u32 = 8;
const MAX_WEB_IMAGE_SEARCH_COUNT: u32 = 20;
const DEFAULT_WEB_IMAGE_SEARCH_PAGE: u32 = 1;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LimeSearchWebImagesToolInput {
query: String,
#[serde(default)]
count: Option<u32>,
#[serde(default)]
aspect: Option<String>,
#[serde(default)]
page: Option<u32>,
}
#[derive(Clone)]
pub(crate) struct LimeSearchWebImagesTool {
app_handle: AppHandle,
}
impl LimeSearchWebImagesTool {
fn new(app_handle: AppHandle) -> Self {
Self { app_handle }
}
fn normalize_optional_text(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn normalize_search_count(count: Option<u32>) -> u32 {
count
.unwrap_or(DEFAULT_WEB_IMAGE_SEARCH_COUNT)
.clamp(1, MAX_WEB_IMAGE_SEARCH_COUNT)
}
fn normalize_search_page(page: Option<u32>) -> u32 {
page.unwrap_or(DEFAULT_WEB_IMAGE_SEARCH_PAGE).max(1)
}
fn normalize_aspect_alias(value: &str) -> Option<&'static str> {
match value.trim().to_ascii_lowercase().as_str() {
"landscape" | "horizontal" | "横版" | "横图" | "宽图" | "16:9" | "4:3" | "3:2" => {
Some("landscape")
}
"portrait" | "vertical" | "竖版" | "竖图" | "长图" | "9:16" | "3:4" | "2:3" => {
Some("portrait")
}
"square" | "方图" | "正方形" | "1:1" => Some("square"),
_ => None,
}
}
fn normalize_aspect(aspect: Option<&str>) -> Result<Option<String>, ToolError> {
let Some(raw) = aspect else {
return Ok(None);
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
Self::normalize_aspect_alias(trimmed)
.map(|value| Some(value.to_string()))
.ok_or_else(|| {
ToolError::invalid_params(
"aspect 仅支持 landscape / portrait / square(也兼容 横版 / 竖版 / 方图)"
.to_string(),
)
})
}
}
#[async_trait]
impl Tool for LimeSearchWebImagesTool {
fn name(&self) -> &str {
LIME_SEARCH_WEB_IMAGES_TOOL_NAME
}
fn description(&self) -> &str {
"使用当前已配置的 Pexels API Key 搜索联网图片素材候选。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "图片检索关键词。"
},
"count": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"description": "返回候选数量,默认 8。"
},
"aspect": {
"type": "string",
"enum": ["landscape", "portrait", "square"],
"description": "画幅方向,可选 landscape / portrait / square。"
},
"page": {
"type": "integer",
"minimum": 1,
"description": "分页页码,默认 1。"
}
},
"required": ["query"],
"additionalProperties": false,
"x-lime": {
"always_visible": true,
"tags": ["image", "search", "resource"],
"allowed_callers": ["assistant", "skill"],
"input_examples": [
{
"query": "cozy coffee shop background",
"count": 8,
"aspect": "landscape"
}
]
}
})
}
async fn execute(
&self,
params: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: LimeSearchWebImagesToolInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
let query = Self::normalize_optional_text(&input.query)
.ok_or_else(|| ToolError::invalid_params("query 不能为空字符串".to_string()))?;
let count = Self::normalize_search_count(input.count);
let page = Self::normalize_search_page(input.page);
let aspect = Self::normalize_aspect(input.aspect.as_deref())?;
let app_state = self.app_handle.state::<AppState>();
let api_key = get_pexels_api_key_from_app_state(app_state.inner()).await;
let result = search_web_images_with_pexels_api_key(
api_key,
WebImageSearchRequest {
query: query.clone(),
page,
per_page: count,
aspect: aspect.clone(),
},
)
.await
.map_err(ToolError::execution_failed)?;
let provider = result.provider.clone();
let total = result.total;
let returned_count = result.hits.len();
let hits = result.hits;
let payload = serde_json::json!({
"provider": provider,
"query": query,
"requestedCount": count,
"returnedCount": returned_count,
"page": page,
"aspect": aspect,
"total": total,
"hits": hits,
});
let output = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string());
Ok(ToolResult::success(output)
.with_metadata("tool_family", serde_json::json!("search"))
.with_metadata("provider", payload["provider"].clone())
.with_metadata("query", payload["query"].clone())
.with_metadata("result", payload))
}
}
pub(super) fn register_resource_search_tools_to_registry(
registry: &mut aster::tools::ToolRegistry,
app_handle: AppHandle,
) {
if !registry.contains(LIME_SEARCH_WEB_IMAGES_TOOL_NAME) {
registry.register(Box::new(LimeSearchWebImagesTool::new(app_handle)));
}
}
#[cfg(test)]
mod tests {
use super::LimeSearchWebImagesTool;
#[test]
fn test_normalize_aspect_alias_supports_common_inputs() {
assert_eq!(
LimeSearchWebImagesTool::normalize_aspect_alias("landscape"),
Some("landscape")
);
assert_eq!(
LimeSearchWebImagesTool::normalize_aspect_alias("横版"),
Some("landscape")
);
assert_eq!(
LimeSearchWebImagesTool::normalize_aspect_alias("9:16"),
Some("portrait")
);
assert_eq!(
LimeSearchWebImagesTool::normalize_aspect_alias("方图"),
Some("square")
);
assert_eq!(
LimeSearchWebImagesTool::normalize_aspect_alias("cinematic"),
None
);
}
}
@@ -0,0 +1,627 @@
use super::*;
use crate::agent_tools::catalog::LIME_RUN_SERVICE_SKILL_TOOL_NAME;
use crate::commands::aster_agent_cmd::service_skill_launch::{
extract_service_scene_launch_context, ServiceSceneLaunchContext,
};
use aster::session::{load_shared_session_runtime_snapshot, SessionRuntimeSnapshot};
const DEFAULT_SERVICE_SKILL_POLL_ATTEMPTS: u32 = 6;
const DEFAULT_SERVICE_SKILL_POLL_INTERVAL_MS: u64 = 1_500;
const MAX_SERVICE_SKILL_POLL_ATTEMPTS: u32 = 20;
const MAX_SERVICE_SKILL_POLL_INTERVAL_MS: u64 = 8_000;
const TERMINAL_SERVICE_SKILL_STATUSES: &[&str] = &["success", "failed", "canceled", "timeout"];
const SERVICE_SCENE_LAUNCH_CONTEXT_ENV_KEYS: &[&str] = &[
"LIME_SERVICE_SCENE_LAUNCH_CONTEXT",
"PROXYCAST_SERVICE_SCENE_LAUNCH_CONTEXT",
];
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct ServiceSkillRunToolInput {
#[serde(default)]
input: Option<String>,
#[serde(default)]
wait_for_completion: Option<bool>,
#[serde(default)]
poll_attempts: Option<u32>,
#[serde(default)]
poll_interval_ms: Option<u64>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
struct ServiceSkillRunRecord {
id: String,
#[serde(default)]
status: String,
#[serde(default)]
run_type: Option<String>,
#[serde(default)]
scene_id: Option<String>,
#[serde(default)]
service_skill_id: Option<String>,
#[serde(default)]
service_skill_key: Option<String>,
#[serde(default)]
executor_kind: Option<String>,
#[serde(default)]
input_summary: Option<String>,
#[serde(default)]
output_summary: Option<String>,
#[serde(default)]
output_text: Option<String>,
#[serde(default)]
error_code: Option<String>,
#[serde(default)]
error_message: Option<String>,
#[serde(default)]
fallback_applied: Option<bool>,
#[serde(default)]
fallback_kind: Option<String>,
#[serde(default)]
started_at: Option<String>,
#[serde(default)]
finished_at: Option<String>,
#[serde(default)]
updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ServiceSkillRunEnvelope {
#[serde(default)]
code: Option<i64>,
#[serde(default)]
message: Option<String>,
#[serde(default)]
data: Option<ServiceSkillRunRecord>,
}
#[derive(Clone)]
pub(crate) struct LimeRunServiceSkillTool;
impl LimeRunServiceSkillTool {
fn new() -> Self {
Self
}
fn normalize_optional_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn normalize_status(status: &str) -> String {
status.trim().to_ascii_lowercase()
}
fn is_terminal_status(status: &str) -> bool {
let normalized = Self::normalize_status(status);
TERMINAL_SERVICE_SKILL_STATUSES
.iter()
.any(|candidate| normalized == *candidate)
}
fn build_request_metadata_value(
metadata: &HashMap<String, serde_json::Value>,
) -> Option<serde_json::Value> {
if metadata.is_empty() {
return None;
}
let map = metadata
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<serde_json::Map<String, serde_json::Value>>();
Some(serde_json::Value::Object(map))
}
fn extract_launch_context_from_runtime_snapshot(
snapshot: &SessionRuntimeSnapshot,
) -> Option<ServiceSceneLaunchContext> {
snapshot
.threads
.iter()
.flat_map(|thread| thread.turns.iter())
.filter_map(|turn| {
let request_metadata = turn
.context_override
.as_ref()
.and_then(|context| Self::build_request_metadata_value(&context.metadata))?;
let launch_context = extract_service_scene_launch_context(Some(&request_metadata))?;
Some((turn.updated_at, launch_context))
})
.max_by_key(|(updated_at, _)| *updated_at)
.map(|(_, launch_context)| launch_context)
.or_else(|| {
snapshot
.threads
.iter()
.filter_map(|thread| {
let request_metadata =
Self::build_request_metadata_value(&thread.thread.metadata)?;
let launch_context =
extract_service_scene_launch_context(Some(&request_metadata))?;
Some((thread.thread.updated_at, launch_context))
})
.max_by_key(|(updated_at, _)| *updated_at)
.map(|(_, launch_context)| launch_context)
})
}
fn resolve_launch_context_from_environment(
context: &ToolContext,
) -> Option<ServiceSceneLaunchContext> {
SERVICE_SCENE_LAUNCH_CONTEXT_ENV_KEYS
.iter()
.find_map(|key| {
let raw = context.environment.get(*key)?;
let parsed = serde_json::from_str::<serde_json::Value>(raw).ok()?;
extract_service_scene_launch_context(Some(&parsed)).or_else(|| {
let wrapped = serde_json::json!({
"harness": {
"service_scene_launch": parsed,
}
});
extract_service_scene_launch_context(Some(&wrapped))
})
})
}
async fn resolve_launch_context(
context: &ToolContext,
) -> Result<ServiceSceneLaunchContext, ToolError> {
let session_id = context.session_id.trim();
if !session_id.is_empty() {
match load_shared_session_runtime_snapshot(session_id).await {
Ok(snapshot) => {
if let Some(launch_context) =
Self::extract_launch_context_from_runtime_snapshot(&snapshot)
{
return Ok(launch_context);
}
}
Err(error) => {
tracing::debug!(
"[AsterAgent][ServiceSkillTool] 读取 runtime snapshot 失败,跳过 session launch context 解析: session_id={}, error={}",
session_id,
error
);
}
}
}
Self::resolve_launch_context_from_environment(context).ok_or_else(|| {
ToolError::execution_failed(
"当前回合未绑定服务型场景启动上下文,无法执行 lime_run_service_skill".to_string(),
)
})
}
fn resolve_effective_input(
launch_context: &ServiceSceneLaunchContext,
input: &ServiceSkillRunToolInput,
) -> Result<String, ToolError> {
let effective_input = Self::normalize_optional_text(input.input.as_deref())
.or_else(|| Self::normalize_optional_text(launch_context.user_input.as_deref()))
.or_else(|| Self::normalize_optional_text(launch_context.raw_text.as_deref()))
.ok_or_else(|| {
ToolError::invalid_params("缺少服务型技能运行输入,请补充 input".to_string())
})?;
Ok(effective_input)
}
fn resolve_scene_base_url(
launch_context: &ServiceSceneLaunchContext,
) -> Result<String, ToolError> {
Self::normalize_optional_text(launch_context.oem_runtime.scene_base_url.as_deref())
.ok_or_else(|| {
ToolError::execution_failed(
"缺少 OEM sceneBaseUrl,请先完成 OEM 云端接线".to_string(),
)
})
}
fn resolve_session_token(
launch_context: &ServiceSceneLaunchContext,
) -> Result<String, ToolError> {
Self::normalize_optional_text(launch_context.oem_runtime.session_token.as_deref())
.ok_or_else(|| {
ToolError::execution_failed(
"缺少 OEM Session Token,请先登录或注入 OEM 云端会话".to_string(),
)
})
}
async fn request_run(
client: &reqwest::Client,
scene_base_url: &str,
session_token: &str,
path: &str,
method: reqwest::Method,
body: Option<serde_json::Value>,
) -> Result<ServiceSkillRunRecord, ToolError> {
let url = format!("{}{}", scene_base_url.trim_end_matches('/'), path);
let mut request = client
.request(method, &url)
.header(reqwest::header::ACCEPT, "application/json")
.bearer_auth(session_token)
.header(reqwest::header::CONTENT_TYPE, "application/json");
if let Some(body) = body {
request = request.json(&body);
}
let response = request.send().await.map_err(|error| {
ToolError::execution_failed(format!("请求服务型技能运行时失败: {error}"))
})?;
let status = response.status();
let payload = response
.json::<ServiceSkillRunEnvelope>()
.await
.map_err(|error| {
ToolError::execution_failed(format!("解析服务型技能运行结果失败: {error}"))
})?;
if !status.is_success() {
let message = payload
.message
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("服务端返回失败");
return Err(ToolError::execution_failed(format!(
"服务型技能运行请求失败 ({}): {}",
status.as_u16(),
message
)));
}
if let Some(code) = payload.code {
if code >= 400 {
return Err(ToolError::execution_failed(
payload
.message
.unwrap_or_else(|| "服务端返回非法运行结果".to_string()),
));
}
}
payload.data.ok_or_else(|| {
ToolError::execution_failed("服务端返回的 service skill run 记录为空".to_string())
})
}
fn build_success_payload(
launch_context: &ServiceSceneLaunchContext,
run: &ServiceSkillRunRecord,
submitted_input: &str,
) -> serde_json::Value {
serde_json::json!({
"ok": run.status == "success",
"submittedInput": submitted_input,
"serviceSkill": {
"id": launch_context.service_skill_id,
"key": launch_context.service_skill_key,
"title": launch_context.skill_title,
"summary": launch_context.skill_summary,
},
"scene": {
"sceneKey": launch_context.scene_key,
"commandPrefix": launch_context.command_prefix,
},
"run": run,
})
}
fn build_result_summary(
launch_context: &ServiceSceneLaunchContext,
run: &ServiceSkillRunRecord,
) -> String {
let title = launch_context
.skill_title
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("服务型技能");
let status = run.status.trim();
if status == "success" {
if let Some(summary) = Self::normalize_optional_text(run.output_summary.as_deref()) {
return format!("{title} 执行完成:{summary}");
}
return format!("{title} 执行完成");
}
if Self::is_terminal_status(status) {
if let Some(message) = Self::normalize_optional_text(run.error_message.as_deref()) {
return format!("{title} 执行失败:{message}");
}
return format!("{title} 已结束,状态为 {status}");
}
if let Some(summary) = Self::normalize_optional_text(run.output_summary.as_deref()) {
return format!("{title} 当前状态 {status}:{summary}");
}
format!("{title} 已提交云端,当前状态 {status}")
}
}
#[async_trait]
impl Tool for LimeRunServiceSkillTool {
fn name(&self) -> &str {
LIME_RUN_SERVICE_SKILL_TOOL_NAME
}
fn description(&self) -> &str {
"运行当前回合绑定的服务型技能场景,提交到 OEM Scene Runtime 并返回最新运行状态。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "可选补充输入。默认取当前 scene launch 里的 user_input 或 raw_text。"
},
"waitForCompletion": {
"type": "boolean",
"description": "是否在当前工具调用内短轮询等待一轮结果,默认 true。"
},
"pollAttempts": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"description": "短轮询次数,默认 6。"
},
"pollIntervalMs": {
"type": "integer",
"minimum": 200,
"maximum": 8000,
"description": "轮询间隔毫秒数,默认 1500。"
}
},
"additionalProperties": false,
"x-lime": {
"always_visible": true,
"tags": ["service-skill", "scene", "cloud-runtime"],
"allowed_callers": ["assistant", "skill"]
}
})
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: ServiceSkillRunToolInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
let launch_context = Self::resolve_launch_context(context).await?;
let effective_input = Self::resolve_effective_input(&launch_context, &input)?;
let scene_base_url = Self::resolve_scene_base_url(&launch_context)?;
let session_token = Self::resolve_session_token(&launch_context)?;
let wait_for_completion = input.wait_for_completion.unwrap_or(true);
let poll_attempts = input
.poll_attempts
.unwrap_or(DEFAULT_SERVICE_SKILL_POLL_ATTEMPTS)
.clamp(1, MAX_SERVICE_SKILL_POLL_ATTEMPTS);
let poll_interval_ms = input
.poll_interval_ms
.unwrap_or(DEFAULT_SERVICE_SKILL_POLL_INTERVAL_MS)
.clamp(200, MAX_SERVICE_SKILL_POLL_INTERVAL_MS);
let client = reqwest::Client::new();
let create_path = format!(
"/v1/service-skills/{}/runs",
urlencoding::encode(&launch_context.service_skill_id)
);
let mut run = Self::request_run(
&client,
&scene_base_url,
&session_token,
&create_path,
reqwest::Method::POST,
Some(serde_json::json!({
"input": effective_input,
})),
)
.await?;
if wait_for_completion && !Self::is_terminal_status(&run.status) {
for _ in 0..poll_attempts {
tokio::time::sleep(std::time::Duration::from_millis(poll_interval_ms)).await;
let run_path = format!(
"/v1/service-skills/runs/{}",
urlencoding::encode(run.id.as_str())
);
run = Self::request_run(
&client,
&scene_base_url,
&session_token,
&run_path,
reqwest::Method::GET,
None,
)
.await?;
if Self::is_terminal_status(&run.status) {
break;
}
}
}
let payload = Self::build_success_payload(&launch_context, &run, &effective_input);
let summary = Self::build_result_summary(&launch_context, &run);
let serialized =
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string());
let mut result = if Self::normalize_status(&run.status) == "failed"
|| Self::normalize_status(&run.status) == "canceled"
|| Self::normalize_status(&run.status) == "timeout"
{
ToolResult::error(summary)
} else {
ToolResult::success(serialized)
};
result = result
.with_metadata("tool_family", serde_json::json!("service_skill"))
.with_metadata("result", payload)
.with_metadata("run_status", serde_json::json!(run.status))
.with_metadata(
"service_skill_id",
serde_json::json!(launch_context.service_skill_id),
);
if let Some(scene_key) = launch_context.scene_key.as_ref() {
result = result.with_metadata("scene_key", serde_json::json!(scene_key));
}
Ok(result)
}
}
pub(super) fn register_service_skill_tools_to_registry(registry: &mut aster::tools::ToolRegistry) {
if !registry.contains(LIME_RUN_SERVICE_SKILL_TOOL_NAME) {
registry.register(Box::new(LimeRunServiceSkillTool::new()));
}
}
pub(super) fn unregister_service_skill_tools_from_registry(
registry: &mut aster::tools::ToolRegistry,
) {
registry.unregister(LIME_RUN_SERVICE_SKILL_TOOL_NAME);
}
#[cfg(test)]
mod tests {
use super::*;
use aster::session::{ThreadRuntime, ThreadRuntimeSnapshot, TurnContextOverride, TurnRuntime};
use chrono::{Duration as ChronoDuration, Utc};
use std::path::PathBuf;
fn metadata_map(value: serde_json::Value) -> HashMap<String, serde_json::Value> {
value
.as_object()
.expect("metadata should be object")
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
}
#[test]
fn should_extract_latest_service_scene_launch_context_from_runtime_snapshot() {
let now = Utc::now();
let mut older_turn = TurnRuntime::new(
"turn-older",
"session-1",
"thread-1",
Some("旧 turn".to_string()),
Some(TurnContextOverride {
metadata: metadata_map(serde_json::json!({
"harness": {
"service_scene_launch": {
"kind": "cloud_scene",
"service_scene_run": {
"skill_id": "skill-older",
"scene_key": "scene-older",
"user_input": "旧输入",
"oem_runtime": {
"scene_base_url": "https://example.com/scene-api",
"session_token": "older-token"
}
}
}
}
})),
..TurnContextOverride::default()
}),
);
older_turn.updated_at = now;
let mut latest_turn = TurnRuntime::new(
"turn-latest",
"session-1",
"thread-1",
Some("新 turn".to_string()),
Some(TurnContextOverride {
metadata: metadata_map(serde_json::json!({
"harness": {
"service_scene_launch": {
"kind": "cloud_scene",
"service_scene_run": {
"skill_id": "skill-latest",
"scene_key": "scene-latest",
"user_input": "最新输入",
"oem_runtime": {
"scene_base_url": "https://example.com/scene-api",
"session_token": "latest-token"
}
}
}
}
})),
..TurnContextOverride::default()
}),
);
latest_turn.updated_at = now + ChronoDuration::seconds(5);
let mut thread =
ThreadRuntime::new("thread-1", "session-1", PathBuf::from("/tmp/service-scene"));
thread.updated_at = latest_turn.updated_at;
let snapshot = SessionRuntimeSnapshot {
session_id: "session-1".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread,
turns: vec![older_turn, latest_turn],
items: Vec::new(),
}],
};
let launch_context =
LimeRunServiceSkillTool::extract_launch_context_from_runtime_snapshot(&snapshot)
.expect("should resolve launch context");
assert_eq!(launch_context.service_skill_id, "skill-latest");
assert_eq!(launch_context.scene_key.as_deref(), Some("scene-latest"));
assert_eq!(launch_context.user_input.as_deref(), Some("最新输入"));
assert_eq!(
launch_context.oem_runtime.session_token.as_deref(),
Some("latest-token")
);
}
#[test]
fn should_extract_launch_context_from_environment_payload() {
let context = ToolContext::new(PathBuf::from("/tmp/service-scene")).with_environment(
HashMap::from([(
SERVICE_SCENE_LAUNCH_CONTEXT_ENV_KEYS[0].to_string(),
serde_json::json!({
"kind": "cloud_scene",
"service_scene_run": {
"skill_id": "skill-env",
"scene_key": "scene-env",
"user_input": "环境输入",
"oem_runtime": {
"scene_base_url": "https://example.com/scene-api",
"session_token": "env-token"
}
}
})
.to_string(),
)]),
);
let launch_context =
LimeRunServiceSkillTool::resolve_launch_context_from_environment(&context)
.expect("should resolve env launch context");
assert_eq!(launch_context.service_skill_id, "skill-env");
assert_eq!(launch_context.scene_key.as_deref(), Some("scene-env"));
assert_eq!(
launch_context.oem_runtime.scene_base_url.as_deref(),
Some("https://example.com/scene-api")
);
}
}
@@ -0,0 +1,193 @@
use super::*;
const TRANSLATION_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_TRANSLATION_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_translation_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["translation_skill_launch", "translationSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_translation_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_translation_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(TRANSLATION_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_translation_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["translation_skill_launch", "translationSkillLaunch"],
)?;
let kind = extract_object_string(launch, &["kind"])
.unwrap_or_else(|| "translation_request".to_string());
if kind != "translation_request" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "translation".to_string());
let translation_request = launch
.get("translation_request")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(translation_request, &["raw_text", "rawText"]);
let prompt = extract_object_string(translation_request, &["prompt"])
.unwrap_or_else(|| "请翻译当前对话中最相关的内容".to_string());
let content = extract_object_string(translation_request, &["content"]);
let source_language =
extract_object_string(translation_request, &["source_language", "sourceLanguage"]);
let target_language =
extract_object_string(translation_request, &["target_language", "targetLanguage"]);
let style = extract_object_string(translation_request, &["style"]);
let output_format =
extract_object_string(translation_request, &["output_format", "outputFormat"]);
let project_id = extract_object_string(translation_request, &["project_id", "projectId"]);
let content_id = extract_object_string(translation_request, &["content_id", "contentId"]);
let entry_source = extract_object_string(translation_request, &["entry_source", "entrySource"])
.unwrap_or_else(|| "at_translation_command".to_string());
let args_payload = serde_json::json!({
"user_input": raw_text.clone().unwrap_or_else(|| prompt.clone()),
"translation_request": serde_json::Value::Object(translation_request.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let request_json = truncate_prompt_text(
serde_json::to_string(translation_request).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_explicit_content = content
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
TRANSLATION_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自翻译技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速判断要翻译什么、要翻译成什么语言,再立刻把任务交给 Skill 工具;不要直接跳过 Skill 在聊天区作答。"
.to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- 这条命令属于 prompt skill 主链,不要创建 task file,也不要回退成普通聊天翻译。".to_string(),
"- 若用户明确给了正文、文件路径或范围,优先翻译这些材料;若未明确给材料,则翻译当前对话中与请求最相关的内容。".to_string(),
"- 译文必须忠于原文,不要补写原文没有的新事实;遇到术语或语义歧义时,要单独标注待确认项。".to_string(),
format!("- 当前翻译请求上下文(JSON):{request_json}"),
format!("- 当前入口来源:{entry_source}。"),
format!("- 当前翻译目标:{prompt}"),
];
if let Some(value) = content.as_deref() {
lines.push(format!("- 当前显式正文:{value}。"));
}
if let Some(value) = source_language.as_deref() {
lines.push(format!("- 当前原语言偏好:{value}。"));
}
if let Some(value) = target_language.as_deref() {
lines.push(format!("- 当前目标语言偏好:{value}。"));
}
if let Some(value) = style.as_deref() {
lines.push(format!("- 当前风格偏好:{value}。"));
}
if let Some(value) = output_format.as_deref() {
lines.push(format!("- 当前输出格式偏好:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if has_explicit_content {
lines
.push("- 当前任务已经显式进入翻译技能主链,不要再追问用户“是否开始翻译”。".to_string());
} else {
lines.push(
"- 当前没有显式正文时,优先尝试翻译当前对话上下文;只有在上下文也不足以完成时,才最多追问 1 个关键问题。"
.to_string(),
);
}
Some(lines.join("\n"))
}
@@ -0,0 +1,204 @@
use super::*;
const TYPESETTING_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_TYPESETTING_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_typesetting_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["typesetting_skill_launch", "typesettingSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_typesetting_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_typesetting_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(TYPESETTING_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_typesetting_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["typesetting_skill_launch", "typesettingSkillLaunch"],
)?;
let kind =
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "typesetting_task".to_string());
if kind != "typesetting_task" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "typesetting".to_string());
let typesetting_task = launch
.get("typesetting_task")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(typesetting_task, &["raw_text", "rawText"]);
let prompt = extract_object_string(typesetting_task, &["prompt"]);
let content = extract_object_string(typesetting_task, &["content"]);
let target_platform =
extract_object_string(typesetting_task, &["target_platform", "targetPlatform"]);
let session_id = extract_object_string(typesetting_task, &["session_id", "sessionId"]);
let project_id = extract_object_string(typesetting_task, &["project_id", "projectId"]);
let content_id = extract_object_string(typesetting_task, &["content_id", "contentId"]);
let entry_source = extract_object_string(typesetting_task, &["entry_source", "entrySource"])
.unwrap_or_else(|| "at_typesetting_command".to_string());
let rules = typesetting_task
.get("rules")
.and_then(serde_json::Value::as_object)
.cloned();
let args_payload = serde_json::json!({
"user_input": raw_text
.clone()
.or(prompt.clone())
.or(content.clone())
.unwrap_or_else(|| "请根据当前要求执行排版优化任务".to_string()),
"typesetting_task": serde_json::Value::Object(typesetting_task.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let task_json = truncate_prompt_text(
serde_json::to_string(typesetting_task).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let content_present = content
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
TYPESETTING_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自排版技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速归纳用户目标,然后立刻把任务交给 Skill 工具;不要停留在泛泛解释。".to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- Skill 执行后,优先沿 typesetting skill 的 Bash / task file 主链提交异步任务;只有 Skill 明确不可用时,才允许直接回退到 lime_create_typesetting_task。".to_string(),
"- 不要伪造“排版已完成”;在 task file 真正返回结果前,只能汇报任务已提交、排队或执行中。".to_string(),
format!("- 当前排版任务上下文(JSON):{task_json}"),
format!("- 当前入口来源:{entry_source}。"),
];
if let Some(value) = prompt.as_deref() {
lines.push(format!("- 当前排版目标:{value}"));
}
if let Some(value) = content.as_deref() {
lines.push(format!(
"- 当前待排版内容摘要:{}",
truncate_prompt_text(value.to_string(), 400)
));
}
if let Some(value) = target_platform.as_deref() {
lines.push(format!("- 当前目标平台:{value}。"));
}
if let Some(value) = session_id.as_deref() {
lines.push(format!("- 当前 session_id:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if let Some(value) = rules.as_ref() {
lines.push(format!(
"- 当前结构化规则(JSON):{}",
truncate_prompt_text(
serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()),
1_000,
)
));
}
if content_present {
lines.push(
"- 当前任务已经显式进入排版技能主链,不要再要求用户额外确认“是否开始排版”。"
.to_string(),
);
} else {
lines.push(
"- 当前还缺少明确待排版内容。你最多只能追问 1 个关键问题,请用户补充正文;在正文补齐前不要创建任务,也不要伪造结果。"
.to_string(),
);
}
Some(lines.join("\n"))
}
+42 -21
View File
@@ -9,7 +9,8 @@ use crate::database::DbConnection;
use serde::{Deserialize, Serialize};
use tauri::State;
pub(crate) const THEME_WORKBENCH_DOCUMENT_META_KEY: &str = "theme_workbench_document_v1";
pub(crate) const GENERAL_WORKBENCH_DOCUMENT_META_KEY: &str = "general_workbench_document_v1";
pub(crate) const LEGACY_GENERAL_WORKBENCH_DOCUMENT_META_KEY: &str = "theme_workbench_document_v1";
/// 内容列表项(用于前端展示)
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -80,7 +81,7 @@ impl From<Content> for ContentDetail {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeWorkbenchVersionState {
pub struct GeneralWorkbenchVersionState {
pub id: String,
pub created_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -91,24 +92,25 @@ pub struct ThemeWorkbenchVersionState {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeWorkbenchDocumentState {
pub struct GeneralWorkbenchDocumentState {
pub content_id: String,
pub current_version_id: String,
pub version_count: usize,
pub versions: Vec<ThemeWorkbenchVersionState>,
pub versions: Vec<GeneralWorkbenchVersionState>,
}
fn is_valid_topic_branch_status(status: &str) -> bool {
matches!(status, "in_progress" | "pending" | "merged" | "candidate")
}
pub(crate) fn parse_theme_workbench_document_state(
pub(crate) fn parse_general_workbench_document_state(
content_id: &str,
metadata: Option<&serde_json::Value>,
) -> Option<ThemeWorkbenchDocumentState> {
) -> Option<GeneralWorkbenchDocumentState> {
let metadata = metadata?.as_object()?;
let raw = metadata
.get(THEME_WORKBENCH_DOCUMENT_META_KEY)?
.get(GENERAL_WORKBENCH_DOCUMENT_META_KEY)
.or_else(|| metadata.get(LEGACY_GENERAL_WORKBENCH_DOCUMENT_META_KEY))?
.as_object()?;
let versions_raw = raw.get("versions")?.as_array()?;
@@ -127,7 +129,7 @@ pub(crate) fn parse_theme_workbench_document_state(
.cloned()
.unwrap_or_default();
let versions: Vec<ThemeWorkbenchVersionState> = versions_raw
let versions: Vec<GeneralWorkbenchVersionState> = versions_raw
.iter()
.filter_map(|version| {
let version_obj = version.as_object()?;
@@ -158,7 +160,7 @@ pub(crate) fn parse_theme_workbench_document_state(
.filter(|value| is_valid_topic_branch_status(value))
.map(ToString::to_string);
Some(ThemeWorkbenchVersionState {
Some(GeneralWorkbenchVersionState {
is_current: id == current_version_id,
id,
created_at,
@@ -179,7 +181,7 @@ pub(crate) fn parse_theme_workbench_document_state(
return None;
}
Some(ThemeWorkbenchDocumentState {
Some(GeneralWorkbenchDocumentState {
content_id: content_id.to_string(),
current_version_id,
version_count: versions.len(),
@@ -270,16 +272,16 @@ pub async fn content_get(
Ok(content.map(|c| c.into()))
}
/// 获取主题工作台文稿版本状态(从 content.metadata 解析)
/// 获取工作区文稿版本状态(从 content.metadata 解析)
#[tauri::command]
pub async fn content_get_theme_workbench_document_state(
pub async fn content_get_general_workbench_document_state(
db: State<'_, DbConnection>,
id: String,
) -> Result<Option<ThemeWorkbenchDocumentState>, String> {
) -> Result<Option<GeneralWorkbenchDocumentState>, String> {
let manager = ContentManager::new(db.inner().clone());
let content = manager.get(&id)?;
Ok(content
.and_then(|item| parse_theme_workbench_document_state(&item.id, item.metadata.as_ref())))
.and_then(|item| parse_general_workbench_document_state(&item.id, item.metadata.as_ref())))
}
/// 列出项目的所有内容
@@ -357,12 +359,15 @@ pub async fn content_stats(
#[cfg(test)]
mod tests {
use super::{parse_theme_workbench_document_state, THEME_WORKBENCH_DOCUMENT_META_KEY};
use super::{
parse_general_workbench_document_state, GENERAL_WORKBENCH_DOCUMENT_META_KEY,
LEGACY_GENERAL_WORKBENCH_DOCUMENT_META_KEY,
};
#[test]
fn test_parse_theme_workbench_document_state_success() {
fn test_parse_general_workbench_document_state_success() {
let metadata = serde_json::json!({
THEME_WORKBENCH_DOCUMENT_META_KEY: {
GENERAL_WORKBENCH_DOCUMENT_META_KEY: {
"currentVersionId": "v2",
"versions": [
{ "id": "v1", "createdAt": 1700000000000_i64, "description": "初稿" },
@@ -375,7 +380,7 @@ mod tests {
}
});
let parsed = parse_theme_workbench_document_state("content-1", Some(&metadata))
let parsed = parse_general_workbench_document_state("content-1", Some(&metadata))
.expect("should parse");
assert_eq!(parsed.content_id, "content-1");
assert_eq!(parsed.current_version_id, "v2");
@@ -385,9 +390,9 @@ mod tests {
}
#[test]
fn test_parse_theme_workbench_document_state_rejects_invalid_current_version() {
fn test_parse_general_workbench_document_state_rejects_invalid_current_version() {
let metadata = serde_json::json!({
THEME_WORKBENCH_DOCUMENT_META_KEY: {
GENERAL_WORKBENCH_DOCUMENT_META_KEY: {
"currentVersionId": "v-not-exists",
"versions": [
{ "id": "v1", "createdAt": 1700000000000_i64, "description": "初稿" }
@@ -396,6 +401,22 @@ mod tests {
}
});
assert!(parse_theme_workbench_document_state("content-1", Some(&metadata)).is_none());
assert!(parse_general_workbench_document_state("content-1", Some(&metadata)).is_none());
}
#[test]
fn test_parse_general_workbench_document_state_accepts_legacy_alias_key() {
let metadata = serde_json::json!({
LEGACY_GENERAL_WORKBENCH_DOCUMENT_META_KEY: {
"currentVersionId": "v1",
"versions": [
{ "id": "v1", "createdAt": 1700000000000_i64, "description": "初稿" }
]
}
});
let parsed = parse_general_workbench_document_state("content-1", Some(&metadata))
.expect("should parse");
assert_eq!(parsed.current_version_id, "v1");
}
}
+25 -25
View File
@@ -88,7 +88,7 @@ pub async fn execution_run_get(
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct ThemeWorkbenchRunTodoItem {
pub struct GeneralWorkbenchRunTodoItem {
pub run_id: String,
pub execution_id: Option<String>,
pub session_id: Option<String>,
@@ -103,7 +103,7 @@ pub struct ThemeWorkbenchRunTodoItem {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct ThemeWorkbenchRunTerminalItem {
pub struct GeneralWorkbenchRunTerminalItem {
pub run_id: String,
pub execution_id: Option<String>,
pub session_id: Option<String>,
@@ -119,19 +119,19 @@ pub struct ThemeWorkbenchRunTerminalItem {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct ThemeWorkbenchRunState {
pub struct GeneralWorkbenchRunState {
pub run_state: String,
pub current_gate_key: String,
pub queue_items: Vec<ThemeWorkbenchRunTodoItem>,
pub latest_terminal: Option<ThemeWorkbenchRunTerminalItem>,
pub recent_terminals: Vec<ThemeWorkbenchRunTerminalItem>,
pub queue_items: Vec<GeneralWorkbenchRunTodoItem>,
pub latest_terminal: Option<GeneralWorkbenchRunTerminalItem>,
pub recent_terminals: Vec<GeneralWorkbenchRunTerminalItem>,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct ThemeWorkbenchRunHistoryPage {
pub items: Vec<ThemeWorkbenchRunTerminalItem>,
pub struct GeneralWorkbenchRunHistoryPage {
pub items: Vec<GeneralWorkbenchRunTerminalItem>,
pub has_more: bool,
pub next_offset: Option<usize>,
}
@@ -239,9 +239,9 @@ fn derive_run_title(run: &AgentRun) -> String {
}
match run.source.as_str() {
"skill" => "执行主题工作台技能".to_string(),
"skill" => "执行工作区技能".to_string(),
"automation" => "执行自动化任务".to_string(),
_ => "执行主题工作台编排".to_string(),
_ => "执行工作区编排".to_string(),
}
}
@@ -280,7 +280,7 @@ fn derive_run_gate_key(run: &AgentRun, title: &str) -> String {
infer_gate_key_from_probe(probe.as_str())
}
fn derive_current_gate_key(queue_items: &[ThemeWorkbenchRunTodoItem]) -> String {
fn derive_current_gate_key(queue_items: &[GeneralWorkbenchRunTodoItem]) -> String {
queue_items
.iter()
.find(|item| item.status == AgentRunStatus::Running)
@@ -323,10 +323,10 @@ fn derive_run_artifact_paths(run: &AgentRun) -> Vec<String> {
.unwrap_or_default()
}
fn build_terminal_item(run: &AgentRun) -> ThemeWorkbenchRunTerminalItem {
fn build_terminal_item(run: &AgentRun) -> GeneralWorkbenchRunTerminalItem {
let title = derive_run_title(run);
let gate_key = derive_run_gate_key(run, title.as_str());
ThemeWorkbenchRunTerminalItem {
GeneralWorkbenchRunTerminalItem {
run_id: run.id.clone(),
execution_id: derive_run_execution_id(run),
session_id: run.session_id.clone(),
@@ -344,7 +344,7 @@ fn build_terminal_item(run: &AgentRun) -> ThemeWorkbenchRunTerminalItem {
fn derive_recent_terminal_items(
runs: &[AgentRun],
limit: usize,
) -> Vec<ThemeWorkbenchRunTerminalItem> {
) -> Vec<GeneralWorkbenchRunTerminalItem> {
runs.iter()
.filter(|run| {
matches!(
@@ -361,11 +361,11 @@ fn derive_recent_terminal_items(
}
#[tauri::command]
pub async fn execution_run_get_theme_workbench_state(
pub async fn execution_run_get_general_workbench_state(
db: State<'_, DbConnection>,
session_id: String,
limit: Option<usize>,
) -> Result<ThemeWorkbenchRunState, String> {
) -> Result<GeneralWorkbenchRunState, String> {
let trimmed_session_id = session_id.trim();
if trimmed_session_id.is_empty() {
return Err("session_id 不能为空".to_string());
@@ -381,14 +381,14 @@ pub async fn execution_run_get_theme_workbench_state(
runs = tracker.list_runs_by_session(trimmed_session_id, safe_limit * 5)?;
}
let queue_items: Vec<ThemeWorkbenchRunTodoItem> = runs
let queue_items: Vec<GeneralWorkbenchRunTodoItem> = runs
.iter()
.filter(|run| matches!(run.status, AgentRunStatus::Running | AgentRunStatus::Queued))
.take(safe_limit)
.map(|run| {
let title = derive_run_title(run);
let gate_key = derive_run_gate_key(run, title.as_str());
ThemeWorkbenchRunTodoItem {
GeneralWorkbenchRunTodoItem {
run_id: run.id.clone(),
execution_id: derive_run_execution_id(run),
session_id: run.session_id.clone(),
@@ -413,7 +413,7 @@ pub async fn execution_run_get_theme_workbench_state(
let recent_terminals = derive_recent_terminal_items(runs.as_slice(), safe_limit);
let latest_terminal = recent_terminals.first().cloned();
Ok(ThemeWorkbenchRunState {
Ok(GeneralWorkbenchRunState {
run_state,
current_gate_key,
queue_items,
@@ -424,12 +424,12 @@ pub async fn execution_run_get_theme_workbench_state(
}
#[tauri::command]
pub async fn execution_run_list_theme_workbench_history(
pub async fn execution_run_list_general_workbench_history(
db: State<'_, DbConnection>,
session_id: String,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<ThemeWorkbenchRunHistoryPage, String> {
) -> Result<GeneralWorkbenchRunHistoryPage, String> {
let trimmed_session_id = session_id.trim();
if trimmed_session_id.is_empty() {
return Err("session_id 不能为空".to_string());
@@ -455,7 +455,7 @@ pub async fn execution_run_list_theme_workbench_history(
.map(|run| build_terminal_item(&run))
.collect::<Vec<_>>();
Ok(ThemeWorkbenchRunHistoryPage {
Ok(GeneralWorkbenchRunHistoryPage {
items,
has_more,
next_offset: if has_more {
@@ -516,7 +516,7 @@ mod tests {
#[test]
fn derive_current_gate_key_should_prefer_running_item() {
let queue_items = vec![
ThemeWorkbenchRunTodoItem {
GeneralWorkbenchRunTodoItem {
run_id: "run-1".to_string(),
execution_id: None,
session_id: None,
@@ -528,7 +528,7 @@ mod tests {
source_ref: None,
started_at: "2026-03-06T00:00:00Z".to_string(),
},
ThemeWorkbenchRunTodoItem {
GeneralWorkbenchRunTodoItem {
run_id: "run-2".to_string(),
execution_id: None,
session_id: None,
@@ -550,7 +550,7 @@ mod tests {
#[test]
fn derive_current_gate_key_should_fallback_to_first_item() {
let queue_items = vec![ThemeWorkbenchRunTodoItem {
let queue_items = vec![GeneralWorkbenchRunTodoItem {
run_id: "run-1".to_string(),
execution_id: None,
session_id: None,
+35 -44
View File
@@ -6,6 +6,22 @@ use crate::app::AppState;
use serde::{Deserialize, Serialize};
use tauri::State;
fn normalize_non_empty_api_key(raw: Option<String>) -> Option<String> {
raw.and_then(|key| {
let trimmed = key.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn resolve_api_key_with_env_fallback(config_key: Option<String>, env_key: &str) -> Option<String> {
normalize_non_empty_api_key(config_key)
.or_else(|| normalize_non_empty_api_key(std::env::var(env_key).ok()))
}
/// Pixabay 搜索请求
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -137,53 +153,21 @@ async fn get_pixabay_api_key(app_state: State<'_, AppState>) -> Option<String> {
state.config.image_gen.image_search_pixabay_api_key.clone()
};
key_from_config
.and_then(|key| {
let trimmed = key.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
.or_else(|| {
std::env::var("PIXABAY_API_KEY").ok().and_then(|key| {
let trimmed = key.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
})
resolve_api_key_with_env_fallback(key_from_config, "PIXABAY_API_KEY")
}
pub(crate) fn resolve_pexels_api_key(config_key: Option<String>) -> Option<String> {
resolve_api_key_with_env_fallback(config_key, "PEXELS_API_KEY")
}
/// 获取 Pexels API Key(优先配置,其次环境变量)
async fn get_pexels_api_key(app_state: State<'_, AppState>) -> Option<String> {
pub(crate) async fn get_pexels_api_key_from_app_state(app_state: &AppState) -> Option<String> {
let key_from_config = {
let state = app_state.read().await;
state.config.image_gen.image_search_pexels_api_key.clone()
};
key_from_config
.and_then(|key| {
let trimmed = key.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
.or_else(|| {
std::env::var("PEXELS_API_KEY").ok().and_then(|key| {
let trimmed = key.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
})
resolve_pexels_api_key(key_from_config)
}
fn map_aspect_to_pexels_orientation(aspect: Option<&str>) -> Option<&'static str> {
@@ -343,13 +327,11 @@ pub async fn search_pixabay_images(
}
/// 联网搜索图片(Pexels)
#[tauri::command]
pub async fn search_web_images(
app_state: State<'_, AppState>,
pub(crate) async fn search_web_images_with_pexels_api_key(
api_key: Option<String>,
req: WebImageSearchRequest,
) -> Result<WebImageSearchResponse, String> {
let api_key = get_pexels_api_key(app_state)
.await
let api_key = resolve_pexels_api_key(api_key)
.ok_or_else(|| "未配置 Pexels API Key,请先在设置 → 系统 → 网络搜索中配置".to_string())?;
let client = reqwest::Client::new();
@@ -392,6 +374,15 @@ pub async fn search_web_images(
Ok(map_pexels_to_web_response(body))
}
#[tauri::command]
pub async fn search_web_images(
app_state: State<'_, AppState>,
req: WebImageSearchRequest,
) -> Result<WebImageSearchResponse, String> {
let api_key = get_pexels_api_key_from_app_state(app_state.inner()).await;
search_web_images_with_pexels_api_key(api_key, req).await
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1,8 +1,8 @@
use super::{args_or_default, get_db, get_string_arg, parse_nested_arg, parse_optional_nested_arg};
use crate::commands::content_cmd::{
parse_theme_workbench_document_state, ContentDetail, ContentListItem,
CreateContentRequest as BridgeCreateContentRequest,
ListContentRequest as BridgeListContentRequest, ThemeWorkbenchDocumentState,
parse_general_workbench_document_state, ContentDetail, ContentListItem,
CreateContentRequest as BridgeCreateContentRequest, GeneralWorkbenchDocumentState,
ListContentRequest as BridgeListContentRequest,
UpdateContentRequest as BridgeUpdateContentRequest,
};
use crate::content::{
@@ -81,13 +81,13 @@ pub(super) fn try_handle(
let manager = content_manager(state)?;
serde_json::to_value(manager.get(&id)?.map(ContentDetail::from))?
}
"content_get_theme_workbench_document_state" => {
"content_get_general_workbench_document_state" => {
let args = args_or_default(args);
let id = get_string_arg(&args, "id", "id")?;
let manager = content_manager(state)?;
let content = manager.get(&id)?;
let document_state: Option<ThemeWorkbenchDocumentState> = content.and_then(|item| {
parse_theme_workbench_document_state(&item.id, item.metadata.as_ref())
let document_state: Option<GeneralWorkbenchDocumentState> = content.and_then(|item| {
parse_general_workbench_document_state(&item.id, item.metadata.as_ref())
});
serde_json::to_value(document_state)?
}
@@ -3,7 +3,7 @@
//! 负责在工作区内生成稳定路径、落盘 JSON 快照,并给前端 workbench
//! 提供可直接消费的 snapshot metadata。
use crate::commands::content_cmd::THEME_WORKBENCH_DOCUMENT_META_KEY;
use crate::commands::content_cmd::GENERAL_WORKBENCH_DOCUMENT_META_KEY;
use crate::content::{ContentManager, ContentUpdateRequest};
use crate::database::DbConnection;
use crate::services::artifact_document_validator::{
@@ -29,7 +29,7 @@ pub struct PersistedArtifactDocument {
pub absolute_path: PathBuf,
pub serialized_document: String,
pub snapshot_metadata: Map<String, Value>,
pub theme_workbench_document_state: Map<String, Value>,
pub general_workbench_document_state: Map<String, Value>,
pub content_body: String,
pub title: String,
pub kind: String,
@@ -274,8 +274,8 @@ pub fn persist_artifact_document_from_text(
&source_links,
version_diff.as_ref(),
);
let theme_workbench_document_state =
build_theme_workbench_document_state(&version_history, current_version.id.as_str());
let general_workbench_document_state =
build_general_workbench_document_state(&version_history, current_version.id.as_str());
let content_body = build_content_body_from_document(&enriched_document);
Ok(PersistedArtifactDocument {
@@ -286,7 +286,7 @@ pub fn persist_artifact_document_from_text(
absolute_path,
serialized_document,
snapshot_metadata,
theme_workbench_document_state,
general_workbench_document_state,
content_body,
title: outcome.title,
kind: outcome.kind,
@@ -423,7 +423,7 @@ fn resolve_topic_branch_status(status: &str) -> Option<&'static str> {
}
}
fn build_theme_workbench_document_state(
fn build_general_workbench_document_state(
version_history: &[ArtifactVersionSummary],
current_version_id: &str,
) -> Map<String, Value> {
@@ -628,8 +628,8 @@ pub fn sync_persisted_artifact_document_to_content(
}
}
next_metadata.insert(
THEME_WORKBENCH_DOCUMENT_META_KEY.to_string(),
Value::Object(persisted.theme_workbench_document_state.clone()),
GENERAL_WORKBENCH_DOCUMENT_META_KEY.to_string(),
Value::Object(persisted.general_workbench_document_state.clone()),
);
manager.update(
@@ -1426,7 +1426,7 @@ mod tests {
.contains("\"currentVersionDiff\""));
assert_eq!(
persisted_second
.theme_workbench_document_state
.general_workbench_document_state
.get("currentVersionId")
.and_then(Value::as_str),
Some("artifact-document:artifact:analysis:demo:v2")
@@ -1512,7 +1512,7 @@ mod tests {
let metadata = updated.metadata.expect("metadata should exist");
assert_eq!(
metadata
.get(THEME_WORKBENCH_DOCUMENT_META_KEY)
.get(GENERAL_WORKBENCH_DOCUMENT_META_KEY)
.and_then(Value::as_object)
.and_then(|value| value.get("currentVersionId"))
.and_then(Value::as_str),
@@ -5,6 +5,9 @@
use serde_json::{Map, Value};
const GENERAL_WORKBENCH_SESSION_MODE: &str = "general_workbench";
const LEGACY_GENERAL_WORKBENCH_SESSION_MODE_ALIAS: &str = "theme_workbench";
const ARTIFACT_MEANINGFUL_KEYS: &[&str] = &[
"artifact_mode",
"artifactMode",
@@ -31,6 +34,16 @@ fn normalize_text(value: Option<&str>) -> Option<String> {
.map(str::to_string)
}
fn normalize_session_mode_text(value: Option<&str>) -> Option<String> {
match normalize_text(value)?.as_str() {
GENERAL_WORKBENCH_SESSION_MODE | LEGACY_GENERAL_WORKBENCH_SESSION_MODE_ALIAS => {
Some(GENERAL_WORKBENCH_SESSION_MODE.to_string())
}
"default" => Some("default".to_string()),
_ => None,
}
}
fn root_object(request_metadata: Option<&Value>) -> Option<&Map<String, Value>> {
request_metadata?.as_object()
}
@@ -51,6 +64,40 @@ fn extract_harness_string(request_metadata: Option<&Value>, keys: &[&str]) -> Op
.and_then(|value| normalize_text(Some(value)))
}
fn extract_harness_session_mode(request_metadata: Option<&Value>) -> Option<String> {
normalize_session_mode_text(
extract_harness_string(request_metadata, &["session_mode", "sessionMode"]).as_deref(),
)
}
fn normalize_harness_session_mode_field(request_metadata: Value) -> Value {
let Some(normalized_session_mode) = extract_harness_session_mode(Some(&request_metadata))
else {
return request_metadata;
};
let mut request_metadata = request_metadata;
let Some(root) = request_metadata.as_object_mut() else {
return request_metadata;
};
if let Some(harness) = root.get_mut("harness").and_then(Value::as_object_mut) {
harness.insert(
"session_mode".to_string(),
Value::String(normalized_session_mode),
);
harness.remove("sessionMode");
return request_metadata;
}
root.insert(
"session_mode".to_string(),
Value::String(normalized_session_mode),
);
root.remove("sessionMode");
request_metadata
}
fn is_flat_artifact_metadata_key(key: &str) -> bool {
matches!(
key,
@@ -117,8 +164,8 @@ fn infer_source_policy(kind: Option<&str>) -> Option<&'static str> {
}
fn should_enable_artifact_draft(request_metadata: Option<&Value>) -> bool {
if extract_harness_string(request_metadata, &["session_mode", "sessionMode"]).as_deref()
!= Some("theme_workbench")
if extract_harness_session_mode(request_metadata).as_deref()
!= Some(GENERAL_WORKBENCH_SESSION_MODE)
{
return false;
}
@@ -183,6 +230,7 @@ pub fn normalize_request_metadata_with_artifact_defaults(
content_id_fallback: Option<&str>,
) -> Option<Value> {
let request_metadata = request_metadata?;
let request_metadata = normalize_harness_session_mode_field(request_metadata);
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["theme", "harness_theme", "harnessTheme"],
@@ -294,11 +342,11 @@ mod tests {
use serde_json::json;
#[test]
fn should_infer_theme_workbench_artifact_defaults_from_harness() {
fn should_infer_general_workbench_artifact_defaults_from_harness() {
let metadata = json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench",
"session_mode": "general_workbench",
"content_id": "content-1"
}
});
@@ -356,7 +404,7 @@ mod tests {
let metadata = json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench",
"session_mode": "general_workbench",
"turn_purpose": "content_review",
"content_id": "content-1"
}
@@ -422,7 +470,7 @@ mod tests {
let metadata = json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench"
"session_mode": "general_workbench"
}
});
@@ -461,7 +509,7 @@ mod tests {
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
Some("general"),
Some("theme_workbench"),
Some("general_workbench"),
None,
None,
None,
@@ -476,7 +524,7 @@ mod tests {
normalized
.pointer("/harness/session_mode")
.and_then(Value::as_str),
Some("theme_workbench")
Some("general_workbench")
);
assert_eq!(
normalized
@@ -491,7 +539,7 @@ mod tests {
let metadata = json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench",
"session_mode": "general_workbench",
"content_id": "content-social-1"
}
});
+55 -5
View File
@@ -7,10 +7,12 @@ use std::path::PathBuf;
use lime_core::app_paths;
use lime_core::models::parse_skill_manifest_from_content;
use lime_core::models::{
BROADCAST_GENERATE_SKILL_DIRECTORY, CONTENT_POST_WITH_COVER_SKILL_DIRECTORY,
COVER_GENERATE_SKILL_DIRECTORY, IMAGE_GENERATE_SKILL_DIRECTORY, LIBRARY_SKILL_DIRECTORY,
MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY, RESEARCH_SKILL_DIRECTORY, SITE_SEARCH_SKILL_DIRECTORY,
TRANSCRIPTION_GENERATE_SKILL_DIRECTORY, TYPESETTING_SKILL_DIRECTORY, URL_PARSE_SKILL_DIRECTORY,
ANALYSIS_SKILL_DIRECTORY, BROADCAST_GENERATE_SKILL_DIRECTORY,
CONTENT_POST_WITH_COVER_SKILL_DIRECTORY, COVER_GENERATE_SKILL_DIRECTORY,
IMAGE_GENERATE_SKILL_DIRECTORY, LIBRARY_SKILL_DIRECTORY, MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY,
PDF_READ_SKILL_DIRECTORY, REPORT_GENERATE_SKILL_DIRECTORY, RESEARCH_SKILL_DIRECTORY,
SITE_SEARCH_SKILL_DIRECTORY, SUMMARY_SKILL_DIRECTORY, TRANSCRIPTION_GENERATE_SKILL_DIRECTORY,
TRANSLATION_SKILL_DIRECTORY, TYPESETTING_SKILL_DIRECTORY, URL_PARSE_SKILL_DIRECTORY,
VIDEO_GENERATE_SKILL_DIRECTORY,
};
@@ -40,9 +42,23 @@ const URL_PARSE_SKILL_CONTENT: &str =
const RESEARCH_SKILL_CONTENT: &str =
include_str!("../../resources/default-skills/research/SKILL.md");
const REPORT_GENERATE_SKILL_CONTENT: &str =
include_str!("../../resources/default-skills/report_generate/SKILL.md");
const SITE_SEARCH_SKILL_CONTENT: &str =
include_str!("../../resources/default-skills/site_search/SKILL.md");
const PDF_READ_SKILL_CONTENT: &str =
include_str!("../../resources/default-skills/pdf_read/SKILL.md");
const SUMMARY_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/summary/SKILL.md");
const TRANSLATION_SKILL_CONTENT: &str =
include_str!("../../resources/default-skills/translation/SKILL.md");
const ANALYSIS_SKILL_CONTENT: &str =
include_str!("../../resources/default-skills/analysis/SKILL.md");
const SITE_SEARCH_ADAPTER_CATALOG_CONTENT: &str =
include_str!("../../resources/default-skills/site_search/references/adapter-catalog.md");
@@ -82,7 +98,7 @@ const SITE_SEARCH_EXTRA_FILES: &[BundledSkillFile] = &[BundledSkillFile {
content: SITE_SEARCH_ADAPTER_CATALOG_CONTENT,
}];
fn default_skills() -> [BundledSkillDefinition; 12] {
fn default_skills() -> [BundledSkillDefinition; 17] {
[
BundledSkillDefinition {
directory: VIDEO_GENERATE_SKILL_DIRECTORY,
@@ -129,11 +145,36 @@ fn default_skills() -> [BundledSkillDefinition; 12] {
skill_content: RESEARCH_SKILL_CONTENT,
extra_files: &[],
},
BundledSkillDefinition {
directory: REPORT_GENERATE_SKILL_DIRECTORY,
skill_content: REPORT_GENERATE_SKILL_CONTENT,
extra_files: &[],
},
BundledSkillDefinition {
directory: SITE_SEARCH_SKILL_DIRECTORY,
skill_content: SITE_SEARCH_SKILL_CONTENT,
extra_files: SITE_SEARCH_EXTRA_FILES,
},
BundledSkillDefinition {
directory: PDF_READ_SKILL_DIRECTORY,
skill_content: PDF_READ_SKILL_CONTENT,
extra_files: &[],
},
BundledSkillDefinition {
directory: SUMMARY_SKILL_DIRECTORY,
skill_content: SUMMARY_SKILL_CONTENT,
extra_files: &[],
},
BundledSkillDefinition {
directory: TRANSLATION_SKILL_DIRECTORY,
skill_content: TRANSLATION_SKILL_CONTENT,
extra_files: &[],
},
BundledSkillDefinition {
directory: ANALYSIS_SKILL_DIRECTORY,
skill_content: ANALYSIS_SKILL_CONTENT,
extra_files: &[],
},
BundledSkillDefinition {
directory: TYPESETTING_SKILL_DIRECTORY,
skill_content: TYPESETTING_SKILL_CONTENT,
@@ -342,7 +383,13 @@ mod tests {
assert!(LIBRARY_SKILL_CONTENT.contains("name: library"));
assert!(URL_PARSE_SKILL_CONTENT.contains("name: url_parse"));
assert!(RESEARCH_SKILL_CONTENT.contains("name: research"));
assert!(REPORT_GENERATE_SKILL_CONTENT.contains("name: report_generate"));
assert!(REPORT_GENERATE_SKILL_CONTENT.contains("allowed-tools: search_query"));
assert!(SITE_SEARCH_SKILL_CONTENT.contains("name: site_search"));
assert!(PDF_READ_SKILL_CONTENT.contains("name: pdf_read"));
assert!(PDF_READ_SKILL_CONTENT.contains("allowed-tools: list_directory, read_file"));
assert!(SUMMARY_SKILL_CONTENT.contains("name: summary"));
assert!(SUMMARY_SKILL_CONTENT.contains("allowed-tools: list_directory, read_file"));
assert!(SITE_SEARCH_ADAPTER_CATALOG_CONTENT.contains("`github/search`"));
assert!(SITE_SEARCH_ADAPTER_CATALOG_CONTENT.contains("`zhihu/hot`"));
assert!(TYPESETTING_SKILL_CONTENT.contains("name: typesetting"));
@@ -356,7 +403,10 @@ mod tests {
assert!(LIBRARY_SKILL_CONTENT.contains("lime_surface: chat"));
assert!(URL_PARSE_SKILL_CONTENT.contains("lime_surface: chat"));
assert!(RESEARCH_SKILL_CONTENT.contains("lime_surface: chat"));
assert!(REPORT_GENERATE_SKILL_CONTENT.contains("lime_surface: chat"));
assert!(SITE_SEARCH_SKILL_CONTENT.contains("lime_surface: chat"));
assert!(PDF_READ_SKILL_CONTENT.contains("lime_surface: chat"));
assert!(SUMMARY_SKILL_CONTENT.contains("lime_surface: chat"));
}
#[test]
+1 -1
View File
@@ -24,7 +24,7 @@ pub use runtime::{
build_skill_run_finish_decision, build_skill_run_start_metadata, prepare_skill_execution,
PreparedSkillExecution, SkillProviderSelection,
};
pub use social_post::infer_theme_workbench_gate_key;
pub use social_post::infer_general_workbench_gate_key;
// Tauri 实现(留在主 crate)
pub use default_skills::ensure_default_local_skills;
pub use execution_callback::TauriExecutionCallback;
+2 -2
View File
@@ -17,7 +17,7 @@ use std::path::Path;
use super::execution::SkillExecutionResult;
use super::execution_callback::TauriExecutionCallback;
use super::social_post::{infer_theme_workbench_gate_key, is_content_post_skill_name};
use super::social_post::{infer_general_workbench_gate_key, is_content_post_skill_name};
#[cfg(test)]
use super::social_post::CONTENT_POST_WITH_COVER_SKILL_NAME;
@@ -243,7 +243,7 @@ pub fn build_skill_run_start_metadata(
serde_json::json!({
"execution_id": execution_id,
"skill_name": skill_name,
"gate_key": infer_theme_workbench_gate_key(skill_name, user_input),
"gate_key": infer_general_workbench_gate_key(skill_name, user_input),
"provider_override": provider_override,
"model_override": model_override,
})
+1 -1
View File
@@ -28,7 +28,7 @@ struct SocialSkillOutputEnvelope {
file_content: String,
}
pub fn infer_theme_workbench_gate_key(skill_name: &str, user_input: &str) -> &'static str {
pub fn infer_general_workbench_gate_key(skill_name: &str, user_input: &str) -> &'static str {
let probe = format!("{} {}", skill_name, user_input).to_lowercase();
if probe.contains("publish")
|| probe.contains("adapt")