refactor(*): 稳定期治理 - 收敛核心架构、错误语义并修复跨项目数据竞态

This commit is contained in:
ddCat
2026-09-09 22:02:10 +08:00
parent bacd502f5c
commit b5f3413ded
298 changed files with 12681 additions and 26428 deletions

View File

@@ -0,0 +1,32 @@
import { SKILL_RUNTIME_AGENT_GUIDANCE, skillRuntime } from "@/services/skill-runtime";
import type { ResponseToolCall } from "@/services/api/image";
export const CANVAS_ONLINE_AGENT_PROMPT =
`你是当前创作工作台内置的在线画布助手。首轮必须先调用 canvas_get_context;涉及已有节点时用 canvas_find_nodes 获取真实 id,涉及媒体参考时用 canvas_get_resources。流水线、工作流、管线、节点图或用户要求连线时,必须使用 canvas_create_workflow:把需求拆成有语义的节点类型、真实内容/提示词、边和布局,禁止把业务阶段退化成几个空文本卡片;工具会自动分配 id、布局并建立连线。复杂写操作先 canvas_validate_ops,再执行 canvas_apply_ops。任何写入后都必须检查工具返回的真实节点类型、connectionCount、overlapWarnings 和 verification;没有真实连线时绝不能说已连线,没有生成资源时绝不能说已完成。不要输出 JSON ops、不要猜 id、不要把未就绪资源当作可用素材、不要编造执行结果。需要用户选择时,给出可点击的短选项,不要只让用户输入 1、2、3。${SKILL_RUNTIME_AGENT_GUIDANCE}`;
export const CANVAS_READ_TOOL_NAMES = [
"canvas_get_state",
"canvas_get_context",
"canvas_find_nodes",
"canvas_get_node",
"canvas_get_connection",
"canvas_get_generation_tasks",
"canvas_get_resources",
"canvas_validate_ops",
"canvas_get_selection",
"canvas_export_snapshot",
] as const;
const CANVAS_READ_TOOL_NAME_SET = new Set<string>(CANVAS_READ_TOOL_NAMES);
export function isCanvasReadTool(name: string) {
return CANVAS_READ_TOOL_NAME_SET.has(name) || skillRuntime.agentToolNames("onlineAgent").has(name);
}
export function isWritableToolCall(call: ResponseToolCall) {
return !isCanvasReadTool(call.function.name);
}
export function buildCanvasOnlineAgentSystemContent(skillCatalog: string) {
return [CANVAS_ONLINE_AGENT_PROMPT, skillCatalog ? `当前可按需加载的技能(仅元数据):\n${skillCatalog}` : ""].filter(Boolean).join("\n\n");
}

View File

