feat: install agent skills from marketplace prompts

This commit is contained in:
coso
2026-05-13 11:53:44 +08:00
parent b3111949ff
commit 0e1351bd84
114 changed files with 6604 additions and 1188 deletions
@@ -51,6 +51,7 @@ pub struct OpenDeepLinkPayload {
pub slug: String,
pub source: Option<String>,
pub version: Option<String>,
pub action: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -76,6 +77,8 @@ pub enum DeepLinkError {
MissingSlug,
/// open 链路里的 kind 无效
InvalidOpenKind(String),
/// open 链路里的 action 无效
InvalidOpenAction(String),
}
impl std::fmt::Display for DeepLinkError {
@@ -89,6 +92,9 @@ impl std::fmt::Display for DeepLinkError {
DeepLinkError::InvalidOpenKind(kind) => {
write!(f, "无效的 open kind: {kind}")
}
DeepLinkError::InvalidOpenAction(action) => {
write!(f, "无效的 open action: {action}")
}
}
}
}
@@ -212,12 +218,22 @@ fn parse_open_payload(parsed: Url) -> Result<OpenDeepLinkPayload, DeepLinkError>
.filter(|value| !value.is_empty())
.cloned();
let version = params.get("v").filter(|value| !value.is_empty()).cloned();
let action = params
.get("action")
.filter(|value| !value.is_empty())
.map(|value| value.trim().to_ascii_lowercase());
if let Some(action) = action.as_deref() {
if action != "open" && action != "install" {
return Err(DeepLinkError::InvalidOpenAction(action.to_string()));
}
}
Ok(OpenDeepLinkPayload {
kind,
slug,
source,
version,
action,
})
}
@@ -322,6 +338,18 @@ mod tests {
assert_eq!(result.slug, "daily-trend-briefing");
assert_eq!(result.source, Some("website".to_string()));
assert_eq!(result.version, Some("1".to_string()));
assert_eq!(result.action, None);
}
#[test]
fn test_parse_open_skill_install_action() {
let url =
"lime://open?kind=skill&slug=viral-content-breakdown&source=website&v=1&action=install";
let result = parse_open_deep_link(url).unwrap();
assert_eq!(result.kind, OpenDeepLinkKind::Skill);
assert_eq!(result.slug, "viral-content-breakdown");
assert_eq!(result.action, Some("install".to_string()));
}
#[test]
@@ -351,6 +379,12 @@ mod tests {
let result = parse_open_deep_link("lime://open?kind=other&slug=test");
assert!(matches!(result, Err(DeepLinkError::InvalidOpenKind(_))));
}
#[test]
fn test_parse_open_invalid_action() {
let result = parse_open_deep_link("lime://open?kind=skill&slug=test&action=run");
assert!(matches!(result, Err(DeepLinkError::InvalidOpenAction(_))));
}
}
#[cfg(test)]
@@ -3,14 +3,13 @@ name: brand-persona-knowledge-builder
description: 将品牌定位、价值观、受众画像、语气风格、内容样例、危机回应和表达禁区,整理成符合 Agent Knowledge v0.6 document-first 标准、可被 AI 安全调用的品牌人设知识库。适用于用户要求“整理品牌人设”“沉淀品牌口吻”“把品牌资料变成可复用语气库”“维护品牌 persona pack”的场景。
license: Apache-2.0
allowed-tools: list_directory, read_file
compatibility:
agentKnowledge: ">=0.6.0"
compatibility: Agent Knowledge >=0.6.0
metadata:
lime_version: 1.0.0
lime_version: 1.0.1
lime_execution_mode: prompt
lime_surface: workbench
lime_category: knowledge
Lime_skill_bundle_version: "1.0.0"
Lime_skill_bundle_version: "1.0.1"
Lime_knowledge_builder: "true"
Lime_knowledge_pack_type: "brand-persona"
Lime_knowledge_template: "brand-persona"
@@ -3,14 +3,13 @@ name: brand-product-knowledge-builder
description: 将品牌产品资料、规格参数、卖点证据、FAQ、价格权益、竞品区别和合规边界,整理成符合 Agent Knowledge v0.6 document-first 标准、可被 AI 安全调用的产品资料知识库。适用于用户要求“整理产品知识库”“沉淀产品 FAQ”“把品牌产品资料变成项目资料”“维护产品资料包”的场景。
license: Apache-2.0
allowed-tools: list_directory, read_file
compatibility:
agentKnowledge: ">=0.6.0"
compatibility: Agent Knowledge >=0.6.0
metadata:
lime_version: 1.0.0
lime_version: 1.0.1
lime_execution_mode: prompt
lime_surface: workbench
lime_category: knowledge
Lime_skill_bundle_version: "1.0.0"
Lime_skill_bundle_version: "1.0.1"
Lime_knowledge_builder: "true"
Lime_knowledge_pack_type: "brand-product"
Lime_knowledge_template: "brand-product"
@@ -3,14 +3,13 @@ name: campaign-operations-knowledge-builder
description: 将活动目标、用户路径、渠道分工、物料资产、时间节奏、风险预案和复盘结论等资料,整理成符合 Agent Knowledge v0.6 document-first 标准、可被 AI 安全调用的运营类知识库。适用于用户要求“整理活动 / Campaign 运营知识库”“沉淀运营 SOP”“把运营资料变成项目资料”“维护运营知识库”的场景。
license: Apache-2.0
allowed-tools: list_directory, read_file
compatibility:
agentKnowledge: ">=0.6.0"
compatibility: Agent Knowledge >=0.6.0
metadata:
lime_version: 1.0.0
lime_version: 1.0.1
lime_execution_mode: prompt
lime_surface: workbench
lime_category: knowledge
Lime_skill_bundle_version: "1.0.0"
Lime_skill_bundle_version: "1.0.1"
Lime_knowledge_builder: "true"
Lime_knowledge_pack_type: "campaign-operations"
Lime_knowledge_template: "campaign-operations"
@@ -3,14 +3,13 @@ name: content-operations-knowledge-builder
description: 将选题日历、栏目矩阵、素材资产、发布节奏、复盘结论和内容边界等资料,整理成符合 Agent Knowledge v0.6 document-first 标准、可被 AI 安全调用的运营类知识库。适用于用户要求“整理内容运营知识库”“沉淀运营 SOP”“把运营资料变成项目资料”“维护运营知识库”的场景。
license: Apache-2.0
allowed-tools: list_directory, read_file
compatibility:
agentKnowledge: ">=0.6.0"
compatibility: Agent Knowledge >=0.6.0
metadata:
lime_version: 1.0.0
lime_version: 1.0.1
lime_execution_mode: prompt
lime_surface: workbench
lime_category: knowledge
Lime_skill_bundle_version: "1.0.0"
Lime_skill_bundle_version: "1.0.1"
Lime_knowledge_builder: "true"
Lime_knowledge_pack_type: "content-operations"
Lime_knowledge_template: "content-operations"
@@ -3,14 +3,13 @@ name: growth-strategy-knowledge-builder
description: 将增长目标、指标体系、渠道策略、实验计划、复盘结论、资源约束和停止条件,整理成符合 Agent Knowledge v0.6 document-first 标准、可被 AI 安全调用的增长策略知识库。适用于用户要求“整理增长知识库”“沉淀增长策略”“把增长计划变成项目资料”“维护增长实验资料包”的场景。
license: Apache-2.0
allowed-tools: list_directory, read_file
compatibility:
agentKnowledge: ">=0.6.0"
compatibility: Agent Knowledge >=0.6.0
metadata:
lime_version: 1.0.0
lime_version: 1.0.1
lime_execution_mode: prompt
lime_surface: workbench
lime_category: knowledge
Lime_skill_bundle_version: "1.0.0"
Lime_skill_bundle_version: "1.0.1"
Lime_knowledge_builder: "true"
Lime_knowledge_pack_type: "growth-strategy"
Lime_knowledge_template: "growth-strategy"
@@ -24,6 +24,8 @@ metadata:
- 当前已经进入 `@配图/@修图/@重绘 -> image_skill_launch -> Skill(image_generate)` 主链,不要先调用 `ToolSearch`、`WebSearch`、`Read`、`Glob`、`Grep` 去“找技能”或“确认工具”。
- 不要搜索 “Skill image_generate”、“lime media image generate --json”、“lime_create_image_generation_task” 之类目录信息;当前上下文已经明确要求执行图片任务。
- 提示词必须包含主体、场景、风格,不要空泛。
- 如果结构化上下文提供 `persona_context`,聊天输出必须服从其中的单条消息、短确认和隐藏运行时细节约束。
- 如果结构化上下文提供 `taste_context`,应结合其中的 `memory_sources`、`style_keywords`、`reference_summaries`、`avoid_keywords` 和 `cold_start_policy` 优化工具参数;这层只影响 prompt/style/reference,不要在聊天区解释内部来源。
- 若调用方在结构化上下文里提供了 `image_task`,必须优先复用其中的 `mode`、`reference_images`、`target_output_*`、`session_id`、`project_id`、`content_id`、`entry_source`、`requested_target`、`executor_mode`、`outer_model`、`runtime_contract`、`modality_contract_key`、`routing_slot`、`slot_id`、`anchor_*` 等字段,不要擅自丢失。
- 若 `image_task.runtime_contract.layered_design` 存在,说明这是 `LayeredDesignDocument -> canvas:design -> DesignCanvas` 的图层生成任务;必须原样透传 `runtime_contract.layered_design`、`target_output_id`、`target_output_ref_id` 和 `slot_id`,不要改写成 `poster_generate`、`canvas:poster`、脚本生成或 markdown 配图流程。
- 若上下文已提供 `provider_id` 或 `model`,提交任务时也要原样透传,不要降级成匿名默认值。
@@ -39,10 +41,10 @@ metadata:
- 不要通过 `Bash` 拼接 `lime media image generate --json`、`lime task create image --json` 或任何 `/tmp/lime_task_image_*.json` 临时任务文件。
- `lime_create_image_generation_task` 返回后,应依赖同一份图片任务文件契约推进执行与结果回填;不要另起一套 markdown“提交成功”假产物,也不要输出独立的任务递交摘要。
- 调用 `lime_create_image_generation_task` 时不要传 `outputPath`,不要把任务写成 markdown 文稿。
- `payload` 中至少包含:`prompt`、`style`、`size`、`count`、`usage`;如有上下文,还应携带 `mode`、`provider_id`、`model`、`executor_mode`、`outer_model`、`reference_images`、`storyboard_slots`、`target_output_id`、`target_output_ref_id`、`session_id`、`project_id`、`content_id`、`entry_source`、`requested_target`、`runtime_contract`、`modality_contract_key`、`routing_slot`、`slot_id`、`anchor_hint`、`anchor_section_title`、`anchor_text`。
- `payload` 中至少包含:`prompt`、`style`、`size`、`count`、`usage`;如有上下文,还应携带 `mode`、`provider_id`、`model`、`executor_mode`、`outer_model`、`reference_images`、`storyboard_slots`、`target_output_id`、`target_output_ref_id`、`session_id`、`project_id`、`content_id`、`entry_source`、`requested_target`、`runtime_contract`、`modality_contract_key`、`routing_slot`、`slot_id`、`anchor_hint`、`anchor_section_title`、`anchor_text`、`persona_context`、`presentation`、`taste_context`。
## 输出规则(固定)
工具调用成功后,不要再输出“任务类型 / 任务 ID / 任务文件 / 状态”这类递交摘要;Lime 会在同一条 assistant 消息里展示工具调用、任务进度和图片结果。若确实需要补充文字,只用一句自然说明,不重复 task_id、path 或排队模板。
聊天输出必须极简自然:工具前最多一句“好嘞,用 <模型> 给你生成...”,随后只保留“先获取下工具参数”“马上生成”这类短过程;不要输出任务表格、任务 ID、任务文件、排队说明、Image Workbench/图片工作台文案,也不要拆成第二条 assistant 回复。
聊天输出必须极简自然:工具前最多保留 `presentation.assistant_intro` 和 `presentation.process_lines` 这类短过程;结果态优先使用 `presentation.completion_caption` / `presentation.result_captions`;不要输出任务表格、任务 ID、任务文件、排队说明、Image Workbench/图片工作台文案,也不要拆成第二条 assistant 回复。
@@ -3,12 +3,11 @@ name: knowledge_builder
description: 兼容旧版 Agent Knowledge 编译流程的 deprecated 兜底 Builder。仅用于未知或历史 pack 类型;标准 persona / data pack 必须优先委托内置专用 Builder Skill。
license: Apache-2.0
allowed-tools: list_directory, read_file
compatibility:
agentKnowledge: ">=0.6.0"
compatibility: Agent Knowledge >=0.6.0
metadata:
lime_argument_hint: 输入知识包类型、pack name、项目根目录、来源目录或粘贴资料,并说明目标使用场景。
lime_when_to_use: 用户需要把个人、品牌产品、组织流程、项目资料或增长策略整理成可确认的 Agent Knowledge 知识包时使用。
lime_version: 1.2.0
lime_version: 1.2.1
lime_execution_mode: prompt
lime_surface: workbench
lime_category: knowledge
@@ -3,14 +3,13 @@ name: live-commerce-operations-knowledge-builder
description: 将直播排期、货盘节奏、场控流程、主播话术、互动机制、异常预案和复盘指标等资料,整理成符合 Agent Knowledge v0.6 document-first 标准、可被 AI 安全调用的运营类知识库。适用于用户要求“整理直播运营知识库”“沉淀运营 SOP”“把运营资料变成项目资料”“维护运营知识库”的场景。
license: Apache-2.0
allowed-tools: list_directory, read_file
compatibility:
agentKnowledge: ">=0.6.0"
compatibility: Agent Knowledge >=0.6.0
metadata:
lime_version: 1.0.0
lime_version: 1.0.1
lime_execution_mode: prompt
lime_surface: workbench
lime_category: knowledge
Lime_skill_bundle_version: "1.0.0"
Lime_skill_bundle_version: "1.0.1"
Lime_knowledge_builder: "true"
Lime_knowledge_pack_type: "live-commerce-operations"
Lime_knowledge_template: "live-commerce-operations"
@@ -3,14 +3,13 @@ name: organization-knowhow-knowledge-builder
description: 将团队 SOP、交付流程、角色职责、项目复盘、FAQ、决策边界和升级机制,整理成符合 Agent Knowledge v0.6 document-first 标准、可被 AI 安全调用的组织经验知识库。适用于用户要求“整理组织知识库”“沉淀团队 SOP”“把交付经验变成项目资料”“维护组织 know-how”的场景。
license: Apache-2.0
allowed-tools: list_directory, read_file
compatibility:
agentKnowledge: ">=0.6.0"
compatibility: Agent Knowledge >=0.6.0
metadata:
lime_version: 1.0.0
lime_version: 1.0.1
lime_execution_mode: prompt
lime_surface: workbench
lime_category: knowledge
Lime_skill_bundle_version: "1.0.0"
Lime_skill_bundle_version: "1.0.1"
Lime_knowledge_builder: "true"
Lime_knowledge_pack_type: "organization-knowhow"
Lime_knowledge_template: "organization-knowhow"
@@ -3,14 +3,13 @@ name: personal-ip-knowledge-builder
description: 将访谈稿、聊天记录、简历、公开内容、业务资料、案例和既有 DOCX/Markdown 文档,提炼成可被 AI 长期调用的个人 IP 知识库。适用于用户要求“生成个人知识库”“整理成个人 IP 成品知识库”“为创始人/专家/讲师/主播/顾问建立AI知识库”“把资料变成个人IP底层提示词/写作风格库/故事素材库/话术库”的场景。
license: Apache-2.0
allowed-tools: list_directory, read_file
compatibility:
agentKnowledge: ">=0.6.0"
compatibility: Agent Knowledge >=0.6.0
metadata:
lime_version: 1.0.0
lime_version: 1.0.1
lime_execution_mode: prompt
lime_surface: workbench
lime_category: knowledge
Lime_skill_bundle_version: "1.0.0"
Lime_skill_bundle_version: "1.0.1"
Lime_knowledge_builder: "true"
Lime_knowledge_pack_type: "personal-profile"
Lime_knowledge_template: "personal-ip"
@@ -3,14 +3,13 @@ name: private-domain-operations-knowledge-builder
description: 将用户分层、社群 SOP、触达节奏、转化话术、活动机制和服务边界等资料,整理成符合 Agent Knowledge v0.6 document-first 标准、可被 AI 安全调用的运营类知识库。适用于用户要求“整理私域 / 社群运营知识库”“沉淀运营 SOP”“把运营资料变成项目资料”“维护运营知识库”的场景。
license: Apache-2.0
allowed-tools: list_directory, read_file
compatibility:
agentKnowledge: ">=0.6.0"
compatibility: Agent Knowledge >=0.6.0
metadata:
lime_version: 1.0.0
lime_version: 1.0.1
lime_execution_mode: prompt
lime_surface: workbench
lime_category: knowledge
Lime_skill_bundle_version: "1.0.0"
Lime_skill_bundle_version: "1.0.1"
Lime_knowledge_builder: "true"
Lime_knowledge_pack_type: "private-domain-operations"
Lime_knowledge_template: "private-domain-operations"
+2
View File
@@ -1079,6 +1079,8 @@ pub fn run() {
commands::skill_cmd::inspect_local_skill_for_app,
commands::skill_cmd::create_skill_scaffold_for_app,
commands::skill_cmd::import_local_skill_for_app,
commands::skill_cmd::install_marketplace_skill_for_app,
commands::skill_cmd::install_skill_from_download_url_for_app,
commands::skill_cmd::inspect_remote_skill,
// Capability Draft commands
commands::capability_draft_cmd::capability_draft_create,
@@ -237,6 +237,15 @@ fn truncate_prompt_text(value: String, max_chars: usize) -> String {
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
fn truncate_json_value(value: Option<&serde_json::Value>, max_chars: usize) -> Option<String> {
value.map(|item| {
truncate_prompt_text(
serde_json::to_string(item).unwrap_or_else(|_| "{}".to_string()),
max_chars,
)
})
}
pub(crate) fn prepare_image_skill_launch_request_metadata(
workspace_root: &Path,
session_id: &str,
@@ -434,6 +443,9 @@ fn build_image_skill_launch_system_prompt(
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
let persona_context_json = truncate_json_value(image_task.get("persona_context"), 2_000);
let presentation_json = truncate_json_value(image_task.get("presentation"), 2_000);
let taste_context_json = truncate_json_value(image_task.get("taste_context"), 2_000);
let args_payload = serde_json::json!({
"user_input": raw_text
.clone()
@@ -480,7 +492,7 @@ fn build_image_skill_launch_system_prompt(
"- 不要伪造“图片已生成完成”;在 task file 真正返回结果前,只能让工具轨迹展示任务已提交、排队或执行中,不要额外输出递交模板。".to_string(),
"- 如果当前回合已经拿到任何图片任务结果,且结果里含 task_id、path,或 status=pending_submit/queued/running/partial/succeeded,说明任务已提交;不要再次调用 Skill(image_generate) 或重复创建第二个图片任务。".to_string(),
"- 拿到上述任务结果后,不要再输出“任务类型 / 任务 ID / 任务文件 / 状态”这类提交摘要;让同一条 assistant 消息内的工具调用和图片任务轻卡继续展示进度与结果。".to_string(),
"- 聊天输出必须保持极简自然:工具前最多一句“好嘞,用 <模型> 给你生成...”,随后只保留“先获取下工具参数”“马上生成”这类短过程;不要输出任务表格、任务 ID、任务文件、排队说明、Image Workbench/图片工作台文案,也不要拆成第二条 assistant 回复。".to_string(),
"- 聊天输出必须服从 persona_context 与 presentation:工具前最多保留短确认和短过程,结果态优先使用 presentation.completion_caption / presentation.result_captions;不要输出任务表格、任务 ID、任务文件、排队说明、Image Workbench/图片工作台文案,也不要拆成第二条 assistant 回复。".to_string(),
format!("- 当前图片任务上下文(JSON):{image_task_json}"),
format!("- 当前模式:{mode}。"),
format!("- 当前入口来源:{entry_source}。"),
@@ -493,6 +505,21 @@ fn build_image_skill_launch_system_prompt(
if let Some(value) = prompt.as_deref() {
lines.push(format!("- 当前用户目标:{value}"));
}
if let Some(value) = persona_context_json.as_deref() {
lines.push(format!(
"- 当前图片生成人设契约(JSON):{value}。这决定同一条对话里的语气、边界和是否隐藏运行时细节。"
));
}
if let Some(value) = presentation_json.as_deref() {
lines.push(format!(
"- 当前聊天展示契约(JSON):{value}。这只用于同一条对话里的自然展示,不要改写成任务摘要。"
));
}
if let Some(value) = taste_context_json.as_deref() {
lines.push(format!(
"- 当前品味/记忆规划上下文(JSON):{value}。调用图片任务前,用它辅助优化 prompt;不要在聊天区暴露内部来源或参考站名称。"
));
}
if let Some(value) = size.as_deref() {
lines.push(format!("- 当前目标尺寸:{value}。"));
}
@@ -5058,6 +5058,24 @@ mod tests {
"provider_id": "custom-f0181b00-35b6-4731-94e2-24f17fd247c9",
"model": "gpt-images-2",
"size": "1024x1024",
"persona_context": {
"version": "lime-image-persona-v1",
"persona_id": "lime_image_creator",
"conversation_policy": {
"single_assistant_message": true,
"no_submission_summary": true
}
},
"presentation": {
"version": "lime-image-chat-v1",
"assistant_intro": "好啊,用 GPT Image 2 处理:把这张海报改成更清爽的青柠风格\n先获取下工具参数\n马上生成"
},
"taste_context": {
"version": "lime-image-taste-v1",
"source": "taste_layer",
"memory_sources": ["explicit_prompt", "reference_images"],
"chat_policy": "do_not_explain_taste_sources_in_conversation"
},
"reference_images": [
"/tmp/lime/turn-inputs/session-1/turn-1/input-1.png"
]
@@ -5106,6 +5124,12 @@ mod tests {
assert!(merged.contains("每一格都必须提供完整 prompt"));
assert!(merged.contains("电影、动漫、短视频、广告"));
assert!(merged.contains("当前任务已经显式进入图片技能主链"));
assert!(merged.contains("当前图片生成人设契约(JSON)"));
assert!(merged.contains("当前聊天展示契约(JSON)"));
assert!(merged.contains("当前品味/记忆规划上下文(JSON)"));
assert!(merged.contains("不要在聊天区暴露内部来源或参考站名称"));
let reference_site_name = ["ri", "bbi"].concat();
assert!(!merged.to_ascii_lowercase().contains(&reference_site_name));
}
#[test]
@@ -597,6 +597,8 @@ fn canonicalize_image_task_alias_fields(record: &mut serde_json::Map<String, ser
("anchorSectionTitle", "anchor_section_title"),
("anchorText", "anchor_text"),
("titleGenerationResult", "title_generation_result"),
("personaContext", "persona_context"),
("tasteContext", "taste_context"),
("targetOutputId", "target_output_id"),
("targetOutputRefId", "target_output_ref_id"),
("referenceImages", "reference_images"),
@@ -644,6 +646,12 @@ struct ImageTaskInput {
title: Option<String>,
#[serde(default, alias = "title_generation_result")]
title_generation_result: Option<serde_json::Value>,
#[serde(default, alias = "persona_context")]
persona_context: Option<serde_json::Value>,
#[serde(default)]
presentation: Option<serde_json::Value>,
#[serde(default, alias = "taste_context")]
taste_context: Option<serde_json::Value>,
#[serde(default)]
mode: Option<String>,
#[serde(default, alias = "raw_text")]
@@ -952,6 +960,16 @@ fn image_task_input_schema() -> serde_json::Value {
"usage",
serde_json::json!({ "type": "string", "description": "用途(可选)。" }),
);
let image_context_schema = serde_json::json!({
"type": "object",
"description": "图片生成人设、展示或品味上下文(由启动链路注入,需原样透传)。",
"additionalProperties": true
});
insert_property("personaContext", image_context_schema.clone());
insert_property("persona_context", image_context_schema.clone());
insert_property("presentation", image_context_schema.clone());
insert_property("tasteContext", image_context_schema.clone());
insert_property("taste_context", image_context_schema);
insert_property(
"providerId",
serde_json::json!({ "type": "string", "description": "Provider 标识(可选)。" }),
@@ -1191,6 +1209,9 @@ fn build_image_generation_task_request(
prompt,
title,
title_generation_result,
persona_context,
presentation,
taste_context,
mode,
raw_text,
layout_hint,
@@ -1271,6 +1292,9 @@ fn build_image_generation_task_request(
prompt,
title,
title_generation_result,
persona_context,
presentation,
taste_context,
mode,
raw_text,
layout_hint,
@@ -2112,6 +2136,9 @@ mod tests {
assert!(properties.contains_key("model"));
assert!(properties.contains_key("modality_contract_key"));
assert!(properties.contains_key("required_capabilities"));
assert!(properties.contains_key("persona_context"));
assert!(properties.contains_key("presentation"));
assert!(properties.contains_key("taste_context"));
assert!(!properties.contains_key("outputPath"));
}
@@ -2151,6 +2178,19 @@ mod tests {
},
"usedFallback": false
})),
persona_context: Some(serde_json::json!({
"version": "lime-image-persona-v1",
"persona_id": "lime_image_creator"
})),
presentation: Some(serde_json::json!({
"version": "lime-image-chat-v1",
"assistant_intro": "好啊,生成:未来感青柠实验室",
"completion_caption": "青柠实验室已生成"
})),
taste_context: Some(serde_json::json!({
"version": "lime-image-taste-v1",
"source": "taste_layer"
})),
mode: Some("generate".to_string()),
raw_text: Some("@配图 未来感青柠实验室".to_string()),
layout_hint: None,
@@ -2235,6 +2275,14 @@ mod tests {
"usedFallback": false
}))
);
assert_eq!(
request
.presentation
.as_ref()
.and_then(|value| value.get("completion_caption"))
.and_then(Value::as_str),
Some("青柠实验室已生成")
);
let output =
create_image_generation_task_artifact_inner(request).expect("create image artifact");
@@ -2259,6 +2307,33 @@ mod tests {
.and_then(Value::as_str),
Some("project-image-compat-1")
);
assert_eq!(
output
.record
.payload
.get("persona_context")
.and_then(|value| value.get("persona_id"))
.and_then(Value::as_str),
Some("lime_image_creator")
);
assert_eq!(
output
.record
.payload
.get("presentation")
.and_then(|value| value.get("completion_caption"))
.and_then(Value::as_str),
Some("青柠实验室已生成")
);
assert_eq!(
output
.record
.payload
.get("taste_context")
.and_then(|value| value.get("source"))
.and_then(Value::as_str),
Some("taste_layer")
);
assert_eq!(
output
.record
@@ -2547,6 +2622,10 @@ mod tests {
"count": "9",
"titleGenerationResult": { "title": "三国主要人物", "sessionId": "title-camel" },
"title_generation_result": { "title": "三国主要人物", "sessionId": "title-snake" },
"personaContext": { "persona_id": "camel-persona" },
"persona_context": { "persona_id": "snake-persona" },
"tasteContext": { "source": "camel-taste" },
"taste_context": { "source": "snake-taste" },
"providerId": "custom-provider-camel",
"provider_id": "custom-provider-snake",
"model": "gpt-images-2",
@@ -2576,10 +2655,20 @@ mod tests {
record.get("title_generation_result"),
Some(&serde_json::json!({ "title": "三国主要人物", "sessionId": "title-snake" }))
);
assert_eq!(
record.get("persona_context"),
Some(&serde_json::json!({ "persona_id": "snake-persona" }))
);
assert_eq!(
record.get("taste_context"),
Some(&serde_json::json!({ "source": "snake-taste" }))
);
assert!(!record.contains_key("providerId"));
assert!(!record.contains_key("projectId"));
assert!(!record.contains_key("anchorHint"));
assert!(!record.contains_key("titleGenerationResult"));
assert!(!record.contains_key("personaContext"));
assert!(!record.contains_key("tasteContext"));
assert!(!record.contains_key("referenceImages"));
assert!(!record.contains_key("skillInputImages"));
@@ -2592,6 +2681,22 @@ mod tests {
input.title_generation_result,
Some(serde_json::json!({ "title": "三国主要人物", "sessionId": "title-snake" }))
);
assert_eq!(
input
.persona_context
.as_ref()
.and_then(|value| value.get("persona_id"))
.and_then(Value::as_str),
Some("snake-persona")
);
assert_eq!(
input
.taste_context
.as_ref()
.and_then(|value| value.get("source"))
.and_then(Value::as_str),
Some("snake-taste")
);
assert!(input.reference_images.is_empty());
}
@@ -2605,6 +2710,9 @@ mod tests {
for field in [
"raw_text",
"title_generation_result",
"persona_context",
"presentation",
"taste_context",
"layout_hint",
"aspect_ratio",
"provider_id",
@@ -89,6 +89,9 @@ pub(crate) fn emit_media_creation_task_event(app_handle: &AppHandle, output: &Me
"count": read_payload_u64(payload_record, &["count", "image_count", "imageCount"]),
"storyboard_slots": payload_record.get("storyboard_slots").cloned(),
"raw_text": read_payload_string(payload_record, &["raw_text", "rawText"]),
"persona_context": payload_record.get("persona_context").cloned(),
"presentation": payload_record.get("presentation").cloned(),
"taste_context": payload_record.get("taste_context").cloned(),
"session_id": read_payload_string(payload_record, &["session_id", "sessionId"]),
"thread_id": read_payload_string(payload_record, &["thread_id", "threadId"]),
"turn_id": read_payload_string(payload_record, &["turn_id", "turnId"]),
+4
View File
@@ -89,6 +89,10 @@ impl From<DeepLinkError> for ConnectError {
"INVALID_OPEN_KIND".to_string(),
format!("无效的 open kind: {kind}"),
),
DeepLinkError::InvalidOpenAction(action) => (
"INVALID_OPEN_ACTION".to_string(),
format!("无效的 open action: {action}"),
),
};
ConnectError { code, message }
}
+86
View File
@@ -103,6 +103,12 @@ pub struct CreateImageGenerationTaskArtifactRequest {
pub title: Option<String>,
#[serde(default, alias = "title_generation_result")]
pub title_generation_result: Option<serde_json::Value>,
#[serde(default, alias = "persona_context")]
pub persona_context: Option<serde_json::Value>,
#[serde(default)]
pub presentation: Option<serde_json::Value>,
#[serde(default, alias = "taste_context")]
pub taste_context: Option<serde_json::Value>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default, alias = "raw_text")]
@@ -4809,6 +4815,9 @@ pub(crate) fn create_image_generation_task_artifact_inner(
let anchor_text = normalize_optional_string(request.anchor_text.clone());
let title_generation_result =
normalize_optional_json_object(request.title_generation_result.clone());
let persona_context = normalize_optional_json_object(request.persona_context.clone());
let presentation = normalize_optional_json_object(request.presentation.clone());
let taste_context = normalize_optional_json_object(request.taste_context.clone());
let target_output_id = normalize_optional_string(request.target_output_id.clone());
let target_output_ref_id = normalize_optional_string(request.target_output_ref_id.clone());
let normalized_reference_images = normalize_reference_images(request.reference_images.clone());
@@ -4863,6 +4872,9 @@ pub(crate) fn create_image_generation_task_artifact_inner(
"anchor_section_title": anchor_section_title,
"anchor_text": anchor_text,
"title_generation_result": title_generation_result,
"persona_context": persona_context,
"presentation": presentation,
"taste_context": taste_context,
"target_output_id": target_output_id,
"target_output_ref_id": target_output_ref_id,
"reference_images": normalized_reference_images,
@@ -5414,6 +5426,9 @@ mod tests {
prompt: "未来感青柠实验室".to_string(),
title: Some("青柠主视觉".to_string()),
title_generation_result: None,
persona_context: None,
presentation: None,
taste_context: None,
mode: Some("generate".to_string()),
raw_text: Some("@配图 生成 未来感青柠实验室".to_string()),
layout_hint: None,
@@ -5737,6 +5752,19 @@ mod tests {
"usedFallback": false,
"fallbackReason": null
})),
persona_context: Some(json!({
"version": "lime-image-persona-v1",
"persona_id": "lime_image_creator"
})),
presentation: Some(json!({
"version": "lime-image-chat-v1",
"assistant_intro": "好啊,生成:未来感青柠实验室",
"completion_caption": "青柠实验室已生成"
})),
taste_context: Some(json!({
"version": "lime-image-taste-v1",
"source": "taste_layer"
})),
mode: Some("variation".to_string()),
raw_text: Some("@配图 变体 #img-1 未来感青柠实验室".to_string()),
layout_hint: Some("storyboard_3x3".to_string()),
@@ -5803,6 +5831,19 @@ mod tests {
"usedFallback": false,
"fallbackReason": null
})),
persona_context: Some(json!({
"version": "lime-image-persona-v1",
"persona_id": "lime_image_creator"
})),
presentation: Some(json!({
"version": "lime-image-chat-v1",
"assistant_intro": "好啊,生成:未来感青柠实验室",
"completion_caption": "青柠实验室已生成"
})),
taste_context: Some(json!({
"version": "lime-image-taste-v1",
"source": "taste_layer"
})),
mode: Some("variation".to_string()),
raw_text: Some("@配图 变体 #img-1 未来感青柠实验室".to_string()),
layout_hint: Some("storyboard_3x3".to_string()),
@@ -5904,6 +5945,36 @@ mod tests {
"fallbackReason": null
}))
);
assert_eq!(
first
.record
.payload
.get("persona_context")
.and_then(serde_json::Value::as_object)
.and_then(|value| value.get("persona_id"))
.and_then(serde_json::Value::as_str),
Some("lime_image_creator")
);
assert_eq!(
first
.record
.payload
.get("presentation")
.and_then(serde_json::Value::as_object)
.and_then(|value| value.get("completion_caption"))
.and_then(serde_json::Value::as_str),
Some("青柠实验室已生成")
);
assert_eq!(
first
.record
.payload
.get("taste_context")
.and_then(serde_json::Value::as_object)
.and_then(|value| value.get("source"))
.and_then(serde_json::Value::as_str),
Some("taste_layer")
);
assert_eq!(
first.record.payload.get("reference_images"),
Some(&json!(["https://example.com/reference-a.png"]))
@@ -7676,6 +7747,9 @@ mod tests {
prompt: "用于正文的未来感实验室配图".to_string(),
title: Some("正文配图".to_string()),
title_generation_result: None,
persona_context: None,
presentation: None,
taste_context: None,
mode: Some("generate".to_string()),
raw_text: Some("@配图 生成 用于正文的未来感实验室配图".to_string()),
layout_hint: None,
@@ -8053,6 +8127,9 @@ mod tests {
prompt: "未来感青柠实验室".to_string(),
title: Some("青柠主视觉".to_string()),
title_generation_result: None,
persona_context: None,
presentation: None,
taste_context: None,
mode: Some("generate".to_string()),
raw_text: Some("@配图 生成 未来感青柠实验室".to_string()),
layout_hint: None,
@@ -8101,6 +8178,9 @@ mod tests {
prompt: "未来感青柠实验室".to_string(),
title: Some("青柠主视觉".to_string()),
title_generation_result: None,
persona_context: None,
presentation: None,
taste_context: None,
mode: Some("generate".to_string()),
raw_text: Some("@配图 生成 未来感青柠实验室".to_string()),
layout_hint: None,
@@ -8152,6 +8232,9 @@ mod tests {
prompt: "未来感青柠实验室".to_string(),
title: Some("青柠主视觉".to_string()),
title_generation_result: None,
persona_context: None,
presentation: None,
taste_context: None,
mode: Some("generate".to_string()),
raw_text: Some("@配图 生成 未来感青柠实验室".to_string()),
layout_hint: None,
@@ -8356,6 +8439,9 @@ mod tests {
prompt: "未来感青柠实验室".to_string(),
title: Some("青柠主视觉".to_string()),
title_generation_result: None,
persona_context: None,
presentation: None,
taste_context: None,
mode: Some("generate".to_string()),
raw_text: Some("@配图 生成 未来感青柠实验室".to_string()),
layout_hint: None,
+724
View File
@@ -9,10 +9,15 @@ use chrono::Utc;
use lime_core::app_paths;
use lime_services::skill_service::SkillService;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::{Cursor, Read};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tauri::State;
use url::Url;
use zip::ZipArchive;
/// 从指定目录扫描已安装的 Skills
///
@@ -535,6 +540,481 @@ pub struct ImportedSkillResult {
pub directory: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MarketplaceSkillBundleFile {
pub path: String,
pub content: String,
#[serde(default)]
pub encoding: Option<String>,
#[serde(default)]
pub sha256: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MarketplaceSkillBundle {
pub manifest_version: String,
pub name: String,
#[serde(default)]
#[allow(dead_code)]
pub aliases: Vec<String>,
#[allow(dead_code)]
pub version: String,
#[serde(default)]
#[allow(dead_code)]
pub content_hash: String,
#[serde(default)]
pub file_count: usize,
pub files: Vec<MarketplaceSkillBundleFile>,
}
#[derive(Debug, Clone, Serialize)]
pub struct MarketplaceSkillInstallResult {
pub directory: String,
pub inspection: SkillPackageInspection,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillDownloadInstallRequest {
pub skill_name: String,
pub download_url: String,
}
const MAX_SKILL_DOWNLOAD_BYTES: usize = 20 * 1024 * 1024;
fn normalize_bundle_sha256(value: &str) -> String {
value
.trim()
.strip_prefix("sha256:")
.unwrap_or_else(|| value.trim())
.to_ascii_lowercase()
}
fn validate_marketplace_file_path(path: &str) -> Result<PathBuf, String> {
let path = path.trim();
if path.is_empty() {
return Err("Marketplace skill file path is required".to_string());
}
if path.contains('\\') || path.starts_with('/') || path.contains("..") {
return Err(format!("Invalid marketplace skill file path: {path}"));
}
let mut normalized = PathBuf::new();
let mut has_component = false;
for component in Path::new(path).components() {
match component {
Component::Normal(value) => {
has_component = true;
normalized.push(value);
}
_ => return Err(format!("Invalid marketplace skill file path: {path}")),
}
}
if !has_component {
return Err("Marketplace skill file path is required".to_string());
}
Ok(normalized)
}
fn verify_marketplace_file_checksum(file: &MarketplaceSkillBundleFile) -> Result<(), String> {
let Some(expected) = file.sha256.as_deref() else {
return Ok(());
};
let expected = normalize_bundle_sha256(expected);
if expected.is_empty() {
return Ok(());
}
let actual = hex::encode(Sha256::digest(file.content.as_bytes()));
if actual != expected {
return Err(format!(
"Marketplace skill file checksum mismatch: {}",
file.path
));
}
Ok(())
}
fn validate_skill_download_url(value: &str) -> Result<String, String> {
let url =
Url::parse(value.trim()).map_err(|error| format!("Invalid skill download URL: {error}"))?;
if url.scheme() != "https" {
return Err("Skill download URL must use https".to_string());
}
Ok(url.to_string())
}
async fn download_skill_package_zip(download_url: &str) -> Result<Vec<u8>, String> {
let download_url = validate_skill_download_url(download_url)?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|error| format!("Failed to create skill download client: {error}"))?;
let response = client
.get(download_url)
.send()
.await
.map_err(|error| format!("Failed to download skill package: {error}"))?;
let status = response.status();
if !status.is_success() {
return Err(format!(
"Skill package download failed with status {status}"
));
}
if let Some(content_length) = response.content_length() {
if content_length > MAX_SKILL_DOWNLOAD_BYTES as u64 {
return Err("Skill package is too large".to_string());
}
}
let bytes = response
.bytes()
.await
.map_err(|error| format!("Failed to read skill package: {error}"))?;
if bytes.len() > MAX_SKILL_DOWNLOAD_BYTES {
return Err("Skill package is too large".to_string());
}
Ok(bytes.to_vec())
}
fn normalize_zip_entry_path(path: &Path) -> Result<PathBuf, String> {
let mut normalized = PathBuf::new();
let mut has_component = false;
for component in path.components() {
match component {
Component::Normal(value) => {
has_component = true;
normalized.push(value);
}
_ => return Err(format!("Invalid skill zip entry path: {}", path.display())),
}
}
if !has_component {
return Err("Skill zip entry path is required".to_string());
}
Ok(normalized)
}
fn is_ignorable_zip_entry(path: &Path) -> bool {
let mut components = path.components();
let Some(first) = components.next() else {
return true;
};
let first = first.as_os_str().to_string_lossy();
if first == "__MACOSX" {
return true;
}
path.file_name()
.map(|value| value.to_string_lossy() == ".DS_Store")
.unwrap_or(false)
}
fn first_zip_component(path: &Path) -> Option<String> {
path.components()
.next()
.and_then(|component| match component {
Component::Normal(value) => Some(value.to_string_lossy().to_string()),
_ => None,
})
}
fn strip_first_zip_component(path: &Path) -> Result<PathBuf, String> {
let mut stripped = PathBuf::new();
let mut components = path.components();
let _ = components.next();
for component in components {
match component {
Component::Normal(value) => stripped.push(value),
_ => return Err(format!("Invalid skill zip entry path: {}", path.display())),
}
}
if stripped.as_os_str().is_empty() {
return Err("Skill zip entry path is required".to_string());
}
Ok(stripped)
}
fn install_skill_zip_bytes_into_root(
skills_root: &Path,
skill_name: &str,
bytes: &[u8],
) -> Result<MarketplaceSkillInstallResult, String> {
let directory = skill_name.trim();
validate_skill_directory(directory)?;
if bytes.is_empty() {
return Err("Skill package is empty".to_string());
}
let cursor = Cursor::new(bytes);
let mut archive =
ZipArchive::new(cursor).map_err(|error| format!("Invalid skill zip package: {error}"))?;
let mut files: Vec<(PathBuf, Vec<u8>)> = Vec::new();
for index in 0..archive.len() {
let mut file = archive
.by_index(index)
.map_err(|error| format!("Failed to read skill zip entry: {error}"))?;
if file.is_dir() {
continue;
}
if file.name().contains('\\') {
return Err(format!("Invalid skill zip entry path: {}", file.name()));
}
let enclosed = file
.enclosed_name()
.ok_or_else(|| format!("Invalid skill zip entry path: {}", file.name()))?;
let normalized = normalize_zip_entry_path(enclosed)?;
if is_ignorable_zip_entry(&normalized) {
continue;
}
if file.size() > MAX_SKILL_DOWNLOAD_BYTES as u64 {
return Err("Skill package file is too large".to_string());
}
let mut content = Vec::new();
file.read_to_end(&mut content)
.map_err(|error| format!("Failed to read skill zip entry {}: {error}", file.name()))?;
if content.len() > MAX_SKILL_DOWNLOAD_BYTES {
return Err("Skill package file is too large".to_string());
}
files.push((normalized, content));
}
if files.is_empty() {
return Err("Skill package contains no files".to_string());
}
let root_skill_md = Path::new("SKILL.md");
let has_root_skill_md = files.iter().any(|(path, _)| path == root_skill_md);
let shared_zip_root = files
.first()
.and_then(|(path, _)| first_zip_component(path));
let should_strip_directory = shared_zip_root
.as_deref()
.map(|root| {
let nested_skill_md = Path::new(root).join("SKILL.md");
!has_root_skill_md
&& files.iter().any(|(path, _)| path == &nested_skill_md)
&& files
.iter()
.all(|(path, _)| first_zip_component(path).as_deref() == Some(root))
})
.unwrap_or(false);
fs::create_dir_all(skills_root).map_err(|e| {
format!(
"Failed to create skills root {}: {e}",
skills_root.display()
)
})?;
let target_dir = skills_root.join(directory);
if target_dir.exists() {
return Err(format!("Skill directory already exists: {directory}"));
}
fs::create_dir_all(&target_dir).map_err(|e| {
format!(
"Failed to create skill directory {}: {e}",
target_dir.display()
)
})?;
let mut has_skill_md = false;
for (path, content) in files {
let relative_path = if should_strip_directory {
match strip_first_zip_component(&path) {
Ok(value) => value,
Err(error) => {
let _ = fs::remove_dir_all(&target_dir);
return Err(error);
}
}
} else {
path
};
let relative_path_text = match relative_path.to_str() {
Some(value) => value,
None => {
let _ = fs::remove_dir_all(&target_dir);
return Err("Skill zip entry path must be valid UTF-8".to_string());
}
};
let relative_path = match validate_marketplace_file_path(relative_path_text) {
Ok(value) => value,
Err(error) => {
let _ = fs::remove_dir_all(&target_dir);
return Err(error);
}
};
if relative_path == root_skill_md {
has_skill_md = true;
}
let destination = target_dir.join(relative_path);
if let Some(parent) = destination.parent() {
if let Err(error) = fs::create_dir_all(parent) {
let _ = fs::remove_dir_all(&target_dir);
return Err(format!(
"Failed to create skill file parent {}: {error}",
parent.display()
));
}
}
if let Err(error) = fs::write(&destination, content) {
let _ = fs::remove_dir_all(&target_dir);
return Err(format!(
"Failed to write skill package file {}: {error}",
destination.display()
));
}
}
if !has_skill_md {
let _ = fs::remove_dir_all(&target_dir);
return Err("Skill package missing SKILL.md".to_string());
}
match SkillService::inspect_skill_dir(&target_dir) {
Ok(inspection) if inspection.standard_compliance.validation_errors.is_empty() => {
Ok(MarketplaceSkillInstallResult {
directory: directory.to_string(),
inspection,
})
}
Ok(inspection) => {
let _ = fs::remove_dir_all(&target_dir);
Err(format!(
"Downloaded skill is not Agent Skills compliant: {}",
inspection.standard_compliance.validation_errors.join("; ")
))
}
Err(error) => {
let _ = fs::remove_dir_all(&target_dir);
Err(format!("Downloaded skill failed inspection: {error}"))
}
}
}
fn install_marketplace_skill_bundle_into_root(
skills_root: &Path,
bundle: &MarketplaceSkillBundle,
) -> Result<MarketplaceSkillInstallResult, String> {
let directory = bundle.name.trim();
validate_skill_directory(directory)?;
if !bundle.manifest_version.trim().is_empty()
&& bundle.manifest_version.trim() != "agentskills.v1"
{
return Err(format!(
"Unsupported marketplace skill manifest version: {}",
bundle.manifest_version
));
}
if bundle.files.is_empty() {
return Err("Marketplace skill bundle is empty".to_string());
}
if bundle.file_count > 0 && bundle.file_count != bundle.files.len() {
return Err("Marketplace skill bundle file count mismatch".to_string());
}
fs::create_dir_all(skills_root).map_err(|e| {
format!(
"Failed to create skills root {}: {e}",
skills_root.display()
)
})?;
let target_dir = skills_root.join(directory);
if target_dir.exists() {
return Err(format!("Skill directory already exists: {directory}"));
}
fs::create_dir_all(&target_dir).map_err(|e| {
format!(
"Failed to create skill directory {}: {e}",
target_dir.display()
)
})?;
let mut has_skill_md = false;
for file in &bundle.files {
let encoding = file
.encoding
.as_deref()
.unwrap_or("utf-8")
.trim()
.to_ascii_lowercase();
if encoding != "utf-8" && encoding != "utf8" {
let _ = fs::remove_dir_all(&target_dir);
return Err(format!(
"Unsupported marketplace skill file encoding: {}",
file.path
));
}
if file.path.trim() == "SKILL.md" {
has_skill_md = true;
}
if let Err(error) = verify_marketplace_file_checksum(file) {
let _ = fs::remove_dir_all(&target_dir);
return Err(error);
}
let relative_path = match validate_marketplace_file_path(&file.path) {
Ok(value) => value,
Err(error) => {
let _ = fs::remove_dir_all(&target_dir);
return Err(error);
}
};
let destination = target_dir.join(relative_path);
if let Some(parent) = destination.parent() {
if let Err(error) = fs::create_dir_all(parent) {
let _ = fs::remove_dir_all(&target_dir);
return Err(format!(
"Failed to create skill file parent {}: {error}",
parent.display()
));
}
}
if let Err(error) = fs::write(&destination, &file.content) {
let _ = fs::remove_dir_all(&target_dir);
return Err(format!(
"Failed to write marketplace skill file {}: {error}",
destination.display()
));
}
}
if !has_skill_md {
let _ = fs::remove_dir_all(&target_dir);
return Err("Marketplace skill bundle missing SKILL.md".to_string());
}
match SkillService::inspect_skill_dir(&target_dir) {
Ok(inspection) if inspection.standard_compliance.validation_errors.is_empty() => {
Ok(MarketplaceSkillInstallResult {
directory: directory.to_string(),
inspection,
})
}
Ok(inspection) => {
let _ = fs::remove_dir_all(&target_dir);
Err(format!(
"Marketplace skill is not Agent Skills compliant: {}",
inspection.standard_compliance.validation_errors.join("; ")
))
}
Err(error) => {
let _ = fs::remove_dir_all(&target_dir);
Err(format!("Marketplace skill failed inspection: {error}"))
}
}
}
/// 获取已安装的 Lime Skills 目录列表
///
/// 扫描 Lime 可发现的 provider Skills 根目录,返回包含 SKILL.md 的子目录名列表。
@@ -611,6 +1091,49 @@ pub fn import_local_skill_for_app(
Ok(ImportedSkillResult { directory })
}
/// 从官方技能市场 bundle 结构化安装 Skill。
///
/// bundle 文件必须是 AgentSkills 标准目录内的相对路径,写入后会立刻执行
/// 标准校验,失败时回滚已写入目录。
#[tauri::command]
pub fn install_marketplace_skill_for_app(
app: String,
bundle: MarketplaceSkillBundle,
) -> Result<MarketplaceSkillInstallResult, String> {
let app_type: AppType = app.parse().map_err(|e: String| e)?;
let skills_root = get_skills_dir(&app_type)?;
let result = install_marketplace_skill_bundle_into_root(&skills_root, &bundle)?;
if matches!(app_type, AppType::Lime) {
AsterAgentState::reload_lime_skills();
}
Ok(result)
}
/// 从安装 Prompt 中的下载 URL 安装 Skill。
///
/// 该入口服务于官网复制的 Agent 安装 Prompt:下载 ZIP、解压到
/// Skills 目录、执行 Agent Skills 标准校验,并在 Lime 下刷新 runtime。
#[tauri::command]
pub async fn install_skill_from_download_url_for_app(
app: String,
request: SkillDownloadInstallRequest,
) -> Result<MarketplaceSkillInstallResult, String> {
let app_type: AppType = app.parse().map_err(|e: String| e)?;
let skill_name = request.skill_name.trim();
validate_skill_directory(skill_name)?;
let bytes = download_skill_package_zip(&request.download_url).await?;
let skills_root = get_skills_dir(&app_type)?;
let result = install_skill_zip_bytes_into_root(&skills_root, skill_name, &bytes)?;
if matches!(app_type, AppType::Lime) {
AsterAgentState::reload_lime_skills();
}
Ok(result)
}
/// 获取远程 Skill 包的标准检查结果
///
/// 直接从远程仓库读取目标 Skill 目录,返回标准检查结果与原始 SKILL.md,
@@ -877,7 +1400,9 @@ mod tests {
use super::*;
use proptest::prelude::*;
use std::collections::HashSet;
use std::io::Write;
use tempfile::TempDir;
use zip::write::FileOptions;
/// 生成有效的 Skill 目录名(字母数字和连字符)
fn skill_name_strategy() -> impl Strategy<Value = String> {
@@ -1253,4 +1778,203 @@ content"#,
let err = import_local_skill_into_root(&target_root, &source_dir).unwrap_err();
assert!(err.contains("already exists"));
}
#[test]
fn test_install_marketplace_skill_bundle_into_root_writes_standard_package() {
let temp_dir = TempDir::new().unwrap();
let target_root = temp_dir.path().join("skills");
let content = "---\nname: Market Skill\ndescription: install me\n---\n";
let checksum = format!("sha256:{}", hex::encode(Sha256::digest(content.as_bytes())));
let result = install_marketplace_skill_bundle_into_root(
&target_root,
&MarketplaceSkillBundle {
manifest_version: "agentskills.v1".to_string(),
name: "market-skill".to_string(),
aliases: Vec::new(),
version: "1.0.0".to_string(),
content_hash: "sha256:bundle".to_string(),
file_count: 2,
files: vec![
MarketplaceSkillBundleFile {
path: "SKILL.md".to_string(),
content: content.to_string(),
encoding: Some("utf-8".to_string()),
sha256: Some(checksum),
},
MarketplaceSkillBundleFile {
path: "references/guide.md".to_string(),
content: "# Guide".to_string(),
encoding: Some("utf-8".to_string()),
sha256: None,
},
],
},
)
.unwrap();
assert_eq!(result.directory, "market-skill");
assert!(target_root.join("market-skill").join("SKILL.md").is_file());
assert!(target_root
.join("market-skill")
.join("references")
.join("guide.md")
.is_file());
assert!(result.inspection.standard_compliance.is_standard);
}
#[test]
fn test_install_marketplace_skill_bundle_into_root_rejects_path_traversal() {
let temp_dir = TempDir::new().unwrap();
let target_root = temp_dir.path().join("skills");
let err = install_marketplace_skill_bundle_into_root(
&target_root,
&MarketplaceSkillBundle {
manifest_version: "agentskills.v1".to_string(),
name: "market-skill".to_string(),
aliases: Vec::new(),
version: "1.0.0".to_string(),
content_hash: String::new(),
file_count: 1,
files: vec![MarketplaceSkillBundleFile {
path: "../SKILL.md".to_string(),
content: "---\nname: Bad\ndescription: bad\n---\n".to_string(),
encoding: Some("utf-8".to_string()),
sha256: None,
}],
},
)
.unwrap_err();
assert!(err.contains("Invalid marketplace skill file path"));
assert!(!target_root.join("market-skill").exists());
}
#[test]
fn test_install_marketplace_skill_bundle_into_root_rejects_checksum_mismatch() {
let temp_dir = TempDir::new().unwrap();
let target_root = temp_dir.path().join("skills");
let err = install_marketplace_skill_bundle_into_root(
&target_root,
&MarketplaceSkillBundle {
manifest_version: "agentskills.v1".to_string(),
name: "market-skill".to_string(),
aliases: Vec::new(),
version: "1.0.0".to_string(),
content_hash: String::new(),
file_count: 1,
files: vec![MarketplaceSkillBundleFile {
path: "SKILL.md".to_string(),
content: "---\nname: Bad\ndescription: bad\n---\n".to_string(),
encoding: Some("utf-8".to_string()),
sha256: Some("sha256:0000".to_string()),
}],
},
)
.unwrap_err();
assert!(err.contains("checksum mismatch"));
assert!(!target_root.join("market-skill").exists());
}
fn build_skill_zip(entries: &[(&str, &str)]) -> Vec<u8> {
let cursor = Cursor::new(Vec::new());
let mut writer = zip::ZipWriter::new(cursor);
let options = FileOptions::default();
for (path, content) in entries {
writer.start_file(*path, options).unwrap();
writer.write_all(content.as_bytes()).unwrap();
}
writer.finish().unwrap().into_inner()
}
#[test]
fn test_install_skill_zip_bytes_into_root_strips_matching_root_directory() {
let temp_dir = TempDir::new().unwrap();
let target_root = temp_dir.path().join("skills");
let package = build_skill_zip(&[
(
"viral-content-breakdown/SKILL.md",
"---\nname: Viral Content Breakdown\ndescription: install me\n---\n",
),
("viral-content-breakdown/references/guide.md", "# Guide"),
]);
let result =
install_skill_zip_bytes_into_root(&target_root, "viral-content-breakdown", &package)
.unwrap();
assert_eq!(result.directory, "viral-content-breakdown");
assert!(target_root
.join("viral-content-breakdown")
.join("SKILL.md")
.is_file());
assert!(target_root
.join("viral-content-breakdown")
.join("references")
.join("guide.md")
.is_file());
assert!(result.inspection.standard_compliance.is_standard);
}
#[test]
fn test_install_skill_zip_bytes_into_root_accepts_root_skill_md() {
let temp_dir = TempDir::new().unwrap();
let target_root = temp_dir.path().join("skills");
let package = build_skill_zip(&[(
"SKILL.md",
"---\nname: Root Skill\ndescription: install me\n---\n",
)]);
let result = install_skill_zip_bytes_into_root(&target_root, "root-skill", &package)
.expect("root SKILL.md zip should install");
assert_eq!(result.directory, "root-skill");
assert!(target_root.join("root-skill").join("SKILL.md").is_file());
}
#[test]
fn test_install_skill_zip_bytes_into_root_strips_single_nonmatching_root_directory() {
let temp_dir = TempDir::new().unwrap();
let target_root = temp_dir.path().join("skills");
let package = build_skill_zip(&[
(
"skill-export/SKILL.md",
"---\nname: Exported Skill\ndescription: install me\n---\n",
),
("skill-export/assets/example.txt", "asset"),
]);
let result = install_skill_zip_bytes_into_root(&target_root, "exported-skill", &package)
.expect("single-root zip should install into requested directory");
assert_eq!(result.directory, "exported-skill");
assert!(target_root
.join("exported-skill")
.join("SKILL.md")
.is_file());
assert!(target_root
.join("exported-skill")
.join("assets")
.join("example.txt")
.is_file());
}
#[test]
fn test_install_skill_zip_bytes_into_root_rejects_zip_path_traversal() {
let temp_dir = TempDir::new().unwrap();
let target_root = temp_dir.path().join("skills");
let package = build_skill_zip(&[(
"../SKILL.md",
"---\nname: Bad Skill\ndescription: bad\n---\n",
)]);
let err = install_skill_zip_bytes_into_root(&target_root, "bad-skill", &package)
.expect_err("path traversal should fail");
assert!(err.contains("Invalid skill zip entry path"));
assert!(!target_root.join("bad-skill").exists());
}
}
@@ -127,6 +127,36 @@ pub(super) async fn try_handle(
.map_err(|e| format!("导入本地 Skill 失败: {e}"))?;
serde_json::to_value(result)?
}
"install_marketplace_skill_for_app" => {
let args = args_or_default(args);
let app = args
.get("app")
.and_then(|value| value.as_str())
.unwrap_or("lime")
.to_string();
let bundle = parse_nested_arg::<crate::commands::skill_cmd::MarketplaceSkillBundle>(
&args, "bundle",
)?;
let result = crate::commands::skill_cmd::install_marketplace_skill_for_app(app, bundle)
.map_err(|e| format!("安装官方 Skill 失败: {e}"))?;
serde_json::to_value(result)?
}
"install_skill_from_download_url_for_app" => {
let args = args_or_default(args);
let app = args
.get("app")
.and_then(|value| value.as_str())
.unwrap_or("lime")
.to_string();
let request = parse_nested_arg::<
crate::commands::skill_cmd::SkillDownloadInstallRequest,
>(&args, "request")?;
let result =
crate::commands::skill_cmd::install_skill_from_download_url_for_app(app, request)
.await
.map_err(|e| format!("安装下载 Skill 失败: {e}"))?;
serde_json::to_value(result)?
}
"uninstall_skill" | "uninstall_skill_for_app" => {
let args = args_or_default(args);
let app = args
+60 -2
View File
@@ -720,11 +720,11 @@ mod tests {
assert!(CONTENT_POST_WITH_COVER_WORKFLOW_CONTENT.contains("\"id\": \"research\""));
assert!(KNOWLEDGE_BUILDER_SKILL_CONTENT.contains("name: knowledge_builder"));
assert!(KNOWLEDGE_BUILDER_SKILL_CONTENT.contains("license: Apache-2.0"));
assert!(KNOWLEDGE_BUILDER_SKILL_CONTENT.contains("agentKnowledge: \">=0.6.0\""));
assert!(KNOWLEDGE_BUILDER_SKILL_CONTENT.contains("compatibility: Agent Knowledge >=0.6.0"));
assert!(
KNOWLEDGE_BUILDER_SKILL_CONTENT.contains("allowed-tools: list_directory, read_file")
);
assert!(KNOWLEDGE_BUILDER_SKILL_CONTENT.contains("lime_version: 1.2.0"));
assert!(KNOWLEDGE_BUILDER_SKILL_CONTENT.contains("lime_version: 1.2.1"));
assert!(KNOWLEDGE_BUILDER_SKILL_CONTENT.contains("Lime_compat_delegate: \"true\""));
assert!(KNOWLEDGE_BUILDER_SKILL_CONTENT.contains("不要生成 `compiled/brief.md`"));
assert!(KNOWLEDGE_BUILDER_SKILL_CONTENT.contains("不要生成 `wiki/`"));
@@ -776,6 +776,64 @@ mod tests {
);
}
#[test]
fn bundled_knowledge_builder_skill_manifests_should_be_agent_skills_standard() {
for (directory, content) in [
(
KNOWLEDGE_BUILDER_SKILL_DIRECTORY,
KNOWLEDGE_BUILDER_SKILL_CONTENT,
),
(
PERSONAL_IP_KNOWLEDGE_BUILDER_SKILL_DIRECTORY,
PERSONAL_IP_KNOWLEDGE_BUILDER_SKILL_CONTENT,
),
(
BRAND_PERSONA_KNOWLEDGE_BUILDER_SKILL_DIRECTORY,
BRAND_PERSONA_KNOWLEDGE_BUILDER_SKILL_CONTENT,
),
(
CONTENT_OPERATIONS_KNOWLEDGE_BUILDER_SKILL_DIRECTORY,
CONTENT_OPERATIONS_KNOWLEDGE_BUILDER_SKILL_CONTENT,
),
(
PRIVATE_DOMAIN_OPERATIONS_KNOWLEDGE_BUILDER_SKILL_DIRECTORY,
PRIVATE_DOMAIN_OPERATIONS_KNOWLEDGE_BUILDER_SKILL_CONTENT,
),
(
LIVE_COMMERCE_OPERATIONS_KNOWLEDGE_BUILDER_SKILL_DIRECTORY,
LIVE_COMMERCE_OPERATIONS_KNOWLEDGE_BUILDER_SKILL_CONTENT,
),
(
CAMPAIGN_OPERATIONS_KNOWLEDGE_BUILDER_SKILL_DIRECTORY,
CAMPAIGN_OPERATIONS_KNOWLEDGE_BUILDER_SKILL_CONTENT,
),
(
BRAND_PRODUCT_KNOWLEDGE_BUILDER_SKILL_DIRECTORY,
BRAND_PRODUCT_KNOWLEDGE_BUILDER_SKILL_CONTENT,
),
(
ORGANIZATION_KNOWHOW_KNOWLEDGE_BUILDER_SKILL_DIRECTORY,
ORGANIZATION_KNOWHOW_KNOWLEDGE_BUILDER_SKILL_CONTENT,
),
(
GROWTH_STRATEGY_KNOWLEDGE_BUILDER_SKILL_DIRECTORY,
GROWTH_STRATEGY_KNOWLEDGE_BUILDER_SKILL_CONTENT,
),
] {
let manifest = parse_skill_manifest_from_content(content)
.unwrap_or_else(|error| panic!("{directory} manifest should parse: {error}"));
assert!(
manifest.compliance.is_standard,
"{directory} should be standard: {:?}",
manifest.compliance.validation_errors
);
assert!(
manifest.metadata.compatibility.as_deref().is_some(),
"{directory} should keep compatibility as scalar frontmatter"
);
}
}
#[test]
fn should_embed_core_default_skills() {
assert!(VIDEO_GENERATE_SKILL_CONTENT.contains("name: video_generate"));
+58 -2
View File
@@ -45,7 +45,12 @@ import {
} from "./features/resource-manager";
import type { OpenDeepLinkPayload } from "./hooks/useDeepLink";
import { buildClawAgentParams } from "./lib/workspace/navigation";
import { resolveWebsiteOpenNavigation } from "./lib/deepLink/websiteLaunch";
import {
resolveWebsiteInstalledSkillNavigation,
resolveWebsiteOpenNavigation,
resolveWebsiteSkillTitle,
} from "./lib/deepLink/websiteLaunch";
import { installOfficialMarketplaceSkill } from "./lib/api/officialSkillMarketplace";
import { toast } from "sonner";
import { SettingsTabs } from "./types/settings";
import { hasTauriInvokeCapability } from "./lib/tauri-runtime";
@@ -280,7 +285,58 @@ function AppContent() {
);
const handleOpenWebsiteDeepLink = useCallback(
(payload: OpenDeepLinkPayload) => {
async (payload: OpenDeepLinkPayload) => {
if (payload.kind === "skill" && payload.action === "install") {
const installNavigation = resolveWebsiteInstalledSkillNavigation(payload);
if (!installNavigation) {
toast.error(t("common.app.websiteDeepLink.unsupported.title"), {
description: t("common.app.websiteDeepLink.unsupported.description"),
});
return;
}
const title = resolveWebsiteSkillTitle(payload.slug) ?? payload.slug;
try {
await installOfficialMarketplaceSkill(payload.slug, "lime");
toast.success(
t("common.app.websiteDeepLink.install.success.title", { title }),
{
description: t(
"common.app.websiteDeepLink.install.success.description",
),
},
);
} catch (error) {
const message =
error instanceof Error && error.message.trim()
? error.message.trim()
: String(error);
if (!/already exists|已存在/i.test(message)) {
toast.error(t("common.app.websiteDeepLink.install.failed.title"), {
description: t(
"common.app.websiteDeepLink.install.failed.description",
{ message },
),
});
return;
}
toast.info(
t("common.app.websiteDeepLink.install.alreadyInstalled.title", {
title,
}),
{
description: t(
"common.app.websiteDeepLink.install.alreadyInstalled.description",
),
},
);
}
handleNavigate(installNavigation.page, installNavigation.params);
return;
}
const resolved = resolveWebsiteOpenNavigation(payload);
if (!resolved) {
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
buildHomePendingPreviewMessages,
type TaskCenterDraftSendRequest,
} from "./homePendingPreview";
describe("buildHomePendingPreviewMessages", () => {
it("首页首发等待态应保留 Skill route 与用户可见文本", () => {
const request: TaskCenterDraftSendRequest = {
id: "draft-send-1",
draftTabId: "session-1",
text: "帮我整理资料",
images: [],
sendExecutionStrategy: "react",
sendOptions: {
displayContent: "帮我整理资料",
capabilityRoute: {
kind: "installed_skill",
skillKey: "brand-product-knowledge-builder",
skillName: "brand-product-knowledge-builder",
},
},
webSearch: false,
thinking: false,
submittedAt: 1_710_000_000_000,
materializeDraft: false,
source: "empty-state",
};
const messages = buildHomePendingPreviewMessages(request, "react");
expect(messages[0]).toMatchObject({
id: "draft-send-1:user",
role: "user",
content: "帮我整理资料",
inputCapabilityRoute: {
kind: "installed_skill",
skillKey: "brand-product-knowledge-builder",
skillName: "brand-product-knowledge-builder",
},
});
expect(messages[1]).toMatchObject({
id: "draft-send-1:assistant",
role: "assistant",
isThinking: true,
runtimeStatus: {
phase: "preparing",
title: "正在进入对话",
},
});
});
});
@@ -100,6 +100,10 @@ import {
buildTeamMemoryShadowRequestMetadata,
readTeamMemorySnapshot,
} from "@/lib/teamMemorySync";
import {
buildHomePendingPreviewMessages,
type TaskCenterDraftSendRequest,
} from "./homePendingPreview";
import type {
Message,
@@ -122,7 +126,6 @@ import {
import { isHiddenInternalArtifactPath } from "./utils/internalArtifactVisibility";
import { buildHarnessRequestMetadata } from "./utils/harnessRequestMetadata";
import { deriveHarnessSessionState } from "./utils/harnessState";
import { buildDiagnosticsRuntimeStatusMetadata } from "./utils/agentRuntimeStatus";
import { resolveWorkflowLayoutBottomSpacing } from "./utils/workflowLayout";
import {
alignChatToolPreferencesWithExecutionStrategy,
@@ -404,20 +407,6 @@ interface TaskCenterDraftTab {
status: TaskCenterTabItem["status"];
}
interface TaskCenterDraftSendRequest {
id: string;
draftTabId: string;
text: string;
images: MessageImage[];
sendExecutionStrategy?: "react" | "code_orchestrated" | "auto";
sendOptions?: HandleSendOptions;
webSearch: boolean;
thinking: boolean;
submittedAt: number;
materializeDraft: boolean;
source: "task-center-empty-state" | "empty-state";
}
type SessionRecentMetadataSyncPriority = "immediate" | "background";
interface SessionRecentMetadataSyncOptions {
@@ -491,47 +480,6 @@ function scheduleAfterNextPaint(callback: () => void): () => void {
};
}
function buildHomePendingPreviewMessages(
request: TaskCenterDraftSendRequest,
executionStrategy: "react" | "code_orchestrated" | "auto",
): Message[] {
const timestamp = new Date(request.submittedAt);
const effectiveExecutionStrategy =
request.sendExecutionStrategy || executionStrategy;
return [
{
id: `${request.id}:user`,
role: "user",
content: request.text,
images: request.images.length > 0 ? request.images : undefined,
timestamp,
},
{
id: `${request.id}:assistant`,
role: "assistant",
content: "",
timestamp,
isThinking: true,
runtimeStatus: {
phase: "preparing",
title: "正在进入对话",
detail: "已收到输入,正在后台准备会话和执行环境。",
checkpoints: [
effectiveExecutionStrategy === "code_orchestrated"
? "代码编排待命"
: effectiveExecutionStrategy === "react"
? "对话执行待命"
: "自动路由待命",
request.webSearch ? "联网搜索候选能力待命" : "直接回答优先",
request.thinking ? "深度思考待命" : "轻量响应优先",
],
metadata: buildDiagnosticsRuntimeStatusMetadata(),
},
},
];
}
function mergeSessionRecentMetadataSyncPriority(
current: SessionRecentMetadataSyncPriority,
next?: SessionRecentMetadataSyncPriority,
@@ -585,6 +585,7 @@ describe("EmptyState", () => {
});
it("从 Skills 页带回的技能应显示在首页输入框内的 @ 标签", async () => {
const onSend = vi.fn();
const skill = {
key: "local:writer",
name: "写作助手",
@@ -595,6 +596,8 @@ describe("EmptyState", () => {
} as Skill;
const container = renderEmptyState({
activeTheme: "general",
input: "整理最近发布计划",
onSend,
skills: [skill],
initialInputCapability: {
capabilityRoute: {
@@ -615,6 +618,24 @@ describe("EmptyState", () => {
expect(badge).toBeTruthy();
expect(badge?.textContent).toContain("@");
expect(badge?.textContent).toContain("写作助手");
const sendButton = container.querySelector(
'button[aria-label="发送"]',
) as HTMLButtonElement | null;
expect(sendButton).toBeTruthy();
act(() => {
sendButton?.click();
});
expect(onSend).toHaveBeenCalledWith("整理最近发布计划", "react", undefined, {
capabilityRoute: {
kind: "installed_skill",
skillKey: "writer",
skillName: "写作助手",
},
displayContent: "整理最近发布计划",
});
});
it("首页添加资料入口应打开输入框资料中枢,而不是预填一段说明", async () => {
@@ -2619,7 +2640,7 @@ describe("EmptyState", () => {
);
});
it("通用对话默认展示 Ribbi 式 Tab 起手建议", async () => {
it("通用对话默认展示 参考站式 Tab 起手建议", async () => {
const container = renderEmptyState({
activeTheme: "general",
serviceSkills: [],
@@ -65,7 +65,7 @@ function renderPlaceholder(
preview.status === "failed" ? preview.statusMessage?.trim() : "";
return (
<div className="flex aspect-[16/10] min-h-[168px] items-center justify-center rounded-[18px] bg-slate-50 px-6 text-center">
<div className="flex h-full min-h-0 w-full items-center justify-center rounded-[12px] bg-slate-50 px-5 text-center">
<div className="space-y-1.5">
{reason === "empty" && preview.status === "running" ? (
<LoaderCircle className="mx-auto h-5 w-5 animate-spin text-slate-500" />
@@ -127,12 +127,17 @@ function renderPreviewMedia(
if (!isStoryboardGrid && previewImages.length <= 1) {
return (
<RenderableTaskImage
src={previewImages[0] || preview.imageUrl}
alt={preview.prompt || t("agentChat.imageWorkbenchPreview.media.alt")}
className="block h-auto max-h-[560px] max-w-full rounded-[18px] object-contain sm:max-w-[480px]"
renderFallback={(reason) => renderPlaceholder(preview, reason, t)}
/>
<div
data-testid={`image-workbench-message-preview-single-media-${preview.taskId}`}
className="aspect-[16/9] w-[358px] max-w-full overflow-hidden rounded-[12px] bg-slate-50"
>
<RenderableTaskImage
src={previewImages[0] || preview.imageUrl}
alt={preview.prompt || t("agentChat.imageWorkbenchPreview.media.alt")}
className="h-full w-full object-cover"
renderFallback={(reason) => renderPlaceholder(preview, reason, t)}
/>
</div>
);
}
@@ -221,7 +226,7 @@ export const ImageWorkbenchMessagePreview: React.FC<
>
<div
data-testid={`image-workbench-message-preview-toolbar-${preview.taskId}`}
className="mb-3 flex min-h-10 w-full max-w-full items-center gap-2 rounded-[12px] border border-stone-200 bg-stone-100/75 px-3.5 py-2 text-[13px] font-medium leading-5 text-[#435d2e] shadow-[0_8px_24px_-22px_rgba(15,23,42,0.32)]"
className="mb-2.5 flex min-h-10 w-full max-w-full items-center gap-2 rounded-[12px] border border-stone-200 bg-stone-100/75 px-3.5 py-2 text-[13px] font-medium leading-5 text-[#435d2e] shadow-[0_8px_24px_-22px_rgba(15,23,42,0.32)]"
>
<Leaf className="h-3.5 w-3.5 shrink-0 fill-[#496631]/15 text-[#496631]" />
<span className="truncate">{toolLabel}</span>
@@ -232,13 +237,13 @@ export const ImageWorkbenchMessagePreview: React.FC<
</>
) : null}
</div>
<div className="relative max-w-full overflow-hidden rounded-[18px] transition">
<div className="relative max-w-full transition">
{renderPreviewMedia(preview, t)}
</div>
{caption ? (
<div
data-testid={`image-workbench-message-preview-caption-${preview.taskId}`}
className="mt-2 max-w-[520px] text-sm leading-6 text-slate-700"
className="mt-2 max-w-[800px] whitespace-pre-line text-sm leading-6 text-slate-700"
>
{caption}
</div>
@@ -911,6 +911,67 @@ describe("Inputbar", () => {
);
});
it("选择本地安装技能发送时,应把 local:* key 归一到目录 slash key", async () => {
const onSend = vi.fn();
const skill = {
key: "local:writer",
name: "写作助手",
description: "用于写作",
directory: "writer",
installed: true,
sourceKind: "other",
} as Skill;
renderInputbar({
input: "整理最近发布计划",
onSend,
activeTheme: "general",
skills: [skill],
});
await act(async () => {
await Promise.resolve();
});
const latestCall =
mockCharacterMention.mock.calls[
mockCharacterMention.mock.calls.length - 1
]?.[0];
await act(async () => {
latestCall?.onSelectInputCapability?.({
kind: "installed_skill",
skill,
});
await Promise.resolve();
});
const sendButton = document.querySelector(
'[data-testid="send-btn"]',
) as HTMLButtonElement | null;
await act(async () => {
sendButton?.click();
await Promise.resolve();
});
expect(onSend).toHaveBeenCalledWith(
undefined,
false,
false,
undefined,
"react",
undefined,
{
capabilityRoute: {
kind: "installed_skill",
skillKey: "writer",
skillName: "写作助手",
},
displayContent: "整理最近发布计划",
},
);
});
it("仅添加路径引用时应允许发送并写入 request metadata", async () => {
const onSend = vi.fn();
renderInputbar({
@@ -1036,7 +1097,7 @@ describe("Inputbar", () => {
initialInputCapability: {
capabilityRoute: {
kind: "installed_skill",
skillKey: "writer",
skillKey: "local:writer",
skillName: "写作助手",
},
requestKey: 20260418,
@@ -2295,7 +2295,13 @@ describe("MessageList", () => {
expect(previewCard?.textContent).not.toContain("可在右侧继续查看与使用");
expect(container.textContent).not.toContain("图片生成已完成");
expect(previewCard?.className).not.toContain("max-w-[620px]");
expect(previewCard?.className).toContain("w-full");
expect(
previewCard
?.querySelector(
'[data-testid="image-workbench-message-preview-single-media-task-1"]',
)
?.className,
).toContain("w-[358px]");
expect(previewCard?.querySelector("img")).not.toBeNull();
act(() => {
@@ -2348,12 +2354,24 @@ describe("MessageList", () => {
it("图片任务消息应在同一条 assistant 回复里呈现自然文案、工具条、图片和结果描述", async () => {
const messages: Message[] = [
{
id: "msg-user-image-workbench-natural",
role: "user",
content:
"@Nanobanana Pro 生成一张广州塔,从花城汇看过去的春天的照片",
timestamp: new Date(),
},
{
id: "msg-assistant-image-workbench-natural",
role: "assistant",
content:
"好嘞,用 Nanobanana Pro 给你生成一张从花城汇看广州塔的春天照片\n先获取下工具参数\n马上生成",
"好啊,用 Nanobanana Pro 生成:一张广州塔,从花城汇看过去的春天的照片\n先获取下工具参数\n马上生成",
timestamp: new Date(),
usage: {
input_tokens: 31_000,
output_tokens: 120,
cached_input_tokens: 0,
},
contentParts: [
{ type: "text", text: "我先按你的描述创建异步图片任务" },
{
@@ -2380,13 +2398,14 @@ describe("MessageList", () => {
],
imageWorkbenchPreview: {
taskId: "task-natural-image",
prompt: "从花城汇看广州塔的春天照片",
prompt: "一张广州塔,从花城汇看过去的春天的照片",
mode: "generate",
status: "complete",
imageUrl: "https://example.com/guangzhou-tower.png",
imageCount: 1,
modelName: "fal-ai/nano-banana-pro",
caption: "搞定,已生成这张图。",
caption:
"搞定,图已经生成好了\n要调整的话直接说,我继续改",
},
},
];
@@ -2397,20 +2416,65 @@ describe("MessageList", () => {
'[data-testid="image-workbench-assistant-intro"]',
);
expect(text).toContain("好嘞,用 Nanobanana Pro");
expect(text).toContain("好啊,用 Nanobanana Pro");
expect(text).toContain("先获取下工具参数");
expect(text).toContain("马上生成");
expect(text).toContain("Image Generation");
expect(text).toContain("Nanobanana Pro");
expect(text).toContain("搞定,已生成这张图。");
expect(text).toContain("Lime");
expect(text).toContain("我继续改");
expect(
container.querySelector('[data-testid="token-usage-display"]'),
).not.toBeNull();
expect(text).not.toContain("limeCreateImageGenerationTask");
expect(text).not.toContain("异步图片任务");
expect(intro?.textContent).toContain("好嘞,用 Nanobanana Pro");
expect(
container.querySelector('[data-testid="image-workbench-assistant-header"]')
?.textContent,
).toContain("Lime");
expect(
container.querySelector('[data-testid="message-user-command-tag"]')
?.textContent,
).toBe("@Nanobanana Pro");
expect(
container.querySelector('[data-testid="message-user-command-content"]')
?.textContent,
).toContain("广州塔");
expect(intro?.textContent).toContain("好啊,用 Nanobanana Pro");
expect(container.querySelector('[data-testid="streaming-renderer"]')).toBe(
null,
);
});
it("用户消息带已安装 Skill route 时应保留 @ Skill 标签展示", async () => {
const container = await renderZh([
{
id: "msg-user-installed-skill",
role: "user",
content: "帮我写一篇关于三国的故事",
timestamp: new Date(),
inputCapabilityRoute: {
kind: "installed_skill",
skillKey: "brand-product-knowledge-builder",
skillName: "brand-product-knowledge-builder",
},
} as Message,
]);
const skillTag = container.querySelector(
'[data-testid="message-user-skill-tag"]',
);
expect(skillTag?.textContent).toContain("@");
expect(skillTag?.textContent).toContain(
"brand-product-knowledge-builder",
);
expect(
container.querySelector('[data-testid="message-user-command-tag"]'),
).toBeNull();
expect(container.textContent).toContain("帮我写一篇关于三国的故事");
});
it("历史助手消息没有图片轻卡时,也不应继续展示旧图片任务详情模板", async () => {
const messages: Message[] = [
{
@@ -124,6 +124,7 @@ import {
type AgentStreamTextOverlaySnapshot,
useAgentStreamTextOverlay,
} from "../hooks/agentStreamTextOverlayStore";
import { INPUTBAR_BUILTIN_COMMANDS } from "../skill-selection/builtinCommands";
interface MessageListProps {
sessionId?: string | null;
@@ -666,7 +667,10 @@ const ImageWorkbenchAssistantIntro: React.FC<{ content: string }> = ({
className="max-w-[680px] space-y-0.5 text-[15px] leading-7 text-slate-950"
>
{lines.map((line, index) => (
<p key={`${index}:${line}`} className="m-0">
<p
key={`${index}:${line}`}
className={index === 2 ? "m-0 pt-2" : "m-0"}
>
{line}
</p>
))}
@@ -674,6 +678,168 @@ const ImageWorkbenchAssistantIntro: React.FC<{ content: string }> = ({
);
};
const ImageWorkbenchAssistantHeader: React.FC<{ label: string }> = ({
label,
}) => (
<div
data-testid="image-workbench-assistant-header"
className="mb-5 flex items-center gap-2.5"
>
<svg
aria-hidden
className="h-8 w-8 shrink-0"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.4 18.2C3.6 12.8 7.7 8.4 14.3 7.4c7-1 12.4 2.1 13.3 7.5.8 5.5-3.5 9.8-10.4 10.8C10.4 26.7 5.2 23.5 4.4 18.2Z"
fill="#D9F222"
stroke="#111111"
strokeWidth="1.7"
/>
<path
d="M7 12.1c1.9-3.1 6.9-4.8 12-3.8 2.3.4 4.2 1.4 5.6 2.7-2.1 1.2-5.1 1.8-8.2 1.4-3.8-.5-7-1.5-9.4-.3Z"
fill="#F0FF63"
/>
<path
d="M5.7 18.4c4.5-.6 7.4-2.4 9.2-5.6 1.1 3.5-.3 7-3.6 8.3-2.2.8-4.3.2-5.6-2.7Z"
fill="#111111"
/>
<path
d="M26.4 15.1c-4.3.4-7.1-.7-9-3.7-.4 3.8 1.8 6.9 5.2 7.4 2.1.4 3.4-.8 3.8-3.7Z"
fill="#111111"
/>
<circle cx="11.7" cy="14.6" r="2.2" fill="#E9FF54" />
<circle cx="22" cy="13.3" r="2" fill="#E9FF54" />
<path
d="M12.1 22.1c2.7 1.1 6.2.6 8.4-1.3"
stroke="#111111"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
<span className="text-[15px] font-semibold leading-6 text-slate-900">
{label}
</span>
</div>
);
const USER_COMMAND_TAG_CANDIDATES = INPUTBAR_BUILTIN_COMMANDS.map((command) =>
command.commandPrefix.trim(),
)
.filter((prefix) => prefix.startsWith("@"))
.sort((left, right) => right.length - left.length);
function resolveUserCommandRoutePrefix(
route: Message["inputCapabilityRoute"],
): string | null {
if (!route) {
return null;
}
if (route.kind === "builtin_command" || route.kind === "runtime_scene") {
const prefix = route.commandPrefix.trim();
return prefix.startsWith("@") ? prefix : null;
}
return null;
}
function parseLeadingUserCommandTag(
content: string,
route?: Message["inputCapabilityRoute"],
): { tag: string; body: string } | null {
const trimmed = content.trimStart();
if (!trimmed.startsWith("@")) {
return null;
}
const routePrefix = resolveUserCommandRoutePrefix(route);
const candidates = routePrefix
? [
routePrefix,
...USER_COMMAND_TAG_CANDIDATES.filter(
(candidate) =>
candidate.toLowerCase() !== routePrefix.toLowerCase(),
),
]
: USER_COMMAND_TAG_CANDIDATES;
const matchedTag = candidates.find((candidate) => {
const prefix = trimmed.slice(0, candidate.length);
if (prefix.toLowerCase() !== candidate.toLowerCase()) {
return false;
}
const nextChar = trimmed.charAt(candidate.length);
return !nextChar || /\s/u.test(nextChar);
});
if (!matchedTag) {
return null;
}
return {
tag: matchedTag,
body: trimmed.slice(matchedTag.length).trimStart(),
};
}
const UserCommandMessageContent: React.FC<{
content: string;
route?: Message["inputCapabilityRoute"];
}> = ({ content, route }) => {
const command = parseLeadingUserCommandTag(content, route);
if (!command) {
return null;
}
return (
<div
data-testid="message-user-command-content"
aria-label={content}
className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[15px] leading-7 text-slate-950"
>
<span
data-testid="message-user-command-tag"
className="inline-flex items-center rounded-[6px] border border-[#c6dadd] bg-[#dcebed] px-1.5 py-[1px] text-[13px] font-semibold leading-5 text-[#2f6f79]"
>
{command.tag}
</span>
{command.body ? (
<span className="whitespace-pre-wrap">{command.body}</span>
) : null}
</div>
);
};
function resolveInstalledSkillMessageLabel(message: Message): string | null {
const route = message.inputCapabilityRoute;
if (route?.kind !== "installed_skill") {
return null;
}
return route.skillName?.trim() || route.skillKey.trim() || null;
}
const UserInstalledSkillMessageContent: React.FC<{
content: string;
label: string;
}> = ({ content, label }) => (
<div
data-testid="message-user-skill-content"
aria-label={`@ ${label} ${content}`.trim()}
className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[15px] leading-7 text-slate-950"
>
<span
data-testid="message-user-skill-tag"
className="inline-flex items-center rounded-[6px] border border-sky-200 bg-sky-50 px-1.5 py-[1px] text-[13px] font-semibold leading-5 text-sky-800"
>
<span className="mr-1 text-sky-500">@</span>
{label}
</span>
{content.trim() ? (
<span className="whitespace-pre-wrap">{content}</span>
) : null}
</div>
);
function shouldRenderRuntimeStatusPill(
status?: AgentRuntimeStatus | null,
): boolean {
@@ -2226,6 +2392,14 @@ const MessageListInner: React.FC<MessageListProps> = ({
? activePendingA2UISource.requestId
: null;
const actionContent = displayContent.trim();
const installedSkillMessageLabel =
msg.role === "user" ? resolveInstalledSkillMessageLabel(msg) : null;
const isUserCommandMessage =
msg.role === "user" &&
!installedSkillMessageLabel &&
Boolean(
parseLeadingUserCommandTag(displayContent, msg.inputCapabilityRoute),
);
const hasVisibleAssistantText = Boolean(actionContent);
const shouldCollapseLongHistoricalMessage =
isRestoredHistoryWindow &&
@@ -2302,6 +2476,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
const canSaveMessageAsSkill = Boolean(
onSaveMessageAsSkill &&
msg.role === "assistant" &&
!msg.imageWorkbenchPreview &&
!msg.isThinking &&
actionContent &&
actionContent.length >= 24,
@@ -2309,6 +2484,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
const canSaveMessageAsInspiration = Boolean(
onSaveMessageAsInspiration &&
msg.role === "assistant" &&
!msg.imageWorkbenchPreview &&
!msg.isThinking &&
actionContent &&
actionContent.length >= 24,
@@ -2322,13 +2498,18 @@ const MessageListInner: React.FC<MessageListProps> = ({
const canSaveMessageAsKnowledge = Boolean(
onSaveMessageAsKnowledge &&
msg.role === "assistant" &&
!msg.imageWorkbenchPreview &&
!msg.isThinking &&
knowledgeSaveContent &&
knowledgeSaveContent.length >= 24,
);
const showMessageActions =
!msg.imageWorkbenchPreview &&
((msg.role === "user" && (canQuoteMessage || canCopyMessage)) ||
((msg.role === "user" &&
!isUserCommandMessage &&
(canQuoteMessage || canCopyMessage)) ||
(msg.role === "assistant" &&
Boolean(msg.imageWorkbenchPreview) &&
(canQuoteMessage || canCopyMessage)) ||
canSaveMessageAsSkill ||
canSaveMessageAsInspiration ||
canSaveMessageAsKnowledge);
@@ -2407,12 +2588,14 @@ const MessageListInner: React.FC<MessageListProps> = ({
tailRuntimeStatusLine?.status === "queued";
const shouldRenderTailRuntimeStatusLine =
msg.role === "assistant" &&
!msg.imageWorkbenchPreview &&
msg.id === lastAssistantMessageId &&
isConversationTailAssistant &&
Boolean(tailRuntimeStatusLine) &&
!shouldSuppressActiveRuntimeLine;
const shouldRenderActiveRuntimeFooterIndicator =
msg.role === "assistant" &&
!msg.imageWorkbenchPreview &&
msg.id === lastAssistantMessageId &&
isConversationTailAssistant &&
hasAssistantBodyContent &&
@@ -2421,15 +2604,27 @@ const MessageListInner: React.FC<MessageListProps> = ({
activeConversationRuntimeStatusLine?.status === "queued") &&
!shouldPreviewHistoricalAssistantMessage &&
!shouldDeferHistoricalMarkdownRender;
const shouldSuppressAssistantMetaFooter = shouldSuppressImageProcessFlow;
const shouldRenderUsageFooter =
const shouldRenderImageWorkbenchUsageFooter =
msg.role === "assistant" &&
Boolean(msg.imageWorkbenchPreview) &&
msg.imageWorkbenchPreview?.status === "complete" &&
isConversationTailAssistant &&
!shouldSuppressAssistantMetaFooter &&
!shouldRenderTailRuntimeStatusLine &&
!shouldRenderActiveRuntimeFooterIndicator &&
!msg.isThinking &&
Boolean(msg.usage);
const shouldSuppressAssistantMetaFooter =
shouldSuppressStandaloneImageWorkbenchProcess ||
Boolean(msg.imageWorkbenchPreview && !shouldRenderImageWorkbenchUsageFooter);
const shouldRenderUsageFooter =
shouldRenderImageWorkbenchUsageFooter ||
(!msg.imageWorkbenchPreview &&
isConversationTailAssistant &&
!shouldSuppressAssistantMetaFooter &&
!shouldRenderTailRuntimeStatusLine &&
!shouldRenderActiveRuntimeFooterIndicator &&
!msg.isThinking &&
Boolean(msg.usage));
const shouldRenderStatusPill =
!msg.imageWorkbenchPreview &&
!shouldSuppressAssistantMetaFooter &&
!shouldRenderTailRuntimeStatusLine &&
shouldRenderRuntimeStatusPill(msg.runtimeStatus);
@@ -2554,8 +2749,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
!hasImages &&
visibleAssistantArtifacts.length === 0 &&
!shouldRenderMessageCanvasShortcut &&
!msg.taskPreview &&
!assistantMetaFooter;
!msg.taskPreview;
return (
<MessageWrapper
@@ -2584,10 +2778,16 @@ const MessageListInner: React.FC<MessageListProps> = ({
<MessageBubble
$isUser={msg.role === "user"}
$bareMedia={shouldRenderImageWorkbenchBareBubble}
className={
isUserCommandMessage ? "message-bubble-user-command" : undefined
}
aria-label={msg.role === "assistant" ? assistantLabel : undefined}
>
{msg.role === "assistant" ? (
<>
{msg.imageWorkbenchPreview ? (
<ImageWorkbenchAssistantHeader label={assistantLabel} />
) : null}
{shouldRenderPrimaryTimelineOutsideBubble
? null
: primaryTimelineNode}
@@ -2739,7 +2939,17 @@ const MessageListInner: React.FC<MessageListProps> = ({
) : null}
</>
) : displayContent ? (
shouldRenderRuntimePeerCards ? (
installedSkillMessageLabel ? (
<UserInstalledSkillMessageContent
content={displayContent}
label={installedSkillMessageLabel}
/>
) : isUserCommandMessage ? (
<UserCommandMessageContent
content={displayContent}
route={msg.inputCapabilityRoute}
/>
) : shouldRenderRuntimePeerCards ? (
<RuntimePeerMessageCards text={rawRuntimePeerContent} />
) : (
<MarkdownRenderer
@@ -2802,7 +3012,16 @@ const MessageListInner: React.FC<MessageListProps> = ({
{assistantMetaFooter}
{showMessageActions ? (
<MessageActions className="message-actions">
<MessageActions
className={[
"message-actions",
msg.imageWorkbenchPreview
? "image-workbench-message-actions"
: "",
]
.filter(Boolean)
.join(" ")}
>
{canQuoteMessage ? (
<Button
variant="ghost"
@@ -27,6 +27,8 @@ export interface ImageWorkbenchTaskView {
prompt: string;
rawText: string;
expectedCount: number;
assistantIntro?: string | null;
caption?: string | null;
layoutHint?: string | null;
storyboardSlots?: ImageStoryboardSlot[];
outputIds: string[];
@@ -73,7 +73,7 @@ function createServiceSkill(): ServiceSkillHomeItem {
}
describe("buildHomeSkillSurface", () => {
it("服务端未下发首页展示时使用 Ribbi 式本地兜底入口", () => {
it("服务端未下发首页展示时使用 参考站式本地兜底入口", () => {
const labels = buildHomeStarterChips().map((chip) => chip.label);
expect(labels).toEqual([
@@ -0,0 +1,61 @@
import type { HandleSendOptions } from "./hooks/handleSendTypes";
import type { Message, MessageImage } from "./types";
import { buildDiagnosticsRuntimeStatusMetadata } from "./utils/agentRuntimeStatus";
export interface TaskCenterDraftSendRequest {
id: string;
draftTabId: string;
text: string;
images: MessageImage[];
sendExecutionStrategy?: "react" | "code_orchestrated" | "auto";
sendOptions?: HandleSendOptions;
webSearch: boolean;
thinking: boolean;
submittedAt: number;
materializeDraft: boolean;
source: "task-center-empty-state" | "empty-state";
}
export function buildHomePendingPreviewMessages(
request: TaskCenterDraftSendRequest,
executionStrategy: "react" | "code_orchestrated" | "auto",
): Message[] {
const timestamp = new Date(request.submittedAt);
const effectiveExecutionStrategy =
request.sendExecutionStrategy || executionStrategy;
const displayContent =
request.sendOptions?.displayContent?.trim() || request.text;
return [
{
id: `${request.id}:user`,
role: "user",
content: displayContent,
images: request.images.length > 0 ? request.images : undefined,
timestamp,
inputCapabilityRoute: request.sendOptions?.capabilityRoute,
},
{
id: `${request.id}:assistant`,
role: "assistant",
content: "",
timestamp,
isThinking: true,
runtimeStatus: {
phase: "preparing",
title: "正在进入对话",
detail: "已收到输入,正在后台准备会话和执行环境。",
checkpoints: [
effectiveExecutionStrategy === "code_orchestrated"
? "代码编排待命"
: effectiveExecutionStrategy === "react"
? "对话执行待命"
: "自动路由待命",
request.webSearch ? "联网搜索候选能力待命" : "直接回答优先",
request.thinking ? "深度思考待命" : "轻量响应优先",
],
metadata: buildDiagnosticsRuntimeStatusMetadata(),
},
},
];
}
@@ -936,6 +936,61 @@ describe("agentChatHistory", () => {
});
});
it("刷新会话详情时应保留本地用户消息的输入能力标签", () => {
const localMessages = [
{
id: "local-user-skill",
role: "user" as const,
content: "请说明 Minimal API 的测试策略",
timestamp: new Date("2026-05-13T03:20:00.000Z"),
inputCapabilityRoute: {
kind: "installed_skill" as const,
skillKey: "aspnet-core",
skillName: "aspnet-core",
},
},
{
id: "local-assistant-skill",
role: "assistant" as const,
content: "",
timestamp: new Date("2026-05-13T03:20:01.000Z"),
runtimeStatus: {
phase: "preparing" as const,
title: "正在准备回复",
detail: "正在准备回复",
},
},
];
const hydratedMessages = [
{
id: "history-user-skill",
role: "user" as const,
content: "请说明 Minimal API 的测试策略",
timestamp: new Date("2026-05-13T03:20:02.000Z"),
},
{
id: "history-assistant-skill",
role: "assistant" as const,
content: "可以从单元测试和集成测试两层设计。",
timestamp: new Date("2026-05-13T03:20:03.000Z"),
},
];
const mergedMessages = mergeHydratedMessagesWithLocalState(
localMessages,
hydratedMessages,
);
expect(mergedMessages[0]).toMatchObject({
id: "local-user-skill",
inputCapabilityRoute: {
kind: "installed_skill",
skillKey: "aspnet-core",
skillName: "aspnet-core",
},
});
});
it("远端详情返回更新的 assistant 正文时应替换本地快照", () => {
const localMessages = [
{
@@ -1149,8 +1204,29 @@ describe("agentChatHistory", () => {
hydratedMessages,
);
expect(mergedMessages[1]?.thinkingContent).toBeUndefined();
expect(mergedMessages[1]?.thinkingContent).toBe(
"先打开页面,再抓取正文和图片。",
);
expect(mergedMessages[1]?.contentParts).toEqual([
{
type: "thinking",
text: "先打开页面,再抓取正文和图片。",
},
{
type: "tool_use",
toolCall: {
id: "tool-site-1",
name: "site_run_adapter",
arguments: '{"url":"https://x.com/example/article/1"}',
status: "completed",
startTime: now,
endTime: now,
result: {
success: true,
output: "saved: articles/google-cloud-tech.md",
},
},
},
{
type: "text",
text: "内容已保存到项目目录。",
@@ -1162,6 +1238,146 @@ describe("agentChatHistory", () => {
});
});
it("刷新会话详情时不应把当前流式 assistant 输出替换成远端纯正文快照", () => {
const localMessages = [
{
id: "local-user-live",
role: "user" as const,
content: "整理 Lime 产品知识库",
timestamp: new Date("2026-05-13T03:36:00.000Z"),
},
{
id: "local-assistant-live",
role: "assistant" as const,
content: "这是用户已经看到的流式输出。",
timestamp: new Date("2026-05-13T03:36:01.000Z"),
isThinking: true,
thinkingContent: "先盘点产品资料,再整理知识库。",
contentParts: [
{
type: "thinking" as const,
text: "先盘点产品资料,再整理知识库。",
},
{
type: "text" as const,
text: "这是用户已经看到的流式输出。",
},
],
},
];
const hydratedMessages = [
{
id: "history-user-live",
role: "user" as const,
content: "整理 Lime 产品知识库",
timestamp: new Date("2026-05-13T03:36:02.000Z"),
},
{
id: "history-assistant-live",
role: "assistant" as const,
content: "这是后端水合返回的纯正文快照。",
contentParts: [
{
type: "text" as const,
text: "这是后端水合返回的纯正文快照。",
},
],
timestamp: new Date("2026-05-13T03:36:03.000Z"),
},
];
const mergedMessages = mergeHydratedMessagesWithLocalState(
localMessages,
hydratedMessages,
);
expect(mergedMessages[1]).toMatchObject({
id: "local-assistant-live",
content: "这是用户已经看到的流式输出。",
thinkingContent: "先盘点产品资料,再整理知识库。",
});
expect(mergedMessages[1]?.contentParts).toEqual([
{
type: "thinking",
text: "先盘点产品资料,再整理知识库。",
},
{
type: "text",
text: "这是用户已经看到的流式输出。",
},
]);
});
it("同轮本地 assistant 已带过程时不应被远端纯正文刷新掉已显示结果", () => {
const localMessages = [
{
id: "local-user-skill-output",
role: "user" as const,
content: "整理 Lime 产品知识库",
timestamp: new Date("2026-05-13T03:37:00.000Z"),
},
{
id: "local-assistant-skill-output",
role: "assistant" as const,
content: "本地流式完成后的产品知识库结果。",
timestamp: new Date("2026-05-13T03:37:01.000Z"),
isThinking: false,
thinkingContent: "先识别产品卖点,再输出知识库。",
contentParts: [
{
type: "thinking" as const,
text: "先识别产品卖点,再输出知识库。",
},
{
type: "text" as const,
text: "本地流式完成后的产品知识库结果。",
},
],
},
];
const hydratedMessages = [
{
id: "history-user-skill-output",
role: "user" as const,
content: "整理 Lime 产品知识库",
timestamp: new Date("2026-05-13T03:37:02.000Z"),
},
{
id: "history-assistant-skill-output",
role: "assistant" as const,
content: "远端会话详情里的纯正文结果。",
contentParts: [
{
type: "text" as const,
text: "远端会话详情里的纯正文结果。",
},
],
timestamp: new Date("2026-05-13T03:37:03.000Z"),
},
];
const mergedMessages = mergeHydratedMessagesWithLocalState(
localMessages,
hydratedMessages,
);
expect(mergedMessages[1]).toMatchObject({
id: "local-assistant-skill-output",
content: "本地流式完成后的产品知识库结果。",
thinkingContent: "先识别产品卖点,再输出知识库。",
});
expect(mergedMessages[1]?.contentParts).toEqual([
{
type: "thinking",
text: "先识别产品卖点,再输出知识库。",
},
{
type: "text",
text: "本地流式完成后的产品知识库结果。",
},
]);
});
it("hydrate 宽松匹配不应把本地 thinking 兜底到远端纯正文 assistant", () => {
const localMessages = [
{
@@ -1046,6 +1046,55 @@ function hasRetainableLocalMessageState(message: Message): boolean {
);
}
function hasRetainableLocalAssistantProcessState(
message: Message | undefined,
): boolean {
if (!message || message.role !== "assistant") {
return false;
}
return (
Boolean(message.thinkingContent?.trim()) ||
(message.contentParts || []).some(contentPartContainsProcess) ||
(message.toolCalls?.length || 0) > 0 ||
(message.actionRequests?.length || 0) > 0 ||
(message.contextTrace?.length || 0) > 0 ||
(message.artifacts?.length || 0) > 0 ||
Boolean(message.imageWorkbenchPreview) ||
Boolean(message.taskPreview) ||
Boolean(message.runtimeStatus)
);
}
function isLocalAssistantInMatchedUserTurn(params: {
localAssistantMessage?: Message;
localMessageIndexById: Map<string, number>;
localMessages: Message[];
lastMatchedLocalUserMessageIndex: number | null;
}): boolean {
const { localAssistantMessage, lastMatchedLocalUserMessageIndex } = params;
if (
!localAssistantMessage?.id ||
lastMatchedLocalUserMessageIndex === null
) {
return false;
}
const assistantIndex = params.localMessageIndexById.get(
localAssistantMessage.id,
);
if (
assistantIndex === undefined ||
assistantIndex <= lastMatchedLocalUserMessageIndex
) {
return false;
}
return !params.localMessages
.slice(lastMatchedLocalUserMessageIndex + 1, assistantIndex)
.some((message) => message.role === "user");
}
export const mergeHydratedMessagesWithLocalState = (
localMessages: Message[],
hydratedMessages: Message[],
@@ -1180,8 +1229,18 @@ export const mergeHydratedMessagesWithLocalState = (
return message;
}
const shouldPreserveLocalRuntimeSnapshot =
isLocalAssistantInMatchedUserTurn({
localAssistantMessage,
localMessageIndexById,
localMessages,
lastMatchedLocalUserMessageIndex,
}) &&
hasRetainableLocalAssistantProcessState(localAssistantMessage) &&
!hasRetainableLocalAssistantProcessState(message);
const shouldRetainLocalProcessState =
!hasRenderableAssistantTextContent(message);
!hasRenderableAssistantTextContent(message) ||
shouldPreserveLocalRuntimeSnapshot;
const shouldRetainLocalThinkingState =
shouldRetainLocalProcessState || Boolean(message.thinkingContent);
const contentParts = shouldRetainLocalProcessState
@@ -1224,6 +1283,8 @@ export const mergeHydratedMessagesWithLocalState = (
: undefined) ??
extractThinkingContentFromParts(contentParts);
const shouldPreserveLocalVisibleOutput =
localAssistantMessage?.isThinking === true ||
shouldPreserveLocalRuntimeSnapshot ||
shouldPreserveLocalAssistantVisibleOutput(
localAssistantMessage,
message,
@@ -1294,6 +1355,8 @@ export const mergeHydratedMessagesWithLocalState = (
return {
...message,
id: localMessage.id || message.id,
inputCapabilityRoute:
message.inputCapabilityRoute ?? localMessage.inputCapabilityRoute,
};
}
@@ -1301,6 +1364,8 @@ export const mergeHydratedMessagesWithLocalState = (
...message,
id: localMessage.id || message.id,
images: localMessage.images,
inputCapabilityRoute:
message.inputCapabilityRoute ?? localMessage.inputCapabilityRoute,
};
});
@@ -12,6 +12,7 @@ import type {
WriteArtifactContext,
} from "../types";
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type { InputCapabilitySendRoute } from "../skill-selection/inputCapabilitySelection";
import { normalizeProjectId } from "../utils/topicProjectResolution";
import { normalizeExecutionStrategy } from "./agentChatCoreUtils";
import { sanitizeMessageTextForPreview } from "../utils/internalImagePlaceholder";
@@ -89,6 +90,7 @@ export interface SendMessageOptions {
requestMetadata?: Record<string, unknown>;
assistantDraft?: AssistantDraftState;
displayContent?: string;
capabilityRoute?: InputCapabilitySendRoute;
skillRequest?: SlashSkillRequest;
providerOverride?: string;
modelOverride?: string;
@@ -255,7 +255,7 @@ describe("agentSessionState", () => {
]);
});
it("同会话 hydrate 时远端已有最终正文应收敛旧 assistant 执行过程到元数据", () => {
it("同会话 hydrate 时远端纯正文不应刷新掉本地 assistant 执行过程", () => {
const now = new Date("2026-04-08T10:00:00.000Z");
const currentMessages = [
createMessage({
@@ -341,8 +341,29 @@ describe("agentSessionState", () => {
topics: [],
});
expect(result.snapshot.messages[1]?.thinkingContent).toBeUndefined();
expect(result.snapshot.messages[1]?.thinkingContent).toBe(
"先抓正文,再下载图片。",
);
expect(result.snapshot.messages[1]?.contentParts).toEqual([
{
type: "thinking",
text: "先抓正文,再下载图片。",
},
{
type: "tool_use",
toolCall: {
id: "tool-site-1",
name: "site_run_adapter",
arguments: '{"url":"https://x.com/example/article/1"}',
status: "completed",
startTime: now,
endTime: now,
result: {
success: true,
output: "saved: articles/google-cloud-tech.md",
},
},
},
{
type: "text",
text: "内容已保存到项目目录。",
@@ -9,6 +9,7 @@ import {
buildAgentStreamMissingFinalReplyFailureSideEffectPlan,
isAgentStreamEmptyFinalReplyError,
reconcileAgentStreamFinalContentParts,
resolveAgentStreamCompletedVisibleContent,
resolveAgentStreamGracefulCompletionContent,
shouldFailAgentStreamMissingFinalReply,
} from "./agentStreamCompletionController";
@@ -117,6 +118,33 @@ describe("agentStreamCompletionController", () => {
});
});
it("完成态最终正文较短时应保留已经显示给用户的前期输出", () => {
expect(
resolveAgentStreamCompletedVisibleContent({
previousContent: "前期已经流式显示的说明。\n\n最终总结。",
finalContent: "最终总结。",
}),
).toBe("前期已经流式显示的说明。\n\n最终总结。");
});
it("完成态最终正文改写且不包含前期输出时应合并显示内容", () => {
expect(
buildAgentStreamCompletedAssistantMessagePatch({
parts: [{ type: "text", text: "前期输出。" }],
previousContent: "前期输出。",
finalContent: "最终输出。",
rawContent: "最终输出。",
surfaceThinkingDeltas: true,
}),
).toEqual({
isThinking: false,
content: "前期输出。\n\n最终输出。",
contentParts: [{ type: "text", text: "前期输出。\n\n最终输出。" }],
thinkingContent: undefined,
runtimeStatus: undefined,
});
});
it("完成态应在持久化 reasoning 接管前保留本地思考兜底", () => {
expect(
buildAgentStreamCompletedAssistantMessagePatch({
@@ -187,9 +187,64 @@ export function reconcileAgentStreamFinalContentParts(params: {
: processParts;
}
function normalizeCompletionTextSignature(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
function mergeVisibleCompletionText(base: string, chunk: string): string {
if (!base) return chunk;
if (!chunk) return base;
if (chunk.startsWith(base)) return chunk;
if (base.endsWith(chunk)) return base;
const maxOverlap = Math.min(base.length, chunk.length);
for (let length = maxOverlap; length > 0; length -= 1) {
if (base.slice(-length) === chunk.slice(0, length)) {
return `${base}${chunk.slice(length)}`;
}
}
return `${base}\n\n${chunk}`;
}
export function resolveAgentStreamCompletedVisibleContent(params: {
finalContent: string;
previousContent?: string;
}): string {
const previousContent = params.previousContent?.trim() || "";
const finalContent = params.finalContent.trim();
if (!previousContent) {
return finalContent;
}
if (!finalContent) {
return previousContent;
}
const previousSignature = normalizeCompletionTextSignature(previousContent);
const finalSignature = normalizeCompletionTextSignature(finalContent);
if (!previousSignature) {
return finalContent;
}
if (!finalSignature) {
return previousContent;
}
if (previousSignature === finalSignature) {
return finalContent;
}
if (finalSignature.includes(previousSignature)) {
return finalContent;
}
if (previousSignature.includes(finalSignature)) {
return previousContent;
}
return mergeVisibleCompletionText(previousContent, finalContent);
}
export function buildAgentStreamCompletedAssistantMessagePatch(params: {
finalContent: string;
parts: Message["contentParts"];
previousContent?: string;
rawContent: string;
surfaceThinkingDeltas: boolean;
thinkingContent?: string;
@@ -200,14 +255,18 @@ export function buildAgentStreamCompletedAssistantMessagePatch(params: {
const retainedThinkingContent = params.surfaceThinkingDeltas
? params.thinkingContent?.trim() || undefined
: undefined;
const finalContent = resolveAgentStreamCompletedVisibleContent({
finalContent: params.finalContent,
previousContent: params.previousContent,
});
return {
isThinking: false,
content: params.finalContent,
content: finalContent,
thinkingContent: retainedThinkingContent,
contentParts: reconcileAgentStreamFinalContentParts({
parts: params.parts,
finalContent: params.finalContent,
finalContent,
rawContent: params.rawContent,
surfaceThinkingDeltas: params.surfaceThinkingDeltas,
}),
@@ -775,7 +775,7 @@ describe("agentStreamRuntimeHandler", () => {
it("图片生成草稿应保留极简文案,不被模型 text_delta 覆盖", () => {
const preservedContent =
"好嘞,用 Nanobanana Pro 给你生成一张广州塔春天照片\n先获取下工具参数\n马上生成";
"好啊,用 Nanobanana Pro 生成:一张广州塔春天照片\n先获取下工具参数\n马上生成";
let messages: Message[] = [
{
id: "assistant-image",
@@ -1263,6 +1263,7 @@ export function handleTurnStreamEvent({
...buildAgentStreamCompletedAssistantMessagePatch({
parts: msg.contentParts,
finalContent,
previousContent: msg.content,
rawContent: requestState.accumulatedContent,
surfaceThinkingDeltas,
thinkingContent: msg.thinkingContent,
@@ -1330,6 +1331,7 @@ export function handleTurnStreamEvent({
...buildAgentStreamCompletedAssistantMessagePatch({
parts: msg.contentParts,
finalContent: gracefulContent,
previousContent: msg.content,
rawContent: requestState.accumulatedContent,
surfaceThinkingDeltas,
thinkingContent: msg.thinkingContent,
@@ -124,6 +124,11 @@ describe("agentStreamSubmitDraft", () => {
prepareAgentStreamSubmitDraft({
content: "/image_generate 生成 春日咖啡馆插画",
displayContent: "@配图 生成 春日咖啡馆插画",
capabilityRoute: {
kind: "installed_skill",
skillKey: "image_generate",
skillName: "配图",
},
images: [],
skipUserMessage: false,
expectingQueue: false,
@@ -149,6 +154,11 @@ describe("agentStreamSubmitDraft", () => {
expect(messages[0]).toMatchObject({
id: "user-3",
content: "@配图 生成 春日咖啡馆插画",
inputCapabilityRoute: {
kind: "installed_skill",
skillKey: "image_generate",
skillName: "配图",
},
});
expect(isSending).toBe(true);
});
@@ -166,7 +176,7 @@ describe("agentStreamSubmitDraft", () => {
userMsgId: "user-image",
assistantDraft: {
content:
"好嘞,用 Nanobanana Pro 给你生成一张广州塔春天照片\n先获取下工具参数\n马上生成",
"好啊,用 Nanobanana Pro 生成:一张广州塔春天照片\n先获取下工具参数\n马上生成",
preserveContent: true,
imageWorkbenchPreview: {
taskId: "draft-image-1",
@@ -197,7 +207,7 @@ describe("agentStreamSubmitDraft", () => {
expect(messages[1]).toMatchObject({
id: "assistant-image",
content:
"好嘞,用 Nanobanana Pro 给你生成一张广州塔春天照片\n先获取下工具参数\n马上生成",
"好啊,用 Nanobanana Pro 生成:一张广州塔春天照片\n先获取下工具参数\n马上生成",
imageWorkbenchPreview: {
taskId: "draft-image-1",
prompt: "一张广州塔春天照片",
@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from "react";
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
import type { Message, MessageImage } from "../types";
import type { AssistantDraftState } from "./agentChatShared";
import type { InputCapabilitySendRoute } from "../skill-selection/inputCapabilitySelection";
import {
buildDiagnosticsRuntimeStatusMetadata,
buildInitialAgentRuntimeStatus,
@@ -54,6 +55,7 @@ interface PrepareAgentStreamSubmitDraftOptions {
assistantDraft?: AssistantDraftState;
requestMetadata?: Record<string, unknown>;
messagePurpose?: Message["purpose"];
capabilityRoute?: InputCapabilitySendRoute;
effectiveExecutionStrategy: AsterExecutionStrategy;
webSearch?: boolean;
thinking?: boolean;
@@ -75,6 +77,7 @@ export function prepareAgentStreamSubmitDraft(
assistantDraft,
requestMetadata,
messagePurpose,
capabilityRoute,
effectiveExecutionStrategy,
webSearch,
thinking,
@@ -116,6 +119,7 @@ export function prepareAgentStreamSubmitDraft(
images: images.length > 0 ? images : undefined,
timestamp: new Date(),
purpose: messagePurpose,
inputCapabilityRoute: capabilityRoute,
};
setMessages((prev) => [...prev, userMsg, assistantMsg]);
}
@@ -109,6 +109,7 @@ export function prepareAgentStreamUserInputSend(
resolveAgentRuntimeStatusPresentation(requestMetadata);
const messagePurpose = sendOptions?.purpose;
const assistantDraft = sendOptions?.assistantDraft;
const capabilityRoute = sendOptions?.capabilityRoute;
const searchMode = sendOptions?.searchMode;
const skipSessionRestore = sendOptions?.skipSessionRestore === true;
const skipSessionStartHooks = sendOptions?.skipSessionStartHooks === true;
@@ -135,6 +136,7 @@ export function prepareAgentStreamUserInputSend(
assistantDraft,
requestMetadata,
messagePurpose,
capabilityRoute,
effectiveExecutionStrategy,
webSearch,
thinking,
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import type { Skill } from "@/lib/api/skills";
import {
resolveInputCapabilitySelectionFromRoute,
resolveInputCapabilitySendRoute,
} from "./inputCapabilitySelection";
function createSkill(overrides: Partial<Skill> = {}): Skill {
return {
key: "local:brand-product-knowledge-builder",
name: "产品知识库",
description: "沉淀品牌与产品资料",
directory: "brand-product-knowledge-builder",
installed: true,
sourceKind: "other",
...overrides,
};
}
describe("inputCapabilitySelection", () => {
it("本地已安装 Skill 发送时应使用目录名作为 slash 执行 key", () => {
const route = resolveInputCapabilitySendRoute({
kind: "installed_skill",
skill: createSkill(),
});
expect(route).toEqual({
kind: "installed_skill",
skillKey: "brand-product-knowledge-builder",
skillName: "产品知识库",
});
});
it("从 Skills 页带回 local:* 路由时仍应匹配已安装 Skill", () => {
const skill = createSkill();
const selection = resolveInputCapabilitySelectionFromRoute({
route: {
kind: "installed_skill",
skillKey: "local:brand-product-knowledge-builder",
skillName: "产品知识库",
},
skills: [skill],
});
expect(selection).toEqual({
kind: "installed_skill",
skill,
});
});
it("没有目录名时应剥离 local: 前缀,避免生成 /local:* 命令", () => {
const route = resolveInputCapabilitySendRoute({
kind: "installed_skill",
skill: createSkill({
key: "local:writer",
name: "写作助手",
directory: "",
}),
});
expect(route).toMatchObject({
kind: "installed_skill",
skillKey: "writer",
skillName: "写作助手",
});
});
});
@@ -77,6 +77,62 @@ export type InputCapabilitySendRoute =
referenceEntries?: CuratedTaskReferenceEntry[];
};
const SLASH_SKILL_KEY_PATTERN = /^[a-zA-Z0-9_-]+$/;
function normalizeSkillRouteCandidate(value: string | null | undefined) {
return value?.trim() || "";
}
function stripLocalSkillKeyPrefix(value: string) {
const normalized = value.trim();
if (!normalized) {
return "";
}
return normalized.includes(":")
? normalized.split(":").pop()?.trim() || normalized
: normalized;
}
function resolveInstalledSkillExecutionKey(skill: Skill): string {
const candidates = [
normalizeSkillRouteCandidate(skill.directory),
normalizeSkillRouteCandidate(skill.name),
stripLocalSkillKeyPrefix(skill.key),
normalizeSkillRouteCandidate(skill.key),
].filter(Boolean);
return (
candidates.find((candidate) => SLASH_SKILL_KEY_PATTERN.test(candidate)) ??
candidates[0] ??
skill.key
);
}
function matchesInstalledSkillRoute(
skill: Skill,
route: Extract<InputCapabilitySendRoute, { kind: "installed_skill" }>,
): boolean {
const routeKey = route.skillKey.trim();
if (!routeKey) {
return false;
}
const routeKeyWithoutPrefix = stripLocalSkillKeyPrefix(routeKey);
const candidates = [
skill.key,
skill.directory,
skill.name,
stripLocalSkillKeyPrefix(skill.key),
resolveInstalledSkillExecutionKey(skill),
].map((value) => value.trim());
return candidates.some(
(candidate) =>
candidate === routeKey ||
(!!routeKeyWithoutPrefix && candidate === routeKeyWithoutPrefix),
);
}
export interface ResolvedInputCapabilityDispatch {
displayContent?: string;
capabilityRoute?: InputCapabilitySendRoute;
@@ -100,7 +156,7 @@ export function resolveInputCapabilitySendRoute(
case "installed_skill":
return {
kind: "installed_skill",
skillKey: capability.skill.key,
skillKey: resolveInstalledSkillExecutionKey(capability.skill),
skillName: capability.skill.name,
};
case "runtime_scene":
@@ -235,7 +291,7 @@ export function resolveInputCapabilitySelectionFromRoute(params: {
if (route.kind === "installed_skill") {
const skill =
skills.find((item) => item.key === route.skillKey) ??
skills.find((item) => matchesInstalledSkillRoute(item, route)) ??
createFallbackInstalledSkill(route);
return {
kind: "installed_skill",
+14 -1
View File
@@ -120,7 +120,7 @@ export const MessageBubble = styled.div<{
$bareMedia || $isUser ? "fit-content" : "100%"};
max-width: ${({ $isUser, $bareMedia }) =>
$bareMedia
? "min(100%, 560px)"
? "min(100%, 800px)"
: $isUser
? "min(72%, 560px)"
: "min(100%, 1040px)"};
@@ -159,6 +159,14 @@ export const MessageBubble = styled.div<{
.markdown-renderer * {
color: inherit;
}
&.message-bubble-user-command {
max-width: min(72%, 560px);
border: 1px solid rgba(226, 232, 240, 0.76);
border-radius: 12px;
background: #f7f7f6;
box-shadow: none;
}
`;
export const MessageActions = styled.div`
@@ -179,4 +187,9 @@ export const MessageActions = styled.div`
margin-top 0.18s ease,
transform 0.18s ease;
background-color: transparent;
&.image-workbench-message-actions {
align-self: flex-start;
margin-top: 8px;
}
`;
+3
View File
@@ -7,6 +7,7 @@ import type {
import type { Artifact, ArtifactStatus } from "@/lib/artifact/types";
import { safeInvoke } from "@/lib/dev-bridge";
import type { AgentUiProjectionEvent } from "./projection/agentUiEventProjection";
import type { InputCapabilitySendRoute } from "./skill-selection/inputCapabilitySelection";
export type {
AgentThreadItem,
@@ -437,6 +438,8 @@ export interface Message {
runtimeTurnId?: string;
/** 消息用途(用于跳过特定副作用) */
purpose?: "content_review" | "text_stylize" | "style_rewrite" | "style_audit";
/** 用户发送时显式选择的输入能力,用于历史气泡还原 @ Skill 等标签。 */
inputCapabilityRoute?: InputCapabilitySendRoute;
}
export interface ChatSession {
@@ -41,7 +41,7 @@ describe("parseBrowserWorkbenchCommand", () => {
});
});
it("应兼容 Ribbi 风格的 @Browser Agent 命令", () => {
it("应兼容 多词别名风格的 @Browser Agent 命令", () => {
const result = parseBrowserWorkbenchCommand(
"@Browser Agent open openai.com/pricing and compare plans",
);
@@ -39,7 +39,7 @@ describe("parseCodeWorkbenchCommand", () => {
});
});
it("应兼容 Ribbi 风格的 @Code Agent 命令", () => {
it("应兼容 多词别名风格的 @Code Agent 命令", () => {
const result = parseCodeWorkbenchCommand(
"@Code Agent refactor the runtime mention registry and remove duplicate branches",
);
@@ -1,12 +1,10 @@
export type ImageWorkbenchCommandTrigger =
| "@配图"
| "@Nanobanana Pro"
| "@分镜"
| "@修图"
| "@重绘"
| "@Vision 1"
| "@image"
| "/image";
import {
getSeededSkillCatalog,
listSkillCatalogCommandEntries,
type SkillCatalogCommandEntry,
} from "@/lib/api/skillCatalog";
export type ImageWorkbenchCommandTrigger = string;
export type ImageWorkbenchCommandMode = "generate" | "edit" | "variation";
@@ -14,8 +12,19 @@ const MAX_IMAGE_WORKBENCH_COUNT = 16;
const STORYBOARD_3X3_REGEX =
/(?:\b3\s*[x×*]\s*3\b|九宫格|storyboard)(?:\s*(?:分镜(?:板|版)?|网格图))?/i;
interface ImageWorkbenchCommandDefinition {
commandKey: string;
trigger: string;
mode?: ImageWorkbenchCommandMode;
layoutHint?: string;
count?: number;
providerId?: string;
modelId?: string;
}
export interface ParsedImageWorkbenchCommand {
rawText: string;
commandKey?: string;
trigger: ImageWorkbenchCommandTrigger;
body: string;
mode: ImageWorkbenchCommandMode;
@@ -29,42 +38,112 @@ export interface ParsedImageWorkbenchCommand {
modelId?: string;
}
const IMAGE_COMMAND_PREFIX_REGEX =
/^\s*(@配图|@Nanobanana Pro|@分镜|@修图|@重绘|@Vision 1|@image|\/image)(?:\s+|$)([\s\S]*)$/i;
const TARGET_REF_REGEX = /#(img-[a-z0-9_-]+)/i;
const SIZE_REGEX = /\b(\d{3,4}x\d{3,4})\b/i;
const ASPECT_RATIO_REGEX = /\b(1:1|16:9|9:16|4:3|3:4|3:2|2:3|21:9|4:5|5:4)\b/i;
function normalizeTrigger(value: string): ImageWorkbenchCommandTrigger {
const normalized = value.trim().toLowerCase();
if (normalized === "@image") {
return "@image";
}
if (normalized === "/image") {
return "/image";
}
if (normalized === "@vision 1") {
return "@Vision 1";
}
if (normalized === "@nanobanana pro") {
return "@Nanobanana Pro";
}
if (normalized === "@分镜") {
return "@分镜";
}
if (normalized === "@修图") {
return "@修图";
}
if (normalized === "@重绘") {
return "@重绘";
}
return "@配图";
function isImageWorkbenchCommandEntry(
entry: SkillCatalogCommandEntry,
): boolean {
return entry.binding?.requestDefaults?.imageWorkbench === "true";
}
function isStoryboardCommandTrigger(
trigger: ImageWorkbenchCommandTrigger,
): boolean {
return trigger === "@分镜";
function readCommandRequestDefault(
entry: SkillCatalogCommandEntry,
...keys: string[]
): string | undefined {
const defaults = entry.binding?.requestDefaults;
if (!defaults) {
return undefined;
}
for (const key of keys) {
const value = defaults[key]?.trim();
if (value) {
return value;
}
}
return undefined;
}
function buildImageWorkbenchCommandDefinitions(): ImageWorkbenchCommandDefinition[] {
const definitions = listSkillCatalogCommandEntries(getSeededSkillCatalog())
.filter(isImageWorkbenchCommandEntry)
.flatMap((entry) =>
entry.triggers.map((trigger) => {
const providerId = readCommandRequestDefault(
entry,
"providerId",
"provider_id",
);
const modelId = readCommandRequestDefault(entry, "model", "modelId");
const modeDefault = readCommandRequestDefault(entry, "mode");
const mode: ImageWorkbenchCommandMode | undefined =
modeDefault === "edit" ||
modeDefault === "variation" ||
modeDefault === "generate"
? modeDefault
: undefined;
const layoutHint = readCommandRequestDefault(
entry,
"layoutHint",
"layout_hint",
);
const count = Number(readCommandRequestDefault(entry, "count")) || 0;
const definition: ImageWorkbenchCommandDefinition = {
commandKey: entry.commandKey,
trigger: trigger.prefix,
};
if (mode) {
definition.mode = mode;
}
if (layoutHint) {
definition.layoutHint = layoutHint;
}
if (count > 0) {
definition.count = count;
}
if (providerId) {
definition.providerId = providerId;
}
if (modelId) {
definition.modelId = modelId;
}
return definition;
}),
);
return definitions.sort(
(left, right) => right.trigger.length - left.trigger.length,
);
}
const IMAGE_WORKBENCH_COMMAND_DEFINITIONS =
buildImageWorkbenchCommandDefinitions();
function matchImageWorkbenchCommandPrefix(
text: string,
): { definition: ImageWorkbenchCommandDefinition; body: string } | null {
const trimmed = text.trimStart();
if (!trimmed) {
return null;
}
for (const definition of IMAGE_WORKBENCH_COMMAND_DEFINITIONS) {
const prefix = trimmed.slice(0, definition.trigger.length);
if (prefix.toLowerCase() !== definition.trigger.toLowerCase()) {
continue;
}
const nextChar = trimmed.charAt(definition.trigger.length);
if (nextChar && !/\s/u.test(nextChar)) {
continue;
}
return {
definition,
body: trimmed.slice(definition.trigger.length).trim(),
};
}
return null;
}
function clampCount(value: number | null | undefined): number {
@@ -92,7 +171,7 @@ function extractExplicitCount(body: string): number | undefined {
}
function resolveLayoutHint(params: {
trigger: ImageWorkbenchCommandTrigger;
commandDefinition: ImageWorkbenchCommandDefinition;
body: string;
explicitCount?: number;
}): string | undefined {
@@ -101,21 +180,21 @@ function resolveLayoutHint(params: {
}
if (
isStoryboardCommandTrigger(params.trigger) &&
params.commandDefinition.layoutHint &&
(params.explicitCount == null || params.explicitCount === 9)
) {
return "storyboard_3x3";
return params.commandDefinition.layoutHint;
}
return undefined;
}
function extractCount(params: {
trigger: ImageWorkbenchCommandTrigger;
commandDefinition: ImageWorkbenchCommandDefinition;
explicitCount?: number;
layoutHint?: string;
}): number {
const { trigger, explicitCount, layoutHint } = params;
const { commandDefinition, explicitCount, layoutHint } = params;
if (layoutHint === "storyboard_3x3") {
return 9;
}
@@ -124,19 +203,16 @@ function extractCount(params: {
return explicitCount;
}
return isStoryboardCommandTrigger(trigger) ? 9 : 1;
return clampCount(commandDefinition.count || 1);
}
function resolveMode(
trigger: ImageWorkbenchCommandTrigger,
commandDefinition: ImageWorkbenchCommandDefinition,
normalizedBody: string,
targetRef?: string,
): ImageWorkbenchCommandMode {
if (trigger === "@修图") {
return "edit";
}
if (trigger === "@重绘") {
return "variation";
if (commandDefinition.mode) {
return commandDefinition.mode;
}
if (/^(编辑|edit|修改)(?:\s|$|[::])/i.test(normalizedBody)) {
return "edit";
@@ -150,19 +226,6 @@ function resolveMode(
return targetRef ? "variation" : "generate";
}
function resolveTriggerModelOverride(
trigger: ImageWorkbenchCommandTrigger,
): Pick<ParsedImageWorkbenchCommand, "providerId" | "modelId"> {
if (trigger === "@Nanobanana Pro") {
return {
providerId: "fal",
modelId: "fal-ai/nano-banana-pro",
};
}
return {};
}
function stripPromptDecorations(body: string, layoutHint?: string): string {
const normalizedBody = layoutHint
? body
@@ -222,33 +285,32 @@ function resolveSize(body: string): { size?: string; aspectRatio?: string } {
export function parseImageWorkbenchCommand(
text: string,
): ParsedImageWorkbenchCommand | null {
const matched = text.match(IMAGE_COMMAND_PREFIX_REGEX);
const matched = matchImageWorkbenchCommandPrefix(text);
if (!matched) {
return null;
}
const trigger = normalizeTrigger(matched[1] || "");
const body = (matched[2] || "").trim();
const { definition, body } = matched;
const targetRef = body.match(TARGET_REF_REGEX)?.[1];
const normalizedBody = body.trim();
const mode = resolveMode(trigger, normalizedBody, targetRef);
const mode = resolveMode(definition, normalizedBody, targetRef);
const explicitCount = extractExplicitCount(normalizedBody);
const layoutHint = resolveLayoutHint({
trigger,
commandDefinition: definition,
body: normalizedBody,
explicitCount,
});
const { size, aspectRatio } = resolveSize(normalizedBody);
const modelOverride = resolveTriggerModelOverride(trigger);
return {
rawText: text,
trigger,
commandKey: definition.commandKey,
trigger: definition.trigger,
body,
mode,
prompt: stripPromptDecorations(normalizedBody, layoutHint),
count: extractCount({
trigger,
commandDefinition: definition,
explicitCount,
layoutHint,
}),
@@ -256,7 +318,8 @@ export function parseImageWorkbenchCommand(
size,
aspectRatio,
targetRef,
...modelOverride,
...(definition.providerId ? { providerId: definition.providerId } : {}),
...(definition.modelId ? { modelId: definition.modelId } : {}),
};
}
@@ -266,7 +329,6 @@ export function shouldRouteImageWorkbenchCommandToSkill(input: {
}): boolean {
const { parsedCommand, attachedImageCount = 0 } = input;
return (
parsedCommand.trigger !== "@修图" &&
parsedCommand.mode === "generate" &&
!parsedCommand.targetRef &&
attachedImageCount === 0
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it } from "vitest";
import { changeLimeLocale, limeI18nResources } from "@/i18n/createI18n";
import {
buildImageTaskAssistantContent,
buildImageTaskPresentationContext,
} from "../workspace/imageTaskPersona";
import {
buildImageWorkbenchCaption,
buildImageWorkbenchProcessLines,
} from "./imageWorkbenchPresentation";
describe("imageWorkbenchPresentation", () => {
beforeEach(async () => {
await changeLimeLocale("zh-CN");
});
it("图片生成人设层只清理命令标签,不重写用户语义", () => {
expect(
buildImageTaskAssistantContent({
prompt: "一张广州塔,从花城汇看过去的春天的照片",
mode: "generate",
modelName: "fal-ai/nano-banana-pro",
}),
).toBe(
"好啊,用 Nanobanana Pro 生成:一张广州塔,从花城汇看过去的春天的照片\n先获取下工具参数\n马上生成",
);
const shanghaiIntro = buildImageTaskAssistantContent({
prompt: "一张外滩,从陆家嘴看过去的夜景的照片",
mode: "generate",
modelName: "fal-ai/nano-banana-pro",
});
expect(shanghaiIntro).toContain("一张外滩,从陆家嘴看过去的夜景的照片");
expect(shanghaiIntro).not.toContain("一张从陆家嘴看外滩的夜景照片");
});
it("展示层过程和完成描述只做本地化收口,不把品味或画面细节硬编码进聊天展示层", () => {
expect(buildImageWorkbenchProcessLines()).toEqual([
"先获取下工具参数",
"马上生成",
]);
expect(
buildImageWorkbenchCaption({
prompt: "一张广州塔,从花城汇看过去的春天的照片",
status: "complete",
imageCount: 1,
}),
).toBe(
"搞定,图已经生成好了\n要调整的话直接说,我继续改",
);
expect(
buildImageTaskPresentationContext({
prompt: "一张广州塔,从花城汇看过去的春天的照片",
mode: "generate",
modelId: "fal-ai/nano-banana-pro",
}),
).toMatchObject({
completion_caption:
"搞定,图已经生成好了\n要调整的话直接说,我继续改",
result_captions: {
complete:
"搞定,图已经生成好了\n要调整的话直接说,我继续改",
},
});
});
it("图片生成人设文案覆盖英文 locale 资源,不污染全局语言", () => {
expect(
limeI18nResources["en-US"]?.agent?.[
"agentChat.imageTaskPersona.intro.generateWithModel"
],
).toBe("Sure — generating with {{model}}: {{target}}");
});
});
@@ -1,3 +1,4 @@
import { getLimeI18n } from "@/i18n/createI18n";
import type { MessageImageWorkbenchPreview } from "../types";
function titleCaseModelSegment(value: string): string {
@@ -55,93 +56,36 @@ export function resolveImageWorkbenchPreviewModelLabel(
);
}
function stripLeadingImageIntent(value: string): string {
return value
.replace(/^@\S+(?:\s+\S+)?\s*/u, "")
.replace(/^(?:请|麻烦你?|帮我|给我)?\s*(?:生成|画|绘制|做|制作|出)\s*/u, "")
.trim();
}
type ImageWorkbenchPresentationKey =
| "agentChat.imageWorkbenchPresentation.process.prepareParameters"
| "agentChat.imageWorkbenchPresentation.process.generateNow"
| "agentChat.imageWorkbenchPresentation.caption.completeWithSubject"
| "agentChat.imageWorkbenchPresentation.caption.completeDefault"
| "agentChat.imageWorkbenchPresentation.caption.partialWithCount"
| "agentChat.imageWorkbenchPresentation.caption.partialDefault"
| "agentChat.imageWorkbenchPresentation.caption.failedWithMessage"
| "agentChat.imageWorkbenchPresentation.caption.failedDefault"
| "agentChat.imageWorkbenchPresentation.caption.cancelled";
function truncatePromptSnippet(value: string, maxLength = 72): string {
const normalized = stripLeadingImageIntent(
collapseImageWorkbenchWhitespace(value)
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[[^\]]*]\([^)]*\)/g, " "),
);
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength).trim()}...`;
}
function resolveGeneratedSubject(params: {
prompt: string;
expectedImageCount?: number | null;
layoutHint?: string | null;
}): string {
const snippet = truncatePromptSnippet(params.prompt);
if (!snippet) {
return "这张图";
}
if (
params.layoutHint === "storyboard_3x3" ||
(params.expectedImageCount ?? 0) > 1
) {
const groupSnippet = snippet.replace(
/^一(?:张|幅|个|颗|只|位|款|片|座|条|枚|束|份|套|辆|间)\s*/u,
"",
);
return /^一组|^这组|^\d+\s*张|^九张|^9张/u.test(snippet)
? snippet
: `一组${groupSnippet || snippet}`;
}
return /^一(?:张|幅|组|个|颗|只|位|款|片|座|条|枚|束|份|套|辆|间)|^这张|^这幅|^从/u.test(
snippet,
)
? snippet
: `一张${snippet}`;
}
function resolveImageActionIntro(params: {
prompt: string;
mode?: MessageImageWorkbenchPreview["mode"];
modelName?: string | null;
expectedImageCount?: number | null;
layoutHint?: string | null;
}): string {
const modelLabel = resolveImageWorkbenchModelLabel(params.modelName);
const modelPrefix = modelLabel ? `用 ${modelLabel} ` : "";
const subject = resolveGeneratedSubject({
prompt: params.prompt,
expectedImageCount: params.expectedImageCount,
layoutHint: params.layoutHint,
function tImageWorkbenchPresentation(
key: ImageWorkbenchPresentationKey,
options?: Record<string, unknown>,
): string {
return getLimeI18n().t(key, {
ns: "agent",
...(options || {}),
});
switch (params.mode) {
case "edit":
return `好嘞,${modelPrefix}按你的要求处理${subject}`;
case "variation":
return `好嘞,${modelPrefix}按你的要求重绘${subject}`;
case "generate":
default:
return `好嘞,${modelPrefix}给你生成${subject}`;
}
}
export function buildImageWorkbenchAssistantContent(params: {
prompt: string;
mode?: MessageImageWorkbenchPreview["mode"];
modelName?: string | null;
expectedImageCount?: number | null;
layoutHint?: string | null;
}): string {
return [resolveImageActionIntro(params), "先获取下工具参数", "马上生成"].join(
"\n",
);
export function buildImageWorkbenchProcessLines(): string[] {
return [
tImageWorkbenchPresentation(
"agentChat.imageWorkbenchPresentation.process.prepareParameters",
),
tImageWorkbenchPresentation(
"agentChat.imageWorkbenchPresentation.process.generateNow",
),
];
}
export function buildImageWorkbenchCaption(params: {
@@ -150,23 +94,33 @@ export function buildImageWorkbenchCaption(params: {
imageCount?: number | null;
statusMessage?: string | null;
}): string | null {
const detail = truncatePromptSnippet(params.prompt, 42);
switch (params.status) {
case "complete":
return detail
? `搞定,已生成${resolveGeneratedSubject({ prompt: detail })}。`
: "搞定,图片已生成。";
return tImageWorkbenchPresentation(
"agentChat.imageWorkbenchPresentation.caption.completeDefault",
);
case "partial":
return params.imageCount && params.imageCount > 0
? `先生成了 ${params.imageCount} 张结果。`
: "先生成了部分结果。";
? tImageWorkbenchPresentation(
"agentChat.imageWorkbenchPresentation.caption.partialWithCount",
{ count: params.imageCount },
)
: tImageWorkbenchPresentation(
"agentChat.imageWorkbenchPresentation.caption.partialDefault",
);
case "failed":
return params.statusMessage?.trim()
? `这次没有生成成功:${params.statusMessage.trim()}`
: "这次没有生成成功。";
? tImageWorkbenchPresentation(
"agentChat.imageWorkbenchPresentation.caption.failedWithMessage",
{ message: params.statusMessage.trim() },
)
: tImageWorkbenchPresentation(
"agentChat.imageWorkbenchPresentation.caption.failedDefault",
);
case "cancelled":
return "已停止生成。";
return tImageWorkbenchPresentation(
"agentChat.imageWorkbenchPresentation.caption.cancelled",
);
case "running":
default:
return null;
@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
changeLimeLocale,
getLimeI18n,
limeI18nResources,
} from "@/i18n/createI18n";
import { SUPPORTED_LOCALES, type SupportedLocale } from "@/i18n/locales";
import {
getCurrentSkillCatalogSnapshot,
listSkillCatalogCommandEntries,
type SkillCatalogCommandEntry,
type SkillCatalogCommandIntentConfirmation,
} from "@/lib/api/skillCatalog";
import {
resolveFirstPlainInputIntentConfirmation,
resolvePlainInputIntentConfirmation,
} from "./plainInputIntentConfirmation";
const RULE_SUFFIXES = ["includeAny", "requireAny", "excludeAny"] as const;
const LEGACY_VISUAL_BRIEF_SEGMENT = ["plain", "Visual", "Brief"].join("");
const LEGACY_VISUAL_BRIEF_ID = ["plain", "visual", "brief"].join("_");
function readAgentResource(locale: SupportedLocale, key: string): string {
const value = limeI18nResources[locale]?.agent?.[key];
return typeof value === "string" ? value : "";
}
function readRuleTerms(locale: SupportedLocale, key: string): string[] {
return readAgentResource(locale, key)
.split("|")
.map((item) => item.trim())
.filter(Boolean);
}
function listCommandsWithIntentConfirmation(): Array<{
command: SkillCatalogCommandEntry;
intent: SkillCatalogCommandIntentConfirmation;
}> {
return listSkillCatalogCommandEntries(getCurrentSkillCatalogSnapshot())
.map((command) => {
const intent = command.binding?.intentConfirmation;
return intent ? { command, intent } : null;
})
.filter(
(
item,
): item is {
command: SkillCatalogCommandEntry;
intent: SkillCatalogCommandIntentConfirmation;
} => Boolean(item),
);
}
function getPrimaryIntentConfirmation() {
const primary = listCommandsWithIntentConfirmation()[0];
if (!primary) {
throw new Error("缺少 catalog intent confirmation fixture");
}
return primary;
}
function buildLocaleMatchText(
locale: SupportedLocale,
intent: SkillCatalogCommandIntentConfirmation,
): string {
const include = readRuleTerms(locale, `${intent.ruleKey}.includeAny`)[0];
const required = readRuleTerms(locale, `${intent.ruleKey}.requireAny`)[0];
return [include, required].filter(Boolean).join(" ");
}
function buildLocaleExcludedText(
locale: SupportedLocale,
intent: SkillCatalogCommandIntentConfirmation,
): string {
const include = readRuleTerms(locale, `${intent.ruleKey}.includeAny`)[0];
const required = readRuleTerms(locale, `${intent.ruleKey}.requireAny`)[0];
const excluded = readRuleTerms(locale, `${intent.ruleKey}.excludeAny`)[0];
return [include, required, excluded].filter(Boolean).join(" ");
}
describe("plainInputIntentConfirmation", () => {
beforeEach(async () => {
await changeLimeLocale("zh-CN");
});
it("catalog 中声明的 plain input intent confirmation 应覆盖 current 五语言", () => {
const entries = listCommandsWithIntentConfirmation();
expect(entries.length).toBeGreaterThan(0);
for (const { intent } of entries) {
expect(intent.id).not.toContain(LEGACY_VISUAL_BRIEF_ID);
expect(intent.ruleKey).not.toContain(LEGACY_VISUAL_BRIEF_SEGMENT);
expect(intent.confirmationKey).not.toContain(
LEGACY_VISUAL_BRIEF_SEGMENT,
);
expect(intent.systemPromptKey || "").not.toContain(
LEGACY_VISUAL_BRIEF_SEGMENT,
);
for (const locale of SUPPORTED_LOCALES) {
expect(readAgentResource(locale, intent.confirmationKey)).toBeTruthy();
if (intent.systemPromptKey) {
expect(readAgentResource(locale, intent.systemPromptKey)).toBeTruthy();
}
for (const suffix of RULE_SUFFIXES) {
expect(
readRuleTerms(locale, `${intent.ruleKey}.${suffix}`).length,
).toBeGreaterThan(0);
}
}
}
});
it("应按当前 locale 读取 catalog 规则,不在运行时固定某一种语言", async () => {
const { command, intent } = getPrimaryIntentConfirmation();
const triggerPrefix = command.triggers[0]?.prefix;
for (const locale of SUPPORTED_LOCALES) {
await changeLimeLocale(locale);
const matchText = buildLocaleMatchText(locale, intent);
const translate = getLimeI18n().t as unknown as (
key: string,
options?: Record<string, unknown>,
) => string;
const expectedConfirmation = translate(intent.confirmationKey, {
ns: "agent",
});
expect(resolvePlainInputIntentConfirmation(matchText)).toMatchObject({
commandKey: command.commandKey,
intentId: intent.id,
confirmation: expectedConfirmation,
});
if (triggerPrefix) {
expect(
resolvePlainInputIntentConfirmation(`${triggerPrefix} ${matchText}`),
).toBeNull();
}
expect(
resolvePlainInputIntentConfirmation(
buildLocaleExcludedText(locale, intent),
),
).toBeNull();
}
});
it("系统提示同样来自 catalog/i18n,并复用同一条确认文案", () => {
const resolved = resolveFirstPlainInputIntentConfirmation();
expect(resolved?.confirmation).toBeTruthy();
expect(resolved?.systemPrompt).toContain(resolved?.confirmation);
});
});
@@ -0,0 +1,226 @@
import { getLimeI18n } from "@/i18n/createI18n";
import {
getCurrentSkillCatalogSnapshot,
listSkillCatalogCommandEntries,
listSkillCatalogEntries,
type SkillCatalogCommandEntry,
type SkillCatalogCommandIntentConfirmation,
type SkillCatalogCommandTrigger,
type SkillCatalogEntry,
} from "@/lib/api/skillCatalog";
interface LocalizedPlainIntentRules {
includeAny: string[];
requireAny?: string[];
excludeAny?: string[];
}
interface PlainInputIntentConfirmationCandidate {
command: SkillCatalogCommandEntry;
intent: SkillCatalogCommandIntentConfirmation;
rules: LocalizedPlainIntentRules;
}
export interface PlainInputIntentConfirmation {
commandKey: string;
intentId: string;
confirmation: string;
systemPrompt?: string;
}
function normalizeIntentText(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, " ");
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function translateAgentString(
key: string,
options?: Record<string, unknown>,
): string {
const i18n = getLimeI18n();
if (!i18n.exists(key, { ns: "agent" })) {
return "";
}
const translate = i18n.t as unknown as (
key: string,
options?: Record<string, unknown>,
) => string;
return translate(key, {
ns: "agent",
...(options || {}),
});
}
function parseTermList(value: string): string[] {
return value
.split("|")
.map((item) => normalizeIntentText(item))
.filter(Boolean);
}
function readLocalizedIntentRules(
key: string,
): LocalizedPlainIntentRules | null {
const includeAny = parseTermList(translateAgentString(`${key}.includeAny`));
if (includeAny.length === 0) {
return null;
}
const requireAny = parseTermList(translateAgentString(`${key}.requireAny`));
const excludeAny = parseTermList(translateAgentString(`${key}.excludeAny`));
return {
includeAny,
...(requireAny.length > 0 ? { requireAny } : {}),
...(excludeAny.length > 0 ? { excludeAny } : {}),
};
}
function hasLocalizedTerm(text: string, terms: readonly string[]): boolean {
return terms.some((term) => {
if (/^[a-z0-9][a-z0-9\s-]*$/i.test(term)) {
return new RegExp(
`(^|[^a-z0-9])${escapeRegex(term)}([^a-z0-9]|$)`,
"i",
).test(text);
}
return text.includes(term);
});
}
function collectExplicitCommandTriggers(
entries: readonly SkillCatalogEntry[],
): SkillCatalogCommandTrigger[] {
const triggers: SkillCatalogCommandTrigger[] = [];
for (const entry of entries) {
if (entry.kind === "command") {
triggers.push(...entry.triggers);
} else if (entry.kind === "scene") {
triggers.push({
mode: "slash",
prefix: entry.commandPrefix,
});
}
}
return triggers.sort(
(left, right) => right.prefix.length - left.prefix.length,
);
}
function startsWithExplicitCatalogCommand(
rawText: string,
triggers: readonly SkillCatalogCommandTrigger[],
): boolean {
const text = rawText.trimStart();
if (!text) {
return false;
}
for (const trigger of triggers) {
const prefix = trigger.prefix.trim();
if (!prefix) {
continue;
}
if (text.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()) {
continue;
}
const nextChar = text.charAt(prefix.length);
if (!nextChar || /\s/u.test(nextChar)) {
return true;
}
}
return false;
}
function listConfirmationCandidates(): PlainInputIntentConfirmationCandidate[] {
return listSkillCatalogCommandEntries(getCurrentSkillCatalogSnapshot())
.map((command) => {
const intent = command.binding?.intentConfirmation;
if (!intent) {
return null;
}
const rules = readLocalizedIntentRules(intent.ruleKey);
if (!rules) {
return null;
}
return { command, intent, rules };
})
.filter((item): item is PlainInputIntentConfirmationCandidate =>
Boolean(item),
);
}
function matchesLocalizedIntentRules(
text: string,
rules: LocalizedPlainIntentRules,
): boolean {
if (rules.excludeAny && hasLocalizedTerm(text, rules.excludeAny)) {
return false;
}
if (!hasLocalizedTerm(text, rules.includeAny)) {
return false;
}
return rules.requireAny ? hasLocalizedTerm(text, rules.requireAny) : true;
}
function buildResolvedConfirmation(
candidate: PlainInputIntentConfirmationCandidate,
): PlainInputIntentConfirmation | null {
const confirmation = translateAgentString(candidate.intent.confirmationKey);
if (!confirmation) {
return null;
}
const systemPrompt = candidate.intent.systemPromptKey
? translateAgentString(candidate.intent.systemPromptKey, {
confirmation,
})
: undefined;
return {
commandKey: candidate.command.commandKey,
intentId: candidate.intent.id,
confirmation,
...(systemPrompt ? { systemPrompt } : {}),
};
}
export function resolvePlainInputIntentConfirmation(
value?: string | null,
): PlainInputIntentConfirmation | null {
const rawText = value || "";
const text = normalizeIntentText(rawText);
if (!text) {
return null;
}
const catalog = getCurrentSkillCatalogSnapshot();
if (
startsWithExplicitCatalogCommand(
rawText,
collectExplicitCommandTriggers(listSkillCatalogEntries(catalog)),
)
) {
return null;
}
const candidate = listConfirmationCandidates().find((item) =>
matchesLocalizedIntentRules(text, item.rules),
);
return candidate ? buildResolvedConfirmation(candidate) : null;
}
export function resolveFirstPlainInputIntentConfirmation():
| PlainInputIntentConfirmation
| null {
const candidate = listConfirmationCandidates()[0];
return candidate ? buildResolvedConfirmation(candidate) : null;
}
export function shouldConfirmPlainInputIntent(value?: string | null): boolean {
return Boolean(resolvePlainInputIntentConfirmation(value));
}
@@ -1,40 +0,0 @@
export const PLAIN_VISUAL_BRIEF_CONFIRMATION =
"这看起来是图片生成需求,要我直接调用画图功能生成吗?";
const EXPLICIT_COMMAND_PATTERN = /^\s*[@/]/;
const VISUAL_OBJECT_PATTERN =
/(配图|图片|图像|插画|海报|封面|主视觉|视觉|画面|画一张|画个|生成一张|出一张|做一张|设计一张)/i;
const VISUAL_BRIEF_PATTERNS = [
/\b(?:1:1|3:4|4:5|9:16|16:9)\b/i,
/(竖版|横版|方图|比例|尺寸|留白|标题区|构图|色调|配色|风格|水彩|插画|摄影|电影感|海报|封面|主视觉|品牌视觉|背景|主体|字体)/i,
];
const TEXT_ONLY_INTENT_PATTERN =
/(提示词|prompt|方案|文案|分析|拆解|描述|改写|润色|翻译|总结|只要|不要生成|不要画|先别生成|不需要生成)/i;
export function shouldConfirmPlainVisualBrief(value?: string | null): boolean {
const text = value?.trim() || "";
if (!text || EXPLICIT_COMMAND_PATTERN.test(text)) {
return false;
}
if (TEXT_ONLY_INTENT_PATTERN.test(text)) {
return false;
}
if (!VISUAL_OBJECT_PATTERN.test(text)) {
return false;
}
const visualBriefScore = VISUAL_BRIEF_PATTERNS.reduce(
(score, pattern) => score + (pattern.test(text) ? 1 : 0),
0,
);
return visualBriefScore >= 1;
}
export function buildPlainVisualBriefConfirmationSystemPrompt(): string {
return [
"你是 Lime 的聊天入口意图确认器。",
"用户这轮没有使用 @配图/@修图/@重绘/@image,但内容明显是图片、海报、封面或视觉生成 brief。",
`本轮只能回复这一句:${PLAIN_VISUAL_BRIEF_CONFIRMATION}`,
"不要输出 HTML/CSS/SVG/Markdown 草图,不要写设计方案,不要生成提示词全文,不要创建任务,不要调用任何图片工具。",
].join("\n");
}
@@ -47,7 +47,7 @@ describe("parseResourceSearchWorkbenchCommand", () => {
});
});
it("应兼容 Ribbi 风格的图片搜索命令,并默认按图片检索", () => {
it("应兼容 多词别名风格的图片搜索命令,并默认按图片检索", () => {
const imageSearchResult = parseResourceSearchWorkbenchCommand(
"@Image Search coffee shop window light for social cover",
);
@@ -31,7 +31,7 @@ describe("parseTranscriptionWorkbenchCommand", () => {
});
});
it("应兼容 Ribbi 风格的 @Audio Extractor 命令", () => {
it("应兼容 多词别名风格的 @Audio Extractor 命令", () => {
const result = parseTranscriptionWorkbenchCommand(
"@Audio Extractor https://example.com/demo.mp4 export markdown",
);
@@ -43,7 +43,7 @@ describe("parseTranslationWorkbenchCommand", () => {
});
});
it("应兼容 Ribbi 风格的 @Write Translate 命令", () => {
it("应兼容 多词别名风格的 @Write Translate 命令", () => {
const result = parseTranslationWorkbenchCommand(
"@Write Translate 把这段产品说明译成英文,保留品牌语气",
);
@@ -67,7 +67,7 @@ describe("parseUrlParseWorkbenchCommand", () => {
});
});
it("应兼容 @Read Webpage 与 @Get Homepage 两个 Ribbi 风格入口", () => {
it("应兼容 @Read Webpage 与 @Get Homepage 两个 多词别名风格入口", () => {
const readResult = parseUrlParseWorkbenchCommand(
"@Read Webpage https://example.com/post summarize the launch notes",
);
@@ -30,7 +30,7 @@ describe("parseVideoWorkbenchCommand", () => {
});
});
it("应为后续多词 Ribbi 别名让路,不误吞 @Video Search", () => {
it("应为后续多词命令别名让路,不误吞 @Video Search", () => {
expect(
parseVideoWorkbenchCommand(
"@Video Search AI startup keynote clips for 9:16 ads",
@@ -29,7 +29,7 @@ describe("parseVoiceWorkbenchCommand", () => {
});
});
it("应兼容 Ribbi 风格的 @Website Voiceover 命令", () => {
it("应兼容 多词别名风格的 @Website Voiceover 命令", () => {
const result = parseVoiceWorkbenchCommand(
"@Website Voiceover 语言: 英文 风格: 纪录片 给这个产品页做配音稿",
);
@@ -28,7 +28,7 @@ describe("parseWebpageWorkbenchCommand", () => {
});
});
it("应兼容 Ribbi 风格的网页编排命令", () => {
it("应兼容 多词别名风格的网页编排命令", () => {
const composerResult = parseWebpageWorkbenchCommand(
"@Web Composer campaign page for spring coffee launch",
);
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { changeLimeLocale } from "@/i18n/createI18n";
import { parseImageWorkbenchCommand } from "../utils/imageWorkbenchCommand";
import { createInitialSessionImageWorkbenchState } from "./imageWorkbenchHelpers";
import { resolveImageWorkbenchSkillRequest } from "./imageSkillLaunch";
@@ -10,6 +11,10 @@ vi.mock("sonner", () => ({
}));
describe("resolveImageWorkbenchSkillRequest", () => {
beforeEach(async () => {
await changeLimeLocale("zh-CN");
});
it("@Nanobanana Pro 应覆盖当前图片工作台模型选择", () => {
const rawText =
"@Nanobanana Pro 生成一张广州塔,从花城汇看过去的春天的照片";
@@ -37,7 +42,26 @@ describe("resolveImageWorkbenchSkillRequest", () => {
raw_text: rawText,
provider_id: "fal",
model: "fal-ai/nano-banana-pro",
persona_context: {
version: "lime-image-persona-v1",
persona_id: "lime_image_creator",
},
presentation: {
version: "lime-image-chat-v1",
assistant_intro:
"好啊,用 Nanobanana Pro 生成:一张广州塔,从花城汇看过去的春天的照片\n先获取下工具参数\n马上生成",
completion_caption:
"搞定,图已经生成好了\n要调整的话直接说,我继续改",
},
taste_context: {
version: "lime-image-taste-v1",
source: "taste_layer",
memory_sources: ["explicit_prompt"],
},
},
});
expect(JSON.stringify(skillRequest?.requestContext)).not.toMatch(
new RegExp(["ri", "bbi"].join(""), "i"),
);
});
});
@@ -9,6 +9,11 @@ import {
type ImageWorkbenchApplyTarget,
type SessionImageWorkbenchState,
} from "./imageWorkbenchHelpers";
import {
buildImageTaskPersonaContext,
buildImageTaskPresentationContext,
buildImageTaskTasteContext,
} from "./imageTaskPersona";
export interface ImageWorkbenchSkillRequest {
images: MessageImage[];
@@ -238,6 +243,20 @@ export function resolveImageWorkbenchSkillRequest(
size: parsedCommand.size || params.imageWorkbenchSelectedSize,
aspect_ratio: parsedCommand.aspectRatio,
usage,
persona_context: buildImageTaskPersonaContext(),
presentation: buildImageTaskPresentationContext({
prompt: effectivePrompt,
mode: parsedCommand.mode,
modelId: parsedCommand.modelId || params.imageWorkbenchSelectedModelId,
}),
taste_context: buildImageTaskTasteContext({
prompt: effectivePrompt,
referenceImageCount: referenceImages.length,
entrySource: params.entrySource,
targetOutputPrompt: targetOutput?.prompt || null,
targetOutputModelName: targetOutput?.modelName || null,
applyTargetKind: effectiveApplyTarget?.kind || null,
}),
provider_id:
parsedCommand.providerId || params.imageWorkbenchSelectedProviderId,
model: parsedCommand.modelId || params.imageWorkbenchSelectedModelId,
@@ -0,0 +1,231 @@
import { getLimeI18n } from "@/i18n/createI18n";
import type { MessageImageWorkbenchPreview } from "../types";
import {
buildImageWorkbenchCaption,
buildImageWorkbenchProcessLines,
collapseImageWorkbenchWhitespace,
resolveImageWorkbenchModelLabel,
} from "../utils/imageWorkbenchPresentation";
type ImageTaskMode = NonNullable<MessageImageWorkbenchPreview["mode"]>;
const IMAGE_TASK_PERSONA_ID = "lime_image_creator";
const IMAGE_TASK_PERSONA_VERSION = "lime-image-persona-v1";
const IMAGE_TASK_PRESENTATION_VERSION = "lime-image-chat-v1";
const IMAGE_TASK_TASTE_VERSION = "lime-image-taste-v1";
type ImageTaskPersonaKey =
| "agentChat.imageTaskPersona.subject.fallback"
| "agentChat.imageTaskPersona.intro.generateWithModel"
| "agentChat.imageTaskPersona.intro.generate"
| "agentChat.imageTaskPersona.intro.editWithModel"
| "agentChat.imageTaskPersona.intro.edit"
| "agentChat.imageTaskPersona.intro.variationWithModel"
| "agentChat.imageTaskPersona.intro.variation";
function tImageTaskPersona(
key: ImageTaskPersonaKey,
options?: Record<string, unknown>,
): string {
return getLimeI18n().t(key, {
ns: "agent",
...(options || {}),
});
}
function uniqueCompactStrings(values: Array<string | null | undefined>) {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = collapseImageWorkbenchWhitespace(value || "");
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(normalized);
}
return result;
}
function normalizeImageTaskDisplayTarget(value: string): string {
const normalized = collapseImageWorkbenchWhitespace(value)
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[[^\]]*]\([^)]*\)/g, " ")
.replace(/^@\S+(?:\s+\S+)?\s*/u, "")
.trim();
if (normalized.length <= 72) {
return normalized;
}
return `${normalized.slice(0, 72).trim()}...`;
}
function resolveImageTaskDisplayTarget(prompt: string): string {
return (
normalizeImageTaskDisplayTarget(prompt) ||
tImageTaskPersona("agentChat.imageTaskPersona.subject.fallback")
);
}
export function buildImageTaskAssistantContent(params: {
prompt: string;
mode?: ImageTaskMode;
modelName?: string | null;
}): string {
const modelLabel = resolveImageWorkbenchModelLabel(params.modelName);
const target = resolveImageTaskDisplayTarget(params.prompt);
const introKey: ImageTaskPersonaKey = (() => {
if (params.mode === "edit") {
return modelLabel
? "agentChat.imageTaskPersona.intro.editWithModel"
: "agentChat.imageTaskPersona.intro.edit";
}
if (params.mode === "variation") {
return modelLabel
? "agentChat.imageTaskPersona.intro.variationWithModel"
: "agentChat.imageTaskPersona.intro.variation";
}
return modelLabel
? "agentChat.imageTaskPersona.intro.generateWithModel"
: "agentChat.imageTaskPersona.intro.generate";
})();
return [
tImageTaskPersona(introKey, { model: modelLabel, target }),
...buildImageWorkbenchProcessLines(),
].join("\n");
}
function buildImageTaskResultCaptions(prompt: string): Record<string, string> {
return {
complete:
buildImageWorkbenchCaption({
prompt,
status: "complete",
imageCount: 1,
}) || "",
partial:
buildImageWorkbenchCaption({
prompt,
status: "partial",
imageCount: 1,
}) || "",
failed:
buildImageWorkbenchCaption({
prompt,
status: "failed",
}) || "",
cancelled:
buildImageWorkbenchCaption({
prompt,
status: "cancelled",
}) || "",
};
}
export function buildImageTaskPersonaContext(): Record<string, unknown> {
return {
version: IMAGE_TASK_PERSONA_VERSION,
persona_id: IMAGE_TASK_PERSONA_ID,
surface: "generate_thread",
role: "image_creator",
tone: "natural_minimal",
conversation_policy: {
single_assistant_message: true,
no_submission_summary: true,
no_task_ids_in_chat: true,
no_second_reply_after_tool_submit: true,
no_internal_tool_names_in_chat: true,
},
opening_policy: {
style: "short_ack_then_action",
max_lines_before_tool: 3,
use_presentation_contract_first: true,
},
result_policy: {
complete_caption_max_lines: 2,
invite_iteration: true,
hide_runtime_details: true,
},
};
}
export function buildImageTaskPresentationContext(params: {
prompt: string;
mode: ImageTaskMode;
modelId?: string;
}): Record<string, unknown> {
const resultCaptions = buildImageTaskResultCaptions(params.prompt);
return {
version: IMAGE_TASK_PRESENTATION_VERSION,
surface: "conversation",
assistant_label: "Lime",
persona_id: IMAGE_TASK_PERSONA_ID,
assistant_intro: buildImageTaskAssistantContent({
prompt: params.prompt,
mode: params.mode,
modelName: params.modelId || null,
}),
process_lines: buildImageWorkbenchProcessLines(),
completion_caption: resultCaptions.complete,
partial_caption: resultCaptions.partial,
failed_caption: resultCaptions.failed,
cancelled_caption: resultCaptions.cancelled,
result_captions: resultCaptions,
message_contract: {
single_assistant_message: true,
preserve_intro_during_stream: true,
hide_runtime_details: true,
},
completion_caption_policy: {
tone: "natural_minimal",
max_lines: 2,
invite_iteration: true,
avoid_terms: [
"任务 ID",
"任务文件",
"排队",
"Image Workbench",
"图片工作台",
],
},
};
}
export function buildImageTaskTasteContext(params: {
prompt: string;
referenceImageCount: number;
entrySource?: string;
targetOutputPrompt?: string | null;
targetOutputModelName?: string | null;
applyTargetKind?: string | null;
}): Record<string, unknown> {
const referenceSummaries = uniqueCompactStrings([
params.targetOutputPrompt,
params.targetOutputModelName
? `model:${params.targetOutputModelName}`
: undefined,
]);
const memorySources = uniqueCompactStrings([
"explicit_prompt",
params.referenceImageCount > 0 ? "reference_images" : undefined,
params.targetOutputPrompt ? "target_output" : undefined,
params.applyTargetKind ? `apply_target:${params.applyTargetKind}` : undefined,
]);
return {
version: IMAGE_TASK_TASTE_VERSION,
source: "taste_layer",
entry_source: params.entrySource || "at_image_command",
memory_sources: memorySources,
prompt_intent: normalizeImageTaskDisplayTarget(params.prompt),
reference_image_count: params.referenceImageCount,
reference_summaries: referenceSummaries,
style_keywords: [],
avoid_keywords: [],
cold_start_policy: "use_platform_baseline_without_explaining_it",
tool_parameter_policy: "taste_only_changes_prompt_and_style_fields",
chat_policy: "do_not_explain_taste_sources_in_conversation",
};
}
@@ -1,7 +1,3 @@
import type {
AgentToolCallState,
AgentToolExecutionResult,
} from "@/lib/api/agentProtocol";
import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas";
import type { PlatformType } from "@/lib/workspace/workbenchCanvas";
import type {
@@ -12,10 +8,8 @@ import {
normalizeSelectionAnchorText,
resolveSectionTitleForSelection,
} from "@/components/workspace/document/utils/autoImageInsert";
import type { ContentPart } from "../types";
import type {
ImageWorkbenchOutputView,
ImageWorkbenchTaskMode,
ImageWorkbenchTaskView,
ImageWorkbenchViewport,
} from "../components/imageWorkbenchTypes";
@@ -78,178 +72,6 @@ export function resolveImageWorkbenchAssistantMessageId(
return `image-workbench:${taskId}:assistant`;
}
type ImageWorkbenchMessageStatus =
| "running"
| "complete"
| "partial"
| "failed"
| "cancelled";
interface BuildImageWorkbenchProcessDescriptorParams {
taskId: string;
prompt: string;
mode: ImageWorkbenchTaskMode;
status: ImageWorkbenchMessageStatus;
rawText?: string;
count?: number;
successCount?: number;
size?: string;
imageUrl?: string | null;
failureMessage?: string | null;
startedAt: Date;
endedAt?: Date;
}
function resolveImageWorkbenchProgressLabel(
mode: ImageWorkbenchTaskMode,
): string {
if (mode === "edit") {
return "修图";
}
if (mode === "variation") {
return "重绘";
}
return "配图";
}
function resolveImageWorkbenchActionVerb(mode: ImageWorkbenchTaskMode): string {
if (mode === "edit") {
return "整理来源图与编辑要求,并创建异步修图任务";
}
if (mode === "variation") {
return "整理参考图与重绘要求,并创建异步图片任务";
}
return "整理画面主题、尺寸和出图数量,并创建异步图片任务";
}
function formatToolArguments(value: Record<string, unknown>): string {
return JSON.stringify(value, null, 2);
}
function resolveImageWorkbenchAnalysisText(
params: BuildImageWorkbenchProcessDescriptorParams,
): string {
const prompt = collapseWhitespace(params.prompt) || "当前图片任务";
const actionLabel = resolveImageWorkbenchProgressLabel(params.mode);
switch (params.status) {
case "running":
return `我先按你的描述${resolveImageWorkbenchActionVerb(params.mode)}:${prompt}`;
case "complete":
return `我已按当前描述完成一轮${actionLabel},并把结果同步回对话:${prompt}`;
case "partial":
return `我已按当前描述完成一轮${actionLabel},但这次只拿到了部分结果:${prompt}`;
case "cancelled":
return `这轮${actionLabel}任务已经停止,当前不会继续生成新的结果:${prompt}`;
case "failed":
default:
return `我已经按当前描述提交过一轮${actionLabel},但这次没有拿到可用结果:${prompt}`;
}
}
function resolveImageWorkbenchTaskToolStatus(
status: ImageWorkbenchMessageStatus,
): AgentToolCallState["status"] {
if (status === "running") {
return "running";
}
if (status === "failed") {
return "failed";
}
return "completed";
}
function buildImageWorkbenchTaskToolResult(
params: BuildImageWorkbenchProcessDescriptorParams,
): AgentToolExecutionResult | undefined {
if (params.status === "running") {
return undefined;
}
const successCount =
params.successCount ??
(params.status === "complete" ? params.count || 1 : undefined);
const images =
params.imageUrl &&
(params.status === "complete" || params.status === "partial")
? [{ src: params.imageUrl, origin: "tool_payload" as const }]
: undefined;
switch (params.status) {
case "complete":
return {
success: true,
output: `图片任务已完成,返回 ${successCount || 1} 张结果。`,
images,
};
case "partial":
return {
success: true,
output: `图片任务已完成部分结果,当前返回 ${successCount || 0} 张图片。`,
images,
};
case "cancelled":
return {
success: true,
output: "图片任务已取消,当前不会继续生成新的结果。",
};
case "failed":
default:
return {
success: false,
output:
params.failureMessage?.trim() || "图片任务执行失败,未返回可用结果。",
error: params.failureMessage?.trim() || "图片任务执行失败",
};
}
}
function buildImageWorkbenchTaskToolCall(
params: BuildImageWorkbenchProcessDescriptorParams,
): AgentToolCallState {
const prompt = collapseWhitespace(params.prompt) || "当前图片任务";
const status = resolveImageWorkbenchTaskToolStatus(params.status);
return {
id: `${params.taskId}:task`,
name: "limeCreateImageGenerationTask",
arguments: formatToolArguments({
prompt,
mode: params.mode,
count: params.count || 1,
size: params.size || undefined,
taskId: params.taskId,
}),
status,
result: buildImageWorkbenchTaskToolResult(params),
startTime: params.startedAt,
endTime:
status === "running" ? undefined : (params.endedAt ?? params.startedAt),
};
}
export function buildImageWorkbenchProcessDescriptor(
params: BuildImageWorkbenchProcessDescriptorParams,
): {
toolCalls: AgentToolCallState[];
contentParts: ContentPart[];
} {
const taskToolCall = buildImageWorkbenchTaskToolCall(params);
return {
toolCalls: [taskToolCall],
contentParts: [
{
type: "text",
text: resolveImageWorkbenchAnalysisText(params),
},
{
type: "tool_use",
toolCall: taskToolCall,
},
],
};
}
export function collapseWhitespace(value: string | null | undefined): string {
return (value || "").replace(/\s+/g, " ").trim();
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { resolveInputCapabilityDispatchContext } from "./inputCapabilityRouting";
describe("inputCapabilityRouting", () => {
it("本地 installed skill route 不应生成 /local:* slash 命令", () => {
const context = resolveInputCapabilityDispatchContext({
sourceText: "帮我写一篇关于三国的故事",
displayContent: "帮我写一篇关于三国的故事",
capabilityRoute: {
kind: "installed_skill",
skillKey: "local:brand-product-knowledge-builder",
skillName: "brand-product-knowledge-builder",
},
});
expect(context.sourceText).toBe(
"/brand-product-knowledge-builder 帮我写一篇关于三国的故事",
);
expect(context.capabilityRoute).toMatchObject({
kind: "installed_skill",
skillKey: "brand-product-knowledge-builder",
skillName: "brand-product-knowledge-builder",
});
expect(context.completedSlashUsage).toMatchObject({
kind: "skill",
entryId: "brand-product-knowledge-builder",
replayText: "帮我写一篇关于三国的故事",
});
});
});
@@ -33,6 +33,37 @@ function normalizeSlashCommandReplayText(
return trimmed.slice(0, 400).trim();
}
function normalizeInstalledSkillExecutionKey(skillKey: string): string {
const normalized = skillKey.trim();
if (!normalized) {
return "";
}
return normalized.startsWith("local:")
? normalized.slice("local:".length).trim()
: normalized;
}
function normalizeInputCapabilityRoute(
route: InputCapabilitySendRoute | undefined,
): InputCapabilitySendRoute | undefined {
if (route?.kind !== "installed_skill") {
return route;
}
const normalizedSkillKey = normalizeInstalledSkillExecutionKey(
route.skillKey,
);
if (!normalizedSkillKey || normalizedSkillKey === route.skillKey) {
return route;
}
return {
...route,
skillKey: normalizedSkillKey,
};
}
function resolveInstalledSkillReplayText(params: {
rawText: string;
skillKey: string;
@@ -81,7 +112,9 @@ function resolveEffectiveInputCapabilityRoute(
capabilityRoute: InputCapabilitySendRoute | undefined,
sourceText: string,
): InputCapabilitySendRoute | undefined {
return capabilityRoute || inferRuntimeSceneCapabilityRoute(sourceText);
return normalizeInputCapabilityRoute(
capabilityRoute || inferRuntimeSceneCapabilityRoute(sourceText),
);
}
function resolveRoutedSourceText(params: {
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { changeLimeLocale } from "@/i18n/createI18n";
import {
listDirectory,
readFilePreview,
@@ -248,7 +249,8 @@ function getDocumentCanvasContent(
}
describe("useWorkspaceImageTaskPreviewRuntime", () => {
beforeEach(() => {
beforeEach(async () => {
await changeLimeLocale("zh-CN");
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
@@ -473,7 +475,7 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
imageUrl: "https://example.com/generated-lime.png",
imageCount: 1,
size: "1024x1024",
caption: "搞定,已生成一张未来感实验室里的青柠主视觉。",
caption: "搞定,图已经生成好了\n要调整的话直接说,我继续改",
}),
}),
]);
@@ -791,7 +793,7 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
taskId: "task-image-skill-1",
status: "complete",
imageUrl: "https://example.com/skill-preview.png",
caption: "搞定,已生成一张春日咖啡馆插画。",
caption: "搞定,图已经生成好了\n要调整的话直接说,我继续改",
}),
});
});
@@ -895,6 +897,11 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
size: "1024x1024",
thread_id: "thread-image-live-1",
turn_id: "turn-image-live-1",
presentation: {
assistant_intro: "好啊,生成:青柠插画\n先获取下工具参数\n马上生成",
completion_caption:
"青柠插画已经好了\n要调整的话直接说,我继续改",
},
},
result: {
images: [{ url: "https://example.com/live-turn-image.png" }],
@@ -910,12 +917,12 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
expect(getValue().messages).toHaveLength(1);
expect(getValue().messages[0]).toMatchObject({
id: "assistant-live-image-1",
content: expect.stringContaining("青柠插画"),
content: "好啊,生成:青柠插画\n先获取下工具参数\n马上生成",
imageWorkbenchPreview: expect.objectContaining({
taskId,
status: "complete",
imageUrl: "https://example.com/live-turn-image.png",
caption: "搞定,已生成一张青柠插画。",
caption: "青柠插画已经好了\n要调整的话直接说,我继续改",
}),
});
});
@@ -1042,7 +1049,7 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
expect(getValue().messages[0]).toMatchObject({
id: "assistant-image-nano-old-submit-1",
content: [
"好嘞,用 Nanobanana Pro 给你生成一张广州塔,从花城汇看过去的春天的照片",
"好啊,用 Nanobanana Pro 生成:一张广州塔,从花城汇看过去的春天的照片",
"先获取下工具参数",
"马上生成",
].join("\n"),
@@ -1155,7 +1162,7 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
phase: "succeeded",
statusMessage: null,
imageUrl: "https://example.com/three-kingdoms-collage.png",
caption: "搞定,已生成一张三国主要人物聚合图。",
caption: "搞定,图已经生成好了\n要调整的话直接说,我继续改",
}),
}),
]);
@@ -1483,7 +1490,7 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
mode: "edit",
status: "complete",
imageUrl: "https://example.com/edited-lime.png",
caption: "搞定,已生成一张把主视觉里的青柠改成玻璃质感。",
caption: "搞定,图已经生成好了\n要调整的话直接说,我继续改",
sourceImageUrl: "https://example.com/source-summary.png",
sourceImagePrompt: "原始青柠主视觉",
sourceImageRef: "img-source-1",
@@ -1660,7 +1667,7 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
id: "assistant-draft-image",
role: "assistant",
content:
"好嘞,用 Nanobanana Pro 给你生成一张广州塔春天照片\n先获取下工具参数\n马上生成",
"好啊,用 Nanobanana Pro 生成:一张广州塔春天照片\n先获取下工具参数\n马上生成",
timestamp: new Date("2026-04-04T12:00:00Z"),
isThinking: true,
imageWorkbenchPreview: {
@@ -2319,7 +2326,7 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
status: "complete",
imageUrl: "https://example.com/browser-template-recover-lime.png",
prompt: "一颗鲜嫩的青柠,水彩插画风格",
caption: "搞定,已生成一颗鲜嫩的青柠,水彩插画风格。",
caption: "搞定,图已经生成好了\n要调整的话直接说,我继续改",
}),
}),
]);
@@ -3304,7 +3311,7 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
imageWorkbenchPreview: expect.objectContaining({
taskId: "task-image-cancelled-1",
status: "cancelled",
caption: "已停止生成。",
caption: "已停止生成",
}),
}),
]);
@@ -3406,7 +3413,7 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => {
imageWorkbenchPreview: expect.objectContaining({
taskId: "task-image-cancelled-1",
status: "cancelled",
caption: "已停止生成。",
caption: "已停止生成",
}),
}),
]);
@@ -43,9 +43,9 @@ import {
type SessionImageWorkbenchState,
} from "./imageWorkbenchHelpers";
import {
buildImageWorkbenchAssistantContent,
buildImageWorkbenchCaption,
} from "../utils/imageWorkbenchPresentation";
import { buildImageTaskAssistantContent } from "./imageTaskPersona";
import {
isImageWorkbenchStatusOnlyText,
isImageWorkbenchSubmissionTemplateText,
@@ -80,6 +80,9 @@ interface CreationTaskSubmittedPayload {
mode?: string;
layout_hint?: string;
raw_text?: string;
persona_context?: Record<string, unknown>;
presentation?: Record<string, unknown>;
taste_context?: Record<string, unknown>;
provider?: string;
provider_id?: string;
model?: string;
@@ -290,6 +293,86 @@ function readString(
return undefined;
}
function readImageTaskPresentationText(
candidates: Array<Record<string, unknown> | null | undefined>,
): string | undefined {
for (const candidate of candidates) {
if (!candidate) {
continue;
}
const presentation = asRecord(candidate.presentation);
const value = readString(
[presentation, candidate],
["assistant_intro", "assistantIntro"],
);
if (value) {
return value;
}
}
return undefined;
}
function readNestedPresentationCaption(
presentation: Record<string, unknown> | null | undefined,
status: MessageImageWorkbenchPreview["status"],
): string | undefined {
if (!presentation) {
return undefined;
}
const resultCaptions = asRecord(presentation.result_captions);
const statusSpecificKeys: string[] = (() => {
switch (status) {
case "complete":
return ["completion_caption", "completionCaption", "complete"];
case "partial":
return ["partial_caption", "partialCaption", "partial"];
case "failed":
return [
"failed_caption",
"failedCaption",
"failure_caption",
"failureCaption",
"failed",
"failure",
];
case "cancelled":
return ["cancelled_caption", "cancelledCaption", "cancelled"];
case "running":
default:
return [];
}
})();
return readString(
[presentation, resultCaptions],
[...statusSpecificKeys, "result_caption", "resultCaption"],
);
}
function readImageTaskPresentationCaption(
candidates: Array<Record<string, unknown> | null | undefined>,
status: MessageImageWorkbenchPreview["status"],
): string | undefined {
if (status === "running") {
return undefined;
}
for (const candidate of candidates) {
if (!candidate) {
continue;
}
const value = readNestedPresentationCaption(
asRecord(candidate.presentation) || candidate,
status,
);
if (value) {
return value;
}
}
return undefined;
}
function readPositiveNumber(
candidates: Array<Record<string, unknown> | null | undefined>,
keys: string[],
@@ -1467,6 +1550,8 @@ function buildParsedImageTaskSnapshot(params: {
const currentAttempt = resolveAttemptRecord(params.taskRecord);
const currentAttemptResult = currentAttempt?.result_snapshot;
const resultValue = params.taskRecord.result;
const resultRecord = asRecord(resultValue);
const attemptResultRecord = asRecord(currentAttemptResult);
const normalizedStatus = normalizeTaskStatus(
typeof params.taskRecord.normalized_status === "string"
? params.taskRecord.normalized_status
@@ -1640,12 +1725,30 @@ function buildParsedImageTaskSnapshot(params: {
runtimeContract?.model ??
null;
const previewPrompt = displayPrompt || prompt || `${taskLabel}进行中`;
const previewCaption = buildImageWorkbenchCaption({
prompt: previewPrompt,
status: previewStatus,
imageCount: successCount || undefined,
statusMessage: lastError || null,
});
const presentationText = readImageTaskPresentationText([
resultRecord,
attemptResultRecord,
payload,
uiHintsRecord,
params.taskRecord,
]);
const previewCaption =
readImageTaskPresentationCaption(
[
resultRecord,
attemptResultRecord,
payload,
uiHintsRecord,
params.taskRecord,
],
previewStatus,
) ||
buildImageWorkbenchCaption({
prompt: previewPrompt,
status: previewStatus,
imageCount: successCount || undefined,
statusMessage: lastError || null,
});
const preview: MessageImageWorkbenchPreview = {
taskId: params.taskId,
prompt: previewPrompt,
@@ -1688,13 +1791,13 @@ function buildParsedImageTaskSnapshot(params: {
message: {
id: resolveImageWorkbenchAssistantMessageId(params.taskId),
role: "assistant",
content: buildImageWorkbenchAssistantContent({
prompt: previewPrompt,
mode: taskMode,
modelName: previewModelName,
expectedImageCount: expectedCount,
layoutHint,
}),
content:
presentationText ||
buildImageTaskAssistantContent({
prompt: previewPrompt,
mode: taskMode,
modelName: previewModelName,
}),
timestamp: messageTimestamp,
imageWorkbenchPreview: preview,
},
@@ -1704,6 +1807,8 @@ function buildParsedImageTaskSnapshot(params: {
mode: taskMode,
status: resolveWorkbenchStatus(normalizedStatus),
prompt: previewPrompt,
assistantIntro: presentationText || null,
caption: previewCaption,
rawText: prompt || previewPrompt,
expectedCount,
layoutHint,
@@ -1796,13 +1901,13 @@ function buildPendingImageTaskSnapshot(params: {
message: {
id: resolveImageWorkbenchAssistantMessageId(params.taskId),
role: "assistant",
content: buildImageWorkbenchAssistantContent({
prompt: previewPrompt,
mode: taskMode,
modelName: previewModelName,
expectedImageCount: expectedCount,
layoutHint,
}),
content:
readImageTaskPresentationText([params.payload || null]) ||
buildImageTaskAssistantContent({
prompt: previewPrompt,
mode: taskMode,
modelName: previewModelName,
}),
timestamp: startedAt,
isThinking: false,
imageWorkbenchPreview: {
@@ -1836,6 +1941,10 @@ function buildPendingImageTaskSnapshot(params: {
mode: taskMode,
status: "queued",
prompt: previewPrompt,
assistantIntro: readImageTaskPresentationText([
params.payload || null,
]) || null,
caption: null,
rawText: previewPrompt,
expectedCount,
layoutHint,
@@ -2231,17 +2340,17 @@ function buildImageWorkbenchMessagePatchFromTask(params: {
const startedAt = new Date(params.task.createdAt || Date.now());
return {
content: buildImageWorkbenchAssistantContent({
prompt,
mode: params.task.mode,
modelName:
params.preview.modelName ||
params.outputs[0]?.modelName ||
params.task.runtimeContract?.model ||
null,
expectedImageCount: params.task.expectedCount,
layoutHint: params.preview.layoutHint ?? params.task.layoutHint ?? null,
}),
content:
params.task.assistantIntro ||
buildImageTaskAssistantContent({
prompt,
mode: params.task.mode,
modelName:
params.preview.modelName ||
params.outputs[0]?.modelName ||
params.task.runtimeContract?.model ||
null,
}),
timestamp: startedAt,
isThinking: false,
toolCalls: undefined,
@@ -2285,12 +2394,14 @@ function buildImageWorkbenchPreviewMessageFromTask(params: {
expectedImageCount: params.task.expectedCount,
providerName: previewProviderName,
modelName: previewModelName,
caption: buildImageWorkbenchCaption({
prompt: previewPrompt,
status: previewStatus,
imageCount: outputs.length || undefined,
statusMessage: params.task.failureMessage || null,
}),
caption:
params.task.caption ||
buildImageWorkbenchCaption({
prompt: previewPrompt,
status: previewStatus,
imageCount: outputs.length || undefined,
statusMessage: params.task.failureMessage || null,
}),
layoutHint: params.task.layoutHint ?? null,
storyboardSlots: params.task.storyboardSlots,
sourceImageUrl: params.task.sourceImageUrl ?? null,
@@ -2491,6 +2602,9 @@ function mergeImageTaskSnapshot(
{
...snapshot.task,
sessionId: previousTask?.sessionId || snapshot.task.sessionId,
assistantIntro:
snapshot.task.assistantIntro ?? previousTask?.assistantIntro ?? null,
caption: snapshot.task.caption ?? previousTask?.caption ?? null,
taskFilePath:
snapshot.task.taskFilePath ?? previousTask?.taskFilePath ?? null,
artifactPath:
@@ -2623,12 +2737,15 @@ function patchMessagesWithImageWorkbenchState(params: {
expectedImageCount: task.expectedCount || preview.expectedImageCount,
providerName: nextProviderName,
modelName: nextModelName,
caption: buildImageWorkbenchCaption({
prompt: preview.prompt || task.prompt,
status: nextPreviewStatus,
imageCount: outputs.length || preview.imageCount,
statusMessage: task.failureMessage || preview.statusMessage || null,
}),
caption:
task.caption ||
preview.caption ||
buildImageWorkbenchCaption({
prompt: preview.prompt || task.prompt,
status: nextPreviewStatus,
imageCount: outputs.length || preview.imageCount,
statusMessage: task.failureMessage || preview.statusMessage || null,
}),
layoutHint: task.layoutHint ?? preview.layoutHint ?? null,
storyboardSlots: task.storyboardSlots ?? preview.storyboardSlots,
sourceImageUrl: task.sourceImageUrl ?? preview.sourceImageUrl ?? null,
@@ -3491,6 +3608,9 @@ export function useWorkspaceImageTaskPreviewRuntime({
mode: payload.mode,
layout_hint: payload.layout_hint,
raw_text: payload.raw_text,
persona_context: payload.persona_context,
presentation: payload.presentation,
taste_context: payload.taste_context,
provider: payload.provider,
provider_id: payload.provider_id,
model: payload.model,
@@ -40,6 +40,7 @@ const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = [];
function createParsedCommand() {
return {
rawText: "@配图 生成 城市夜景主视觉",
commandKey: "image_generate",
trigger: "@配图" as const,
body: "生成 城市夜景主视觉",
mode: "generate" as const,
@@ -317,6 +318,7 @@ describe("useWorkspaceImageWorkbenchActionRuntime", () => {
rawText: "@配图 编辑 #img-2 去掉角标,保留主体",
parsedCommand: {
rawText: "@配图 编辑 #img-2 去掉角标,保留主体",
commandKey: "image_edit",
trigger: "@配图",
body: "编辑 #img-2 去掉角标,保留主体",
mode: "edit",
@@ -371,6 +373,7 @@ describe("useWorkspaceImageWorkbenchActionRuntime", () => {
rawText: "@配图 生成 柴犬头像暖色插画",
parsedCommand: {
rawText: "@配图 生成 柴犬头像暖色插画",
commandKey: "image_generate",
trigger: "@配图",
body: "生成 柴犬头像暖色插画",
mode: "generate",
@@ -1,6 +1,7 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { changeLimeLocale, getLimeI18n } from "@/i18n/createI18n";
import type { TeamMemorySnapshot } from "@/lib/teamMemorySync";
import type { ServiceSkillHomeItem } from "../service-skills/types";
import type { Message } from "../types";
@@ -27,6 +28,7 @@ const mockGetOrCreateDefaultProject = vi.hoisted(() => vi.fn());
const mockResolveOemCloudRuntimeContext = vi.hoisted(() => vi.fn());
const mockGetOemCloudBootstrapSnapshot = vi.hoisted(() => vi.fn());
const mockUseGlobalMediaGenerationDefaults = vi.hoisted(() => vi.fn());
const mockInstallSkillFromPromptInstruction = vi.hoisted(() => vi.fn());
vi.mock("../utils/browserAssistPreheat", () => ({
preheatBrowserAssistInBackground: mockPreheatBrowserAssistInBackground,
@@ -65,6 +67,20 @@ vi.mock("@/hooks/useGlobalMediaGenerationDefaults", () => ({
mockUseGlobalMediaGenerationDefaults(),
}));
vi.mock("@/lib/skills/skillInstallPrompt", async () => {
const actual =
await vi.importActual<typeof import("@/lib/skills/skillInstallPrompt")>(
"@/lib/skills/skillInstallPrompt",
);
return {
...actual,
installSkillFromPromptInstruction: (
...args: Parameters<typeof actual.installSkillFromPromptInstruction>
) => mockInstallSkillFromPromptInstruction(...args),
};
});
vi.mock("@/lib/api/project", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api/project")>(
@@ -505,7 +521,8 @@ function mountHook(initialProps?: Partial<HookProps>): HookHarness {
}
describe("useWorkspaceSendActions", () => {
beforeEach(() => {
beforeEach(async () => {
await changeLimeLocale("zh-CN");
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
@@ -528,6 +545,24 @@ describe("useWorkspaceSendActions", () => {
mediaDefaults: {},
loading: false,
});
mockInstallSkillFromPromptInstruction.mockResolvedValue({
directory: "viral-content-breakdown",
inspection: {
content: "",
metadata: {},
allowedTools: [],
resourceSummary: {
hasScripts: false,
hasReferences: false,
hasAssets: false,
},
standardCompliance: {
isStandard: true,
validationErrors: [],
deprecatedFields: [],
},
},
});
mockOpenRuntimeSceneGate.mockResolvedValue(undefined);
});
@@ -767,6 +802,10 @@ describe("useWorkspaceSendActions", () => {
| undefined;
expect(applyLocalMessages).toBeTypeOf("function");
const nextMessages = applyLocalMessages?.([]);
const confirmationText = getLimeI18n().t(
"agentChat.inputIntent.imageGeneration.confirm",
{ ns: "agent" },
);
expect(nextMessages?.[0]).toMatchObject({
role: "user",
content:
@@ -774,7 +813,7 @@ describe("useWorkspaceSendActions", () => {
});
expect(nextMessages?.[1]).toMatchObject({
role: "assistant",
content: "这看起来是图片生成需求,要我直接调用画图功能生成吗?",
content: confirmationText,
});
expect(nextMessages?.[1]?.isThinking).toBeUndefined();
expect(nextMessages?.[1]?.runtimeStatus).toBeUndefined();
@@ -785,6 +824,91 @@ describe("useWorkspaceSendActions", () => {
}
});
it("粘贴官网 Agent Skill 安装 Prompt 时应本地安装并停止发送给模型", async () => {
const harness = mountHook();
const prompt = `Download and install a skill. Follow these steps EXACTLY. If any step fails, STOP and report the error.
SKILL_NAME="viral-content-breakdown"
DOWNLOAD_URL="https://limeai.run/skill-packages/viral-content-breakdown/latest/viral-content-breakdown.zip"
1. Download the Skill package.
2. Extract it into the Agent Skills directory.
3. Restart or reload the Agent so the Skill becomes available.`;
try {
await act(async () => {
const started = await harness
.getValue()
.handleSend([], false, false, prompt, "react");
expect(started).toBe(true);
});
expect(mockInstallSkillFromPromptInstruction).toHaveBeenCalledTimes(1);
expect(mockInstallSkillFromPromptInstruction.mock.calls[0]?.[0]).toEqual({
skillName: "viral-content-breakdown",
downloadUrl:
"https://limeai.run/skill-packages/viral-content-breakdown/latest/viral-content-breakdown.zip",
source: "assignment_prompt",
});
expect(mockInstallSkillFromPromptInstruction.mock.calls[0]?.[1]).toBe(
"lime",
);
expect(mockSendMessage).not.toHaveBeenCalled();
const applyLocalMessages = mockSetChatMessages.mock.calls[0]?.[0] as
| ((previous: Message[]) => Message[])
| undefined;
const nextMessages = applyLocalMessages?.([]);
expect(nextMessages?.[0]).toMatchObject({
role: "user",
content: prompt,
});
expect(nextMessages?.[1]).toMatchObject({
role: "assistant",
content: getLimeI18n().t(
"agentChat.skillInstallPrompt.installedConfirmation",
{
ns: "agent",
skill: "viral-content-breakdown",
},
),
});
expect(mockFinalizeAfterSendSuccess).toHaveBeenCalledTimes(1);
} finally {
harness.unmount();
}
});
it("Skill 安装 Prompt 的本地确认应跟随全局语言", async () => {
await changeLimeLocale("en-US");
const harness = mountHook();
try {
await act(async () => {
const started = await harness
.getValue()
.handleSend(
[],
false,
false,
`Download and install a skill.
SKILL_NAME="viral-content-breakdown"
DOWNLOAD_URL="https://limeai.run/skill-packages/viral-content-breakdown/latest/viral-content-breakdown.zip"
Extract it into the Agent Skills directory.`,
"react",
);
expect(started).toBe(true);
});
const applyLocalMessages = mockSetChatMessages.mock.calls[0]?.[0] as
| ((previous: Message[]) => Message[])
| undefined;
const nextMessages = applyLocalMessages?.([]);
expect(nextMessages?.[1]?.content).toContain(
"Installed Skill: viral-content-breakdown",
);
} finally {
harness.unmount();
}
});
it("后端快速响应路由不应等待 Provider 列表预加载", async () => {
const harness = mountHook();
@@ -1373,7 +1497,7 @@ describe("useWorkspaceSendActions", () => {
},
assistantDraft: {
content:
"好嘞,给你生成一组春日咖啡馆插画\n先获取下工具参数\n马上生成",
"好啊,生成:一张春日咖啡馆插画\n先获取下工具参数\n马上生成",
preserveContent: true,
imageWorkbenchPreview: {
prompt: "一张春日咖啡馆插画",
@@ -1425,7 +1549,7 @@ describe("useWorkspaceSendActions", () => {
expect(sendOptions).toMatchObject({
assistantDraft: {
content:
"好嘞,用 Nanobanana Pro 给你生成一张广州塔,从花城汇看过去的春天的照片\n先获取下工具参数\n马上生成",
"好啊,用 Nanobanana Pro 生成:一张广州塔,从花城汇看过去的春天的照片\n先获取下工具参数\n马上生成",
preserveContent: true,
imageWorkbenchPreview: {
prompt: "一张广州塔,从花城汇看过去的春天的照片",
@@ -2871,7 +2995,7 @@ describe("useWorkspaceSendActions", () => {
}
});
it("通过 active installed skill route 发送时,应保留原始显示文案并回写 slash skill 最近使用", async () => {
it("通过 active installed skill route 发送时,应归一 local:* 并回写 slash skill 最近使用", async () => {
const harness = mountHook({
input: "整理最近发布计划",
});
@@ -2890,7 +3014,7 @@ describe("useWorkspaceSendActions", () => {
{
capabilityRoute: {
kind: "installed_skill",
skillKey: "writer",
skillKey: "local:writer",
skillName: "写作助手",
},
displayContent: "整理最近发布计划",
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import type { Dispatch, SetStateAction } from "react";
import type {
@@ -73,11 +74,7 @@ import {
type AgentFastResponseRoutingDecision,
type AgentFastResponseMode,
} from "../utils/fastResponseRouting";
import {
PLAIN_VISUAL_BRIEF_CONFIRMATION,
shouldConfirmPlainVisualBrief,
} from "../utils/plainVisualBriefConfirmation";
import { buildImageWorkbenchAssistantContent } from "../utils/imageWorkbenchPresentation";
import { resolvePlainInputIntentConfirmation } from "../utils/plainInputIntentConfirmation";
import { detectBrowserTaskRequirement } from "../utils/browserTaskRequirement";
import { isTeamRuntimeRecommendation } from "../utils/contextualRecommendations";
import {
@@ -88,6 +85,7 @@ import {
saveChatToolPreferences,
type ChatToolPreferences,
} from "../utils/chatToolPreferences";
import { buildImageTaskAssistantContent } from "./imageTaskPersona";
import type { HandleSendOptions } from "../hooks/handleSendTypes";
import { extractAgentUiPerformanceTraceMetadata } from "../hooks/agentStreamPerformanceMetrics";
import type { UseRuntimeTeamFormationResult } from "../hooks/useRuntimeTeamFormation";
@@ -164,6 +162,11 @@ import { recordServiceSkillUsage } from "../service-skills/storage";
import { composeServiceSkillPrompt } from "../service-skills/promptComposer";
import { recordSlashEntryUsage } from "../skill-selection/slashEntryUsage";
import { CONTENT_POST_SKILL_KEY } from "../utils/contentPostSkill";
import {
installSkillFromPromptInstruction,
parseSkillInstallPromptInstruction,
type SkillInstallPromptInstruction,
} from "@/lib/skills/skillInstallPrompt";
type ExecutionStrategy = "react" | "code_orchestrated" | "auto";
type SetStringState = (value: string) => void;
@@ -492,6 +495,16 @@ function readPositiveInteger(value: unknown): number | undefined {
: undefined;
}
function readImageTaskPresentationText(
imageTask: Record<string, unknown>,
): string | undefined {
const presentation = asRecord(imageTask.presentation);
return normalizeOptionalText(
(presentation?.assistant_intro as string | undefined) ||
(presentation?.assistantIntro as string | undefined),
);
}
function normalizeImageWorkbenchMode(
value: unknown,
): MessageImageWorkbenchPreview["mode"] {
@@ -562,13 +575,13 @@ function buildImageWorkbenchAssistantDraft(
};
return {
content: buildImageWorkbenchAssistantContent({
prompt,
mode,
modelName,
expectedImageCount,
layoutHint,
}),
content:
readImageTaskPresentationText(imageTask) ||
buildImageTaskAssistantContent({
prompt,
mode,
modelName,
}),
imageWorkbenchPreview: preview,
preserveContent: true,
};
@@ -918,24 +931,9 @@ function resolveMentionCommandUsageLaunchUserInput(
}
function resolveImageMentionCommandKey(
trigger: ParsedImageWorkbenchCommand["trigger"],
parsedCommand: ParsedImageWorkbenchCommand,
): string | null {
if (trigger === "@分镜") {
return "image_storyboard";
}
if (trigger === "@修图") {
return "image_edit";
}
if (trigger === "@重绘") {
return "image_variation";
}
if (trigger === "@Nanobanana Pro") {
return "image_generate_nanobanana_pro";
}
if (trigger === "@配图" || trigger === "@Vision 1" || trigger === "@image") {
return "image_generate";
}
return null;
return normalizeOptionalText(parsedCommand.commandKey) ?? null;
}
const MAX_MENTION_COMMAND_REPLAY_TEXT_LENGTH = 400;
@@ -3180,6 +3178,37 @@ type WorkspaceSendResolution =
plan: WorkspaceSendPlan;
};
type AgentWorkspaceTranslator = (
key: string,
options?: Record<string, unknown>,
) => string;
async function resolveSkillInstallPromptConfirmation(
instruction: SkillInstallPromptInstruction,
translate: AgentWorkspaceTranslator,
): Promise<string> {
try {
const result = await installSkillFromPromptInstruction(instruction, "lime");
return translate("agentChat.skillInstallPrompt.installedConfirmation", {
skill: result.directory,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/already exists|已存在/i.test(message)) {
return translate(
"agentChat.skillInstallPrompt.alreadyInstalledConfirmation",
{
skill: instruction.skillName,
},
);
}
return translate("agentChat.skillInstallPrompt.failedConfirmation", {
skill: instruction.skillName,
error: message,
});
}
}
export type WorkspaceHandleSend = (
images?: MessageImage[],
webSearch?: boolean,
@@ -3233,6 +3262,7 @@ export function useWorkspaceSendActions({
ensureSessionForCommandMetadata,
resolveImageWorkbenchSkillRequest,
}: UseWorkspaceSendActionsParams) {
const { t } = useTranslation("agent");
const messagesCount = messages.length;
const [runtimeTeamDispatchPreview, setRuntimeTeamDispatchPreview] =
useState<RuntimeTeamDispatchPreviewSnapshot | null>(null);
@@ -3241,6 +3271,16 @@ export function useWorkspaceSendActions({
const [isPreparingSend, setIsPreparingSend] = useState(false);
const isPreparingSendRef = useRef(false);
const { mediaDefaults } = useGlobalMediaGenerationDefaults();
const translateAgentWorkspace = useCallback<AgentWorkspaceTranslator>(
(key, options) => {
const translate = t as unknown as (
key: string,
options?: Record<string, unknown>,
) => string;
return String(translate(key, options));
},
[t],
);
const { mentionCommandSkillIdMap, mentionCommandPrefixKeyMap } =
useRuntimeMentionCommandCatalog();
const clearRuntimeTeamDispatchPreview = useCallback(() => {
@@ -3487,6 +3527,8 @@ export function useWorkspaceSendActions({
createSubmissionPreviewSnapshot({
key: submissionPreviewKey,
prompt: sourceText,
displayContent: sendOptions?.displayContent,
inputCapabilityRoute: sendOptions?.capabilityRoute,
images: previewImages,
executionStrategy: sendExecutionStrategy ?? executionStrategy,
webSearch: effectiveWebSearch,
@@ -3600,6 +3642,27 @@ export function useWorkspaceSendActions({
});
}
const skillInstallPromptInstruction =
!sendOptions?.purpose && !hasBoundSkillLaunch && sourceText.trim()
? parseSkillInstallPromptInstruction(sourceText)
: null;
if (skillInstallPromptInstruction) {
const confirmation = await resolveSkillInstallPromptConfirmation(
skillInstallPromptInstruction,
translateAgentWorkspace,
);
return {
kind: "local_confirmation",
plan: {
sourceText,
images: effectiveImages,
sendBoundary,
submissionPreviewKey,
confirmation,
},
};
}
const parsedImageWorkbenchCommand =
!sendOptions?.purpose && !hasBoundSkillLaunch && sourceText.trim()
? parseImageWorkbenchCommand(sourceText)
@@ -3637,7 +3700,7 @@ export function useWorkspaceSendActions({
),
};
const mentionCommandKey = resolveImageMentionCommandKey(
parsedImageWorkbenchCommand.trigger,
parsedImageWorkbenchCommand,
);
if (mentionCommandKey) {
markCompletedMentionCommand(
@@ -3662,6 +3725,7 @@ export function useWorkspaceSendActions({
rawText: sourceText,
parsedCommand: {
rawText: parsedPosterWorkbenchCommand.rawText,
commandKey: "poster_generate",
trigger: "@配图",
body: parsedPosterWorkbenchCommand.body,
mode: "generate",
@@ -3710,11 +3774,11 @@ export function useWorkspaceSendActions({
hasBoundSkillLaunch = true;
}
if (
!sendOptions?.purpose &&
!hasBoundSkillLaunch &&
shouldConfirmPlainVisualBrief(sourceText)
) {
const plainInputIntentConfirmation =
!sendOptions?.purpose && !hasBoundSkillLaunch
? resolvePlainInputIntentConfirmation(sourceText)
: null;
if (plainInputIntentConfirmation) {
return {
kind: "local_confirmation",
plan: {
@@ -3722,7 +3786,7 @@ export function useWorkspaceSendActions({
images: effectiveImages,
sendBoundary,
submissionPreviewKey,
confirmation: PLAIN_VISUAL_BRIEF_CONFIRMATION,
confirmation: plainInputIntentConfirmation.confirmation,
},
};
}
@@ -5516,6 +5580,7 @@ export function useWorkspaceSendActions({
resolveSendBoundary,
resourcePromptRewritePreference,
serviceSkills,
translateAgentWorkspace,
mentionCommandPrefixKeyMap,
mentionCommandSkillIdMap,
workspaceRequestMetadataBase,
@@ -224,7 +224,13 @@ describe("workspaceSendHelpers runtime team preview", () => {
const snapshot = createSubmissionPreviewSnapshot({
key: "submission-preview-1",
prompt: "继续处理当前任务",
prompt: "/brand-product-knowledge-builder 继续处理当前任务",
displayContent: "继续处理当前任务",
inputCapabilityRoute: {
kind: "installed_skill",
skillKey: "brand-product-knowledge-builder",
skillName: "产品知识库",
},
images: [],
executionStrategy: "react",
webSearch: true,
@@ -233,7 +239,8 @@ describe("workspaceSendHelpers runtime team preview", () => {
expect(snapshot).toMatchObject({
key: "submission-preview-1",
prompt: "继续处理当前任务",
prompt: "/brand-product-knowledge-builder 继续处理当前任务",
displayContent: "继续处理当前任务",
createdAt: 1_710_000_000_000,
});
expect(snapshot.runtimeStatus).not.toBeNull();
@@ -244,6 +251,11 @@ describe("workspaceSendHelpers runtime team preview", () => {
id: "submission-preview:submission-preview-1:user",
role: "user",
content: "继续处理当前任务",
inputCapabilityRoute: {
kind: "installed_skill",
skillKey: "brand-product-knowledge-builder",
skillName: "产品知识库",
},
});
expect(messages[1]).toMatchObject({
id: "submission-preview:submission-preview-1:assistant",
@@ -17,6 +17,7 @@ import {
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
import type { AssistantDraftState } from "../hooks/agentChatShared";
import type { HandleSendOptions } from "../hooks/handleSendTypes";
import type { InputCapabilitySendRoute } from "../skill-selection/inputCapabilitySelection";
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type {
AgentRuntimeStatus,
@@ -108,6 +109,8 @@ export function buildInitialDispatchKey(
export interface SubmissionPreviewSnapshot {
key: string;
prompt: string;
displayContent?: string;
inputCapabilityRoute?: InputCapabilitySendRoute;
images: MessageImage[];
createdAt: number;
runtimeStatus: NonNullable<Message["runtimeStatus"]>;
@@ -1199,17 +1202,29 @@ interface CreateSubmissionPreviewSnapshotOptions {
executionStrategy: AsterExecutionStrategy;
webSearch?: boolean;
thinking?: boolean;
displayContent?: string;
inputCapabilityRoute?: InputCapabilitySendRoute;
}
export function createSubmissionPreviewSnapshot(
options: CreateSubmissionPreviewSnapshotOptions,
): SubmissionPreviewSnapshot {
const { key, prompt, images, executionStrategy, webSearch, thinking } =
options;
const {
key,
prompt,
images,
executionStrategy,
webSearch,
thinking,
displayContent,
inputCapabilityRoute,
} = options;
return {
key,
prompt,
displayContent,
inputCapabilityRoute,
images,
createdAt: Date.now(),
runtimeStatus: buildWaitingAgentRuntimeStatus({
@@ -1229,9 +1244,10 @@ export function buildSubmissionPreviewMessages(
{
id: `submission-preview:${snapshot.key}:user`,
role: "user",
content: snapshot.prompt,
content: snapshot.displayContent ?? snapshot.prompt,
images: snapshot.images.length > 0 ? snapshot.images : undefined,
timestamp,
inputCapabilityRoute: snapshot.inputCapabilityRoute,
},
{
id: `submission-preview:${snapshot.key}:assistant`,
@@ -2,6 +2,10 @@ import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Skill } from "@/lib/api/skills";
import type {
SkillMarketplaceBundle,
SkillMarketplaceItem,
} from "@/lib/api/officialSkillMarketplace";
import type {
ServiceSkillGroup,
ServiceSkillHomeItem,
@@ -12,15 +16,21 @@ import { SkillsWorkspacePage } from "./SkillsWorkspacePage";
const mocks = vi.hoisted(() => ({
refreshServiceSkills: vi.fn(),
refreshOfficialMarketplace: vi.fn(),
recordServiceSkillUsage: vi.fn(),
refreshLocalSkills: vi.fn(),
uninstallLocalSkill: vi.fn(),
installOfficialMarketplaceSkill: vi.fn(),
getOfficialSkillMarketplaceBundle: vi.fn(),
toastSuccess: vi.fn(),
toastError: vi.fn(),
openDialog: vi.fn(),
createSkillScaffold: vi.fn(),
importLocalSkill: vi.fn(),
serviceSkills: [] as ServiceSkillHomeItem[],
officialMarketplaceSkills: [] as SkillMarketplaceItem[],
officialMarketplaceError: null as string | null,
officialMarketplaceLoading: false,
skillGroups: [] as ServiceSkillGroup[],
localSkills: [] as Skill[],
}));
@@ -58,6 +68,15 @@ vi.mock("@/components/agent/chat/service-skills/useServiceSkills", () => ({
}),
}));
vi.mock("@/hooks/useOfficialSkillMarketplace", () => ({
useOfficialSkillMarketplace: () => ({
skills: mocks.officialMarketplaceSkills,
isLoading: mocks.officialMarketplaceLoading,
error: mocks.officialMarketplaceError,
refresh: mocks.refreshOfficialMarketplace,
}),
}));
vi.mock("@/hooks/useSkills", () => ({
useSkills: () => ({
skills: mocks.localSkills,
@@ -86,12 +105,15 @@ vi.mock("@/lib/api/skills", async (importOriginal) => {
};
});
vi.mock("./SkillScaffoldDialog", () => ({
SkillScaffoldDialog: () => null,
vi.mock("@/lib/api/officialSkillMarketplace", () => ({
installOfficialMarketplaceSkill: (...args: unknown[]) =>
mocks.installOfficialMarketplaceSkill(...args),
getOfficialSkillMarketplaceBundle: (...args: unknown[]) =>
mocks.getOfficialSkillMarketplaceBundle(...args),
}));
vi.mock("@/components/agent/chat/components/CuratedTaskLauncherDialog", () => ({
CuratedTaskLauncherDialog: () => null,
vi.mock("./SkillScaffoldDialog", () => ({
SkillScaffoldDialog: () => null,
}));
vi.mock("@/components/ui/dialog", () => ({
@@ -188,6 +210,62 @@ function createDefaultServiceSkills(): ServiceSkillHomeItem[] {
];
}
function createDefaultMarketplaceSkills(): SkillMarketplaceItem[] {
return [
{
id: "official:analysis",
name: "analysis",
aliases: ["data-analysis"],
title: "数据分析",
summary: "整理数据、提炼结论,并输出可继续追问的分析摘要。",
category: "数据",
outputHint: "分析摘要",
version: "2026.05",
sort: 10,
icon: {
kind: "svg",
svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="12" fill="#e0f2fe"/></svg>',
},
cover: {
kind: "svg",
svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180"><rect width="320" height="180" rx="20" fill="#f0f9ff"/></svg>',
},
bundle: {
name: "analysis",
description: "标准 AgentSkills 数据分析包。",
resourceSummary: {
hasReferences: true,
hasScripts: false,
hasAssets: false,
},
standardCompliance: {
isStandard: true,
validationErrors: [],
deprecatedFields: [],
},
},
},
];
}
function createDefaultMarketplaceBundle(): SkillMarketplaceBundle {
return {
manifestVersion: "agentskills.v1",
name: "analysis",
aliases: ["data-analysis"],
version: "2026.05",
contentHash: "sha256:bundle",
fileCount: 1,
files: [
{
path: "SKILL.md",
content:
"---\nname: Analysis\n---\n# Deep Research\n\n## Core Purpose\n\nDeliver citation-backed reports.\n\n> CRITICAL - Phase 0 is mandatory.\n\n## Decision Tree\n\n```\nRequest received\n```",
},
],
};
}
function createDefaultSkillGroups(): ServiceSkillGroup[] {
return [
{
@@ -236,6 +314,20 @@ function findButton(container: HTMLElement, label: string) {
) as HTMLButtonElement | undefined;
}
function findMarketplaceCard(container: HTMLElement, title: string) {
return Array.from(
container.querySelectorAll('[data-testid="skills-marketplace-card"]'),
).find((card) => card.textContent?.includes(title)) as
| HTMLElement
| undefined;
}
function findButtonIn(container: HTMLElement, label: string) {
return Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim().includes(label),
) as HTMLButtonElement | undefined;
}
function getLatestNavigationPayload(onNavigate: ReturnType<typeof vi.fn>) {
return onNavigate.mock.calls.at(-1)?.[1] as
| Record<string, unknown>
@@ -252,15 +344,43 @@ describe("SkillsWorkspacePage", () => {
).IS_REACT_ACT_ENVIRONMENT = true;
mocks.serviceSkills = createDefaultServiceSkills();
mocks.officialMarketplaceSkills = createDefaultMarketplaceSkills();
mocks.officialMarketplaceError = null;
mocks.officialMarketplaceLoading = false;
mocks.skillGroups = createDefaultSkillGroups();
mocks.localSkills = createDefaultLocalSkills();
mocks.refreshServiceSkills.mockReset();
mocks.refreshServiceSkills.mockResolvedValue(undefined);
mocks.refreshOfficialMarketplace.mockReset();
mocks.refreshOfficialMarketplace.mockResolvedValue(undefined);
mocks.recordServiceSkillUsage.mockReset();
mocks.refreshLocalSkills.mockReset();
mocks.refreshLocalSkills.mockResolvedValue(undefined);
mocks.uninstallLocalSkill.mockReset();
mocks.uninstallLocalSkill.mockResolvedValue(undefined);
mocks.installOfficialMarketplaceSkill.mockReset();
mocks.installOfficialMarketplaceSkill.mockResolvedValue({
directory: "analysis",
inspection: {
content: "# Analysis",
metadata: {},
allowedTools: [],
resourceSummary: {
hasScripts: false,
hasReferences: true,
hasAssets: false,
},
standardCompliance: {
isStandard: true,
validationErrors: [],
deprecatedFields: [],
},
},
});
mocks.getOfficialSkillMarketplaceBundle.mockReset();
mocks.getOfficialSkillMarketplaceBundle.mockResolvedValue(
createDefaultMarketplaceBundle(),
);
mocks.toastSuccess.mockReset();
mocks.toastError.mockReset();
mocks.openDialog.mockReset();
@@ -297,9 +417,12 @@ describe("SkillsWorkspacePage", () => {
).toBeNull();
expect(container.textContent).toContain("技能广场");
expect(container.textContent).toContain("官方精选");
expect(container.textContent).toContain("深度研究");
expect(container.textContent).toContain("数据分析");
expect(container.textContent).not.toContain("深度研究");
expect(container.textContent).not.toContain("写作助手");
expect(container.textContent).not.toContain("卸载");
expect(
findButtonIn(findMarketplaceCard(container, "数据分析")!, "卸载"),
).toBeUndefined();
act(() => {
findButton(container, "内置")?.click();
@@ -379,4 +502,142 @@ describe("SkillsWorkspacePage", () => {
expect(mocks.uninstallLocalSkill).toHaveBeenCalledWith("writer");
expect(onNavigate).not.toHaveBeenCalled();
});
it("技能广场点击未安装官方技能应安装标准包并刷新本地列表", async () => {
const { container } = renderPage();
const card = findMarketplaceCard(container, "数据分析");
await act(async () => {
findButtonIn(card!, "安装")?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.installOfficialMarketplaceSkill).toHaveBeenCalledWith(
"analysis",
"lime",
);
expect(mocks.refreshLocalSkills).toHaveBeenCalled();
expect(mocks.toastSuccess).toHaveBeenCalledWith("已安装「数据分析」");
});
it("技能广场点击详情应打开当前技能的 SKILL.md 内容", async () => {
const { container } = renderPage();
const card = findMarketplaceCard(container, "数据分析");
expect(
container.querySelector('[data-testid="skills-marketplace-detail"]'),
).toBeNull();
await act(async () => {
findButtonIn(card!, "详情")?.click();
await Promise.resolve();
await Promise.resolve();
});
const detail = container.querySelector(
'[data-testid="skills-marketplace-detail"]',
);
expect(detail).toBeTruthy();
expect(mocks.getOfficialSkillMarketplaceBundle).toHaveBeenCalledWith(
"analysis",
);
expect(detail?.textContent).toContain("数据分析");
expect(detail?.textContent).toContain("以下内容来自该技能的 SKILL.md 原文");
expect(detail?.textContent).toContain("Deep Research");
expect(detail?.textContent).toContain("Core Purpose");
expect(detail?.textContent).toContain("Decision Tree");
});
it("已安装的官方技能点击使用应回首页输入框预选本地 Skill", () => {
mocks.localSkills = [
...createDefaultLocalSkills(),
{
key: "local:analysis",
name: "数据分析",
description: "已安装的数据分析技能",
directory: "analysis",
installed: true,
sourceKind: "other",
catalogSource: "user",
},
] as Skill[];
const { container, onNavigate } = renderPage();
const card = findMarketplaceCard(container, "数据分析");
act(() => {
findButtonIn(card!, "使用")?.click();
});
expect(onNavigate).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
agentEntry: "new-task",
theme: "general",
preferHomeForInitialInputCapability: true,
initialInputCapability: {
capabilityRoute: {
kind: "installed_skill",
skillKey: "local:analysis",
skillName: "数据分析",
},
requestKey: expect.any(Number),
},
}),
);
});
it("已安装的官方技能点击卸载应走结构化卸载", async () => {
mocks.localSkills = [
...createDefaultLocalSkills(),
{
key: "local:analysis",
name: "数据分析",
description: "已安装的数据分析技能",
directory: "analysis",
installed: true,
sourceKind: "other",
catalogSource: "user",
},
] as Skill[];
const { container, onNavigate } = renderPage();
const card = findMarketplaceCard(container, "数据分析");
await act(async () => {
findButtonIn(card!, "卸载")?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.uninstallLocalSkill).toHaveBeenCalledWith("analysis");
expect(onNavigate).not.toHaveBeenCalled();
});
it("官方技能市场不可用时应回退显示本地可用技能", () => {
mocks.officialMarketplaceSkills = [];
mocks.officialMarketplaceError = "network unavailable";
const { container, onNavigate } = renderPage();
const card = findMarketplaceCard(container, "深度研究");
expect(container.textContent).toContain(
"官方技能市场暂时不可用,已先显示本地可用技能。",
);
expect(card).toBeTruthy();
act(() => {
findButtonIn(card!, "打开")?.click();
});
expect(onNavigate).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
agentEntry: "new-task",
theme: "general",
initialPendingServiceSkillLaunch: expect.objectContaining({
skillId: "service-skill-research",
requestKey: expect.any(Number),
}),
}),
);
});
});
File diff suppressed because it is too large Load Diff
+34
View File
@@ -183,6 +183,40 @@ describe("useDeepLink", () => {
});
});
it("应把官网 install action 透传给前端安装链路", async () => {
const onOpenWebsiteDeepLink = vi.fn();
vi.mocked(getCurrent).mockResolvedValue([
"lime://open?kind=skill&slug=viral-content-breakdown&source=website&v=1&action=install",
]);
vi.mocked(safeInvoke).mockImplementation(async (command) => {
if (command === "handle_open_deep_link") {
return {
payload: {
kind: "skill",
slug: "viral-content-breakdown",
source: "website",
version: "1",
action: "install",
},
};
}
return {};
});
await renderHook({
onOpenWebsiteDeepLink,
});
expect(onOpenWebsiteDeepLink).toHaveBeenCalledWith({
kind: "skill",
slug: "viral-content-breakdown",
source: "website",
version: "1",
action: "install",
});
});
it("应解析支付回跳 deep link 并分发给云端购买状态刷新链路", async () => {
const received: unknown[] = [];
const listener = (event: Event) => {
+1
View File
@@ -208,6 +208,7 @@ export interface OpenDeepLinkPayload {
slug: string;
source?: string | null;
version?: string | null;
action?: "open" | "install" | null;
}
interface OpenDeepLinkResult {
+46
View File
@@ -0,0 +1,46 @@
import { useCallback, useEffect, useState } from "react";
import {
listOfficialSkillMarketplace,
type SkillMarketplaceItem,
} from "@/lib/api/officialSkillMarketplace";
interface UseOfficialSkillMarketplaceResult {
skills: SkillMarketplaceItem[];
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
export function useOfficialSkillMarketplace(
enabled = true,
): UseOfficialSkillMarketplaceResult {
const [skills, setSkills] = useState<SkillMarketplaceItem[]>([]);
const [isLoading, setIsLoading] = useState(enabled);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!enabled) {
setSkills([]);
setError(null);
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const nextSkills = await listOfficialSkillMarketplace({ sort: "default" });
setSkills(nextSkills);
setError(null);
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason));
} finally {
setIsLoading(false);
}
}, [enabled]);
useEffect(() => {
void refresh();
}, [refresh]);
return { skills, isLoading, error, refresh };
}
+60 -1
View File
@@ -158,6 +158,23 @@
"agentChat.imageWorkbenchPreview.placeholder.generating": "Generating image",
"agentChat.imageWorkbenchPreview.placeholder.imageUnavailable": "Image is temporarily unavailable",
"agentChat.imageWorkbenchPreview.placeholder.redrawing": "Redrawing image",
"agentChat.inputIntent.imageGeneration.confirm": "This looks like an image generation request. Should I generate it with the image tool?",
"agentChat.imageTaskPersona.subject.fallback": "this image",
"agentChat.imageTaskPersona.intro.generateWithModel": "Sure — generating with {{model}}: {{target}}",
"agentChat.imageTaskPersona.intro.generate": "Sure — generating: {{target}}",
"agentChat.imageTaskPersona.intro.editWithModel": "Sure — editing with {{model}}: {{target}}",
"agentChat.imageTaskPersona.intro.edit": "Sure — editing: {{target}}",
"agentChat.imageTaskPersona.intro.variationWithModel": "Sure — redrawing with {{model}}: {{target}}",
"agentChat.imageTaskPersona.intro.variation": "Sure — redrawing: {{target}}",
"agentChat.imageWorkbenchPresentation.process.prepareParameters": "Getting the tool parameters",
"agentChat.imageWorkbenchPresentation.process.generateNow": "Generating now",
"agentChat.imageWorkbenchPresentation.caption.completeWithSubject": "Done — the image is ready\nTell me what to adjust and I’ll keep refining it",
"agentChat.imageWorkbenchPresentation.caption.completeDefault": "Done — the image is ready\nTell me what to adjust and I’ll keep refining it",
"agentChat.imageWorkbenchPresentation.caption.partialWithCount": "Generated {{count}} result(s) so far\nTell me what to adjust and I’ll keep refining it",
"agentChat.imageWorkbenchPresentation.caption.partialDefault": "Generated part of the result\nTell me what to adjust and I’ll keep refining it",
"agentChat.imageWorkbenchPresentation.caption.failedWithMessage": "This generation did not finish: {{message}}",
"agentChat.imageWorkbenchPresentation.caption.failedDefault": "This generation did not finish",
"agentChat.imageWorkbenchPresentation.caption.cancelled": "Generation stopped",
"agentChat.incidentPanel.empty": "No active incidents detected",
"agentChat.incidentPanel.priorityBadge": "{{severity}} priority",
"agentChat.messageList.firstTokenStatus.cancelled.detail": "This request has stopped.",
@@ -1489,6 +1506,41 @@
"skills.workspace.autoLoad.on": "On",
"skills.workspace.autoLoad.label": "Auto load",
"skills.workspace.store.officialBadge": "Featured",
"skills.workspace.marketplace.title": "Official Skills",
"skills.workspace.marketplace.subtitle": "Install standard AgentSkills packages from the Lime official marketplace. Installed skills appear in your list, where you can use or uninstall them anytime.",
"skills.workspace.marketplace.syncing": "Syncing",
"skills.workspace.marketplace.count": "{{count}} skills",
"skills.workspace.marketplace.featuredTitle": "Featured",
"skills.workspace.marketplace.otherTitle": "Other skills · {{count}}",
"skills.workspace.marketplace.fallbackNotice": "The official skill marketplace is temporarily unavailable, so local skills are shown first.",
"skills.workspace.marketplace.empty": "No matching skills found. Try another keyword.",
"skills.workspace.marketplace.officialByline": "By @Lime",
"skills.workspace.marketplace.localByline": "Local",
"skills.workspace.marketplace.defaultCategory": "General skill",
"skills.workspace.marketplace.defaultOutputHint": "Bring it back to the home input",
"skills.workspace.marketplace.action.install": "Install",
"skills.workspace.marketplace.action.installing": "Installing",
"skills.workspace.marketplace.action.useInstalled": "Use",
"skills.workspace.marketplace.action.useBuiltin": "Built in",
"skills.workspace.marketplace.action.uninstalling": "Uninstalling",
"skills.workspace.marketplace.action.useLocal": "Open",
"skills.workspace.marketplace.action.uninstall": "Uninstall",
"skills.workspace.marketplace.action.detail": "Details",
"skills.workspace.marketplace.installSuccess": "Installed “{{title}}”",
"skills.workspace.marketplace.installFailed": "Install failed: {{message}}",
"skills.workspace.marketplace.error.requestFailed": "Request failed ({{status}})",
"skills.workspace.marketplace.error.skillNameRequired": "Skill name is required",
"skills.workspace.marketplace.error.invalidBundle": "The official skill bundle is invalid",
"skills.workspace.marketplace.detail.version": "Version {{version}}",
"skills.workspace.marketplace.detail.deliverable": "Deliverable",
"skills.workspace.marketplace.detail.package": "Package",
"skills.workspace.marketplace.detail.sourceNotice": "This content comes from the skill's original SKILL.md.",
"skills.workspace.marketplace.detail.loadingSkillContent": "Reading SKILL.md",
"skills.workspace.marketplace.detail.loadSkillContentFailed": "Failed to read SKILL.md: {{message}}",
"skills.workspace.marketplace.resource.references": "Guides",
"skills.workspace.marketplace.resource.scripts": "Scripts",
"skills.workspace.marketplace.resource.assets": "Assets",
"skills.workspace.marketplace.resource.guideOnly": "Guide file",
"skills.workspace.scaffold.createFailed": "Failed to create skill: {{message}}",
"skills.workspace.import.failed": "Failed to install skill: {{message}}",
"skills.workspace.import.success": "Skill installed and added to User Installed.",
@@ -1982,5 +2034,12 @@
"agentChat.threadReliability.view.recommendation.nextQueuedTurn": "Continue queued turn: {{title}}",
"agentChat.threadReliability.view.recommendation.retryable": "The latest result supports retry; resume or start a new turn",
"skills.inputBadge.remove": "Remove @{{name}}",
"skills.workspace.view.tabsLabel": "Skill pages"
"skills.workspace.view.tabsLabel": "Skill pages",
"agentChat.inputIntent.imageGeneration.rules.includeAny": "image | illustration | poster | cover | key visual | visual | draw | generate an image | create an image | make an image | design an image",
"agentChat.inputIntent.imageGeneration.rules.requireAny": "portrait | landscape | square | ratio | size | canvas | composition | palette | color tone | style | watercolor | photography | cinematic | background | subject | typography | headline area | negative space | brand visual | 1:1 | 3:4 | 4:5 | 9:16 | 16:9 | 4:3 | 3:2 | 2:3 | 21:9 | 5:4",
"agentChat.inputIntent.imageGeneration.rules.excludeAny": "prompt | plan | copy | analyze | analysis | describe | description | rewrite | polish | translate | summarize | summary | only | do not generate | don't generate | no image | no drawing",
"agentChat.inputIntent.imageGeneration.systemPrompt": "You are Lime's chat-entry intent confirmer.\nThe user did not use an explicit image-generation command, but this input may be asking for image generation.\nReply with this sentence only: {{confirmation}}\nDo not write a plan, full prompt, draft, or task status, and do not call any image tool.",
"agentChat.skillInstallPrompt.installedConfirmation": "Installed Skill: {{skill}}\n\nLime has reloaded Skills. You can @ mention this skill in the input box or use it from the Skills workspace.",
"agentChat.skillInstallPrompt.alreadyInstalledConfirmation": "Skill already installed: {{skill}}\n\nThe install Prompt requires no overwrite, so Lime will keep the existing directory and use that Skill.",
"agentChat.skillInstallPrompt.failedConfirmation": "Skill install failed: {{skill}}\n\nError: {{error}}\n\nStopped as required by the install Prompt. Fix the download URL or package structure, then try again."
}
+7 -1
View File
@@ -237,5 +237,11 @@
"common.createProjectDialog.error.staleSchema": "The database schema is outdated. Restart Lime to run migrations.",
"common.createProjectDialog.error.invalidPath": "The project folder is invalid. Choose again.",
"common.createProjectDialog.action.creating": "Creating...",
"common.createProjectDialog.action.create": "Create"
"common.createProjectDialog.action.create": "Create",
"common.app.websiteDeepLink.install.success.title": "Installed “{{title}}”",
"common.app.websiteDeepLink.install.success.description": "The skill was written to the Lime Skills directory and opened for you.",
"common.app.websiteDeepLink.install.failed.title": "Failed to install website skill",
"common.app.websiteDeepLink.install.failed.description": "{{message}}",
"common.app.websiteDeepLink.install.alreadyInstalled.title": "“{{title}}” is already installed",
"common.app.websiteDeepLink.install.alreadyInstalled.description": "Opened the skill for you directly."
}
+60 -1
View File
@@ -158,6 +158,23 @@
"agentChat.imageWorkbenchPreview.placeholder.generating": "画像を生成中",
"agentChat.imageWorkbenchPreview.placeholder.imageUnavailable": "画像を一時的に表示できません",
"agentChat.imageWorkbenchPreview.placeholder.redrawing": "画像を再生成中",
"agentChat.inputIntent.imageGeneration.confirm": "画像生成の依頼に見えます。画像生成機能でそのまま生成しますか?",
"agentChat.imageTaskPersona.subject.fallback": "この画像",
"agentChat.imageTaskPersona.intro.generateWithModel": "はい、{{model}} で生成します:{{target}}",
"agentChat.imageTaskPersona.intro.generate": "はい、生成します:{{target}}",
"agentChat.imageTaskPersona.intro.editWithModel": "はい、{{model}} で処理します:{{target}}",
"agentChat.imageTaskPersona.intro.edit": "はい、処理します:{{target}}",
"agentChat.imageTaskPersona.intro.variationWithModel": "はい、{{model}} で描き直します:{{target}}",
"agentChat.imageTaskPersona.intro.variation": "はい、描き直します:{{target}}",
"agentChat.imageWorkbenchPresentation.process.prepareParameters": "ツールのパラメータを取得します",
"agentChat.imageWorkbenchPresentation.process.generateNow": "すぐに生成します",
"agentChat.imageWorkbenchPresentation.caption.completeWithSubject": "完了しました。画像が生成されました\n調整したい点があれば、そのまま伝えてください",
"agentChat.imageWorkbenchPresentation.caption.completeDefault": "完了しました。画像が生成されました\n調整したい点があれば、そのまま伝えてください",
"agentChat.imageWorkbenchPresentation.caption.partialWithCount": "{{count}} 件の結果を先に生成しました\n調整したい点があれば、そのまま伝えてください",
"agentChat.imageWorkbenchPresentation.caption.partialDefault": "一部の結果を先に生成しました\n調整したい点があれば、そのまま伝えてください",
"agentChat.imageWorkbenchPresentation.caption.failedWithMessage": "今回は生成できませんでした:{{message}}",
"agentChat.imageWorkbenchPresentation.caption.failedDefault": "今回は生成できませんでした",
"agentChat.imageWorkbenchPresentation.caption.cancelled": "生成を停止しました",
"agentChat.incidentPanel.empty": "アクティブな incident は検出されていません",
"agentChat.incidentPanel.priorityBadge": "{{severity}} 優先度",
"agentChat.messageList.firstTokenStatus.cancelled.detail": "このリクエストは停止しました。",
@@ -1489,6 +1506,41 @@
"skills.workspace.autoLoad.on": "オン",
"skills.workspace.autoLoad.label": "自動読み込み",
"skills.workspace.store.officialBadge": "公式おすすめ",
"skills.workspace.marketplace.title": "公式 Skill",
"skills.workspace.marketplace.subtitle": "Lime 公式 Skill マーケットから標準 AgentSkills パッケージをインストールします。インストール後は一覧に入り、個別に使用またはアンインストールできます。",
"skills.workspace.marketplace.syncing": "同期中",
"skills.workspace.marketplace.count": "{{count}} 件の Skill",
"skills.workspace.marketplace.featuredTitle": "公式ピックアップ",
"skills.workspace.marketplace.otherTitle": "その他のスキル · {{count}}",
"skills.workspace.marketplace.fallbackNotice": "公式 Skill マーケットを一時的に利用できないため、まずローカルで使える Skill を表示しています。",
"skills.workspace.marketplace.empty": "一致する Skill が見つかりません。別のキーワードを試してください。",
"skills.workspace.marketplace.officialByline": "By @Lime",
"skills.workspace.marketplace.localByline": "ローカルで利用可",
"skills.workspace.marketplace.defaultCategory": "汎用 Skill",
"skills.workspace.marketplace.defaultOutputHint": "ホーム入力欄に戻して使う",
"skills.workspace.marketplace.action.install": "インストール",
"skills.workspace.marketplace.action.installing": "インストール中",
"skills.workspace.marketplace.action.useInstalled": "使う",
"skills.workspace.marketplace.action.useBuiltin": "内蔵で利用可",
"skills.workspace.marketplace.action.uninstalling": "アンインストール中",
"skills.workspace.marketplace.action.useLocal": "開く",
"skills.workspace.marketplace.action.uninstall": "アンインストール",
"skills.workspace.marketplace.action.detail": "詳細",
"skills.workspace.marketplace.installSuccess": "「{{title}}」をインストールしました",
"skills.workspace.marketplace.installFailed": "インストールに失敗しました: {{message}}",
"skills.workspace.marketplace.error.requestFailed": "リクエストに失敗しました({{status}})",
"skills.workspace.marketplace.error.skillNameRequired": "Skill 名が必要です",
"skills.workspace.marketplace.error.invalidBundle": "公式 Skill バンドルの形式が不正です",
"skills.workspace.marketplace.detail.version": "バージョン {{version}}",
"skills.workspace.marketplace.detail.deliverable": "成果物",
"skills.workspace.marketplace.detail.package": "パッケージ",
"skills.workspace.marketplace.detail.sourceNotice": "以下の内容はこのスキルの SKILL.md 原文です",
"skills.workspace.marketplace.detail.loadingSkillContent": "SKILL.md を読み込み中",
"skills.workspace.marketplace.detail.loadSkillContentFailed": "SKILL.md の読み込みに失敗しました:{{message}}",
"skills.workspace.marketplace.resource.references": "ガイド",
"skills.workspace.marketplace.resource.scripts": "スクリプト",
"skills.workspace.marketplace.resource.assets": "素材",
"skills.workspace.marketplace.resource.guideOnly": "ガイドファイル",
"skills.workspace.scaffold.createFailed": "スキルの作成に失敗しました:{{message}}",
"skills.workspace.import.failed": "スキルのインストールに失敗しました:{{message}}",
"skills.workspace.import.success": "スキルをインストールし、ユーザーインストールに追加しました。",
@@ -1982,5 +2034,12 @@
"agentChat.threadReliability.view.recommendation.nextQueuedTurn": "キュー内ターンを続行できます:{{title}}",
"agentChat.threadReliability.view.recommendation.retryable": "直近の結果は再試行可能です。再開するか新しいターンを開始してください",
"skills.inputBadge.remove": "@{{name}}を外す",
"skills.workspace.view.tabsLabel": "スキルページ"
"skills.workspace.view.tabsLabel": "スキルページ",
"agentChat.inputIntent.imageGeneration.rules.includeAny": "画像 | イラスト | ポスター | カバー | キービジュアル | ビジュアル | 画面 | 描いて | 生成して | 画像を生成 | 画像を作成",
"agentChat.inputIntent.imageGeneration.rules.requireAny": "縦長 | 横長 | 正方形 | 比率 | サイズ | 余白 | 見出し領域 | 構図 | 配色 | 色調 | スタイル | 水彩 | 写真 | 映画風 | 背景 | 被写体 | タイポグラフィ | ブランドビジュアル | 1:1 | 3:4 | 4:5 | 9:16 | 16:9 | 4:3 | 3:2 | 2:3 | 21:9 | 5:4",
"agentChat.inputIntent.imageGeneration.rules.excludeAny": "プロンプト | prompt | 案 | 文章 | 分析 | 説明 | 書き換え | 推敲 | 翻訳 | 要約 | だけ | 生成しない | 描かない | 画像不要",
"agentChat.inputIntent.imageGeneration.systemPrompt": "あなたは Lime のチャット入口の意図確認役です。\nユーザーは明示的な画像生成コマンドを使っていませんが、この入力は画像生成の依頼かもしれません。\nこの一文だけを返してください:{{confirmation}}\n案、完全なプロンプト、草案、タスク状態は書かず、画像ツールも呼び出さないでください。",
"agentChat.skillInstallPrompt.installedConfirmation": "Skill をインストールしました: {{skill}}\n\nLime は Skills を再読み込みしました。入力欄でこの Skill を @ メンションするか、Skills ワークスペースから使用できます。",
"agentChat.skillInstallPrompt.alreadyInstalledConfirmation": "Skill はすでにインストール済みです: {{skill}}\n\nインストール Prompt の要件に従って既存ディレクトリは上書きせず、Lime はその Skill をそのまま使用します。",
"agentChat.skillInstallPrompt.failedConfirmation": "Skill のインストールに失敗しました: {{skill}}\n\nエラー: {{error}}\n\nインストール Prompt の要件に従って後続ステップを停止しました。ダウンロード URL またはパッケージ構造を修正してから再試行してください。"
}
+7 -1
View File
@@ -237,5 +237,11 @@
"common.createProjectDialog.error.staleSchema": "データベーススキーマが古くなっています。Lime を再起動してマイグレーションを実行してください。",
"common.createProjectDialog.error.invalidPath": "プロジェクトフォルダーが無効です。もう一度選択してください。",
"common.createProjectDialog.action.creating": "作成中...",
"common.createProjectDialog.action.create": "作成"
"common.createProjectDialog.action.create": "作成",
"common.app.websiteDeepLink.install.success.title": "「{{title}}」をインストールしました",
"common.app.websiteDeepLink.install.success.description": "Skill を Lime Skills ディレクトリに書き込み、開きました。",
"common.app.websiteDeepLink.install.failed.title": "公式サイトの Skill をインストールできませんでした",
"common.app.websiteDeepLink.install.failed.description": "{{message}}",
"common.app.websiteDeepLink.install.alreadyInstalled.title": "「{{title}}」はインストール済みです",
"common.app.websiteDeepLink.install.alreadyInstalled.description": "Skill を直接開きました。"
}
+60 -1
View File
@@ -158,6 +158,23 @@
"agentChat.imageWorkbenchPreview.placeholder.generating": "이미지 생성 중",
"agentChat.imageWorkbenchPreview.placeholder.imageUnavailable": "이미지를 일시적으로 표시할 수 없습니다",
"agentChat.imageWorkbenchPreview.placeholder.redrawing": "이미지 다시 그리는 중",
"agentChat.inputIntent.imageGeneration.confirm": "이미지 생성 요청으로 보입니다. 이미지 도구로 바로 생성할까요?",
"agentChat.imageTaskPersona.subject.fallback": "이 이미지",
"agentChat.imageTaskPersona.intro.generateWithModel": "좋아요, {{model}}로 생성할게요: {{target}}",
"agentChat.imageTaskPersona.intro.generate": "좋아요, 생성할게요: {{target}}",
"agentChat.imageTaskPersona.intro.editWithModel": "좋아요, {{model}}로 처리할게요: {{target}}",
"agentChat.imageTaskPersona.intro.edit": "좋아요, 처리할게요: {{target}}",
"agentChat.imageTaskPersona.intro.variationWithModel": "좋아요, {{model}}로 다시 그릴게요: {{target}}",
"agentChat.imageTaskPersona.intro.variation": "좋아요, 다시 그릴게요: {{target}}",
"agentChat.imageWorkbenchPresentation.process.prepareParameters": "도구 파라미터를 가져올게요",
"agentChat.imageWorkbenchPresentation.process.generateNow": "바로 생성할게요",
"agentChat.imageWorkbenchPresentation.caption.completeWithSubject": "완료됐어요. 이미지가 준비됐어요\n조정할 부분을 말해주면 계속 다듬을게요",
"agentChat.imageWorkbenchPresentation.caption.completeDefault": "완료됐어요. 이미지가 준비됐어요\n조정할 부분을 말해주면 계속 다듬을게요",
"agentChat.imageWorkbenchPresentation.caption.partialWithCount": "{{count}}개 결과를 먼저 생성했어요\n조정할 부분을 말해주면 계속 다듬을게요",
"agentChat.imageWorkbenchPresentation.caption.partialDefault": "일부 결과를 먼저 생성했어요\n조정할 부분을 말해주면 계속 다듬을게요",
"agentChat.imageWorkbenchPresentation.caption.failedWithMessage": "이번에는 생성하지 못했어요: {{message}}",
"agentChat.imageWorkbenchPresentation.caption.failedDefault": "이번에는 생성하지 못했어요",
"agentChat.imageWorkbenchPresentation.caption.cancelled": "생성을 중지했어요",
"agentChat.incidentPanel.empty": "활성 incident가 감지되지 않았습니다",
"agentChat.incidentPanel.priorityBadge": "{{severity}} 우선순위",
"agentChat.messageList.firstTokenStatus.cancelled.detail": "이 요청은 중지되었습니다.",
@@ -1489,6 +1506,41 @@
"skills.workspace.autoLoad.on": "켜짐",
"skills.workspace.autoLoad.label": "자동 로드",
"skills.workspace.store.officialBadge": "공식 추천",
"skills.workspace.marketplace.title": "공식 Skill",
"skills.workspace.marketplace.subtitle": "Lime 공식 Skill 마켓에서 표준 AgentSkills 패키지를 설치합니다. 설치 후 사용자 설치 목록에서 개별적으로 사용하거나 제거할 수 있습니다.",
"skills.workspace.marketplace.syncing": "동기화 중",
"skills.workspace.marketplace.count": "Skill {{count}}개",
"skills.workspace.marketplace.featuredTitle": "공식 추천",
"skills.workspace.marketplace.otherTitle": "기타 스킬 · {{count}}",
"skills.workspace.marketplace.fallbackNotice": "공식 Skill 마켓을 일시적으로 사용할 수 없어 로컬에서 사용할 수 있는 Skill을 먼저 표시합니다.",
"skills.workspace.marketplace.empty": "일치하는 Skill을 찾지 못했습니다. 다른 키워드로 시도해 보세요.",
"skills.workspace.marketplace.officialByline": "By @Lime",
"skills.workspace.marketplace.localByline": "로컬 사용 가능",
"skills.workspace.marketplace.defaultCategory": "일반 Skill",
"skills.workspace.marketplace.defaultOutputHint": "홈 입력창으로 가져와 계속 사용",
"skills.workspace.marketplace.action.install": "설치",
"skills.workspace.marketplace.action.installing": "설치 중",
"skills.workspace.marketplace.action.useInstalled": "사용",
"skills.workspace.marketplace.action.useBuiltin": "내장 사용 가능",
"skills.workspace.marketplace.action.uninstalling": "제거 중",
"skills.workspace.marketplace.action.useLocal": "열기",
"skills.workspace.marketplace.action.uninstall": "제거",
"skills.workspace.marketplace.action.detail": "상세",
"skills.workspace.marketplace.installSuccess": "“{{title}}”을(를) 설치했습니다",
"skills.workspace.marketplace.installFailed": "설치 실패: {{message}}",
"skills.workspace.marketplace.error.requestFailed": "요청 실패({{status}})",
"skills.workspace.marketplace.error.skillNameRequired": "Skill 이름이 필요합니다",
"skills.workspace.marketplace.error.invalidBundle": "공식 Skill 번들 형식이 올바르지 않습니다",
"skills.workspace.marketplace.detail.version": "버전 {{version}}",
"skills.workspace.marketplace.detail.deliverable": "결과물",
"skills.workspace.marketplace.detail.package": "설치 패키지",
"skills.workspace.marketplace.detail.sourceNotice": "아래 내용은 이 스킬의 원본 SKILL.md에서 가져왔습니다",
"skills.workspace.marketplace.detail.loadingSkillContent": "SKILL.md 읽는 중",
"skills.workspace.marketplace.detail.loadSkillContentFailed": "SKILL.md 읽기 실패: {{message}}",
"skills.workspace.marketplace.resource.references": "가이드",
"skills.workspace.marketplace.resource.scripts": "스크립트",
"skills.workspace.marketplace.resource.assets": "소재",
"skills.workspace.marketplace.resource.guideOnly": "가이드 파일",
"skills.workspace.scaffold.createFailed": "스킬 생성 실패: {{message}}",
"skills.workspace.import.failed": "스킬 설치 실패: {{message}}",
"skills.workspace.import.success": "스킬을 설치했고 사용자 설치에 추가했습니다.",
@@ -1982,5 +2034,12 @@
"agentChat.threadReliability.view.recommendation.nextQueuedTurn": "대기 중인 턴 계속 가능: {{title}}",
"agentChat.threadReliability.view.recommendation.retryable": "최근 결과는 재시도를 지원합니다. 재개하거나 새 턴을 시작하세요",
"skills.inputBadge.remove": "@{{name}} 제거",
"skills.workspace.view.tabsLabel": "스킬 페이지"
"skills.workspace.view.tabsLabel": "스킬 페이지",
"agentChat.inputIntent.imageGeneration.rules.includeAny": "이미지 | 일러스트 | 포스터 | 커버 | 키 비주얼 | 비주얼 | 화면 | 그려 | 생성해 | 이미지 생성 | 이미지 만들어",
"agentChat.inputIntent.imageGeneration.rules.requireAny": "세로형 | 가로형 | 정사각형 | 비율 | 크기 | 여백 | 제목 영역 | 구도 | 색감 | 배색 | 스타일 | 수채화 | 사진 | 시네마틱 | 배경 | 피사체 | 타이포그래피 | 브랜드 비주얼 | 1:1 | 3:4 | 4:5 | 9:16 | 16:9 | 4:3 | 3:2 | 2:3 | 21:9 | 5:4",
"agentChat.inputIntent.imageGeneration.rules.excludeAny": "프롬프트 | prompt | 기획안 | 카피 | 분석 | 설명 | 다시 쓰기 | 윤문 | 번역 | 요약 | 만 | 생성하지 | 그리지 | 이미지 말고 | 그림 말고",
"agentChat.inputIntent.imageGeneration.systemPrompt": "당신은 Lime 채팅 입력의 의도 확인자입니다.\n사용자가 명시적인 이미지 생성 명령을 쓰지는 않았지만, 이 입력은 이미지 생성을 요청하는 것일 수 있습니다.\n이 문장만 답하세요: {{confirmation}}\n기획안, 전체 프롬프트, 초안, 작업 상태를 쓰지 말고 이미지 도구도 호출하지 마세요.",
"agentChat.skillInstallPrompt.installedConfirmation": "Skill 설치 완료: {{skill}}\n\nLime가 Skills를 다시 불러왔습니다. 입력창에서 이 Skill을 @ 멘션하거나 Skills 작업 공간에서 사용할 수 있습니다.",
"agentChat.skillInstallPrompt.alreadyInstalledConfirmation": "Skill이 이미 설치되어 있습니다: {{skill}}\n\n설치 Prompt 요구에 따라 기존 디렉터리를 덮어쓰지 않았으며, Lime는 해당 Skill을 그대로 사용합니다.",
"agentChat.skillInstallPrompt.failedConfirmation": "Skill 설치 실패: {{skill}}\n\n오류: {{error}}\n\n설치 Prompt 요구에 따라 후속 단계를 중지했습니다. 다운로드 URL 또는 패키지 구조를 수정한 뒤 다시 시도하세요."
}
+7 -1
View File
@@ -237,5 +237,11 @@
"common.createProjectDialog.error.staleSchema": "데이터베이스 스키마가 오래되었습니다. Lime을 다시 시작해 마이그레이션을 실행하세요.",
"common.createProjectDialog.error.invalidPath": "프로젝트 폴더가 유효하지 않습니다. 다시 선택하세요.",
"common.createProjectDialog.action.creating": "생성 중...",
"common.createProjectDialog.action.create": "생성"
"common.createProjectDialog.action.create": "생성",
"common.app.websiteDeepLink.install.success.title": "“{{title}}”을(를) 설치했습니다",
"common.app.websiteDeepLink.install.success.description": "Skill을 Lime Skills 디렉터리에 쓰고 열었습니다.",
"common.app.websiteDeepLink.install.failed.title": "공식 사이트 Skill 설치 실패",
"common.app.websiteDeepLink.install.failed.description": "{{message}}",
"common.app.websiteDeepLink.install.alreadyInstalled.title": "“{{title}}”은(는) 이미 설치되어 있습니다",
"common.app.websiteDeepLink.install.alreadyInstalled.description": "Skill을 바로 열었습니다."
}
+60 -1
View File
@@ -158,6 +158,23 @@
"agentChat.imageWorkbenchPreview.placeholder.generating": "正在生成图片",
"agentChat.imageWorkbenchPreview.placeholder.imageUnavailable": "图片暂时无法显示",
"agentChat.imageWorkbenchPreview.placeholder.redrawing": "正在重绘图片",
"agentChat.inputIntent.imageGeneration.confirm": "这看起来是图片生成需求,要我直接调用画图功能生成吗?",
"agentChat.imageTaskPersona.subject.fallback": "这张图",
"agentChat.imageTaskPersona.intro.generateWithModel": "好啊,用 {{model}} 生成:{{target}}",
"agentChat.imageTaskPersona.intro.generate": "好啊,生成:{{target}}",
"agentChat.imageTaskPersona.intro.editWithModel": "好啊,用 {{model}} 处理:{{target}}",
"agentChat.imageTaskPersona.intro.edit": "好啊,处理:{{target}}",
"agentChat.imageTaskPersona.intro.variationWithModel": "好啊,用 {{model}} 重绘:{{target}}",
"agentChat.imageTaskPersona.intro.variation": "好啊,重绘:{{target}}",
"agentChat.imageWorkbenchPresentation.process.prepareParameters": "先获取下工具参数",
"agentChat.imageWorkbenchPresentation.process.generateNow": "马上生成",
"agentChat.imageWorkbenchPresentation.caption.completeWithSubject": "搞定,图已经生成好了\n要调整的话直接说,我继续改",
"agentChat.imageWorkbenchPresentation.caption.completeDefault": "搞定,图已经生成好了\n要调整的话直接说,我继续改",
"agentChat.imageWorkbenchPresentation.caption.partialWithCount": "先生成了 {{count}} 张结果\n要调整的话直接说,我继续改",
"agentChat.imageWorkbenchPresentation.caption.partialDefault": "先生成了一部分结果\n要调整的话直接说,我继续改",
"agentChat.imageWorkbenchPresentation.caption.failedWithMessage": "这次没有生成成功:{{message}}",
"agentChat.imageWorkbenchPresentation.caption.failedDefault": "这次没有生成成功",
"agentChat.imageWorkbenchPresentation.caption.cancelled": "已停止生成",
"agentChat.incidentPanel.empty": "当前未发现活跃 incident",
"agentChat.incidentPanel.priorityBadge": "{{severity}}优先级",
"agentChat.messageList.firstTokenStatus.cancelled.detail": "当前请求已停止。",
@@ -1489,6 +1506,41 @@
"skills.workspace.autoLoad.on": "已开启",
"skills.workspace.autoLoad.label": "自动加载",
"skills.workspace.store.officialBadge": "官方精选",
"skills.workspace.marketplace.title": "官方技能",
"skills.workspace.marketplace.subtitle": "从 Lime 官方技能市场安装标准 AgentSkills 包。安装后会进入用户安装列表,可单独使用或卸载。",
"skills.workspace.marketplace.syncing": "正在同步",
"skills.workspace.marketplace.count": "{{count}} 个技能",
"skills.workspace.marketplace.featuredTitle": "官方精选",
"skills.workspace.marketplace.otherTitle": "其他技能 · {{count}}",
"skills.workspace.marketplace.fallbackNotice": "官方技能市场暂时不可用,已先显示本地可用技能。",
"skills.workspace.marketplace.empty": "没有找到匹配的技能。换个关键词试试。",
"skills.workspace.marketplace.officialByline": "By @Lime",
"skills.workspace.marketplace.localByline": "本地可用",
"skills.workspace.marketplace.defaultCategory": "通用技能",
"skills.workspace.marketplace.defaultOutputHint": "带回首页输入框继续使用",
"skills.workspace.marketplace.action.install": "安装",
"skills.workspace.marketplace.action.installing": "安装中",
"skills.workspace.marketplace.action.useInstalled": "使用",
"skills.workspace.marketplace.action.useBuiltin": "内置可用",
"skills.workspace.marketplace.action.uninstalling": "卸载中",
"skills.workspace.marketplace.action.useLocal": "打开",
"skills.workspace.marketplace.action.uninstall": "卸载",
"skills.workspace.marketplace.action.detail": "详情",
"skills.workspace.marketplace.installSuccess": "已安装「{{title}}」",
"skills.workspace.marketplace.installFailed": "安装失败:{{message}}",
"skills.workspace.marketplace.error.requestFailed": "请求失败({{status}})",
"skills.workspace.marketplace.error.skillNameRequired": "缺少技能名称",
"skills.workspace.marketplace.error.invalidBundle": "官方技能安装包格式非法",
"skills.workspace.marketplace.detail.version": "版本 {{version}}",
"skills.workspace.marketplace.detail.deliverable": "交付内容",
"skills.workspace.marketplace.detail.package": "安装包",
"skills.workspace.marketplace.detail.sourceNotice": "以下内容来自该技能的 SKILL.md 原文",
"skills.workspace.marketplace.detail.loadingSkillContent": "正在读取 SKILL.md",
"skills.workspace.marketplace.detail.loadSkillContentFailed": "读取 SKILL.md 失败:{{message}}",
"skills.workspace.marketplace.resource.references": "指南",
"skills.workspace.marketplace.resource.scripts": "脚本",
"skills.workspace.marketplace.resource.assets": "素材",
"skills.workspace.marketplace.resource.guideOnly": "指南文件",
"skills.workspace.scaffold.createFailed": "创建技能失败:{{message}}",
"skills.workspace.import.failed": "安装技能失败:{{message}}",
"skills.workspace.import.success": "技能已安装,已收进用户安装。",
@@ -1982,5 +2034,12 @@
"agentChat.threadReliability.view.recommendation.nextQueuedTurn": "可继续排队回合:{{title}}",
"agentChat.threadReliability.view.recommendation.retryable": "最近结果支持重试,可恢复或重新发起新回合",
"skills.inputBadge.remove": "移除 @{{name}}",
"skills.workspace.view.tabsLabel": "技能分页"
"skills.workspace.view.tabsLabel": "技能分页",
"agentChat.inputIntent.imageGeneration.rules.includeAny": "配图 | 图片 | 图像 | 插画 | 海报 | 封面 | 主视觉 | 视觉 | 画面 | 画一张 | 画个 | 生成一张 | 出一张 | 做一张 | 设计一张 | image | poster | cover",
"agentChat.inputIntent.imageGeneration.rules.requireAny": "竖版 | 横版 | 方图 | 比例 | 尺寸 | 留白 | 标题区 | 构图 | 色调 | 配色 | 风格 | 水彩 | 插画 | 摄影 | 电影感 | 海报 | 封面 | 主视觉 | 品牌视觉 | 背景 | 主体 | 字体 | 1:1 | 3:4 | 4:5 | 9:16 | 16:9 | 4:3 | 3:2 | 2:3 | 21:9 | 5:4",
"agentChat.inputIntent.imageGeneration.rules.excludeAny": "提示词 | prompt | 方案 | 文案 | 分析 | 拆解 | 描述 | 改写 | 润色 | 翻译 | 总结 | 只要 | 不要生成 | 不要画 | 先别生成 | 不需要生成 | no image",
"agentChat.inputIntent.imageGeneration.systemPrompt": "你是 Lime 的聊天入口意图确认器。\n用户这轮没有使用显式图片生成命令,但输入可能是在请求生成图片。\n本轮只能回复这一句:{{confirmation}}\n不要输出方案、提示词全文、草图或任务状态,也不要调用任何图片工具。",
"agentChat.skillInstallPrompt.installedConfirmation": "已安装 Skill:{{skill}}\n\nLime 已刷新 Skills,可在输入框 @ 该技能或从 Skills 工作台使用。",
"agentChat.skillInstallPrompt.alreadyInstalledConfirmation": "Skill 已安装:{{skill}}\n\n按安装 Prompt 要求未重复覆盖现有目录,Lime 可直接使用该 Skill。",
"agentChat.skillInstallPrompt.failedConfirmation": "Skill 安装失败:{{skill}}\n\n错误:{{error}}\n\n已按安装 Prompt 要求停止后续步骤,请修复下载地址或包结构后重试。"
}
+7 -1
View File
@@ -237,5 +237,11 @@
"common.createProjectDialog.error.staleSchema": "数据库结构过旧,请重启应用以执行迁移",
"common.createProjectDialog.error.invalidPath": "项目目录无效,请重新选择",
"common.createProjectDialog.action.creating": "创建中...",
"common.createProjectDialog.action.create": "创建"
"common.createProjectDialog.action.create": "创建",
"common.app.websiteDeepLink.install.success.title": "已安装「{{title}}」",
"common.app.websiteDeepLink.install.success.description": "技能已写入 Lime Skills 目录,并已为你打开。",
"common.app.websiteDeepLink.install.failed.title": "安装官网技能失败",
"common.app.websiteDeepLink.install.failed.description": "{{message}}",
"common.app.websiteDeepLink.install.alreadyInstalled.title": "「{{title}}」已安装",
"common.app.websiteDeepLink.install.alreadyInstalled.description": "已直接为你打开这个技能。"
}
+60 -1
View File
@@ -158,6 +158,23 @@
"agentChat.imageWorkbenchPreview.placeholder.generating": "正在生成圖片",
"agentChat.imageWorkbenchPreview.placeholder.imageUnavailable": "圖片暫時無法顯示",
"agentChat.imageWorkbenchPreview.placeholder.redrawing": "正在重繪圖片",
"agentChat.inputIntent.imageGeneration.confirm": "這看起來是圖片生成需求,要我直接呼叫繪圖功能生成嗎?",
"agentChat.imageTaskPersona.subject.fallback": "這張圖",
"agentChat.imageTaskPersona.intro.generateWithModel": "好啊,用 {{model}} 生成:{{target}}",
"agentChat.imageTaskPersona.intro.generate": "好啊,生成:{{target}}",
"agentChat.imageTaskPersona.intro.editWithModel": "好啊,用 {{model}} 處理:{{target}}",
"agentChat.imageTaskPersona.intro.edit": "好啊,處理:{{target}}",
"agentChat.imageTaskPersona.intro.variationWithModel": "好啊,用 {{model}} 重繪:{{target}}",
"agentChat.imageTaskPersona.intro.variation": "好啊,重繪:{{target}}",
"agentChat.imageWorkbenchPresentation.process.prepareParameters": "先取得工具參數",
"agentChat.imageWorkbenchPresentation.process.generateNow": "馬上生成",
"agentChat.imageWorkbenchPresentation.caption.completeWithSubject": "搞定,圖已經生成好了\n要調整的話直接說,我繼續改",
"agentChat.imageWorkbenchPresentation.caption.completeDefault": "搞定,圖已經生成好了\n要調整的話直接說,我繼續改",
"agentChat.imageWorkbenchPresentation.caption.partialWithCount": "先生成了 {{count}} 張結果\n要調整的話直接說,我繼續改",
"agentChat.imageWorkbenchPresentation.caption.partialDefault": "先生成了一部分結果\n要調整的話直接說,我繼續改",
"agentChat.imageWorkbenchPresentation.caption.failedWithMessage": "這次沒有生成成功:{{message}}",
"agentChat.imageWorkbenchPresentation.caption.failedDefault": "這次沒有生成成功",
"agentChat.imageWorkbenchPresentation.caption.cancelled": "已停止生成",
"agentChat.incidentPanel.empty": "目前未發現活躍 incident",
"agentChat.incidentPanel.priorityBadge": "{{severity}}優先級",
"agentChat.messageList.firstTokenStatus.cancelled.detail": "目前請求已停止。",
@@ -1489,6 +1506,41 @@
"skills.workspace.autoLoad.on": "已開啟",
"skills.workspace.autoLoad.label": "自動載入",
"skills.workspace.store.officialBadge": "官方精選",
"skills.workspace.marketplace.title": "官方技能",
"skills.workspace.marketplace.subtitle": "從 Lime 官方技能市場安裝標準 AgentSkills 包。安裝後會進入使用者安裝列表,可單獨使用或解除安裝。",
"skills.workspace.marketplace.syncing": "正在同步",
"skills.workspace.marketplace.count": "{{count}} 個技能",
"skills.workspace.marketplace.featuredTitle": "官方精選",
"skills.workspace.marketplace.otherTitle": "其他技能 · {{count}}",
"skills.workspace.marketplace.fallbackNotice": "官方技能市場暫時無法使用,已先顯示本地可用技能。",
"skills.workspace.marketplace.empty": "沒有找到相符的技能。換個關鍵字試試。",
"skills.workspace.marketplace.officialByline": "By @Lime",
"skills.workspace.marketplace.localByline": "本地可用",
"skills.workspace.marketplace.defaultCategory": "通用技能",
"skills.workspace.marketplace.defaultOutputHint": "帶回首頁輸入框繼續使用",
"skills.workspace.marketplace.action.install": "安裝",
"skills.workspace.marketplace.action.installing": "安裝中",
"skills.workspace.marketplace.action.useInstalled": "使用",
"skills.workspace.marketplace.action.useBuiltin": "內建可用",
"skills.workspace.marketplace.action.uninstalling": "解除安裝中",
"skills.workspace.marketplace.action.useLocal": "打開",
"skills.workspace.marketplace.action.uninstall": "解除安裝",
"skills.workspace.marketplace.action.detail": "詳情",
"skills.workspace.marketplace.installSuccess": "已安裝「{{title}}」",
"skills.workspace.marketplace.installFailed": "安裝失敗:{{message}}",
"skills.workspace.marketplace.error.requestFailed": "請求失敗({{status}})",
"skills.workspace.marketplace.error.skillNameRequired": "缺少技能名稱",
"skills.workspace.marketplace.error.invalidBundle": "官方技能安裝包格式不合法",
"skills.workspace.marketplace.detail.version": "版本 {{version}}",
"skills.workspace.marketplace.detail.deliverable": "交付內容",
"skills.workspace.marketplace.detail.package": "安裝包",
"skills.workspace.marketplace.detail.sourceNotice": "以下內容來自該技能的 SKILL.md 原文",
"skills.workspace.marketplace.detail.loadingSkillContent": "正在讀取 SKILL.md",
"skills.workspace.marketplace.detail.loadSkillContentFailed": "讀取 SKILL.md 失敗:{{message}}",
"skills.workspace.marketplace.resource.references": "指南",
"skills.workspace.marketplace.resource.scripts": "腳本",
"skills.workspace.marketplace.resource.assets": "素材",
"skills.workspace.marketplace.resource.guideOnly": "指南檔案",
"skills.workspace.scaffold.createFailed": "建立技能失敗:{{message}}",
"skills.workspace.import.failed": "安裝技能失敗:{{message}}",
"skills.workspace.import.success": "技能已安裝,已收進使用者安裝。",
@@ -1982,5 +2034,12 @@
"agentChat.threadReliability.view.recommendation.nextQueuedTurn": "可繼續排隊回合:{{title}}",
"agentChat.threadReliability.view.recommendation.retryable": "最近結果支援重試,可恢復或重新發起新回合",
"skills.inputBadge.remove": "移除 @{{name}}",
"skills.workspace.view.tabsLabel": "技能分頁"
"skills.workspace.view.tabsLabel": "技能分頁",
"agentChat.inputIntent.imageGeneration.rules.includeAny": "配圖 | 圖片 | 圖像 | 插畫 | 海報 | 封面 | 主視覺 | 視覺 | 畫面 | 畫一張 | 畫個 | 生成一張 | 出一張 | 做一張 | 設計一張 | image | poster | cover",
"agentChat.inputIntent.imageGeneration.rules.requireAny": "直式 | 橫版 | 方圖 | 比例 | 尺寸 | 留白 | 標題區 | 構圖 | 色調 | 配色 | 風格 | 水彩 | 插畫 | 攝影 | 電影感 | 海報 | 封面 | 主視覺 | 品牌視覺 | 背景 | 主體 | 字體 | 1:1 | 3:4 | 4:5 | 9:16 | 16:9 | 4:3 | 3:2 | 2:3 | 21:9 | 5:4",
"agentChat.inputIntent.imageGeneration.rules.excludeAny": "提示詞 | prompt | 方案 | 文案 | 分析 | 拆解 | 描述 | 改寫 | 潤色 | 翻譯 | 總結 | 只要 | 不要生成 | 不要畫 | 先別生成 | 不需要生成 | no image",
"agentChat.inputIntent.imageGeneration.systemPrompt": "你是 Lime 的聊天入口意圖確認器。\n使用者這輪沒有使用明確圖片生成命令,但輸入可能是在請求生成圖片。\n本輪只能回覆這一句:{{confirmation}}\n不要輸出方案、完整提示詞、草圖或任務狀態,也不要呼叫任何圖片工具。",
"agentChat.skillInstallPrompt.installedConfirmation": "已安裝 Skill:{{skill}}\n\nLime 已重新載入 Skills。你可以在輸入框 @ 這個技能,或從 Skills 工作區使用。",
"agentChat.skillInstallPrompt.alreadyInstalledConfirmation": "Skill 已安裝:{{skill}}\n\n依照安裝 Prompt 要求未重複覆蓋既有目錄,Lime 可直接使用該 Skill。",
"agentChat.skillInstallPrompt.failedConfirmation": "Skill 安裝失敗:{{skill}}\n\n錯誤:{{error}}\n\n已依照安裝 Prompt 要求停止後續步驟,請修正下載網址或套件結構後重試。"
}
+7 -1
View File
@@ -237,5 +237,11 @@
"common.createProjectDialog.error.staleSchema": "資料庫結構過舊,請重新啟動 Lime 以執行遷移",
"common.createProjectDialog.error.invalidPath": "專案目錄無效,請重新選擇",
"common.createProjectDialog.action.creating": "建立中...",
"common.createProjectDialog.action.create": "建立"
"common.createProjectDialog.action.create": "建立",
"common.app.websiteDeepLink.install.success.title": "已安裝「{{title}}」",
"common.app.websiteDeepLink.install.success.description": "技能已寫入 Lime Skills 目錄,並已為你開啟。",
"common.app.websiteDeepLink.install.failed.title": "安裝官網技能失敗",
"common.app.websiteDeepLink.install.failed.description": "{{message}}",
"common.app.websiteDeepLink.install.alreadyInstalled.title": "「{{title}}」已安裝",
"common.app.websiteDeepLink.install.alreadyInstalled.description": "已直接為你開啟這個技能。"
}
+5
View File
@@ -1367,6 +1367,11 @@ export interface CreateImageGenerationTaskArtifactRequest {
prompt: string;
title?: string;
titleGenerationResult?: AgentRuntimeGeneratedTitleResult | null;
personaContext?: Record<string, unknown>;
persona_context?: Record<string, unknown>;
presentation?: Record<string, unknown>;
tasteContext?: Record<string, unknown>;
taste_context?: Record<string, unknown>;
mode?: "generate" | "edit" | "variation";
rawText?: string;
layoutHint?: string;
@@ -0,0 +1,161 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getOfficialSkillMarketplaceBundle,
listOfficialSkillMarketplace,
} from "./officialSkillMarketplace";
function mockJsonResponse(payload: unknown, ok = true, status = 200) {
return {
ok,
status,
json: vi.fn().mockResolvedValue(payload),
} as unknown as Response;
}
describe("officialSkillMarketplace", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("应按官网 marketplace envelope 解析官方技能列表", async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce(
mockJsonResponse({
code: 0,
message: "ok",
data: {
items: [
{
id: "official:analysis",
name: "analysis",
aliases: ["data-analysis", ""],
title: "数据分析",
summary: "整理数据并输出结论。",
category: "数据",
outputHint: "分析摘要",
version: "2026.05",
sort: 10,
icon: {
kind: "svg",
svg: "<svg></svg>",
},
bundle: {
name: "analysis",
description: "标准包",
resourceSummary: {
hasReferences: true,
},
standardCompliance: {
isStandard: true,
},
},
},
{
id: "",
title: "非法条目",
},
],
},
}),
);
const items = await listOfficialSkillMarketplace({ sort: "default" });
expect(fetchMock).toHaveBeenCalledWith(
"https://lime-api.limeai.run/api/v1/public/service-skills/marketplace?sort=default",
expect.objectContaining({
method: "GET",
headers: { Accept: "application/json" },
}),
);
expect(items).toEqual([
expect.objectContaining({
id: "official:analysis",
name: "analysis",
aliases: ["data-analysis"],
title: "数据分析",
bundle: expect.objectContaining({
resourceSummary: {
hasScripts: false,
hasReferences: true,
hasAssets: false,
},
standardCompliance: {
isStandard: true,
validationErrors: [],
deprecatedFields: [],
},
}),
}),
]);
});
it("应解析官方技能安装包并保留文件校验信息", async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce(
mockJsonResponse({
code: 0,
message: "ok",
data: {
manifestVersion: "agentskills.v1",
name: "analysis",
aliases: ["data-analysis"],
version: "2026.05",
contentHash: "sha256:bundle",
fileCount: 1,
files: [
{
path: "SKILL.md",
content: "# Analysis",
encoding: "utf-8",
sha256: "sha256:file",
},
],
},
}),
);
await expect(getOfficialSkillMarketplaceBundle("analysis")).resolves.toEqual({
manifestVersion: "agentskills.v1",
name: "analysis",
aliases: ["data-analysis"],
version: "2026.05",
contentHash: "sha256:bundle",
fileCount: 1,
files: [
{
path: "SKILL.md",
content: "# Analysis",
encoding: "utf-8",
sha256: "sha256:file",
},
],
});
expect(fetchMock).toHaveBeenCalledWith(
"https://lime-api.limeai.run/api/v1/public/service-skills/marketplace/analysis/bundle",
expect.any(Object),
);
});
it("接口返回错误时应透传服务端 message", async () => {
vi.mocked(fetch).mockResolvedValueOnce(
mockJsonResponse(
{
code: 500,
message: "marketplace unavailable",
},
false,
503,
),
);
await expect(listOfficialSkillMarketplace()).rejects.toThrow(
"marketplace unavailable",
);
});
});
+390
View File
@@ -0,0 +1,390 @@
import { resolveOemCloudRuntimeContext } from "./oemCloudRuntime";
import { skillsApi, type AppType, type SkillInspection } from "./skills";
import { getLimeI18n } from "@/i18n/createI18n";
const DEFAULT_SKILL_MARKETPLACE_API_BASE_URL =
"https://lime-api.limeai.run/api";
export interface SkillMarketplaceVisualAsset {
kind?: string;
url?: string;
svg?: string;
prompt?: string;
generatedAt?: string;
}
export interface SkillMarketplaceBundleResourceSummary {
hasScripts: boolean;
hasReferences: boolean;
hasAssets: boolean;
}
export interface SkillMarketplaceBundleStandardCompliance {
isStandard: boolean;
validationErrors?: string[];
deprecatedFields?: string[];
}
export interface SkillMarketplaceBundleSummary {
name: string;
description: string;
license?: string;
compatibility?: string;
metadata?: Record<string, string>;
allowedTools?: string[];
resourceSummary: SkillMarketplaceBundleResourceSummary;
standardCompliance: SkillMarketplaceBundleStandardCompliance;
}
export interface SkillMarketplaceItem {
id: string;
name: string;
aliases: string[];
title: string;
summary: string;
category: string;
outputHint: string;
version: string;
sort: number;
icon?: SkillMarketplaceVisualAsset;
cover?: SkillMarketplaceVisualAsset;
contentHash?: string;
bundle?: SkillMarketplaceBundleSummary;
updatedAt?: string;
}
export interface SkillMarketplaceFile {
path: string;
content: string;
encoding?: string;
sha256?: string;
}
export interface SkillMarketplaceBundle {
manifestVersion: string;
name: string;
aliases: string[];
version: string;
contentHash: string;
fileCount: number;
files: SkillMarketplaceFile[];
}
export interface SkillMarketplaceInstallResult {
directory: string;
inspection: SkillInspection;
}
interface SkillMarketplaceListEnvelope {
code?: number;
message?: string;
data?: unknown;
}
export interface ListSkillMarketplaceParams {
query?: string;
category?: string;
sort?: string;
}
function normalizeText(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function normalizeOptionalText(value: unknown): string | undefined {
const normalized = normalizeText(value);
return normalized ? normalized : undefined;
}
function normalizeBaseUrl(value: unknown): string | null {
const normalized = normalizeText(value);
return normalized ? normalized.replace(/\/+$/, "") : null;
}
function tMarketplaceError(
key: string,
options?: Record<string, unknown>,
): string {
const translate = getLimeI18n().t as unknown as (
key: string,
options?: Record<string, unknown>,
) => string;
return translate(`skills.workspace.marketplace.error.${key}`, {
ns: "agent",
...(options || {}),
});
}
function readEnvValue(name: string): string | null {
const env = import.meta.env as Record<string, string | boolean | undefined>;
const value = env[name];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object";
}
function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => normalizeText(item))
.filter((item) => item.length > 0);
}
function normalizeStringRecord(
value: unknown,
): Record<string, string> | undefined {
if (!isRecord(value)) {
return undefined;
}
const result: Record<string, string> = {};
for (const [key, item] of Object.entries(value)) {
const normalizedKey = normalizeText(key);
const normalizedValue = normalizeText(item);
if (normalizedKey && normalizedValue) {
result[normalizedKey] = normalizedValue;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
function normalizeVisualAsset(
value: unknown,
): SkillMarketplaceVisualAsset | undefined {
if (!isRecord(value)) {
return undefined;
}
const svg = normalizeOptionalText(value.svg);
const url = normalizeOptionalText(value.url);
if (!svg && !url) {
return undefined;
}
return {
kind: normalizeOptionalText(value.kind),
url,
svg,
prompt: normalizeOptionalText(value.prompt),
generatedAt: normalizeOptionalText(value.generatedAt),
};
}
function normalizeResourceSummary(
value: unknown,
): SkillMarketplaceBundleResourceSummary {
const record = isRecord(value) ? value : {};
return {
hasScripts: Boolean(record.hasScripts),
hasReferences: Boolean(record.hasReferences),
hasAssets: Boolean(record.hasAssets),
};
}
function normalizeStandardCompliance(
value: unknown,
): SkillMarketplaceBundleStandardCompliance {
const record = isRecord(value) ? value : {};
return {
isStandard: Boolean(record.isStandard),
validationErrors: normalizeStringList(record.validationErrors),
deprecatedFields: normalizeStringList(record.deprecatedFields),
};
}
function normalizeBundleSummary(
value: unknown,
): SkillMarketplaceBundleSummary | undefined {
if (!isRecord(value)) {
return undefined;
}
const name = normalizeText(value.name);
const description = normalizeText(value.description);
if (!name && !description) {
return undefined;
}
return {
name,
description,
license: normalizeOptionalText(value.license),
compatibility: normalizeOptionalText(value.compatibility),
metadata: normalizeStringRecord(value.metadata),
allowedTools: normalizeStringList(value.allowedTools),
resourceSummary: normalizeResourceSummary(value.resourceSummary),
standardCompliance: normalizeStandardCompliance(value.standardCompliance),
};
}
function normalizeMarketplaceItem(value: unknown): SkillMarketplaceItem | null {
if (!isRecord(value)) {
return null;
}
const name = normalizeText(value.name);
const id = normalizeText(value.id) || name;
const title = normalizeText(value.title) || name;
if (!id || !name || !title) {
return null;
}
return {
id,
name,
aliases: normalizeStringList(value.aliases),
title,
summary: normalizeText(value.summary),
category: normalizeText(value.category),
outputHint: normalizeText(value.outputHint),
version: normalizeText(value.version),
sort: typeof value.sort === "number" ? value.sort : 0,
icon: normalizeVisualAsset(value.icon),
cover: normalizeVisualAsset(value.cover),
contentHash: normalizeOptionalText(value.contentHash),
bundle: normalizeBundleSummary(value.bundle),
updatedAt: normalizeOptionalText(value.updatedAt),
};
}
function normalizeMarketplaceItems(value: unknown): SkillMarketplaceItem[] {
if (!isRecord(value) || !Array.isArray(value.items)) {
return [];
}
return value.items
.map(normalizeMarketplaceItem)
.filter((item): item is SkillMarketplaceItem => Boolean(item));
}
function normalizeMarketplaceFile(value: unknown): SkillMarketplaceFile | null {
if (!isRecord(value)) {
return null;
}
const path = normalizeText(value.path);
const content = typeof value.content === "string" ? value.content : "";
if (!path) {
return null;
}
return {
path,
content,
encoding: normalizeOptionalText(value.encoding),
sha256: normalizeOptionalText(value.sha256),
};
}
function normalizeMarketplaceBundle(
value: unknown,
): SkillMarketplaceBundle | null {
if (!isRecord(value)) {
return null;
}
const name = normalizeText(value.name);
const files = Array.isArray(value.files)
? value.files
.map(normalizeMarketplaceFile)
.filter((file): file is SkillMarketplaceFile => Boolean(file))
: [];
if (!name || files.length === 0) {
return null;
}
return {
manifestVersion: normalizeText(value.manifestVersion) || "agentskills.v1",
name,
aliases: normalizeStringList(value.aliases),
version: normalizeText(value.version),
contentHash: normalizeText(value.contentHash),
fileCount:
typeof value.fileCount === "number" ? value.fileCount : files.length,
files,
};
}
async function requestMarketplaceJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
method: "GET",
headers: { Accept: "application/json" },
});
let payload: SkillMarketplaceListEnvelope | null = null;
try {
payload = (await response.json()) as SkillMarketplaceListEnvelope;
} catch {
payload = null;
}
if (!response.ok) {
throw new Error(
payload?.message?.trim() ||
tMarketplaceError("requestFailed", { status: response.status }),
);
}
return payload?.data as T;
}
export function resolveSkillMarketplaceApiBaseUrl(): string {
const explicitBaseUrl = normalizeBaseUrl(
readEnvValue("VITE_LIME_SKILL_MARKETPLACE_API_BASE_URL") ??
readEnvValue("VITE_LIME_CONTROL_PLANE_API_BASE_URL"),
);
if (explicitBaseUrl) {
return explicitBaseUrl;
}
const runtime = resolveOemCloudRuntimeContext();
if (runtime?.controlPlaneBaseUrl) {
return runtime.controlPlaneBaseUrl;
}
return DEFAULT_SKILL_MARKETPLACE_API_BASE_URL;
}
export async function listOfficialSkillMarketplace(
params: ListSkillMarketplaceParams = {},
): Promise<SkillMarketplaceItem[]> {
const search = new URLSearchParams();
if (params.query?.trim()) {
search.set("query", params.query.trim());
}
if (params.category?.trim()) {
search.set("category", params.category.trim());
}
if (params.sort?.trim()) {
search.set("sort", params.sort.trim());
}
const query = search.toString();
const data = await requestMarketplaceJson<unknown>(
`${resolveSkillMarketplaceApiBaseUrl()}/v1/public/service-skills/marketplace${
query ? `?${query}` : ""
}`,
);
return normalizeMarketplaceItems(data);
}
export async function getOfficialSkillMarketplaceBundle(
skillName: string,
): Promise<SkillMarketplaceBundle> {
const normalizedSkillName = skillName.trim();
if (!normalizedSkillName) {
throw new Error(tMarketplaceError("skillNameRequired"));
}
const data = await requestMarketplaceJson<unknown>(
`${resolveSkillMarketplaceApiBaseUrl()}/v1/public/service-skills/marketplace/${encodeURIComponent(
normalizedSkillName,
)}/bundle`,
);
const bundle = normalizeMarketplaceBundle(data);
if (!bundle) {
throw new Error(tMarketplaceError("invalidBundle"));
}
return bundle;
}
export async function installOfficialMarketplaceSkill(
skillName: string,
app: AppType = "lime",
): Promise<SkillMarketplaceInstallResult> {
const bundle = await getOfficialSkillMarketplaceBundle(skillName);
return skillsApi.installMarketplaceBundle(bundle, app);
}
+37
View File
@@ -158,6 +158,17 @@ function buildBaseSetupPackage() {
policy_profile_ref: "sceneapp-policy-profile",
aliases: ["短视频配音", "story-voice"],
trigger_hints: ["@配音", "/voice-runtime"],
command_binding: {
request_defaults: {
launch_hint: "voice_scene",
},
intent_confirmation: {
id: "plain_voice_request",
rule_key: "agentChat.voice.intentRules",
confirmation_key: "agentChat.voice.confirmPlainRequest",
system_prompt_key: "agentChat.voice.confirmPlainRequestPrompt",
},
},
},
],
slot_profiles: [
@@ -490,6 +501,15 @@ describe("skillCatalog", () => {
binding: {
skillId: "sceneapp-service",
executionKind: "agent_turn",
requestDefaults: {
launch_hint: "voice_scene",
},
intentConfirmation: {
id: "plain_voice_request",
ruleKey: "agentChat.voice.intentRules",
confirmationKey: "agentChat.voice.confirmPlainRequest",
systemPromptKey: "agentChat.voice.confirmPlainRequestPrompt",
},
},
}),
);
@@ -595,6 +615,9 @@ describe("skillCatalog", () => {
const formEntry = listSkillCatalogCommandEntries(seeded).find(
(entry) => entry.commandKey === "form_generate",
);
const imageEntry = listSkillCatalogCommandEntries(seeded).find(
(entry) => entry.commandKey === "image_generate",
);
const posterEntry = listSkillCatalogCommandEntries(seeded).find(
(entry) => entry.commandKey === "poster_generate",
);
@@ -685,6 +708,20 @@ describe("skillCatalog", () => {
supportsStreaming: true,
supportsTimeline: true,
});
expect(imageEntry?.binding).toMatchObject({
skillId: "image_generate",
executionKind: "task_queue",
requestDefaults: {
imageWorkbench: "true",
},
intentConfirmation: {
id: "plain_image_generation",
ruleKey: "agentChat.inputIntent.imageGeneration.rules",
confirmationKey: "agentChat.inputIntent.imageGeneration.confirm",
systemPromptKey:
"agentChat.inputIntent.imageGeneration.systemPrompt",
},
});
expect(posterEntry?.binding).toMatchObject({
skillId: "image_generate",
executionKind: "task_queue",
+72 -6
View File
@@ -95,6 +95,13 @@ export interface SkillCatalogCommandTrigger {
prefix: string;
}
export interface SkillCatalogCommandIntentConfirmation {
id: string;
ruleKey: string;
confirmationKey: string;
systemPromptKey?: string;
}
export interface SkillCatalogSkillEntry {
id: string;
kind: "skill";
@@ -127,6 +134,8 @@ export interface SkillCatalogCommandEntry {
| "task_queue"
| "server_api"
| "cli";
requestDefaults?: Record<string, string>;
intentConfirmation?: SkillCatalogCommandIntentConfirmation;
};
renderContract?: SkillCatalogRenderContract;
}
@@ -366,6 +375,34 @@ function parseCommandBindingExecutionKind(
return undefined;
}
function parseCommandIntentConfirmation(
value: unknown,
): SkillCatalogCommandIntentConfirmation | undefined {
if (!isPlainRecord(value)) {
return undefined;
}
const id = normalizeText(value.id);
const ruleKey = normalizeText(value.ruleKey) ?? normalizeText(value.rule_key);
const confirmationKey =
normalizeText(value.confirmationKey) ??
normalizeText(value.confirmation_key);
if (!id || !ruleKey || !confirmationKey) {
return undefined;
}
const systemPromptKey =
normalizeText(value.systemPromptKey) ??
normalizeText(value.system_prompt_key);
return {
id,
ruleKey,
confirmationKey,
...(systemPromptKey ? { systemPromptKey } : {}),
};
}
function parseSceneExecutionKind(
value: unknown,
): SkillCatalogSceneEntry["executionKind"] | undefined {
@@ -395,6 +432,24 @@ function normalizeSearchAliases(value: unknown): string[] | undefined {
return aliases.length > 0 ? aliases : undefined;
}
function normalizeStringRecord(
value: unknown,
): Record<string, string> | undefined {
if (!isPlainRecord(value)) {
return undefined;
}
const result: Record<string, string> = {};
for (const [key, item] of Object.entries(value)) {
const normalized = normalizeText(item);
if (normalized) {
result[key] = normalized;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
function normalizeSurfaceScopes(
value: unknown,
): ServiceSkillSurfaceScope[] | undefined {
@@ -1080,12 +1135,23 @@ function parseSkillCatalogEntry(value: unknown): SkillCatalogEntry | null {
}
const binding = isPlainRecord(value.binding)
? {
skillId: normalizeText(value.binding.skillId) ?? undefined,
executionKind: parseCommandBindingExecutionKind(
value.binding.executionKind,
),
}
? (() => {
const requestDefaults =
normalizeStringRecord(value.binding.requestDefaults) ??
normalizeStringRecord(value.binding.request_defaults);
const intentConfirmation = parseCommandIntentConfirmation(
value.binding.intentConfirmation ??
value.binding.intent_confirmation,
);
return {
skillId: normalizeText(value.binding.skillId) ?? undefined,
executionKind: parseCommandBindingExecutionKind(
value.binding.executionKind,
),
...(requestDefaults ? { requestDefaults } : {}),
...(intentConfirmation ? { intentConfirmation } : {}),
};
})()
: undefined;
return {
+35
View File
@@ -1,4 +1,8 @@
import { safeInvoke } from "@/lib/dev-bridge";
import type {
SkillMarketplaceBundle,
SkillMarketplaceInstallResult,
} from "./officialSkillMarketplace";
export type SkillSourceKind = "builtin" | "other";
export type SkillCatalogSource = "project" | "user" | "remote";
@@ -78,6 +82,11 @@ export interface ImportedSkillResult {
directory: string;
}
export interface SkillDownloadInstallRequest extends Record<string, unknown> {
skillName: string;
downloadUrl: string;
}
export type AppType = "claude" | "codex" | "gemini" | "lime";
function normalizeStandardCompliance(
@@ -237,6 +246,32 @@ export const skillsApi = {
});
},
async installMarketplaceBundle(
bundle: SkillMarketplaceBundle,
app: AppType = "lime",
): Promise<SkillMarketplaceInstallResult> {
return safeInvoke<SkillMarketplaceInstallResult>(
"install_marketplace_skill_for_app",
{
app,
bundle,
},
);
},
async installFromDownloadUrl(
request: SkillDownloadInstallRequest,
app: AppType = "lime",
): Promise<SkillMarketplaceInstallResult> {
return safeInvoke<SkillMarketplaceInstallResult>(
"install_skill_from_download_url_for_app",
{
app,
request,
},
);
},
async inspectRemoteSkill(
locator: RemoteSkillLocator,
): Promise<SkillInspection> {
@@ -168,6 +168,16 @@ describe("compileCommandCatalogProjection", () => {
commandBinding: {
skillId: "image_generate",
executionKind: "task_queue",
requestDefaults: {
imageWorkbench: "true",
},
intentConfirmation: {
id: "plain_image_generation",
ruleKey: "agentChat.inputIntent.imageGeneration.rules",
confirmationKey: "agentChat.inputIntent.imageGeneration.confirm",
systemPromptKey:
"agentChat.inputIntent.imageGeneration.systemPrompt",
},
},
commandRenderContract: {
resultKind: "image_gallery",
@@ -190,6 +200,17 @@ describe("compileCommandCatalogProjection", () => {
binding: {
skillId: "image_generate",
executionKind: "task_queue",
requestDefaults: {
imageWorkbench: "true",
},
intentConfirmation: {
id: "plain_image_generation",
ruleKey: "agentChat.inputIntent.imageGeneration.rules",
confirmationKey:
"agentChat.inputIntent.imageGeneration.confirm",
systemPromptKey:
"agentChat.inputIntent.imageGeneration.systemPrompt",
},
},
renderContract: {
resultKind: "image_gallery",
@@ -176,6 +176,12 @@ function compileCommandProjectionEntry(params: {
const { projection, bindingProfile, policyProfile, linkedSkillId } = params;
const commandKey = projection.skillKey?.trim() || projection.entryKey.trim();
const executionKind = resolveCommandExecutionKind(bindingProfile, projection);
const requestDefaults = projection.commandBinding?.requestDefaults
? { ...projection.commandBinding.requestDefaults }
: undefined;
const intentConfirmation = projection.commandBinding?.intentConfirmation
? { ...projection.commandBinding.intentConfirmation }
: undefined;
return {
id: `command:${commandKey}`,
@@ -191,6 +197,8 @@ function compileCommandProjectionEntry(params: {
binding: {
skillId: linkedSkillId,
executionKind,
...(requestDefaults ? { requestDefaults } : {}),
...(intentConfirmation ? { intentConfirmation } : {}),
},
renderContract: resolveCommandRenderContract(
executionKind,
@@ -31,10 +31,18 @@ describe("seededCommandPackage", () => {
expect.objectContaining({
id: "command:image_generate",
commandKey: "image_generate",
binding: {
binding: expect.objectContaining({
skillId: "image_generate",
executionKind: "task_queue",
},
intentConfirmation: {
id: "plain_image_generation",
ruleKey: "agentChat.inputIntent.imageGeneration.rules",
confirmationKey:
"agentChat.inputIntent.imageGeneration.confirm",
systemPromptKey:
"agentChat.inputIntent.imageGeneration.systemPrompt",
},
}),
renderContract: expect.objectContaining({
resultKind: "image_gallery",
detailKind: "media_detail",
@@ -42,15 +50,17 @@ describe("seededCommandPackage", () => {
triggers: expect.arrayContaining([
{ mode: "mention", prefix: "@配图" },
{ mode: "mention", prefix: "@Vision 1" },
{ mode: "mention", prefix: "@image" },
{ mode: "slash", prefix: "/image" },
]),
}),
expect.objectContaining({
id: "command:image_storyboard",
commandKey: "image_storyboard",
binding: {
binding: expect.objectContaining({
skillId: "image_generate",
executionKind: "task_queue",
},
}),
renderContract: expect.objectContaining({
resultKind: "image_gallery",
detailKind: "media_detail",
@@ -59,10 +69,14 @@ describe("seededCommandPackage", () => {
expect.objectContaining({
id: "command:image_generate_nanobanana_pro",
commandKey: "image_generate_nanobanana_pro",
binding: {
binding: expect.objectContaining({
skillId: "image_generate",
executionKind: "task_queue",
},
requestDefaults: expect.objectContaining({
providerId: "fal",
model: "fal-ai/nano-banana-pro",
}),
}),
renderContract: expect.objectContaining({
resultKind: "image_gallery",
detailKind: "media_detail",

Some files were not shown because too many files have changed in this diff Show More