diff --git a/src-tauri/crates/core/src/connect/deep_link.rs b/src-tauri/crates/core/src/connect/deep_link.rs index c6819daa5..9ca81abaa 100644 --- a/src-tauri/crates/core/src/connect/deep_link.rs +++ b/src-tauri/crates/core/src/connect/deep_link.rs @@ -51,6 +51,7 @@ pub struct OpenDeepLinkPayload { pub slug: String, pub source: Option, pub version: Option, + pub action: Option, } #[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 .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)] diff --git a/src-tauri/resources/default-skills/brand-persona-knowledge-builder/SKILL.md b/src-tauri/resources/default-skills/brand-persona-knowledge-builder/SKILL.md index 0e74ab664..a794ebe8f 100644 --- a/src-tauri/resources/default-skills/brand-persona-knowledge-builder/SKILL.md +++ b/src-tauri/resources/default-skills/brand-persona-knowledge-builder/SKILL.md @@ -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" diff --git a/src-tauri/resources/default-skills/brand-product-knowledge-builder/SKILL.md b/src-tauri/resources/default-skills/brand-product-knowledge-builder/SKILL.md index 7574de44c..ad21be40e 100644 --- a/src-tauri/resources/default-skills/brand-product-knowledge-builder/SKILL.md +++ b/src-tauri/resources/default-skills/brand-product-knowledge-builder/SKILL.md @@ -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" diff --git a/src-tauri/resources/default-skills/campaign-operations-knowledge-builder/SKILL.md b/src-tauri/resources/default-skills/campaign-operations-knowledge-builder/SKILL.md index f0803df26..665aa885e 100644 --- a/src-tauri/resources/default-skills/campaign-operations-knowledge-builder/SKILL.md +++ b/src-tauri/resources/default-skills/campaign-operations-knowledge-builder/SKILL.md @@ -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" diff --git a/src-tauri/resources/default-skills/content-operations-knowledge-builder/SKILL.md b/src-tauri/resources/default-skills/content-operations-knowledge-builder/SKILL.md index af22e4291..4dbe9f125 100644 --- a/src-tauri/resources/default-skills/content-operations-knowledge-builder/SKILL.md +++ b/src-tauri/resources/default-skills/content-operations-knowledge-builder/SKILL.md @@ -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" diff --git a/src-tauri/resources/default-skills/growth-strategy-knowledge-builder/SKILL.md b/src-tauri/resources/default-skills/growth-strategy-knowledge-builder/SKILL.md index 4d5309223..363562105 100644 --- a/src-tauri/resources/default-skills/growth-strategy-knowledge-builder/SKILL.md +++ b/src-tauri/resources/default-skills/growth-strategy-knowledge-builder/SKILL.md @@ -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" diff --git a/src-tauri/resources/default-skills/image_generate/SKILL.md b/src-tauri/resources/default-skills/image_generate/SKILL.md index 5a399130e..f526025df 100644 --- a/src-tauri/resources/default-skills/image_generate/SKILL.md +++ b/src-tauri/resources/default-skills/image_generate/SKILL.md @@ -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 回复。 diff --git a/src-tauri/resources/default-skills/knowledge_builder/SKILL.md b/src-tauri/resources/default-skills/knowledge_builder/SKILL.md index 320e9e0c1..55af7d203 100644 --- a/src-tauri/resources/default-skills/knowledge_builder/SKILL.md +++ b/src-tauri/resources/default-skills/knowledge_builder/SKILL.md @@ -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 diff --git a/src-tauri/resources/default-skills/live-commerce-operations-knowledge-builder/SKILL.md b/src-tauri/resources/default-skills/live-commerce-operations-knowledge-builder/SKILL.md index a223c1f7c..672c27fc3 100644 --- a/src-tauri/resources/default-skills/live-commerce-operations-knowledge-builder/SKILL.md +++ b/src-tauri/resources/default-skills/live-commerce-operations-knowledge-builder/SKILL.md @@ -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" diff --git a/src-tauri/resources/default-skills/organization-knowhow-knowledge-builder/SKILL.md b/src-tauri/resources/default-skills/organization-knowhow-knowledge-builder/SKILL.md index bef5b9c28..60a796171 100644 --- a/src-tauri/resources/default-skills/organization-knowhow-knowledge-builder/SKILL.md +++ b/src-tauri/resources/default-skills/organization-knowhow-knowledge-builder/SKILL.md @@ -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" diff --git a/src-tauri/resources/default-skills/personal-ip-knowledge-builder/SKILL.md b/src-tauri/resources/default-skills/personal-ip-knowledge-builder/SKILL.md index cd6d6aa66..345ca9ab0 100644 --- a/src-tauri/resources/default-skills/personal-ip-knowledge-builder/SKILL.md +++ b/src-tauri/resources/default-skills/personal-ip-knowledge-builder/SKILL.md @@ -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" diff --git a/src-tauri/resources/default-skills/private-domain-operations-knowledge-builder/SKILL.md b/src-tauri/resources/default-skills/private-domain-operations-knowledge-builder/SKILL.md index 94185cfea..1c308727f 100644 --- a/src-tauri/resources/default-skills/private-domain-operations-knowledge-builder/SKILL.md +++ b/src-tauri/resources/default-skills/private-domain-operations-knowledge-builder/SKILL.md @@ -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" diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 208be0b59..521bc5954 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -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, diff --git a/src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs index 0b9975731..cd69a8f29 100644 --- a/src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs @@ -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 { + 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}。")); } diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index dfb6acf82..62a1c8e7e 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -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] diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs index 3afc9c115..30a9f91cc 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs @@ -597,6 +597,8 @@ fn canonicalize_image_task_alias_fields(record: &mut serde_json::Map, #[serde(default, alias = "title_generation_result")] title_generation_result: Option, + #[serde(default, alias = "persona_context")] + persona_context: Option, + #[serde(default)] + presentation: Option, + #[serde(default, alias = "taste_context")] + taste_context: Option, #[serde(default)] mode: Option, #[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", diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/media_cli_bridge.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/media_cli_bridge.rs index 991309a42..df750af34 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/media_cli_bridge.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/media_cli_bridge.rs @@ -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"]), diff --git a/src-tauri/src/commands/connect_cmd.rs b/src-tauri/src/commands/connect_cmd.rs index bc49600c0..9d4d10cea 100644 --- a/src-tauri/src/commands/connect_cmd.rs +++ b/src-tauri/src/commands/connect_cmd.rs @@ -89,6 +89,10 @@ impl From 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 } } diff --git a/src-tauri/src/commands/media_task_cmd.rs b/src-tauri/src/commands/media_task_cmd.rs index 51dfbbc52..8ac2a0a0f 100644 --- a/src-tauri/src/commands/media_task_cmd.rs +++ b/src-tauri/src/commands/media_task_cmd.rs @@ -103,6 +103,12 @@ pub struct CreateImageGenerationTaskArtifactRequest { pub title: Option, #[serde(default, alias = "title_generation_result")] pub title_generation_result: Option, + #[serde(default, alias = "persona_context")] + pub persona_context: Option, + #[serde(default)] + pub presentation: Option, + #[serde(default, alias = "taste_context")] + pub taste_context: Option, #[serde(default)] pub mode: Option, #[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, diff --git a/src-tauri/src/commands/skill_cmd.rs b/src-tauri/src/commands/skill_cmd.rs index ce9fd34bf..6faeaf5e1 100644 --- a/src-tauri/src/commands/skill_cmd.rs +++ b/src-tauri/src/commands/skill_cmd.rs @@ -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, + #[serde(default)] + pub sha256: Option, +} + +#[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, + #[allow(dead_code)] + pub version: String, + #[serde(default)] + #[allow(dead_code)] + pub content_hash: String, + #[serde(default)] + pub file_count: usize, + pub files: Vec, +} + +#[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 { + 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 { + 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, 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 { + 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 { + 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 { + 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 { + 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)> = 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 { + 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 { + 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 { + 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 { @@ -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 { + 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()); + } } diff --git a/src-tauri/src/dev_bridge/dispatcher/skills.rs b/src-tauri/src/dev_bridge/dispatcher/skills.rs index 8c2e212ea..0c1a188ec 100644 --- a/src-tauri/src/dev_bridge/dispatcher/skills.rs +++ b/src-tauri/src/dev_bridge/dispatcher/skills.rs @@ -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::( + &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 diff --git a/src-tauri/src/skills/default_skills.rs b/src-tauri/src/skills/default_skills.rs index 50e104ea1..984cbfe6c 100644 --- a/src-tauri/src/skills/default_skills.rs +++ b/src-tauri/src/skills/default_skills.rs @@ -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")); diff --git a/src/App.tsx b/src/App.tsx index 4f6391001..8fa30d326 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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) { diff --git a/src/components/agent/chat/AgentChatWorkspace.homePendingPreview.test.ts b/src/components/agent/chat/AgentChatWorkspace.homePendingPreview.test.ts new file mode 100644 index 000000000..39132c63a --- /dev/null +++ b/src/components/agent/chat/AgentChatWorkspace.homePendingPreview.test.ts @@ -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: "正在进入对话", + }, + }); + }); +}); diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 2793f9426..198e808e5 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -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, diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index 4f8b85fb6..33f129468 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -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: [], diff --git a/src/components/agent/chat/components/ImageWorkbenchMessagePreview.tsx b/src/components/agent/chat/components/ImageWorkbenchMessagePreview.tsx index 7e8727903..a946953f3 100644 --- a/src/components/agent/chat/components/ImageWorkbenchMessagePreview.tsx +++ b/src/components/agent/chat/components/ImageWorkbenchMessagePreview.tsx @@ -65,7 +65,7 @@ function renderPlaceholder( preview.status === "failed" ? preview.statusMessage?.trim() : ""; return ( -
+
{reason === "empty" && preview.status === "running" ? ( @@ -127,12 +127,17 @@ function renderPreviewMedia( if (!isStoryboardGrid && previewImages.length <= 1) { return ( - renderPlaceholder(preview, reason, t)} - /> +
+ renderPlaceholder(preview, reason, t)} + /> +
); } @@ -221,7 +226,7 @@ export const ImageWorkbenchMessagePreview: React.FC< >
{toolLabel} @@ -232,13 +237,13 @@ export const ImageWorkbenchMessagePreview: React.FC< ) : null}
-
+
{renderPreviewMedia(preview, t)}
{caption ? (
{caption}
diff --git a/src/components/agent/chat/components/Inputbar/index.test.tsx b/src/components/agent/chat/components/Inputbar/index.test.tsx index e12c41831..ea4fffb45 100644 --- a/src/components/agent/chat/components/Inputbar/index.test.tsx +++ b/src/components/agent/chat/components/Inputbar/index.test.tsx @@ -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, diff --git a/src/components/agent/chat/components/MessageList.test.tsx b/src/components/agent/chat/components/MessageList.test.tsx index 3af89d442..21cbd1cb6 100644 --- a/src/components/agent/chat/components/MessageList.test.tsx +++ b/src/components/agent/chat/components/MessageList.test.tsx @@ -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[] = [ { diff --git a/src/components/agent/chat/components/MessageList.tsx b/src/components/agent/chat/components/MessageList.tsx index ce7430602..fabac73a4 100644 --- a/src/components/agent/chat/components/MessageList.tsx +++ b/src/components/agent/chat/components/MessageList.tsx @@ -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) => ( -

+

{line}

))} @@ -674,6 +678,168 @@ const ImageWorkbenchAssistantIntro: React.FC<{ content: string }> = ({ ); }; +const ImageWorkbenchAssistantHeader: React.FC<{ label: string }> = ({ + label, +}) => ( +
+ + + + + + + + + + + {label} + +
+); + +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 ( +
+ + {command.tag} + + {command.body ? ( + {command.body} + ) : null} +
+ ); +}; + +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 }) => ( +
+ + @ + {label} + + {content.trim() ? ( + {content} + ) : null} +
+); + function shouldRenderRuntimeStatusPill( status?: AgentRuntimeStatus | null, ): boolean { @@ -2226,6 +2392,14 @@ const MessageListInner: React.FC = ({ ? 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 = ({ const canSaveMessageAsSkill = Boolean( onSaveMessageAsSkill && msg.role === "assistant" && + !msg.imageWorkbenchPreview && !msg.isThinking && actionContent && actionContent.length >= 24, @@ -2309,6 +2484,7 @@ const MessageListInner: React.FC = ({ const canSaveMessageAsInspiration = Boolean( onSaveMessageAsInspiration && msg.role === "assistant" && + !msg.imageWorkbenchPreview && !msg.isThinking && actionContent && actionContent.length >= 24, @@ -2322,13 +2498,18 @@ const MessageListInner: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ !hasImages && visibleAssistantArtifacts.length === 0 && !shouldRenderMessageCanvasShortcut && - !msg.taskPreview && - !assistantMetaFooter; + !msg.taskPreview; return ( = ({ {msg.role === "assistant" ? ( <> + {msg.imageWorkbenchPreview ? ( + + ) : null} {shouldRenderPrimaryTimelineOutsideBubble ? null : primaryTimelineNode} @@ -2739,7 +2939,17 @@ const MessageListInner: React.FC = ({ ) : null} ) : displayContent ? ( - shouldRenderRuntimePeerCards ? ( + installedSkillMessageLabel ? ( + + ) : isUserCommandMessage ? ( + + ) : shouldRenderRuntimePeerCards ? ( ) : ( = ({ {assistantMetaFooter} {showMessageActions ? ( - + {canQuoteMessage ? ( + ) : null} + + +
+
+ + ); + }; + + const renderMarketplaceSkillSection = ( + title: string, + items: SkillStoreItem[], + startIndex = 0, + meta?: string, + ) => { + if (items.length === 0) { + return null; + } + + return ( +
+
+

+ {title} +

+ {meta ? ( + {meta} + ) : null} +
+
+ {items.map((item, index) => + renderMarketplaceSkillCard(item, startIndex + index), + )} +
+
+ ); + }; + return ( <> -
-
+
+
-
-
-
-
+
+
+
-

- {t("skills.workspace.header.title", "技能")} +

+ {t("skills.workspace.header.title")}

-

- {t( - "skills.workspace.header.subtitle", - "安装和管理技能,需要时带回首页输入框使用。", - )} +

+ {t("skills.workspace.header.subtitle")}

-
-
- -
-
-
- {t("skills.workspace.hero.title", "为你精选的职场技能")} +
+
+
+ {t("skills.workspace.hero.title")}
-

- {t( - "skills.workspace.hero.description", - "覆盖写作、效率、设计、数据分析等多种场景,一键安装。", - )} +

+ {t("skills.workspace.hero.description")}

+
+ +
@@ -1254,7 +1753,7 @@ export function SkillsWorkspacePage({
- {t("skills.workspace.activeScaffold.badge", "新技能")} + {t("skills.workspace.activeScaffold.badge")} {activeScaffoldTitle} @@ -1270,10 +1769,7 @@ export function SkillsWorkspacePage({ handleBringScaffoldToCreation(activeScaffoldDraft) } > - {t( - "skills.workspace.activeScaffold.backToCreation", - "回去继续完善", - )} + {t("skills.workspace.activeScaffold.backToCreation")}
@@ -1299,9 +1795,9 @@ export function SkillsWorkspacePage({
) : null} @@ -1476,13 +1902,10 @@ export function SkillsWorkspacePage({ >

- {t("skills.workspace.builtin.title", "内置技能")} + {t("skills.workspace.builtin.title")}

- {t( - "skills.workspace.builtin.subtitle", - "Lime 自带的技能。开关只控制是否自动匹配,不影响你继续使用 Lime。", - )} + {t("skills.workspace.builtin.subtitle")}

{visibleBuiltinLocalSkills.length > 0 ? ( @@ -1505,10 +1928,7 @@ export function SkillsWorkspacePage({ )}

- {t( - "skills.workspace.autoLoad.description", - "开启后,Lime 会在相关任务里自动带上;关闭后,不会主动匹配。", - )} + {t("skills.workspace.autoLoad.description")}

{renderAutoLoadControl(skill)} @@ -1517,10 +1937,7 @@ export function SkillsWorkspacePage({
) : (
- {t( - "skills.workspace.builtin.empty", - "可以尝试刷新,或换个关键词继续找。", - )} + {t("skills.workspace.builtin.empty")}
)} @@ -1535,13 +1952,10 @@ export function SkillsWorkspacePage({ >

- {t("skills.workspace.installed.title", "用户安装")} + {t("skills.workspace.installed.title")}

- {t( - "skills.workspace.installed.subtitle", - "你安装的技能。自动加载、手动使用、卸载是三个独立操作。", - )} + {t("skills.workspace.installed.subtitle")}

{visibleUserInstalledSkills.length > 0 ? ( @@ -1572,10 +1986,7 @@ export function SkillsWorkspacePage({ )}

- {t( - "skills.workspace.autoLoad.description", - "开启后,Lime 会在相关任务里自动带上;关闭后,不会主动匹配。", - )} + {t("skills.workspace.autoLoad.description")}

@@ -1587,10 +1998,7 @@ export function SkillsWorkspacePage({ className="h-8 rounded-full border-slate-200 bg-white px-3 text-xs text-slate-700 shadow-none hover:bg-slate-50" onClick={() => handleInstalledSkillSelect(skill)} > - {t( - "skills.workspace.installedSkill.action.use", - "使用", - )} + {t("skills.workspace.installedSkill.action.use")} {skill.sourceKind !== "builtin" && skill.catalogSource !== "project" ? ( @@ -1609,11 +2017,9 @@ export function SkillsWorkspacePage({ {uninstallingSkillDirectory === skill.directory ? t( "skills.workspace.installedSkill.action.uninstalling", - "卸载中", ) : t( "skills.workspace.installedSkill.action.uninstall", - "卸载", )} ) : null} @@ -1649,18 +2055,141 @@ export function SkillsWorkspacePage({ onBringBackToCreation={handleBringScaffoldToCreation} /> - + { + if (!open) { + setDetailMarketplaceSkillName(null); + } + }} + > + + {detailStoreItem ? ( +
+
+ +
+ +
+ + {detailStoreItem.skill.title} + +
+ {detailStoreItem.skill.name} +
+
+
+
+
+ +
+
+ {t("skills.workspace.marketplace.detail.sourceNotice")} +
+
+ {(() => { + const contentState = + detailContentState?.skillName === + detailStoreItem.skill.name + ? detailContentState + : null; + + if (!contentState || contentState.status === "loading") { + return ( +
+ {t( + "skills.workspace.marketplace.detail.loadingSkillContent", + )} +
+ ); + } + + if (contentState.status === "error") { + return ( + <> +
+ {t( + "skills.workspace.marketplace.detail.loadSkillContentFailed", + { + message: contentState.message, + }, + )} +
+ {renderSkillMarkdown( + buildFallbackSkillMarkdown(detailStoreItem), + )} + + ); + } + + return renderSkillMarkdown(contentState.content); + })()} +
+
+ +
+ {(() => { + const actionState = + resolveMarketplaceSkillActionState(detailStoreItem); + const localSkill = localSkillByDirectory.get( + detailStoreItem.skill.name, + ); + const canUninstall = + detailStoreItem.source === "official" && + Boolean(localSkill) && + localSkill?.sourceKind !== "builtin"; + return ( +
+ {canUninstall ? ( + + ) : null} + +
+ ); + })()} +
+
+ ) : null} +
+
); } diff --git a/src/hooks/useDeepLink.test.tsx b/src/hooks/useDeepLink.test.tsx index adfa67d05..15c4e833b 100644 --- a/src/hooks/useDeepLink.test.tsx +++ b/src/hooks/useDeepLink.test.tsx @@ -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) => { diff --git a/src/hooks/useDeepLink.ts b/src/hooks/useDeepLink.ts index 7d353f863..a876e31fd 100644 --- a/src/hooks/useDeepLink.ts +++ b/src/hooks/useDeepLink.ts @@ -208,6 +208,7 @@ export interface OpenDeepLinkPayload { slug: string; source?: string | null; version?: string | null; + action?: "open" | "install" | null; } interface OpenDeepLinkResult { diff --git a/src/hooks/useOfficialSkillMarketplace.ts b/src/hooks/useOfficialSkillMarketplace.ts new file mode 100644 index 000000000..ca8cd1379 --- /dev/null +++ b/src/hooks/useOfficialSkillMarketplace.ts @@ -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; +} + +export function useOfficialSkillMarketplace( + enabled = true, +): UseOfficialSkillMarketplaceResult { + const [skills, setSkills] = useState([]); + const [isLoading, setIsLoading] = useState(enabled); + const [error, setError] = useState(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 }; +} diff --git a/src/i18n/resources/en-US/agent.json b/src/i18n/resources/en-US/agent.json index 861fc8c91..1b1b5542e 100644 --- a/src/i18n/resources/en-US/agent.json +++ b/src/i18n/resources/en-US/agent.json @@ -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." } diff --git a/src/i18n/resources/en-US/common.json b/src/i18n/resources/en-US/common.json index f3e789756..345ff184c 100644 --- a/src/i18n/resources/en-US/common.json +++ b/src/i18n/resources/en-US/common.json @@ -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." } diff --git a/src/i18n/resources/ja-JP/agent.json b/src/i18n/resources/ja-JP/agent.json index 90aa4e9dd..9af581861 100644 --- a/src/i18n/resources/ja-JP/agent.json +++ b/src/i18n/resources/ja-JP/agent.json @@ -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 またはパッケージ構造を修正してから再試行してください。" } diff --git a/src/i18n/resources/ja-JP/common.json b/src/i18n/resources/ja-JP/common.json index a1c387a54..86ca86993 100644 --- a/src/i18n/resources/ja-JP/common.json +++ b/src/i18n/resources/ja-JP/common.json @@ -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 を直接開きました。" } diff --git a/src/i18n/resources/ko-KR/agent.json b/src/i18n/resources/ko-KR/agent.json index 2b0fc23be..d3fee3b69 100644 --- a/src/i18n/resources/ko-KR/agent.json +++ b/src/i18n/resources/ko-KR/agent.json @@ -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 또는 패키지 구조를 수정한 뒤 다시 시도하세요." } diff --git a/src/i18n/resources/ko-KR/common.json b/src/i18n/resources/ko-KR/common.json index 9dcc576ef..af7ba17a0 100644 --- a/src/i18n/resources/ko-KR/common.json +++ b/src/i18n/resources/ko-KR/common.json @@ -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을 바로 열었습니다." } diff --git a/src/i18n/resources/zh-CN/agent.json b/src/i18n/resources/zh-CN/agent.json index 65b0d51e2..efd709af7 100644 --- a/src/i18n/resources/zh-CN/agent.json +++ b/src/i18n/resources/zh-CN/agent.json @@ -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 要求停止后续步骤,请修复下载地址或包结构后重试。" } diff --git a/src/i18n/resources/zh-CN/common.json b/src/i18n/resources/zh-CN/common.json index 9e78d52f9..b8f5de9c5 100644 --- a/src/i18n/resources/zh-CN/common.json +++ b/src/i18n/resources/zh-CN/common.json @@ -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": "已直接为你打开这个技能。" } diff --git a/src/i18n/resources/zh-TW/agent.json b/src/i18n/resources/zh-TW/agent.json index d3ba6e914..cc4e8f70c 100644 --- a/src/i18n/resources/zh-TW/agent.json +++ b/src/i18n/resources/zh-TW/agent.json @@ -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 要求停止後續步驟,請修正下載網址或套件結構後重試。" } diff --git a/src/i18n/resources/zh-TW/common.json b/src/i18n/resources/zh-TW/common.json index 2b30f65a6..38708e84a 100644 --- a/src/i18n/resources/zh-TW/common.json +++ b/src/i18n/resources/zh-TW/common.json @@ -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": "已直接為你開啟這個技能。" } diff --git a/src/lib/api/agentRuntime/types.ts b/src/lib/api/agentRuntime/types.ts index c9ba6ef9a..d6f85160d 100644 --- a/src/lib/api/agentRuntime/types.ts +++ b/src/lib/api/agentRuntime/types.ts @@ -1367,6 +1367,11 @@ export interface CreateImageGenerationTaskArtifactRequest { prompt: string; title?: string; titleGenerationResult?: AgentRuntimeGeneratedTitleResult | null; + personaContext?: Record; + persona_context?: Record; + presentation?: Record; + tasteContext?: Record; + taste_context?: Record; mode?: "generate" | "edit" | "variation"; rawText?: string; layoutHint?: string; diff --git a/src/lib/api/officialSkillMarketplace.test.ts b/src/lib/api/officialSkillMarketplace.test.ts new file mode 100644 index 000000000..8b58139c6 --- /dev/null +++ b/src/lib/api/officialSkillMarketplace.test.ts @@ -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: "", + }, + 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", + ); + }); +}); diff --git a/src/lib/api/officialSkillMarketplace.ts b/src/lib/api/officialSkillMarketplace.ts new file mode 100644 index 000000000..7eb080e01 --- /dev/null +++ b/src/lib/api/officialSkillMarketplace.ts @@ -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; + 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 { + const translate = getLimeI18n().t as unknown as ( + key: string, + options?: Record, + ) => string; + return translate(`skills.workspace.marketplace.error.${key}`, { + ns: "agent", + ...(options || {}), + }); +} + +function readEnvValue(name: string): string | null { + const env = import.meta.env as Record; + const value = env[name]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function isRecord(value: unknown): value is Record { + 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 | undefined { + if (!isRecord(value)) { + return undefined; + } + const result: Record = {}; + 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(url: string): Promise { + 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 { + 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( + `${resolveSkillMarketplaceApiBaseUrl()}/v1/public/service-skills/marketplace${ + query ? `?${query}` : "" + }`, + ); + return normalizeMarketplaceItems(data); +} + +export async function getOfficialSkillMarketplaceBundle( + skillName: string, +): Promise { + const normalizedSkillName = skillName.trim(); + if (!normalizedSkillName) { + throw new Error(tMarketplaceError("skillNameRequired")); + } + + const data = await requestMarketplaceJson( + `${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 { + const bundle = await getOfficialSkillMarketplaceBundle(skillName); + return skillsApi.installMarketplaceBundle(bundle, app); +} diff --git a/src/lib/api/skillCatalog.test.ts b/src/lib/api/skillCatalog.test.ts index 8237be8be..e52564b4a 100644 --- a/src/lib/api/skillCatalog.test.ts +++ b/src/lib/api/skillCatalog.test.ts @@ -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", diff --git a/src/lib/api/skillCatalog.ts b/src/lib/api/skillCatalog.ts index 8dee1ddc5..e775de5b0 100644 --- a/src/lib/api/skillCatalog.ts +++ b/src/lib/api/skillCatalog.ts @@ -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; + 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 | undefined { + if (!isPlainRecord(value)) { + return undefined; + } + + const result: Record = {}; + 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 { diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 6c7e0249c..bda5264c5 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -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 { + 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 { + return safeInvoke( + "install_marketplace_skill_for_app", + { + app, + bundle, + }, + ); + }, + + async installFromDownloadUrl( + request: SkillDownloadInstallRequest, + app: AppType = "lime", + ): Promise { + return safeInvoke( + "install_skill_from_download_url_for_app", + { + app, + request, + }, + ); + }, + async inspectRemoteSkill( locator: RemoteSkillLocator, ): Promise { diff --git a/src/lib/base-setup/compat/commandCatalogProjection.test.ts b/src/lib/base-setup/compat/commandCatalogProjection.test.ts index 8a9162dd1..dcb0d99fd 100644 --- a/src/lib/base-setup/compat/commandCatalogProjection.test.ts +++ b/src/lib/base-setup/compat/commandCatalogProjection.test.ts @@ -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", diff --git a/src/lib/base-setup/compat/commandCatalogProjection.ts b/src/lib/base-setup/compat/commandCatalogProjection.ts index d891fb1b2..e67c8380e 100644 --- a/src/lib/base-setup/compat/commandCatalogProjection.ts +++ b/src/lib/base-setup/compat/commandCatalogProjection.ts @@ -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, diff --git a/src/lib/base-setup/seededCommandPackage.test.ts b/src/lib/base-setup/seededCommandPackage.test.ts index 4bedf1d00..38bd3fae9 100644 --- a/src/lib/base-setup/seededCommandPackage.test.ts +++ b/src/lib/base-setup/seededCommandPackage.test.ts @@ -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", diff --git a/src/lib/base-setup/seededCommandPackage.ts b/src/lib/base-setup/seededCommandPackage.ts index 06400bb5d..31ab04511 100644 --- a/src/lib/base-setup/seededCommandPackage.ts +++ b/src/lib/base-setup/seededCommandPackage.ts @@ -87,12 +87,22 @@ const SEEDED_COMMAND_PROJECTION_SPECS: SeededCommandProjectionSpec[] = [ summary: "根据文字描述生成新的图片结果。", aliases: ["image", "img", "vision 1", "nanobanana", "图片", "生图"], trigger: "@配图", - triggerHints: ["@Vision 1"], + triggerHints: ["@Vision 1", "@image", "/image"], category: "图像生成", outputHint: "图片结果集", 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: COMMAND_IMAGE_GALLERY_CONTRACT, }, @@ -114,6 +124,11 @@ const SEEDED_COMMAND_PROJECTION_SPECS: SeededCommandProjectionSpec[] = [ commandBinding: { skillId: "image_generate", executionKind: "task_queue", + requestDefaults: { + imageWorkbench: "true", + providerId: "fal", + model: "fal-ai/nano-banana-pro", + }, }, commandRenderContract: COMMAND_IMAGE_GALLERY_CONTRACT, }, @@ -128,6 +143,11 @@ const SEEDED_COMMAND_PROJECTION_SPECS: SeededCommandProjectionSpec[] = [ commandBinding: { skillId: "image_generate", executionKind: "task_queue", + requestDefaults: { + imageWorkbench: "true", + layoutHint: "storyboard_3x3", + count: "9", + }, }, commandRenderContract: COMMAND_IMAGE_GALLERY_CONTRACT, }, @@ -171,6 +191,10 @@ const SEEDED_COMMAND_PROJECTION_SPECS: SeededCommandProjectionSpec[] = [ commandBinding: { skillId: "image_generate", executionKind: "task_queue", + requestDefaults: { + imageWorkbench: "true", + mode: "edit", + }, }, commandRenderContract: COMMAND_IMAGE_GALLERY_CONTRACT, }, @@ -185,6 +209,10 @@ const SEEDED_COMMAND_PROJECTION_SPECS: SeededCommandProjectionSpec[] = [ commandBinding: { skillId: "image_generate", executionKind: "task_queue", + requestDefaults: { + imageWorkbench: "true", + mode: "variation", + }, }, commandRenderContract: COMMAND_IMAGE_GALLERY_CONTRACT, }, diff --git a/src/lib/base-setup/serviceSkillCatalogAdapter.ts b/src/lib/base-setup/serviceSkillCatalogAdapter.ts index 014d47fb8..fc18c75e3 100644 --- a/src/lib/base-setup/serviceSkillCatalogAdapter.ts +++ b/src/lib/base-setup/serviceSkillCatalogAdapter.ts @@ -18,6 +18,7 @@ import { evaluateBaseSetupRollout } from "./rolloutGate"; import type { BaseSetupAutomationProfile, BaseSetupAutomationSchedulePreset, + BaseSetupCommandIntentConfirmation, BaseSetupCommandExecutionKind, BaseSetupPackage, BaseSetupRenderDetailKind, @@ -100,6 +101,39 @@ function readStringRecord( return Object.keys(output).length > 0 ? output : undefined; } +function readCommandIntentConfirmation( + record: UnknownRecord, +): BaseSetupCommandIntentConfirmation | undefined { + const value = readValue(record, "intentConfirmation", "intent_confirmation"); + const source = asRecord(value); + if (!source) { + return undefined; + } + + const id = readString(source, "id"); + const ruleKey = readString(source, "ruleKey", "rule_key"); + const confirmationKey = readString( + source, + "confirmationKey", + "confirmation_key", + ); + if (!id || !ruleKey || !confirmationKey) { + return undefined; + } + + const systemPromptKey = readString( + source, + "systemPromptKey", + "system_prompt_key", + ); + return { + id, + ruleKey, + confirmationKey, + ...(systemPromptKey ? { systemPromptKey } : {}), + }; +} + function readArray( record: UnknownRecord, ...keys: string[] @@ -696,14 +730,25 @@ function toCatalogProjection( readValue(record, "sceneBinding", "scene_binding"), ) as ServiceSkillSceneBinding | undefined, commandBinding: commandBindingRecord - ? { - skillId: readString(commandBindingRecord, "skillId", "skill_id"), - executionKind: readString( + ? (() => { + const requestDefaults = readStringRecord( commandBindingRecord, - "executionKind", - "execution_kind", - ) as BaseSetupCommandExecutionKind | undefined, - } + "requestDefaults", + "request_defaults", + ); + const intentConfirmation = + readCommandIntentConfirmation(commandBindingRecord); + return { + skillId: readString(commandBindingRecord, "skillId", "skill_id"), + executionKind: readString( + commandBindingRecord, + "executionKind", + "execution_kind", + ) as BaseSetupCommandExecutionKind | undefined, + ...(requestDefaults ? { requestDefaults } : {}), + ...(intentConfirmation ? { intentConfirmation } : {}), + }; + })() : undefined, commandRenderContract: commandRenderContractRecord ? { diff --git a/src/lib/base-setup/types.ts b/src/lib/base-setup/types.ts index 9de6faadb..5759d0305 100644 --- a/src/lib/base-setup/types.ts +++ b/src/lib/base-setup/types.ts @@ -201,6 +201,15 @@ export interface BaseSetupCompositionDeliveryContract { export interface BaseSetupCommandBinding { skillId?: string; executionKind?: BaseSetupCommandExecutionKind; + requestDefaults?: Record; + intentConfirmation?: BaseSetupCommandIntentConfirmation; +} + +export interface BaseSetupCommandIntentConfirmation { + id: string; + ruleKey: string; + confirmationKey: string; + systemPromptKey?: string; } export interface BaseSetupRenderContract { diff --git a/src/lib/deepLink/websiteLaunch.test.ts b/src/lib/deepLink/websiteLaunch.test.ts index 89f9eb76c..ab0290e2b 100644 --- a/src/lib/deepLink/websiteLaunch.test.ts +++ b/src/lib/deepLink/websiteLaunch.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { resolveWebsiteOpenNavigation } from "./websiteLaunch"; +import { + resolveWebsiteInstalledSkillNavigation, + resolveWebsiteOpenNavigation, +} from "./websiteLaunch"; describe("resolveWebsiteOpenNavigation", () => { it("应把官网 skill slug 解析成可直接进入 Agent 的服务技能启动参数", () => { @@ -52,4 +55,30 @@ describe("resolveWebsiteOpenNavigation", () => { }), ).toBeNull(); }); + + it("应把官网 install action 解析成已安装 Skill 输入能力入口", () => { + const result = resolveWebsiteInstalledSkillNavigation({ + kind: "skill", + slug: "viral-content-breakdown", + source: "website", + version: "1", + action: "install", + }); + + expect(result).toMatchObject({ + page: "agent", + params: { + agentEntry: "new-task", + initialSessionName: "拆解一条爆款内容", + initialInputCapability: { + capabilityRoute: { + kind: "installed_skill", + skillKey: "viral-content-breakdown", + skillName: "拆解一条爆款内容", + }, + requestKey: expect.any(Number), + }, + }, + }); + }); }); diff --git a/src/lib/deepLink/websiteLaunch.ts b/src/lib/deepLink/websiteLaunch.ts index 08905e381..9b8a2d2d0 100644 --- a/src/lib/deepLink/websiteLaunch.ts +++ b/src/lib/deepLink/websiteLaunch.ts @@ -7,6 +7,7 @@ export interface WebsiteOpenDeepLinkPayload { slug: string; source?: string | null; version?: string | null; + action?: "open" | "install" | null; } type SkillLaunchTarget = @@ -183,6 +184,15 @@ function buildWebsiteEntryBanner(title: string, source?: string | null) { return `已从${sourceLabel}详情页打开“${title}”,可继续补上下文后直接开始。`; } +function buildWebsiteInstallBanner(title: string, source?: string | null) { + const normalizedSource = source?.trim().toLowerCase(); + const sourceLabel = + !normalizedSource || normalizedSource === "website" + ? "官网" + : source!.trim(); + return `已从${sourceLabel}安装并打开“${title}”,可继续补上下文后直接开始。`; +} + function buildServiceSkillParams(params: { skillId: string; title: string; @@ -223,13 +233,48 @@ function buildCuratedTaskParams(params: { }); } +function findWebsiteSkillLaunchEntry(slug: string) { + return WEBSITE_SKILL_LAUNCH_ENTRIES.find((item) => item.slug === slug) ?? null; +} + +export function resolveWebsiteSkillTitle(slug: string): string | null { + return findWebsiteSkillLaunchEntry(slug)?.title ?? null; +} + +export function resolveWebsiteInstalledSkillNavigation( + payload: WebsiteOpenDeepLinkPayload, +): WebsiteOpenNavigationResult | null { + if (payload.kind !== "skill") { + return null; + } + + const entry = findWebsiteSkillLaunchEntry(payload.slug); + if (!entry) { + return null; + } + + return { + page: "agent", + params: buildHomeAgentParams({ + initialInputCapability: { + capabilityRoute: { + kind: "installed_skill", + skillKey: entry.slug, + skillName: entry.title, + }, + requestKey: Date.now(), + }, + entryBannerMessage: buildWebsiteInstallBanner(entry.title, payload.source), + initialSessionName: entry.title, + }), + }; +} + export function resolveWebsiteOpenNavigation( payload: WebsiteOpenDeepLinkPayload, ): WebsiteOpenNavigationResult | null { if (payload.kind === "skill") { - const entry = - WEBSITE_SKILL_LAUNCH_ENTRIES.find((item) => item.slug === payload.slug) ?? - null; + const entry = findWebsiteSkillLaunchEntry(payload.slug); if (!entry) { return null; } diff --git a/src/lib/dev-bridge/mockPriorityCommands.test.ts b/src/lib/dev-bridge/mockPriorityCommands.test.ts index 847e8f986..494c0a57d 100644 --- a/src/lib/dev-bridge/mockPriorityCommands.test.ts +++ b/src/lib/dev-bridge/mockPriorityCommands.test.ts @@ -164,6 +164,14 @@ describe("mockPriorityCommands", () => { expect( shouldDisallowMockFallbackInBrowser("import_local_skill_for_app"), ).toBe(true); + expect( + shouldDisallowMockFallbackInBrowser("install_marketplace_skill_for_app"), + ).toBe(true); + expect( + shouldDisallowMockFallbackInBrowser( + "install_skill_from_download_url_for_app", + ), + ).toBe(true); }); it("运行时真相事件在浏览器模式下禁止静默退回 mock", () => { diff --git a/src/lib/dev-bridge/mockPriorityCommands.ts b/src/lib/dev-bridge/mockPriorityCommands.ts index f89045b2c..3174db8b3 100644 --- a/src/lib/dev-bridge/mockPriorityCommands.ts +++ b/src/lib/dev-bridge/mockPriorityCommands.ts @@ -147,6 +147,8 @@ const bridgeTruthCommands = new Set([ "inspect_local_skill_for_app", "create_skill_scaffold_for_app", "import_local_skill_for_app", + "install_marketplace_skill_for_app", + "install_skill_from_download_url_for_app", "inspect_remote_skill", "knowledge_import_source", "knowledge_compile_pack", diff --git a/src/lib/governance/agentCommandCatalog.json b/src/lib/governance/agentCommandCatalog.json index e6d0dcbf9..e24e14748 100644 --- a/src/lib/governance/agentCommandCatalog.json +++ b/src/lib/governance/agentCommandCatalog.json @@ -39,6 +39,8 @@ "list_executable_skills", "get_skill_detail", "execute_skill", + "install_marketplace_skill_for_app", + "install_skill_from_download_url_for_app", "agent_runtime_update_session", "agent_runtime_delete_session", "sceneapp_list_catalog", diff --git a/src/lib/governance/legacySurfaceCatalog.json b/src/lib/governance/legacySurfaceCatalog.json index d9653063e..670154c19 100644 --- a/src/lib/governance/legacySurfaceCatalog.json +++ b/src/lib/governance/legacySurfaceCatalog.json @@ -282,6 +282,15 @@ ], "allowedPaths": [] }, + { + "id": "agent-chat-plain-visual-brief-confirmation-helper", + "classification": "dead-candidate", + "description": "已删除的普通视觉 brief 确认旧 helper;普通输入意图确认当前收敛到 catalog + i18n 驱动的 plainInputIntentConfirmation", + "targets": [ + "src/components/agent/chat/utils/plainVisualBriefConfirmation.ts" + ], + "allowedPaths": [] + }, { "id": "tools-page-shell-entry", "classification": "dead-candidate", diff --git a/src/lib/governance/legacySurfaceCatalog.test.ts b/src/lib/governance/legacySurfaceCatalog.test.ts index c4fa8c770..f79ab6598 100644 --- a/src/lib/governance/legacySurfaceCatalog.test.ts +++ b/src/lib/governance/legacySurfaceCatalog.test.ts @@ -134,6 +134,20 @@ describe("legacySurfaceCatalog", () => { ]); }); + it("应记录已删除的普通视觉 brief 确认旧 helper", () => { + const monitor = legacySurfaceCatalogJson.imports.find( + (entry) => + entry.id === "agent-chat-plain-visual-brief-confirmation-helper", + ); + + expect(monitor).toBeTruthy(); + expect(monitor?.classification).toBe("dead-candidate"); + expect(monitor?.allowedPaths).toEqual([]); + expect(monitor?.targets).toEqual([ + "src/components/agent/chat/utils/plainVisualBriefConfirmation.ts", + ]); + }); + it("应记录已删除的首页空态 SceneApp 旧面板", () => { const importMonitor = legacySurfaceCatalogJson.imports.find( (entry) => entry.id === "empty-state-legacy-sceneapps-panel-entry", diff --git a/src/lib/skills/skillInstallPrompt.test.ts b/src/lib/skills/skillInstallPrompt.test.ts new file mode 100644 index 000000000..534cf6ef9 --- /dev/null +++ b/src/lib/skills/skillInstallPrompt.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { parseSkillInstallPromptInstruction } from "./skillInstallPrompt"; + +describe("skill install prompt", () => { + it("解析官网复制的 Agent Skill 安装 Prompt", () => { + const instruction = parseSkillInstallPromptInstruction(`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.`); + + expect(instruction).toEqual({ + skillName: "viral-content-breakdown", + downloadUrl: + "https://limeai.run/skill-packages/viral-content-breakdown/latest/viral-content-breakdown.zip", + source: "assignment_prompt", + }); + }); + + it("支持从旧详情页 URL 推断 skill name", () => { + const instruction = parseSkillInstallPromptInstruction(`Download and install a skill. +DOWNLOAD_URL="https://limeai.run/skills/viral-content-breakdown/" +Extract it into the Agent Skills directory.`); + + expect(instruction?.skillName).toBe("viral-content-breakdown"); + expect(instruction?.downloadUrl).toBe( + "https://limeai.run/skill-packages/viral-content-breakdown/latest/viral-content-breakdown.zip", + ); + }); + + it("缺少安装语义时不误拦普通消息", () => { + expect( + parseSkillInstallPromptInstruction( + 'SKILL_NAME="demo"\nDOWNLOAD_URL="https://example.com/demo.zip"', + ), + ).toBeNull(); + }); +}); diff --git a/src/lib/skills/skillInstallPrompt.ts b/src/lib/skills/skillInstallPrompt.ts new file mode 100644 index 000000000..ba83f11d7 --- /dev/null +++ b/src/lib/skills/skillInstallPrompt.ts @@ -0,0 +1,134 @@ +import { skillsApi, type AppType } from "@/lib/api/skills"; +import type { SkillMarketplaceInstallResult } from "@/lib/api/officialSkillMarketplace"; + +export interface SkillInstallPromptInstruction { + skillName: string; + downloadUrl: string; + source: "assignment_prompt"; +} + +const INSTALL_PROMPT_MARKERS = [ + "download and install a skill", + "extract it into the agent skills directory", + "restart or reload the agent", + "agent skills directory", + "下载 skill", + "解压到 agent", + "skills 目录", +]; + +function normalizeText(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function readPromptAssignment(text: string, key: string): string | null { + const pattern = new RegExp( + `(?:^|\\n)\\s*${key}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s\\n]+))`, + "i", + ); + const match = text.match(pattern); + return normalizeText(match?.[1] ?? match?.[2] ?? match?.[3]) || null; +} + +function looksLikeInstallPrompt(text: string): boolean { + const normalized = text.toLowerCase(); + return INSTALL_PROMPT_MARKERS.some((marker) => normalized.includes(marker)); +} + +function normalizeDownloadUrl(value: string | null): string | null { + const normalized = normalizeText(value); + if (!normalized) { + return null; + } + try { + const url = new URL(normalized); + if (url.protocol !== "https:") { + return null; + } + return url.toString(); + } catch { + return null; + } +} + +function normalizeOfficialSkillDetailUrl(downloadUrl: string): string { + const url = new URL(downloadUrl); + const hostname = url.hostname.toLowerCase(); + const segments = url.pathname.split("/").filter(Boolean); + const skillsIndex = segments.indexOf("skills"); + if ( + (hostname === "limeai.run" || hostname === "www.limeai.run") && + skillsIndex >= 0 + ) { + const skillSlug = normalizeText(segments[skillsIndex + 1]); + if (skillSlug) { + return `https://limeai.run/skill-packages/${skillSlug}/latest/${skillSlug}.zip`; + } + } + return url.toString(); +} + +function inferSkillNameFromDownloadUrl(downloadUrl: string | null): string | null { + if (!downloadUrl) { + return null; + } + try { + const url = new URL(downloadUrl); + const segments = url.pathname.split("/").filter(Boolean); + const skillPackageIndex = segments.indexOf("skill-packages"); + if (skillPackageIndex >= 0) { + return normalizeText(segments[skillPackageIndex + 1]) || null; + } + const skillsIndex = segments.indexOf("skills"); + if (skillsIndex >= 0) { + return normalizeText(segments[skillsIndex + 1]) || null; + } + const fileName = segments.at(-1) || ""; + const zipName = fileName.replace(/\.zip$/i, "").trim(); + return zipName || null; + } catch { + return null; + } +} + +export function parseSkillInstallPromptInstruction( + text: string, +): SkillInstallPromptInstruction | null { + if (!looksLikeInstallPrompt(text)) { + return null; + } + + const downloadUrl = normalizeDownloadUrl( + readPromptAssignment(text, "DOWNLOAD_URL"), + ); + if (!downloadUrl) { + return null; + } + const packageDownloadUrl = normalizeOfficialSkillDetailUrl(downloadUrl); + + const skillName = + normalizeText(readPromptAssignment(text, "SKILL_NAME")) || + inferSkillNameFromDownloadUrl(packageDownloadUrl); + if (!skillName) { + return null; + } + + return { + skillName, + downloadUrl: packageDownloadUrl, + source: "assignment_prompt", + }; +} + +export async function installSkillFromPromptInstruction( + instruction: SkillInstallPromptInstruction, + app: AppType = "lime", +): Promise { + return skillsApi.installFromDownloadUrl( + { + skillName: instruction.skillName, + downloadUrl: instruction.downloadUrl, + }, + app, + ); +} diff --git a/src/lib/tauri-mock/core.ts b/src/lib/tauri-mock/core.ts index 5341923fb..ff166bf98 100644 --- a/src/lib/tauri-mock/core.ts +++ b/src/lib/tauri-mock/core.ts @@ -445,6 +445,11 @@ function buildMockMediaTaskOutput( mode: request.mode ?? "generate", size: request.size ?? "1024x1024", count: request.count ?? 1, + persona_context: + request.personaContext ?? request.persona_context ?? null, + presentation: request.presentation ?? null, + taste_context: + request.tasteContext ?? request.taste_context ?? null, provider_id: request.providerId ?? request.provider_id ?? null, model: request.model ?? null, session_id: sessionId, @@ -8142,6 +8147,44 @@ const defaultMocks: Record = { install_skill_for_app: () => ({ success: true }), uninstall_skill_for_app: () => ({ success: true }), import_local_skill_for_app: () => ({ directory: "mock-skill" }), + install_marketplace_skill_for_app: (args?: { bundle?: { name?: string } }) => ({ + directory: args?.bundle?.name || "mock-marketplace-skill", + inspection: { + content: "# Mock Marketplace Skill", + metadata: {}, + allowedTools: [], + resourceSummary: { + hasScripts: false, + hasReferences: false, + hasAssets: false, + }, + standardCompliance: { + isStandard: true, + validationErrors: [], + deprecatedFields: [], + }, + }, + }), + install_skill_from_download_url_for_app: (args?: { + request?: { skillName?: string }; + }) => ({ + directory: args?.request?.skillName || "mock-downloaded-skill", + inspection: { + content: "# Mock Downloaded Skill", + metadata: {}, + allowedTools: [], + resourceSummary: { + hasScripts: false, + hasReferences: false, + hasAssets: false, + }, + standardCompliance: { + isStandard: true, + validationErrors: [], + deprecatedFields: [], + }, + }, + }), enable_skill: () => ({ success: true }), disable_skill: () => ({ success: true }), diff --git a/src/lib/teamMemorySync.test.ts b/src/lib/teamMemorySync.test.ts index 351f37451..e90029236 100644 --- a/src/lib/teamMemorySync.test.ts +++ b/src/lib/teamMemorySync.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { webcrypto } from "node:crypto"; +import { beforeAll, describe, expect, it } from "vitest"; import { buildTeamMemoryShadowRequestMetadata, @@ -12,6 +13,15 @@ import { writeTeamMemorySnapshot, } from "./teamMemorySync"; +beforeAll(() => { + if (!globalThis.crypto?.subtle) { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: webcrypto, + }); + } +}); + function createMemoryStorage() { const store = new Map(); return {