mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v1.4.0
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user