@@ -0,0 +1,195 @@
import { type ResponseFunctionTool } from "@/services/api/image";
import { skillRuntime } from "@/services/skill-runtime";
const JSON_RECORD_SCHEMA = { type: "object", additionalProperties: true };
const POSITION_SCHEMA = { type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"], additionalProperties: false };
const VIEWPORT_SCHEMA = { type: "object", properties: { x: { type: "number" }, y: { type: "number" }, k: { type: "number" } }, required: ["x", "y", "k"], additionalProperties: false };
const NODE_TYPE_SCHEMA = { type: "string", enum: ["image", "text", "skill", "video", "audio"] };
const WORKFLOW_NODE_KIND_SCHEMA = { type: "string", enum: ["text", "script", "image", "video", "audio", "character_cards", "character_three_view", "storyboard_video"] };
const GENERATION_MODE_SCHEMA = { type: "string", enum: ["text", "image", "video", "audio"] };
const GENERATION_OPTION_PROPERTIES = {
model: { type: "string" },
size: { type: "string" },
quality: { type: "string" },
transparentBackground: { type: "string", enum: ["true", "false"] },
count: { type: "number" },
seconds: { type: "string" },
vquality: { type: "string" },
generateAudio: { type: "string" },
watermark: { type: "string" },
audioVoice: { type: "string" },
audioFormat: { type: "string" },
audioSpeed: { type: "string" },
audioInstructions: { type: "string" },
};
const CANVAS_OP_SCHEMA = {
type: "object",
properties: {
type: { type: "string", enum: ["add_node", "update_node", "delete_node", "delete_connections", "connect_nodes", "set_viewport", "select_nodes", "run_generation"] },
id: { type: "string" },
ids: { type: "array", items: { type: "string" } },
nodeType: NODE_TYPE_SCHEMA,
title: { type: "string" },
x: { type: "number" },
y: { type: "number" },
width: { type: "number" },
height: { type: "number" },
position: POSITION_SCHEMA,
metadata: JSON_RECORD_SCHEMA,
patch: JSON_RECORD_SCHEMA,
all: { type: "boolean" },
fromNodeId: { type: "string" },
toNodeId: { type: "string" },
viewport: VIEWPORT_SCHEMA,
nodeId: { type: "string" },
mode: GENERATION_MODE_SCHEMA,
prompt: { type: "string" },
retry: { type: "boolean" },
},
required: ["type"],
additionalProperties: false,
};
function toolDefinition(name: string, description: string, properties: Record<string, unknown>, required: string[] = [], strict = false): ResponseFunctionTool {
return { type: "function", function: { name, description, parameters: { type: "object", properties, required, additionalProperties: false }, strict } };
}
function generationToolDefinition(name: string, description: string, mode?: "text" | "image" | "video" | "audio") {
return toolDefinition(
name,
description,
{
prompt: { type: "string" },
title: { type: "string" },
x: { type: "number" },
y: { type: "number" },
referenceNodeIds: { type: "array", items: { type: "string" } },
...(mode ? {} : { mode: GENERATION_MODE_SCHEMA }),
autoRun: { type: "boolean" },
...GENERATION_OPTION_PROPERTIES,
},
["prompt"],
);
}
export const CANVAS_ONLINE_AGENT_TOOLS: ResponseFunctionTool[] = [
...skillRuntime.agentTools("onlineAgent"),
toolDefinition("canvas_get_state", "读取当前网页画布的节点、连线、选区和视口。", {}),
toolDefinition("canvas_get_context", "读取语义化画布上下文、真实节点 id、连接关系、资源就绪状态和状态哈希。", {}),
toolDefinition("canvas_find_nodes", "按标题、内容、提示词、类型、状态或资产检索真实节点。", { query: { type: "string" }, ids: { type: "array", items: { type: "string" } }, types: { type: "array", items: { type: "string" } }, statuses: { type: "array", items: { type: "string" } }, resourceOnly: { type: "boolean" }, limit: { type: "number" } }),
toolDefinition("canvas_get_node", "按真实节点 id 精确读取单个节点、资源状态和关联连线。", { id: { type: "string" } }, ["id"]),
toolDefinition("canvas_get_connection", "按真实连线 id 精确读取端点节点和 handle 信息。", { id: { type: "string" } }, ["id"]),
toolDefinition("canvas_get_generation_tasks", "读取当前画布绑定的生成任务观察状态,不主动轮询上游。", { status: { type: "string" }, nodeIds: { type: "array", items: { type: "string" } }, limit: { type: "number" } }),
toolDefinition("canvas_get_resources", "读取画布媒体资源引用、类型、尺寸、大小、时长和就绪状态,不返回媒体 URL。", { nodeIds: { type: "array", items: { type: "string" } }, status: { type: "string" }, limit: { type: "number" } }),
toolDefinition("canvas_validate_ops", "在写入前校验节点 id、连接关系和批量操作参数。", { ops: { type: "array", items: CANVAS_OP_SCHEMA } }, ["ops"]),
toolDefinition("canvas_get_selection", "读取当前网页画布选中的节点。", {}),
toolDefinition("canvas_export_snapshot", "导出当前画布快照,用于理解布局。", {}),
toolDefinition(
"canvas_apply_ops",
"批量操作当前网页画布。复杂写操作应先 canvas_validate_ops;可传 canvas_get_context 返回的 expectedStateHash 防止基于过期状态写入。",
{ ops: { type: "array", items: CANVAS_OP_SCHEMA }, expectedRevision: { type: "number" }, expectedStateHash: { type: "string" } },
["ops"],
false,
),
toolDefinition(
"canvas_create_workflow",
"创建语义化工作流/流水线:节点使用真实的文本、脚本、图片、视频或音频类型;character_cards=角色拆分图片卡片,character_three_view=角色三视图,storyboard_video=分镜剧情视频。工具会自动生成唯一 id、按节点实际尺寸布局、创建 edges/referenceRefs/referenceNodeIds 连线、选择新节点并复核重叠。媒体节点必须提供有意义的 prompt 或 content;已有素材先 canvas_find_nodes/canvas_get_resources,再把真实 node id 放入 referenceNodeIds。不要用 canvas_create_text_nodes 代替工作流。",
{
title: { type: "string" },
description: { type: "string" },
nodes: {
type: "array",
minItems: 1,
items: {
type: "object",
properties: {
ref: { type: "string" },
kind: WORKFLOW_NODE_KIND_SCHEMA,
title: { type: "string" },
content: { type: "string" },
prompt: { type: "string" },
description: { type: "string" },
referenceRefs: { type: "array", items: { type: "string" } },
referenceNodeIds: { type: "array", items: { type: "string" } },
runGeneration: { type: "boolean" },
width: { type: "number" },
height: { type: "number" },
},
required: ["ref", "kind", "title"],
additionalProperties: false,
},
},
edges: { type: "array", items: { type: "object", properties: { from: { type: "string" }, to: { type: "string" } }, required: ["from", "to"], additionalProperties: false } },
direction: { type: "string", enum: ["horizontal", "vertical"] },
start: POSITION_SCHEMA,
gap: { type: "number" },
autoRun: { type: "boolean" },
},
["nodes"],
),
toolDefinition(
"canvas_create_node",
"创建任意类型节点:text、image、video、audio。适合创建文本、媒体占位或自定义 metadata 节点。",
{ nodeType: NODE_TYPE_SCHEMA, title: { type: "string" }, x: { type: "number" }, y: { type: "number" }, width: { type: "number" }, height: { type: "number" }, metadata: JSON_RECORD_SCHEMA },
["nodeType"],
),
toolDefinition("canvas_create_text_node", "在当前画布创建单个文本节点。", { text: { type: "string" }, x: { type: "number" }, y: { type: "number" }, title: { type: "string" }, width: { type: "number" }, height: { type: "number" } }),
toolDefinition(
"canvas_create_text_nodes",
"批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。",
{
items: {
type: "array",
minItems: 1,
items: {
type: "object",
properties: { text: { type: "string" }, title: { type: "string" }, x: { type: "number" }, y: { type: "number" }, width: { type: "number" }, height: { type: "number" } },
required: ["text"],
additionalProperties: false,
},
},
x: { type: "number" },
y: { type: "number" },
gap: { type: "number" },
direction: { type: "string", enum: ["row", "column"] },
},
["items"],
),
toolDefinition("canvas_create_cinematic_session", "把自然语言创作指令提交给后端影视 Agent 会话,后端拆解为剧本、场景、分镜、镜头和成片节点,并返回可写回画布的操作。", { prompt: { type: "string" } }, ["prompt"]),
toolDefinition(
"canvas_create_image_prompt_flow",
"创建提示词文本节点和图片目标节点并自动连线,可选择立即触发生图。",
{ prompt: { type: "string" }, x: { type: "number" }, y: { type: "number" }, autoRun: { type: "boolean" }, ...GENERATION_OPTION_PROPERTIES },
["prompt"],
),
generationToolDefinition("canvas_create_generation_flow", "创建通用生成流程:提示词文本节点、对应类型的生成目标节点和参考节点连线,可用于文案、生图、视频或音频。"),
generationToolDefinition("canvas_generate_text", "创建通用文本生成流程并立即触发生成。", "text"),
generationToolDefinition("canvas_generate_image", "创建通用图片生成流程并立即触发生成。", "image"),
generationToolDefinition("canvas_generate_video", "创建通用视频生成流程并立即触发生成。", "video"),
generationToolDefinition("canvas_generate_audio", "创建通用音频生成流程并立即触发生成。", "audio"),
toolDefinition("canvas_update_node", "更新节点基础字段或 metadata。", { id: { type: "string" }, patch: JSON_RECORD_SCHEMA, metadata: JSON_RECORD_SCHEMA }, ["id"]),
toolDefinition("canvas_update_node_text", "更新文本节点内容和标题。", { id: { type: "string" }, text: { type: "string" }, title: { type: "string" } }, ["id", "text"]),
toolDefinition(
"canvas_move_nodes",
"移动一个或多个节点,支持绝对坐标或 dx/dy 偏移。",
{
items: {
type: "array",
minItems: 1,
items: { type: "object", properties: { id: { type: "string" }, x: { type: "number" }, y: { type: "number" }, dx: { type: "number" }, dy: { type: "number" } }, required: ["id"], additionalProperties: false },
},
},
["items"],
),
toolDefinition("canvas_resize_node", "调整节点尺寸。", { id: { type: "string" }, width: { type: "number" }, height: { type: "number" }, freeResize: { type: "boolean" } }, ["id", "width", "height"]),
toolDefinition("canvas_delete_nodes", "删除指定节点及相关连线。", { ids: { type: "array", items: { type: "string" }, minItems: 1 } }, ["ids"]),
toolDefinition(
"canvas_connect_nodes",
"批量连接节点。",
{ connections: { type: "array", minItems: 1, items: { type: "object", properties: { fromNodeId: { type: "string" }, toNodeId: { type: "string" } }, required: ["fromNodeId", "toNodeId"], additionalProperties: false } } },
["connections"],
),
toolDefinition("canvas_select_nodes", "设置当前选中节点。", { ids: { type: "array", items: { type: "string" } } }, ["ids"]),
toolDefinition("canvas_set_viewport", "调整画布视口。", { viewport: VIEWPORT_SCHEMA }, ["viewport"]),
toolDefinition("canvas_run_generation", "触发指定节点生成;对已有生成任务明确重试时传 retry=true。", { nodeId: { type: "string" }, mode: GENERATION_MODE_SCHEMA, prompt: { type: "string" }, retry: { type: "boolean" } }, ["nodeId"]),
];

View File

@@ -1,6 +1,7 @@
import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { workflowStarterPrompt } from "@/lib/prompts";
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
import type { AiConfig } from "@/stores/use-config-store";
import type { CanvasAgentOp, CanvasAgentSnapshot } from "./canvas-agent-ops";
@@ -143,9 +144,9 @@ export function buildCanvasWorkflowOps(input: CanvasWorkflowInput, snapshot: Can
function workflowPrompt(kind: CanvasWorkflowNodeKind, title: string, input: CanvasWorkflowInput) {
const workflowTitle = (input.title || input.description || "当前创作项目").trim();
if (kind === "character_cards") return `请基于「${workflowTitle}」拆分主要角色,并为每个角色生成可用于后续创作的角色图片卡片:外观、服饰、身份、性格和视觉辨识点。`;
if (kind === "character_three_view") return `请基于上游角色卡片生成「${title}」:同一角色的正面、侧面、背面三视图,保持服饰、发型、道具和比例一致。`;
if (kind === "storyboard_video") return `请基于上游角色三视图,为「${workflowTitle}」制作分镜剧情视频方案:包含镜头顺序、景别、动作、节奏和画面连续性。`;
if (kind === "character_cards" || kind === "character_three_view" || kind === "storyboard_video") {
return workflowStarterPrompt(kind, title, workflowTitle);
}
return "";
}

View File

@@ -12,8 +12,15 @@ type CanvasNodeAssetOptions = {
};
export function canvasNodeToAsset(node: CanvasNodeData, options: CanvasNodeAssetOptions): NewAsset | null {
const content = node.metadata?.content?.trim();
if (!content) return null;
const content = node.metadata?.content?.trim() || "";
const storageKey = node.metadata?.storageKey?.trim() || undefined;
const isMedia = node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio;
// 文本素材必须直接携带正文;媒体则允许只有持久 storageKey。后者常见于页面重载后:
// blob/object URL 已失效,但资源定位符仍可从后端重新换取可访问地址,不能因此丢掉素材记录。
if (node.type === CanvasNodeType.Text && !content) return null;
if (isMedia && !content && !storageKey) return null;
if (!isMedia && node.type !== CanvasNodeType.Text) return null;
const title = node.metadata?.prompt?.slice(0, 24) || node.title || canvasAssetFallbackTitle(node.type);
const metadata = {
source: options.source,
@@ -21,7 +28,7 @@ export function canvasNodeToAsset(node: CanvasNodeData, options: CanvasNodeAsset
nodeId: node.id,
taskId: options.taskId || node.metadata?.taskId,
prompt: node.metadata?.prompt,
resourceKey: node.metadata?.storageKey,
resourceKey: storageKey,
};
const base = {
title,
@@ -37,13 +44,13 @@ export function canvasNodeToAsset(node: CanvasNodeData, options: CanvasNodeAsset
return { ...base, kind: "text", data: { content } };
}
if (node.type === CanvasNodeType.Image) {
const dataUrl = node.metadata?.storageKey ? "" : content;
const dataUrl = storageKey ? "" : content;
return {
...base,
kind: "image",
data: {
dataUrl,
storageKey: node.metadata?.storageKey,
storageKey,
width: node.metadata?.naturalWidth || node.width,
height: node.metadata?.naturalHeight || node.height,
bytes: node.metadata?.bytes || getDataUrlByteSize(dataUrl),
@@ -57,7 +64,7 @@ export function canvasNodeToAsset(node: CanvasNodeData, options: CanvasNodeAsset
kind: "video",
data: {
url: content,
storageKey: node.metadata?.storageKey,
storageKey,
width: node.metadata?.naturalWidth || node.width,
height: node.metadata?.naturalHeight || node.height,
durationMs: node.metadata?.durationMs,
@@ -73,7 +80,7 @@ export function canvasNodeToAsset(node: CanvasNodeData, options: CanvasNodeAsset
kind: "audio",
data: {
url: content,
storageKey: node.metadata?.storageKey,
storageKey,
durationMs: node.metadata?.durationMs,
bytes: node.metadata?.bytes || 0,
mimeType: node.metadata?.mimeType || "audio/mpeg",

View File

@@ -1,4 +1,5 @@
import { NODE_DEFAULT_SIZE, getNodeSpec } from "@/constant/canvas";
import { PromptTemplateOperation } from "@/lib/prompts";
import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
import { normalizeStoryboardAssetBindings } from "@/lib/canvas/canvas-storyboard-assets";
import { bindingForConnectedNode, storyboardComposerContent, storyboardRowReferenceNodeIds } from "@/lib/canvas/canvas-storyboard-materializer";
@@ -93,7 +94,7 @@ export function createStoryboardRow(shotNumber: number, patch: Partial<Storyboar
export function storyboardPromptTemplateMetadata(row: StoryboardRow, kind: "image" | "video"): Pick<CanvasNodeMetadata, "promptTemplateOperation" | "promptTemplateVariables"> {
const variables = kind === "image" ? row.imagePromptTemplateVariables : row.videoPromptTemplateVariables;
return variables
? { promptTemplateOperation: kind === "image" ? "storyboard_first_frame" : "storyboard_video", promptTemplateVariables: variables }
? { promptTemplateOperation: kind === "image" ? PromptTemplateOperation.StoryboardFirstFrame : PromptTemplateOperation.StoryboardVideo, promptTemplateVariables: variables }
: { promptTemplateOperation: undefined, promptTemplateVariables: undefined };
}

View File

@@ -39,7 +39,7 @@ export function canvasNodeMentionToken(nodeId: string) {
export function canvasResourceMentionToken(reference: CanvasResourceReference) {
if (reference.mentionToken) return reference.mentionToken;
if (reference.kind === "skill" && reference.skill?.skill_id) return canvasSkillMentionToken(reference.skill.skill_id);
if (reference.kind === "skill" && reference.skill?.skillId) return canvasSkillMentionToken(reference.skill.skillId);
if (reference.assetId) return `@[asset:${reference.assetId}]`;
return `@${reference.label}`;
}
@@ -239,7 +239,10 @@ export function getMentionResourceNodes(nodeId: string, nodes: CanvasNodeData[],
if (configInputs.length) return configInputs;
const ownInputs = getContextResourceNodes(nodeId, nodes, connections);
if (ownInputs.length) return ownInputs;
return [];
// 没有入边时,资源节点可以把自身当作 @图片1 / @视频1,用于图生图、视频再编辑。
// 有入边时仍只暴露上游,避免自身把槽位序号挤掉。
const self = nodes.find((node) => node.id === nodeId);
return self && isResourceNode(self) ? [self] : [];
}
export function getGenerationResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {

View File

@@ -2,7 +2,7 @@ import { localForageStorageForScope } from "@/lib/localforage-storage";
import type { DirectorMode } from "@/lib/canvas/director/director-modes";
/**
* 导演台首次上手引导(P2)。
* 导演台首次上手引导。
*
* 为什么存在:导演台把模板、四个一级模式、对象操作、保存/导出堆在同一个工作台里,
* 第一次进来的人没有任何线索该先做什么。引导给出一条确定的最短路径。
@@ -44,7 +44,7 @@ export type DirectorOnboardingStep = {
mode?: DirectorMode;
};
/** 顺序与 Issue #305 的首次任务一致:添加演员 → 移动 → 调姿 → 轨迹 → CAM → 应用。 */
/** 引导步骤按首次使用的最短操作路径排列:添加演员 → 移动 → 调姿 → 轨迹 → CAM → 应用。 */
export const DIRECTOR_ONBOARDING_STEPS: DirectorOnboardingStep[] = [
{ id: "actor", title: "添加并选中演员", detail: "在左侧「快速添加」点演员;模板已有演员时,直接在场景树或视口选中它。", mode: "layout" },
{ id: "move", title: "把演员移动到表演位置", detail: "保持摆场模式,在底部 dock 选移动工具(W),拖动轴手柄;右栏可输入精确坐标。", mode: "layout" },
@@ -207,9 +207,8 @@ export async function advanceDirectorOnboarding(scope: string, progress: Directo
}
/**
* 独立的重启 API。不依赖调用方持有当前内存进度 —— 未来工作台设置里的
* 「重新开始引导」按钮不必先加载一次旧进度才能重置,直接落盘初始状态即可,
* 对已 dismissed/completed 的账号同样生效。
* 独立的重启 API。不依赖调用方持有当前内存进度;设置页的「重新开始引导」
* 按钮可以直接落盘初始状态,对已 dismissed/completed 的账号同样生效。
*/
export async function resetDirectorOnboardingProgress(scope: string, storage?: DirectorOnboardingStorage): Promise<DirectorOnboardingProgress> {
const normalizedScope = requireScope(scope);

View File

@@ -1,28 +1,15 @@
import { buildGenerationConfig } from "@/lib/canvas/canvas-project-generation";
import { PromptTemplateOperation, promptTemplateTaskPlaceholder } from "@/lib/prompts";
import { runBackendGenerationTask } from "@/services/api/generation-task";
import type { AiConfig } from "@/stores/use-config-store";
export type SkillDraft = {
skill_name: string;
skillName: string;
description: string;
instruction: string;
tag?: string;
};
const SKILL_DRAFT_PROMPT = `你是一位技能编写助手。根据用户的想法,为一个「可复用的创作技能」生成一份草稿。
只输出一个 JSON 对象,不要输出任何其他文字、解释或 Markdown 代码块。JSON 字段:
- "skill_name": 技能名称,简短(不超过 20 个字)
- "tag": 分类,从以下值中选一个字符串:drama(短剧影视)、ecommerce(电商营销)、creative(创意设计)、social(社媒内容)、others(其他)
- "description": 技能简介(不超过 120 字),说明适用场景、输入条件和最终产出
- "instruction": 技能指令(Markdown 格式,至少 300 字),写给后续在画布中使用该技能的 AI 模型阅读,必须包含:角色设定、输入与约束、分步执行流程、检查清单、输出格式
用户的想法:
{{{IDEA}}}`;
export function buildSkillDraftPrompt(idea: string): string {
return SKILL_DRAFT_PROMPT.replace("{{{IDEA}}}", idea.trim());
}
const MAX_NAME_LENGTH = 80;
const MAX_DESCRIPTION_LENGTH = 500;
@@ -36,7 +23,7 @@ export function parseSkillDraft(text: string): Partial<SkillDraft> {
const raw = JSON.parse(trimmed.slice(start, end + 1)) as Record<string, unknown>;
if (raw && typeof raw === "object") {
return {
skill_name: String(raw.skill_name ?? "").trim().slice(0, MAX_NAME_LENGTH),
skillName: String(raw.skillName ?? raw.skill_name ?? "").trim().slice(0, MAX_NAME_LENGTH),
description: String(raw.description ?? "").trim().slice(0, MAX_DESCRIPTION_LENGTH),
instruction: String(raw.instruction ?? raw.instructions ?? "").trim(),
tag: typeof raw.tag === "string" && raw.tag.trim() ? raw.tag.trim() : undefined,
@@ -49,7 +36,7 @@ export function parseSkillDraft(text: string): Partial<SkillDraft> {
// 回退:首行作为名称,正文作为简介与指令,用户随后可自由编辑
const firstLine = trimmed.split(/\r?\n/)[0]?.trim().slice(0, 30) || "未命名技能";
return {
skill_name: firstLine,
skillName: firstLine,
description: trimmed.slice(0, MAX_DESCRIPTION_LENGTH),
instruction: trimmed,
};
@@ -59,8 +46,13 @@ export async function generateSkillDraft(idea: string, config: AiConfig, signal?
const generationConfig = buildGenerationConfig(config, undefined, "text");
const result = await runBackendGenerationTask({
mode: "text",
prompt: buildSkillDraftPrompt(idea),
prompt: promptTemplateTaskPlaceholder("技能草稿"),
config: generationConfig,
metadata: {
source: "skill-draft",
promptTemplateOperation: PromptTemplateOperation.SkillDraft,
promptTemplateVariables: { 用户想法: idea.trim() },
},
signal,
});
return parseSkillDraft(result.text || "");