From 7a817d684703a338532636bb6f34eacc5d8068b8 Mon Sep 17 00:00:00 2001 From: dlh <2773843782@qq.com> Date: Thu, 20 Aug 2026 19:46:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E6=8A=80=E8=83=BD=20-=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20AI=20=E8=B5=B7=E8=8D=89=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E5=B9=B6=E4=BF=9D=E5=AD=98=E4=B8=BA=E6=88=91?= =?UTF-8?q?=E7=9A=84=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 技能编辑器新增「AI 起草」:描述想法后用文本模型生成 name/description/instruction/tag 草稿并回填表单 - 复用后端文本任务通道 runBackendGenerationTask 与现有技能保存/启停 API - 容错解析 JSON,失败回退首行作名称;未配置文本模型时引导到设置页 --- web/src/lib/canvas/skill-drafting.ts | 67 ++++++++++++++++++++ web/src/pages/skills/skill-editor-drawer.tsx | 57 ++++++++++++++++- 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/canvas/skill-drafting.ts diff --git a/web/src/lib/canvas/skill-drafting.ts b/web/src/lib/canvas/skill-drafting.ts new file mode 100644 index 000000000..9d625ebe6 --- /dev/null +++ b/web/src/lib/canvas/skill-drafting.ts @@ -0,0 +1,67 @@ +import { buildGenerationConfig } from "@/lib/canvas/canvas-project-generation"; +import { runBackendGenerationTask } from "@/services/api/generation-task"; +import type { AiConfig } from "@/stores/use-config-store"; + +export type SkillDraft = { + skill_name: 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; + +export function parseSkillDraft(text: string): Partial { + const trimmed = (text || "").trim(); + if (!trimmed) return {}; + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start >= 0 && end > start) { + try { + const raw = JSON.parse(trimmed.slice(start, end + 1)) as Record; + if (raw && typeof raw === "object") { + return { + skill_name: String(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, + }; + } + } catch { + // JSON 解析失败时回退到文本整体方案 + } + } + // 回退:首行作为名称,正文作为简介与指令,用户随后可自由编辑 + const firstLine = trimmed.split(/\r?\n/)[0]?.trim().slice(0, 30) || "未命名技能"; + return { + skill_name: firstLine, + description: trimmed.slice(0, MAX_DESCRIPTION_LENGTH), + instruction: trimmed, + }; +} + +export async function generateSkillDraft(idea: string, config: AiConfig, signal?: AbortSignal): Promise> { + const generationConfig = buildGenerationConfig(config, undefined, "text"); + const result = await runBackendGenerationTask({ + mode: "text", + prompt: buildSkillDraftPrompt(idea), + config: generationConfig, + signal, + }); + return parseSkillDraft(result.text || ""); +} \ No newline at end of file diff --git a/web/src/pages/skills/skill-editor-drawer.tsx b/web/src/pages/skills/skill-editor-drawer.tsx index 90093dc17..9ed4787f6 100644 --- a/web/src/pages/skills/skill-editor-drawer.tsx +++ b/web/src/pages/skills/skill-editor-drawer.tsx @@ -1,8 +1,11 @@ import { App, Button, Drawer, Form, Input, Select, Switch } from "antd"; -import { Minus, Plus, Save } from "lucide-react"; +import { Minus, Plus, Save, Wand2 } from "lucide-react"; import { useEffect, useState } from "react"; import { fallbackSkillCategories } from "@/pages/skills/skill-catalog"; +import { generateSkillDraft } from "@/lib/canvas/skill-drafting"; +import { navigateToSettings } from "@/lib/settings-navigation"; +import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { createSkill, updateSkill, type Skill, type SkillMutationInput, type SkillShowcaseMedia } from "@/services/api/skills"; type SkillFormValues = Omit & { is_public: boolean }; @@ -12,6 +15,9 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo const [form] = Form.useForm(); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false); + const [draftIdea, setDraftIdea] = useState(""); + const [drafting, setDrafting] = useState(false); + const effectiveConfig = useEffectiveConfig(); useEffect(() => { if (!open) return; @@ -60,8 +66,57 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo } }; + const draftFromIdea = async () => { + const idea = draftIdea.trim(); + if (!idea) { + message.warning("请先描述你想沉淀的技能"); + return; + } + if (!useConfigStore.getState().isAiConfigReady(effectiveConfig, effectiveConfig.model)) { + message.info("尚未配置可用的文本模型,请先到设置页配置"); + navigateToSettings({ section: "models", continueCreation: true }); + return; + } + setDrafting(true); + try { + const draft = await generateSkillDraft(idea, effectiveConfig); + form.setFieldsValue({ + skill_name: draft.skill_name || "", + description: draft.description || "", + instruction: draft.instruction || "", + ...(draft.tag ? { tag: draft.tag } : {}), + }); + setDirty(true); + message.success("草稿已生成,请检查并调整后保存"); + } catch (error) { + message.error(error instanceof Error ? `起草失败:${error.message}` : "起草失败"); + } finally { + setDrafting(false); + } + }; + return ( } onClick={() => form.submit()}>保存技能}> +
+
+ + AI 起草 + 描述想法,一键生成名称、简介与指令草稿(可再编辑) +
+ setDraftIdea(event.target.value)} + autoSize={{ minRows: 3, maxRows: 6 }} + maxLength={2000} + showCount + disabled={drafting} + placeholder="例如:我要一个竖屏短剧分镜技能——输入剧本段落,输出按景别排列的分镜表,每个镜头包含画面、台词、时长与转场…" + /> +
+ 将使用你的文本模型生成一次草稿 + +
+
setDirty(true)}>