mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: install agent skills from marketplace prompts
This commit is contained in:
@@ -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
-4
@@ -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
-4
@@ -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"
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -208,6 +208,7 @@ export interface OpenDeepLinkPayload {
|
||||
slug: string;
|
||||
source?: string | null;
|
||||
version?: string | null;
|
||||
action?: "open" | "install" | null;
|
||||
}
|
||||
|
||||
interface OpenDeepLinkResult {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 またはパッケージ構造を修正してから再試行してください。"
|
||||
}
|
||||
|
||||
@@ -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 を直接開きました。"
|
||||
}
|
||||
|
||||
@@ -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 또는 패키지 구조를 수정한 뒤 다시 시도하세요."
|
||||
}
|
||||
|
||||
@@ -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을 바로 열었습니다."
|
||||
}
|
||||
|
||||
@@ -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 要求停止后续步骤,请修复下载地址或包结构后重试。"
|
||||
}
|
||||
|
||||
@@ -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": "已直接为你打开这个技能。"
|
||||
}
|
||||
|
||||
@@ -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 要求停止後續步驟,請修正下載網址或套件結構後重試。"
|
||||
}
|
||||
|
||||
@@ -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": "已直接為你開啟這個技能。"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user