import { fetchAvailableSkills, fetchSkillRuntime, readSkillResource, runSkillScript, type InstalledSkill, type SkillRuntime, type SkillScriptArtifact, } from "@/lib/api"; import { type ChatCompletionResponse, type ChatToolPart } from "@/lib/chat-runtime"; import { httpRequest } from "@/lib/request"; export type SkillCapability = "chat" | "web_search" | "image_generation" | "image_edit"; export type SkillImageWorkflow = { version: 1 | 2; summary: string; tasks: Array<{ id: string; title?: string; operation?: "source_or_generate" | "generate" | "edit"; reference?: string; prompt: string; size?: string; }>; postprocess?: { script: string; args: string[]; title?: string; inputs?: string[]; }; }; export type PreparedSkillInvocation = { skill: InstalledSkill; runtime: SkillRuntime; request: string; capability: SkillCapability; prompt: string; summary: string; toolParts: ChatToolPart[]; artifacts: SkillScriptArtifact[]; plannerContext: string; imageWorkflow?: SkillImageWorkflow; awaitingInput?: { missing: Array<{ id: string; label: string; type: string }>; question: string; }; }; type SkillAgentDecision = { action: "read_resource" | "run_script" | "complete"; path?: string; script?: string; args?: string[]; capability?: SkillCapability; prompt?: string; summary?: string; }; type MediaParserPayload = { success?: boolean; summary?: Record; results?: Array>; }; function assistantText(response: ChatCompletionResponse) { const value = response.choices?.[0]?.message?.content; if (typeof value === "string") return value.trim(); if (!Array.isArray(value)) return ""; return value.flatMap((item) => { if (typeof item === "string") return [item]; if (!item || typeof item !== "object") return []; const record = item as { text?: unknown; content?: unknown }; if (typeof record.text === "string") return [record.text]; if (typeof record.content === "string") return [record.content]; return []; }).join("\n").trim(); } function jsonDecision(value: string): SkillAgentDecision { const clean = value.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim(); const start = clean.indexOf("{"); const end = clean.lastIndexOf("}"); if (start < 0 || end <= start) { return { action: "complete", capability: "chat", prompt: clean, summary: "Skill 已完成处理" }; } try { return JSON.parse(clean.slice(start, end + 1)) as SkillAgentDecision; } catch { return { action: "complete", capability: "chat", prompt: clean, summary: "Skill 已完成处理" }; } } function invocationId(prefix: string, index: number) { return `${prefix}-${Date.now()}-${index}`; } function requiredResourcePaths(runtime: SkillRuntime) { const known = runtime.resources .filter((item) => item.readable && item.path !== "SKILL.md") .map((item) => item.path); const trigger = /\b(?:read|load|consult|review)\b|(?:读取|阅读|加载|查阅)/i; const required = new Set(); for (const line of runtime.content.split(/\r?\n/)) { if (!trigger.test(line)) continue; for (const path of known) { if (line.includes(path)) required.add(path); } } return [...required]; } function markdownEnumeration(path: string, content: string) { const block = content.match(/(?:^|\n)##\s+Contents\s*\n([\s\S]*?)(?=\n##\s|$)/i)?.[1] || ""; const items = [...block.matchAll(/^\s*[-*]\s+(.+?)\s*$/gm)] .map((match) => match[1].replace(/\s+[-–—].*$/, "").trim()) .filter((item) => item && !/(?:blending|mixing)\s+rules?|混合规则/i.test(item)); if (items.length < 2) return ""; return `${JSON.stringify(items)}`; } function buildPlannerContext(runtime: SkillRuntime, readContents: Map) { const sources = new Map([["SKILL.md", runtime.content], ...readContents.entries()]); const prioritized = [...sources.entries()].sort(([left], [right]) => { const score = (path: string) => /director-grammars|reference-gallery|color-signatures/i.test(path) ? 0 : 1; return score(left) - score(right) || left.localeCompare(right); }); const variants = prioritized.map(([path, content]) => markdownEnumeration(path, content)).filter(Boolean); const resources = prioritized.map(([path, content]) => ( `\n${content}\n` )).join("\n\n").slice(0, 60_000); return [...variants, resources].filter(Boolean).join("\n\n"); } function parseImageWorkflow(value: string): SkillImageWorkflow | undefined { try { const raw = JSON.parse(value) as Partial; if (![1, 2].includes(Number(raw.version)) || !Array.isArray(raw.tasks) || !raw.tasks.length || raw.tasks.length > 32) return undefined; const tasks = raw.tasks.flatMap((task) => { const id = String(task?.id || "").trim(); const prompt = String(task?.prompt || "").trim(); if (!id || !prompt) return []; const operation = String(task?.operation || "").trim(); if (operation && !["source_or_generate", "generate", "edit"].includes(operation)) return []; return [{ id, prompt, ...(task.title ? { title: String(task.title) } : {}), ...(operation ? { operation: operation as "source_or_generate" | "generate" | "edit" } : {}), ...(task.reference ? { reference: String(task.reference).trim() } : {}), ...(task.size ? { size: String(task.size) } : {}), }]; }); if (tasks.length !== raw.tasks.length || new Set(tasks.map((task) => task.id)).size !== tasks.length) return undefined; const taskIds = new Set(tasks.map((task) => task.id)); if (tasks.some((task) => task.operation === "edit" && (!task.reference || !taskIds.has(task.reference)))) return undefined; const postprocess = raw.postprocess && typeof raw.postprocess === "object" ? { script: String(raw.postprocess.script || "").trim(), args: Array.isArray(raw.postprocess.args) ? raw.postprocess.args.map(String) : [], ...(raw.postprocess.title ? { title: String(raw.postprocess.title) } : {}), ...(Array.isArray(raw.postprocess.inputs) ? { inputs: raw.postprocess.inputs.map(String).map((item) => item.trim()).filter(Boolean) } : {}), } : undefined; if (postprocess && (!postprocess.script.startsWith("scripts/") || !postprocess.args.length)) return undefined; if (postprocess?.inputs?.some((input) => !taskIds.has(input))) return undefined; return { version: Number(raw.version) as 1 | 2, summary: String(raw.summary || "按 Skill 工作流生成独立图片并组装最终成品").trim(), tasks, ...(postprocess ? { postprocess } : {}), }; } catch { return undefined; } } export async function loadSkillPlannerContext(skillId: string) { const runtime = await fetchSkillRuntime(skillId); const contents = new Map(); const paths = requiredResourcePaths(runtime); const results = await Promise.allSettled(paths.map(async (path) => { const resource = await readSkillResource(skillId, path); return [path, resource.content] as const; })); for (const result of results) { if (result.status === "fulfilled") contents.set(result.value[0], result.value[1]); } return buildPlannerContext(runtime, contents); } export async function resolveInstalledSkill(prompt: string, forcedSkillId = "") { const skills = (await fetchAvailableSkills()).items; const mentions = [...prompt.matchAll(/\$([a-z0-9._-]+)/gi)]; const match = forcedSkillId ? null : mentions.find((item) => !["search", "chat", "image", "image-generation"].includes(item[1].toLowerCase())); const requested = (forcedSkillId || match?.[1] || "").toLowerCase(); if (!requested) return null; const skill = skills.find((item) => item.id.toLowerCase() === requested || item.name.toLowerCase() === requested); if (!skill) return null; const request = match && match.index !== undefined ? `${prompt.slice(0, match.index)}${prompt.slice(match.index + match[0].length)}`.replace(/\s{2,}/g, " ").trim() : prompt.trim(); return { skill, request }; } export async function prepareInstalledSkill({ prompt, model, attachmentFiles, attachmentSummary, invocationKey, forcedSkillId = "", conversationContext = "", onProgress, }: { prompt: string; model: string; attachmentFiles: File[]; attachmentSummary: Array<{ name: string; type: string; size: number }>; invocationKey: string; forcedSkillId?: string; conversationContext?: string; onProgress?: (parts: ChatToolPart[]) => void | Promise; }): Promise { const resolved = await resolveInstalledSkill(prompt, forcedSkillId); if (!resolved) return null; const runtime = await fetchSkillRuntime(resolved.skill.id); const toolParts: ChatToolPart[] = []; const artifacts: SkillScriptArtifact[] = []; const requiredInputs = runtime.presentation.inputs.filter((input) => input.required); const missingInputs = requiredInputs.filter((input) => { const type = input.type.toLowerCase(); if (["image", "photo"].includes(type)) return !attachmentFiles.some((file) => file.type.startsWith("image/")); if (["file", "document", "pdf", "archive", "video", "audio"].includes(type)) return attachmentFiles.length === 0; if (["text", "prompt", "request"].includes(type)) return !resolved.request.trim(); return false; }); if (missingInputs.length) { const labels = missingInputs.map((input) => input.label); const question = `开始使用「${runtime.name}」前,还需要:${labels.join("、")}。请补充或上传后继续。`; return { skill: resolved.skill, runtime, request: resolved.request, capability: "chat", prompt: question, summary: question, toolParts, artifacts, plannerContext: "", awaitingInput: { missing: missingInputs.map((input) => ({ id: input.id, label: input.label, type: input.type })), question, }, }; } const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [ { role: "system", content: [ "You are a Skill runtime controller. Follow the installed SKILL.md exactly and prepare one executable action for the host application.", "You can request one tool action at a time. Return JSON only, with no Markdown fences.", 'Read tool: {"action":"read_resource","path":"relative/path"}. Read every resource the Skill explicitly requires before execution.', 'Script tool: {"action":"run_script","script":"scripts/name.py","args":["..."]}. Only choose a listed, allowed script. Use {{input:0}}, {{output:name.ext}}, {{workspace}}, {{outputs}}, or {{skill}} for paths.', 'Complete action: {"action":"complete","capability":"chat|web_search|image_generation|image_edit","prompt":"fully prepared downstream instruction","summary":"short user-facing summary"}.', "The host has native chat, web search, image generation, and image editing. Prefer those native capabilities when the Skill says to prefer a built-in capability; do not run a provider fallback script in that case.", "Use image_edit when an uploaded image must be transformed. Use image_generation only without a source image.", "Never claim a resource was read or a script ran until its tool result is supplied. Do not expose credentials, private environment variables, or internal authentication.", "Do not silently invent unresolved options when the Skill requires user choice. In that case complete with capability chat and a concise question in prompt.", ].join("\n"), }, { role: "user", content: JSON.stringify({ skill: { id: runtime.id, name: runtime.name, description: runtime.description, instructions: runtime.content, resources: runtime.resources.map((item) => ({ path: item.path, size: item.size, readable: item.readable })), scripts: runtime.scripts.map((item) => ({ path: item.path, kind: item.kind, allowed: item.allowed })), }, user_request: resolved.request, conversation_context: conversationContext || undefined, attachments: attachmentSummary, }), }, ]; const readPaths = new Set(); const readContents = new Map(); const scriptEvidence: string[] = []; let imageWorkflow: SkillImageWorkflow | undefined; let scriptRuns = 0; let lastDecision: SkillAgentDecision | null = null; const readResource = async (path: string, key: string) => { const tool: ChatToolPart = { type: "tool", toolId: `skill_read_resource_${key}`, invocationId: invocationId("skill-resource", toolParts.length), status: "running", title: `正在读取 Skill 资源 · ${path}`, input: { skill: runtime.id, path }, }; toolParts.push(tool); await onProgress?.([...toolParts]); try { const resource = await readSkillResource(runtime.id, path); readPaths.add(path); readContents.set(path, resource.content); Object.assign(tool, { status: "success", title: `已读取 Skill 资源 · ${path}`, output: { path, size: resource.size } }); messages.push({ role: "user", content: `\n${resource.content}\n` }); } catch (error) { Object.assign(tool, { status: "error", error: error instanceof Error ? error.message : "读取失败" }); await onProgress?.([...toolParts]); throw error; } await onProgress?.([...toolParts]); }; const workflowPath = runtime.resources.find((item) => item.path.toLowerCase() === "skill-workflow.json" && item.readable)?.path; if (workflowPath) { await readResource(workflowPath, "host_workflow"); imageWorkflow = parseImageWorkflow(readContents.get(workflowPath) || ""); if (imageWorkflow) { // A host workflow is already an executable, validated plan. Returning it // directly avoids both the Skill-controller LLM and the image-planner LLM, // which add minutes of latency and can accidentally discard postprocess. return { skill: resolved.skill, runtime, request: resolved.request, capability: attachmentFiles.length ? "image_edit" : "image_generation", prompt: imageWorkflow.summary, summary: imageWorkflow.summary, toolParts, artifacts, plannerContext: buildPlannerContext(runtime, readContents), imageWorkflow, }; } } const mandatoryResources = requiredResourcePaths(runtime).filter((path) => !readPaths.has(path)); for (let index = 0; index < mandatoryResources.length; index += 1) { await readResource(mandatoryResources[index], `required_${index}`); } // Media parsing is an execution Skill, not a prompt-writing Skill. Route // it deterministically through its approved CLI so the model cannot replace // a real download with a narrated/fabricated JSON response. if (runtime.id.toLowerCase() === "media-parser") { const script = runtime.scripts.find((item) => ( item.allowed && item.path.replace(/\\/g, "/").endsWith("/media_parser_cli.py") )); if (!script) throw new Error("media-parser 的执行入口尚未获得管理员授权"); const noDownload = /(?:不|不要|无需|仅|只).{0,12}下载|只.{0,12}(?:直链|元数据)|(?:直链|元数据).{0,12}(?:不|不要)下载/i.test(resolved.request); // Always parse remote media URLs without saving the payload to this server. // The backend turns those signed CDN URLs into expiring streamed artifacts, // forwarding platform-required headers (notably Bilibili Referer/User-Agent). const args = [resolved.request, "--no-download", "--pretty"]; const tool: ChatToolPart = { type: "tool", toolId: "skill_run_script_media_parser", invocationId: invocationId("skill-script", toolParts.length), status: "running", title: noDownload ? "正在解析媒体链接" : "正在解析可下载直链", input: { skill: runtime.id, script: script.path, args, attachmentCount: attachmentFiles.length }, }; toolParts.push(tool); await onProgress?.([...toolParts]); try { const result = await runSkillScript(runtime.id, script.path, args, attachmentFiles, `${invocationKey}:media-parser`); let parsed: MediaParserPayload | undefined; try { parsed = JSON.parse(result.stdout) as MediaParserPayload; } catch { parsed = undefined; } const parserErrors = Array.isArray(parsed?.results) ? parsed.results.map((item) => String(item.error || "").trim()).filter(Boolean) : []; if (result.status !== "success" || parsed?.success === false) { throw new Error(parserErrors.join(";") || result.stderr || result.stdout || "媒体解析失败"); } artifacts.push(...result.artifacts); Object.assign(tool, { status: "success", title: noDownload ? "媒体链接解析完成" : `媒体直链已就绪 · ${result.artifacts.length} 个文件`, output: { exitCode: result.exit_code, durationMs: result.duration_ms, summary: parsed?.summary, results: parsed?.results, artifacts: result.artifacts, }, }); await onProgress?.([...toolParts]); const summary = noDownload ? "媒体链接已真实解析完成" : `媒体解析已完成,共生成 ${result.artifacts.length} 个可流式下载文件`; return { skill: resolved.skill, runtime, request: resolved.request, capability: "chat", prompt: [ "media-parser 已由系统真实执行完成。请依据下面的结构化结果简洁汇总标题、作者和成功/失败数量。", noDownload ? "用户要求只解析,不要声称已经下载文件。" : "可下载媒体已经作为本条回复的临时流式附件提供;明确告诉用户可点击附件上的下载按钮直接从源站保存,不要声称服务器已经保存完整媒体。", `解析结果:\n${result.stdout}`, result.artifacts.length ? `真实下载附件:\n${JSON.stringify(result.artifacts)}` : "", ].filter(Boolean).join("\n\n"), summary, toolParts, artifacts, plannerContext: buildPlannerContext(runtime, readContents), }; } catch (error) { Object.assign(tool, { status: "error", error: error instanceof Error ? error.message : "媒体解析失败" }); await onProgress?.([...toolParts]); throw error; } } for (let step = 0; step < 12; step += 1) { const response = await httpRequest("/v1/chat/completions", { method: "POST", body: { model: model || "auto", messages, stream: false }, }); const raw = assistantText(response); const decision = jsonDecision(raw); lastDecision = decision; messages.push({ role: "assistant", content: JSON.stringify(decision) }); if (decision.action === "read_resource") { const path = String(decision.path || "").trim(); if (!path) throw new Error("Skill 请求读取资源,但没有提供路径"); if (readPaths.has(path)) { messages.push({ role: "user", content: `\n${readContents.get(path) || ""}\n`, }); continue; } await readResource(path, `agent_${step}`); continue; } if (decision.action === "run_script") { const script = String(decision.script || "").trim(); if (!script) throw new Error("Skill 请求执行脚本,但没有提供脚本路径"); if (scriptRuns >= 3) throw new Error("单轮 Skill 最多执行 3 次脚本"); scriptRuns += 1; const args = Array.isArray(decision.args) ? decision.args.map(String) : []; const tool: ChatToolPart = { type: "tool", toolId: `skill_run_script_${step}`, invocationId: invocationId("skill-script", step), status: "running", title: `正在执行 Skill 脚本 · ${script}`, input: { skill: runtime.id, script, args, attachmentCount: attachmentFiles.length }, }; toolParts.push(tool); await onProgress?.([...toolParts]); try { const result = await runSkillScript(runtime.id, script, args, attachmentFiles, `${invocationKey}:${step}`); artifacts.push(...result.artifacts); scriptEvidence.push([ `脚本:${script}`, `状态:${result.status}(退出码 ${result.exit_code})`, result.stdout ? `标准输出:\n${result.stdout}` : "", result.stderr ? `标准错误:\n${result.stderr}` : "", result.artifacts.length ? `产物:${JSON.stringify(result.artifacts)}` : "", ].filter(Boolean).join("\n")); Object.assign(tool, { status: result.status === "success" ? "success" : "error", title: result.status === "success" ? `Skill 脚本已完成 · ${script}` : `Skill 脚本执行失败 · ${script}`, output: { exitCode: result.exit_code, durationMs: result.duration_ms, stdout: result.stdout, stderr: result.stderr, artifacts: result.artifacts, }, ...(result.status === "error" ? { error: result.stderr || `脚本退出码 ${result.exit_code}` } : {}), }); messages.push({ role: "user", content: `\n${JSON.stringify(result)}\n` }); } catch (error) { Object.assign(tool, { status: "error", error: error instanceof Error ? error.message : "脚本执行失败" }); await onProgress?.([...toolParts]); throw error; } await onProgress?.([...toolParts]); continue; } if (decision.action === "complete") { const capability: SkillCapability = ["chat", "web_search", "image_generation", "image_edit"].includes(String(decision.capability)) ? decision.capability as SkillCapability : attachmentFiles.length ? "image_edit" : "chat"; const preparedPrompt = String(decision.prompt || resolved.request).trim(); const resourceEvidence = [...readContents.entries()] .map(([path, content]) => `资源:${path}\n${content}`) .join("\n\n---\n\n") .slice(0, 18_000); const promptForHost = capability === "chat" ? [ "你是已安装 Skill 的最终执行模型。下面的 Skill 说明和资源已经由系统真实读取并提供给你;不要声称无法访问、无法加载或要求用户换环境。", "请直接完成原始用户请求,不要只复述计划、征询下一步或把工具结果说成是用户提供的。只有当 Skill 明确要求必须先由用户选择且原请求没有给出选择时,才提出一个必要问题。", `已安装 Skill「${runtime.name}」的 SKILL.md:\n${runtime.content.slice(0, 20_000)}`, resourceEvidence ? `已读取的 Skill 资源:\n${resourceEvidence}` : "", conversationContext ? `与本次 Skill 延续相关的对话上下文:\n${conversationContext}` : "", `原始用户请求:\n${resolved.request}`, `Skill 控制器给出的执行指令:\n${preparedPrompt}`, scriptEvidence.length ? `已真实执行的 Skill 脚本结果(这是工具证据,不是用户输入):\n${scriptEvidence.join("\n\n---\n\n")}` : "", "基于以上内容给出结构清晰、可直接使用的最终交付。用户要求文件时必须创建真实文件附件;若兼容接口不能创建文件,至少返回完整的 fenced SVG/HTML 源码,系统会自动转成可预览附件。若工具已返回具体数值、颜色、字体、布局或文件,必须在答案中呈现。", ].filter(Boolean).join("\n\n") : preparedPrompt; return { skill: resolved.skill, runtime, request: resolved.request, capability, prompt: promptForHost, summary: String(decision.summary || `已按 Skill「${runtime.name}」准备执行`).trim(), toolParts, artifacts, plannerContext: buildPlannerContext(runtime, readContents), imageWorkflow, }; } } throw new Error(`Skill 执行超过最大步骤数${lastDecision ? `(最后动作:${lastDecision.action})` : ""}`); }