diff --git a/RULES.md b/RULES.md
index 2d84cd4..2475262 100644
--- a/RULES.md
+++ b/RULES.md
@@ -12,7 +12,7 @@
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
-- 当前产品方向(2026-05-25 三模式 + logo-only 根域名画布版):默认首页彻底从“信息流广告复刻管线”切换为多人通用的 SKG 营销内容生产平台入口,服务公司内部成员同时使用。`https://marketing.skg.com` 登录后直接进入个人生成画布,`/canvas/` 只作为旧链接兼容跳转到根域名。终端可见品牌位只放 SKG logo,不再把“生图生视频”“SKG 生成画布”或长系统名放在主界面上。画布底部输入框只保留三个用户能直接理解的入口:文生图、文生视频、图生视频;不再把“首帧生视频 / 首尾帧生视频”这类模型实现概念作为主入口。图生视频只显示“上传图片”,内部仍用后端 first_image 能力提交。用户选择生成方式、必要时上传图片、手写提示词并点击生成;图片模式显示尺寸选择,视频模式显示画幅和真实可用时长选择。后端 `/health` 向前端返回可选图片 / 视频模型、图片尺寸、视频画幅和视频时长,首页允许用户选择图片模型(自动、GPT Image 2、Gemini 图片兜底)和视频模型(Seedance、Kling、Veo 3 等别名;实际可用模型以环境变量映射为准)。当前 Doubao / Seedance 生产链路单条视频最长按 15 秒暴露,不在 UI 显示 30 秒;如后续要 30 秒,需要改成多段生成后合成。用户登录后仍只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离;生成调用本项目后端 `/api`,每个浏览器的画布项目先保存在本地 localStorage,图片/视频资产仍按登录用户写入后端 job。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。
+- 当前产品方向(2026-05-25 上游画布能力恢复版):默认入口是多人通用的 SKG 营销内容生产平台,`https://marketing.skg.com` 登录后直接进入个人生成画布,`/canvas/` 只作为旧链接兼容跳转到根域名。终端可见品牌位只放 SKG logo,不在主界面展示“生图生视频”“SKG 生成画布”或长系统名。画布本体尽量恢复 `chatfire-AI/huobao-canvas` 的成熟交互,不再削成三模式单输入框:保留首页推荐词、画布底部推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载等上游能力;多角度分镜、故事板、图转视频、绘本等工作流按上游结构创建节点。API 接入是例外:生成调用继续走本项目后端 `/api` 和当前登录 Cookie,不要求员工在浏览器配置个人 API Key;API 设置弹窗只保留模型/端点配置外观和本地模型管理,不能出现上游注册链接或外部品牌。用户登录后仍只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离;每个浏览器的画布项目先保存在本地 localStorage,图片/视频资产按登录用户写入后端 job。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。
## 部署事实
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index db5b2fc..fd9ba40 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -7,6 +7,6 @@ Portions of the internal SKG canvas module are adapted from `chatfire-AI/huobao-
- Source: https://github.com/chatfire-AI/huobao-canvas
- License note: the upstream README declares MIT licensing and links to a `LICENSE` file, but the cloned snapshot used for this integration did not include that file.
- Local integration path: `web/canvas-app/`
-- SKG changes: branding, visible product text, model options, auth behavior, and API calls were changed for SKG internal use.
+- SKG changes: branding, visible product text, routing, auth behavior, and API calls were changed for SKG internal use; visible upstream registration links and external provider branding are removed from the product UI.
This notice is kept in the repository for engineering traceability and is not shown in the product UI.
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 0304152..ee36118 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -579,16 +579,17 @@
2026-05-25 即梦 generate 式简化:默认首页进一步压缩为窄导航栏、会话侧栏和中央 prompt composer,不再把四入口、参考图、我的任务和结果区平铺成三栏。图片 / 视频 / 图文模式、自动设置和参考上传都收进 composer 底部的小按钮;参考图是输入框左侧倾斜上传卡;结果只用右下角浮层提示,完整沉淀交给详情页。
2026-05-25 三模式版:默认首页再收敛为一个中央对话框,首页和画布底部输入框只让用户选文生图、文生视频、图生视频,然后手写提示词生成。图生视频只显示“上传图片”,不再把首帧 / 首尾帧这类模型实现概念作为主入口;营销图文不再作为首页默认入口。后端 /health 返回可选图片 / 视频模型、图片尺寸、视频画幅和真实可用视频时长,首页按返回值显示模型和规格选择;当前 Doubao / Seedance 生产链路单条最长 15 秒,不向用户暴露 30 秒按钮。
2026-05-25 根域名画布版:https://marketing.skg.com 登录后直接进入个人生成画布,不再先进入 React 单对话框首页再点画布;/canvas/ 只保留为旧链接兼容跳转。后续优先少改成熟画布结构,只在必要时改模式文案、生成接入和结果/队列显示。
+ 2026-05-25 上游能力恢复版:用户明确要求“API 没关系,其他恢复,别削弱”。因此根域名画布恢复 chatfire-AI/huobao-canvas 的成熟节点和工作流结构:推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、图片/视频/LLM 配置、多角度分镜、故事板、绘本和批量下载都保留;只继续替换品牌、路由和 API 接入。生成请求仍走 SKG 后端 /api 与登录 Cookie,员工不需要个人 API Key。
- 当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 在画布底部选择生成方式 → 选择模型和规格 → 图生视频时上传图片 → 手写提示词 → 在画布生成图片或视频节点 → 进入详情页继续沉淀”。默认只做三件事:文生图、文生视频、图生视频。底层仍复用既有 /creative/jobs/image、/jobs/{id}/frames/{idx}/generate、/jobs/{id}/frames/{idx}/storyboard/video;图生视频把上传图片作为 first_image 提交给视频接口,但 UI 不展示“首帧”概念。生图接口现在按前端 model 和 size 字段走 auto / gpt-image-2 / gemini-3-pro-image-preview 与 1024x1536 / 1024x1024 / 1536x1024 等图片尺寸;视频接口继续按 model 字段走 seedance / kling / veo3 / veo 别名映射,并按后端返回的 video_size_options 与 video_duration_options 提交画幅和时长,实际模型和上限以服务器环境变量为准。多人互不影响依赖后端 owner_id 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。
+ 当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 用提示词、推荐词、AI 润色或工作流模板创建节点 → 画布自动执行或手动连接图片/视频/文本节点 → 生成结果沉淀在当前个人画布 → 需要时进入详情页继续编辑”。画布不再被削成三模式入口;首帧、尾帧、参考图、图生视频、多角度分镜、故事板和绘本等上游概念按节点能力保留。底层生成仍由 web/canvas-app/src/hooks/useApi.js 适配到本项目 /creative/jobs/image、/jobs/{id}/frames/{idx}/generate、/jobs/{id}/frames/{idx}/storyboard/video,并按当前登录用户写入个人 job。多人互不影响依赖后端 owner_id 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。
01
个人任务
GET /jobs 按当前登录用户过滤;旧无 owner 任务只对备用账号可见。
-
02
选择方式
首页对话框只提供文生图、文生视频、图生视频三个按钮。
-
03
选择模型和规格
GET /health 返回 image_options、image_size_options、video_options、video_size_options 和 video_duration_options;首页按当前生成方式切换模型、图片尺寸、视频画幅和视频时长。
-
04
上传图片 / 空白任务
POST /creative/jobs/image 创建轻量任务;文生图和文生视频可空白创建,图生视频上传一张图片作为视频参考。
-
05
手写提示词
首页不再生成营销文案或自动展开产品 / 人群配置,用户直接写图片或视频提示词。
-
06
生成图片 / 视频
generateImage 传 mode=text、图片模型和图片尺寸;generateStoryboardVideo 提交文本、模型、画幅、时长,图生视频额外提交 first_image。视频提交后先写入 queued 占位,再由后端队列按并发上限启动。
-
07
结果沉淀
首页只在对话框下方显示最新图片或视频;视频会显示排队位置、生成进度、完成播放或失败可重试状态;所有图片/视频缩略图继续复用 MediaAssetTile。
+
02
进入画布
用户直接在根域名个人画布里操作,上游项目列表、推荐词、节点菜单、工作流模板和批量下载能力保留。
+
03
组织节点
可通过底部 prompt、AI 润色、自动执行、手动添加节点或工作流模板创建文本、图片、视频、LLM、配置和参考图节点。
+
04
参考素材
首帧、尾帧、参考图和图片节点按上游节点语义保留;提交到后端时由 useApi.js 转成 first_image、last_image 或图片编辑参考。
+
05
工作流执行
自动执行会根据提示词创建文生图、图转视频、故事板、多角度分镜或绘本等节点组;手动模式下用户可自行连接节点。
+
06
生成图片 / 视频
generateImage 和 generateStoryboardVideo 继续走 SKG 后端 /api;视频提交后先写入 queued 占位,再由后端队列按并发上限启动。
+
07
结果沉淀
生成图、视频 URL、任务状态和下载入口回填到画布节点;完整任务结果仍可进入 /detail/?job= 查看。
08
详情页
/detail/?job=<id> 展示参考图、全量生成图、视频候选、提示词和营销图文,并支持继续生成。
09
高级复刻
旧 AdRecreationBoard 与 /agent/ 作为高级入口保留,不再是默认路径。
@@ -604,8 +605,8 @@
web/next.config.mjs | Next.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 eslint 顶层配置,避免本地 dev 出现配置 Issue 提示。 |
web/app/globals.css | 全局主题变量、登录页视觉样式、信息流工作台玻璃拟态 token、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。工作台在 skg-board-theme 内按 Figma 本地 MCP 参考改成黑灰玻璃系统:深灰背景、#383838 胶囊侧栏、rgba(255,255,255,.1) 玻璃面、backdrop-filter: blur(5px)、20px 圆角、10px 10px 10px rgba(0,0,0,.3) 阴影和绿黄状态色;新增 skg-board-shell、skg-board-rail、skg-glass-card、skg-glass-card--flat、skg-status-orb 等样式。侧栏改为跟随视口拉满工作台可用高度的悬停胶囊,桌面最小 600px,展开时在同一侧栏内承载素材输入抽屉。明暗主题已分开维护 shell、panel、glass、stat、action 和音频波形 token;暗色压低灰雾和面板底色,明亮模式改为暖白工作台,避免指标卡、按钮和波形继续残留黑底/白线;顶部指标卡增加紫、黄绿、琥珀、青绿、绿色光斑变量,接近原版多色玻璃卡效果。主/次按钮、指标卡和空状态继续走统一类,避免各板块散写不同玻璃效果。 |
web/app/page.tsx | 旧 React 单对话框生成台源码仍保留,便于以后回滚或抽能力;当前生产根域名已经由 web/canvas-app/ 画布产物覆盖,不再把这个 React 首页作为默认首屏。该页面里的模式也已收敛为文生图、文生视频、图生视频;图生视频只显示“上传图片”,不把“首帧/首尾帧”作为用户入口。旧 TK 复刻工作台组件仍保留在 web/components/ad-recreation-board.tsx,但不再作为默认首页渲染。 |
- web/canvas-app/ | SKG 内部画布应用:从 chatfire-AI/huobao-canvas 交互逻辑改造而来,保留 Vue Flow 节点画布、项目列表、节点连接和批量下载等核心画布能力;移除可见原品牌、GitHub 链接、本地 API Key 设置和第三方 base URL 配置,终端可见品牌收敛为 SKG logo。生产路径固定为根域名 /,内部路由用 /p/:id?;来源说明保存在 THIRD_PARTY_NOTICES.md,不展示给终端用户。 |
- web/canvas-app/src/views/Canvas.vue | 画布主交互:底部悬浮 prompt composer 吸附在画布下方,提供文生图、文生视频、图生视频三种模式;图生视频只显示“上传图片”,底部不再常驻推荐提示词 chips,避免遮挡画布操作。提交后自动创建文本节点、参考图节点、图片配置节点或视频配置节点,并用 autoExecute 触发生成;图片连到视频节点时默认作为视频参考图。 |
+ web/canvas-app/ | SKG 内部画布应用:从 chatfire-AI/huobao-canvas 交互逻辑改造而来。当前策略是“保留成熟画布能力,替换品牌/路由/API”:Vue Flow 节点画布、项目列表、推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载都保留;可见品牌收敛为 SKG logo,不展示上游注册链接或外部品牌。生产路径固定为根域名 /,内部路由用 /p/:id?;来源说明保存在 THIRD_PARTY_NOTICES.md,不展示给终端用户。 |
+ web/canvas-app/src/views/Canvas.vue | 画布主交互:恢复上游底部 prompt composer、AI 润色、自动执行、推荐词、节点菜单、工作流面板、API/模型设置入口和批量下载入口。自动执行会调用 useWorkflowOrchestrator 分析提示词,创建文生图、图转视频、故事板、多角度分镜或绘本节点组;手动模式只创建文本节点,用户自行连接节点。 |
web/canvas-app/src/hooks/useApi.js | 画布到本项目后端的适配层:不再读取浏览器 API Key,而是使用当前登录会话 Cookie 调用 /api。文生图 / 图生图先创建轻量 creative job,再调用 /frames/0/generate;文生视频 / 图生视频调用 /storyboard/video 并轮询 /jobs/{id},完成后把图片或 mp4 URL 写回画布节点。 |
web/scripts/sync-canvas-root.mjs | 构建桥接脚本:在 next build 静态导出完成后,把 Vite 画布产物 web/canvas-app/dist 覆盖到 web/out 根目录,使 https://marketing.skg.com 登录后直接进入画布;旧 web/scripts/sync-canvas-dist.mjs 保留但不再由生产构建调用。 |
web/app/detail/page.tsx | 任务详情页:静态导出路由 /detail/?job=<id>,通过 query 读取 job id,调用 getJob 恢复同一任务。页面展示参考图、全部生成图、视频候选、营销图文方案和历史提示词,可继续调用 generateImage、generateStoryboardVideo、generateCreativeCopy,并支持删除图片/视频。该页继续依赖后端 owner 过滤,用户不能通过切换 URL 读取别人的任务。 |
@@ -1114,8 +1115,8 @@ ProductRefStateItem {
| 内容生产画布 |
- 承载个人自由排列的创作空间:用户在画布上用对话框生成文本、图片和视频节点,结果按节点位置沉淀,不和默认首页的单条结果卡互相挤压。画布项目先保存在浏览器本地,生成资产进入后端个人 job。 |
- 当前不做团队共享画布、管理员总览、多人协同编辑或跨浏览器同步;也不让员工在浏览器里配置上游 API Key。 |
+ 承载个人自由排列的创作空间:用户在画布上通过提示词、推荐词、AI 润色、自动执行、工作流模板或手动节点连接生成文本、图片和视频节点,结果按节点位置沉淀。画布项目先保存在浏览器本地,生成资产进入后端个人 job。 |
+ 当前不做团队共享画布、管理员总览、多人协同编辑或跨浏览器同步;API 设置不能接回上游外部注册链接,生成调用必须继续走本项目后端 /api 和登录会话。 |
web/canvas-app/、deploy/nginx.conf、web/scripts/sync-canvas-root.mjs |
@@ -1205,6 +1206,19 @@ ProductRefStateItem {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-25 · 恢复上游画布能力,API 保持 SKG 接入
+ UI
+ Product
+ Docs
+
+
+
问题:三模式精简版把成熟画布削弱过多,用户明确要求 api 没关系,其他上游画布能力直接恢复,不再把早先的 SKG 简化想法强行融入当前成熟版面。
+
改动:从 chatfire-AI/huobao-canvas 恢复 ApiSettings.vue、模型/渠道/工作流配置、useWorkflowOrchestrator、首页、画布和图片/视频/文本/LLM 节点。保留 SKG logo-only 品牌、根路径 /、内部路由 /p/:id? 和 useApi.js 的 SKG 后端适配;API 设置弹窗去掉上游注册链接和外部品牌文案。
+
影响:画布重新具备推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、多角度分镜、故事板、绘本和批量下载等成熟能力;生成请求仍使用当前登录会话调用本项目 /api,员工不需要个人 API Key。
+
+
2026-05-25 · 根域名直接进入个人生成画布
diff --git a/web/canvas-app/src/components/ApiSettings.vue b/web/canvas-app/src/components/ApiSettings.vue
new file mode 100644
index 0000000..5851903
--- /dev/null
+++ b/web/canvas-app/src/components/ApiSettings.vue
@@ -0,0 +1,396 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 端点路径
+
+
+
+
+ 问答
+ {{ currentEndpoints.chat }}
+
+
+ 生图
+ {{ currentEndpoints.image }}
+
+
+ 视频生成
+ {{ currentEndpoints.video }}
+
+
+ 视频查询
+ {{ currentEndpoints.videoQuery }}
+
+
+
+
+
+
当前使用 SKG 内部登录会话调用生成接口。
+
+
+
+
+ API 已就绪,可以使用 AI 功能
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 添加
+
+
+
+
+ {{ model.label }}
+
+
+
+
+
+
+
+
+
+
+ 添加
+
+
+
+
+ {{ model.label }}
+
+
+
+
+
+
+
+
+
+
+ 添加
+
+
+
+
+ {{ model.label }}
+
+
+
+
+
+
+
+
+
+
生成调用走当前登录会话,无需个人 API Key
+
+ 清除配置
+ 取消
+ 保存
+
+
+
+
+
+
+
+
+
diff --git a/web/canvas-app/src/components/edges/ImageRoleEdge.vue b/web/canvas-app/src/components/edges/ImageRoleEdge.vue
index 14ea3ed..68d4928 100644
--- a/web/canvas-app/src/components/edges/ImageRoleEdge.vue
+++ b/web/canvas-app/src/components/edges/ImageRoleEdge.vue
@@ -55,8 +55,8 @@ const props = defineProps({
// Image role options | 图片角色选项
const imageRoleOptions = [
- { label: '图片', key: 'first_frame_image' },
- { label: '结束图', key: 'last_frame_image' },
+ { label: '首帧', key: 'first_frame_image' },
+ { label: '尾帧', key: 'last_frame_image' },
{ label: '参考图', key: 'input_reference' }
]
@@ -66,7 +66,7 @@ const currentRole = computed(() => props.data?.imageRole || 'first_frame_image')
// Current role label | 当前角色标签
const currentRoleLabel = computed(() => {
const option = imageRoleOptions.find(o => o.key === currentRole.value)
- return option?.label || '图片'
+ return option?.label || '首帧'
})
// Calculate bezier path | 计算贝塞尔路径
@@ -95,7 +95,7 @@ const edgeStyle = computed(() => ({
// Handle role selection | 处理角色选择
const handleRoleSelect = (role) => {
- // Keep endpoint image roles unique when advanced users edit edge roles.
+ // If selecting first_frame or last_frame, ensure uniqueness | 如果选择首帧或尾帧,确保唯一性
if (role === 'first_frame_image' || role === 'last_frame_image') {
// Find other edges connected to the same target with the same role | 查找连接到同一目标且具有相同角色的其他边
const sameTargetEdges = edges.value.filter(edge =>
diff --git a/web/canvas-app/src/components/nodes/ImageConfigNode.vue b/web/canvas-app/src/components/nodes/ImageConfigNode.vue
index 69d4578..19653ac 100644
--- a/web/canvas-app/src/components/nodes/ImageConfigNode.vue
+++ b/web/canvas-app/src/components/nodes/ImageConfigNode.vue
@@ -586,7 +586,7 @@ const handleGenerate = async (mode = 'auto') => {
}
if (!isConfigured.value) {
- window.$message?.warning('登录状态异常,请重新进入工作台')
+ window.$message?.warning('生成接口未就绪,请稍后重试')
return
}
diff --git a/web/canvas-app/src/components/nodes/ImageNode.vue b/web/canvas-app/src/components/nodes/ImageNode.vue
index 41dd7d9..caa48dc 100644
--- a/web/canvas-app/src/components/nodes/ImageNode.vue
+++ b/web/canvas-app/src/components/nodes/ImageNode.vue
@@ -932,7 +932,7 @@ const handleVideoGen = () => {
sourceHandle: 'right',
targetHandle: 'left',
type: 'imageRole',
- data: { imageRole: 'first_frame_image' } // Default reference image | 默认参考图
+ data: { imageRole: 'first_frame_image' } // Default to first frame | 默认首帧
})
// Connect text node to config node | 连接文本节点到配置节点
diff --git a/web/canvas-app/src/components/nodes/LLMConfigNode.vue b/web/canvas-app/src/components/nodes/LLMConfigNode.vue
index a8dbf28..05a955b 100644
--- a/web/canvas-app/src/components/nodes/LLMConfigNode.vue
+++ b/web/canvas-app/src/components/nodes/LLMConfigNode.vue
@@ -764,7 +764,7 @@ const getInputFromConnections = () => {
// Handle generate | 处理生成
const handleGenerate = async () => {
if (!isApiConfigured.value) {
- window.$message?.warning('登录状态异常,请重新进入工作台')
+ window.$message?.warning('生成接口未就绪,请稍后重试')
return
}
diff --git a/web/canvas-app/src/components/nodes/TextNode.vue b/web/canvas-app/src/components/nodes/TextNode.vue
index 1084604..a928ede 100644
--- a/web/canvas-app/src/components/nodes/TextNode.vue
+++ b/web/canvas-app/src/components/nodes/TextNode.vue
@@ -623,7 +623,7 @@ const handlePolish = async () => {
// Check API configuration | 检查 API 配置
if (!isApiConfigured.value) {
- window.$message?.warning('登录状态异常,请重新进入工作台')
+ window.$message?.warning('生成接口未就绪,请稍后重试')
return
}
diff --git a/web/canvas-app/src/components/nodes/VideoConfigNode.vue b/web/canvas-app/src/components/nodes/VideoConfigNode.vue
index d1d1890..499b985 100644
--- a/web/canvas-app/src/components/nodes/VideoConfigNode.vue
+++ b/web/canvas-app/src/components/nodes/VideoConfigNode.vue
@@ -82,8 +82,16 @@
提示词 {{ connectedPrompt ? '✓' : '○' }}
- 图片 {{ connectedImages.length > 0 ? `✓ ${connectedImages.length}` : '○' }}
+ :class="imagesByRole.firstFrame ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
+ 首帧 {{ imagesByRole.firstFrame ? '✓' : '○' }}
+
+
+ 尾帧 {{ imagesByRole.lastFrame ? '✓' : '○' }}
+
+
+ 参考图 {{ imagesByRole.referenceImages.length > 0 ? `✓ ${imagesByRole.referenceImages.length}` : '○' }}
@@ -187,7 +195,7 @@ const connectedImages = computed(() => {
edgeId: edge.id,
url: sourceNode.data.url,
base64: sourceNode.data.base64,
- role: edge.data?.imageRole || 'first_frame_image' // Default reference image | 默认参考图
+ role: edge.data?.imageRole || 'first_frame_image' // Default to first frame | 默认首帧
})
}
}
@@ -334,7 +342,7 @@ const handleGenerate = async () => {
}
if (!isConfigured.value) {
- window.$message?.warning('登录状态异常,请重新进入工作台')
+ window.$message?.warning('生成接口未就绪,请稍后重试')
isGenerating.value = false
return
}
@@ -377,12 +385,12 @@ const handleGenerate = async () => {
params.prompt = prompt
}
- // Add primary reference image | 添加主参考图
+ // Add first frame image | 添加首帧图片
if (first_frame_image) {
params.first_frame_image = first_frame_image
}
- // Add optional ending reference image | 添加可选结束参考图
+ // Add last frame image | 添加尾帧图片
if (last_frame_image) {
params.last_frame_image = last_frame_image
}
diff --git a/web/canvas-app/src/config/models.js b/web/canvas-app/src/config/models.js
index c2af530..2dbaf72 100644
--- a/web/canvas-app/src/config/models.js
+++ b/web/canvas-app/src/config/models.js
@@ -1,118 +1,269 @@
/**
- * SKG model and size configuration.
- * These values mirror the backend /health capabilities and keep the canvas UI simple.
+ * Models Configuration | 模型配置
+ * Centralized model configuration | 集中模型配置
*/
-export const SKG_IMAGE_SIZE_OPTIONS = [
- { label: '自动', key: 'auto' },
- { label: '竖图 2:3', key: '1024x1536' },
- { label: '方图 1:1', key: '1024x1024' },
- { label: '横图 3:2', key: '1536x1024' }
+// Seedream image size options | 豆包图片尺寸选项
+export const SEEDREAM_SIZE_OPTIONS = [
+ { label: '21:9', key: '3024x1296' },
+ { label: '16:9', key: '2560x1440' },
+ { label: '4:3', key: '2304x1728' },
+ { label: '3:2', key: '2496x1664' },
+ { label: '1:1', key: '2048x2048' },
+ { label: '2:3', key: '1664x2496' },
+ { label: '3:4', key: '1728x2304' },
+ { label: '9:16', key: '1440x2560' },
+ { label: '9:21', key: '1296x3024' }
]
-export const SKG_IMAGE_QUALITY_OPTIONS = [
- { label: '标准', key: 'standard' }
+// Seedream 4K image size options | 豆包4K图片尺寸选项
+export const SEEDREAM_4K_SIZE_OPTIONS = [
+ { label: '21:9', key: '6198x2656' },
+ { label: '16:9', key: '5404x3040' },
+ { label: '4:3', key: '4694x3520' },
+ { label: '3:2', key: '4992x3328' },
+ { label: '1:1', key: '4096x4096' },
+ { label: '2:3', key: '3328x4992' },
+ { label: '3:4', key: '3520x4694' },
+ { label: '9:16', key: '3040x5404' },
+ { label: '9:21', key: '2656x6198' }
]
-export const SKG_VIDEO_SIZE_OPTIONS = [
- { label: '竖屏 9:16', key: '720x1280' },
- { label: '横屏 16:9', key: '1280x720' },
- { label: '方形 1:1', key: '1024x1024' },
- { label: '竖屏 3:4', key: '960x1280' }
+// Seedream quality options | 豆包画质选项
+export const SEEDREAM_QUALITY_OPTIONS = [
+ { label: '标准画质', key: 'standard' },
+ { label: '4K 高清', key: '4k' }
]
-export const VIDEO_RATIO_LIST = SKG_VIDEO_SIZE_OPTIONS
-
-export const SEEDANCE_RESOLUTION_OPTIONS = [
- { label: '720p', key: '720p' },
- { label: '1080p', key: '1080p' }
+export const BANANA_SIZE_OPTIONS = [
+ { label: '16:9', key: '16x9' },
+ { label: '4:3', key: '4x3' },
+ { label: '3:2', key: '3x2' },
+ { label: '1:1', key: '1x1' },
+ { label: '2:3', key: '2x3' },
+ { label: '3:4', key: '3x4' },
+ { label: '9:16', key: '9x16' },
]
+// Image generation models | 图片生成模型
export const IMAGE_MODELS = [
- {
- label: '自动',
- key: 'auto',
- provider: ['skg'],
- sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key),
- qualities: SKG_IMAGE_QUALITY_OPTIONS,
- defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' }
- },
- {
- label: 'GPT Image 2',
- key: 'gpt-image-2',
- provider: ['skg'],
- sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key),
- qualities: SKG_IMAGE_QUALITY_OPTIONS,
- defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' }
- },
- {
- label: 'Gemini 图片备用',
- key: 'gemini-3-pro-image-preview',
- provider: ['skg'],
- sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key),
- qualities: SKG_IMAGE_QUALITY_OPTIONS,
- defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' }
- }
+ {
+ label: 'Nano Banana 2',
+ key: 'nano-banana-2',
+ provider: ['chatfire'], // 火宝渠道
+ sizes: BANANA_SIZE_OPTIONS.map(s => s.key),
+ // qualities: SEEDREAM_QUALITY_OPTIONS,
+ // getSizesByQuality: (quality) => quality === '4k' ? SEEDREAM_4K_SIZE_OPTIONS : SEEDREAM_SIZE_OPTIONS,
+ defaultParams: {
+ size: '1x1',
+ quality: 'standard',
+ style: 'vivid'
+ }
+ },
+ {
+ label: 'Nano Banana Pro',
+ key: 'nano-banana-pro',
+ provider: ['chatfire'], // 火宝渠道
+ sizes: BANANA_SIZE_OPTIONS.map(s => s.key),
+ // qualities: SEEDREAM_QUALITY_OPTIONS,
+ // getSizesByQuality: (quality) => quality === '4k' ? SEEDREAM_4K_SIZE_OPTIONS : SEEDREAM_SIZE_OPTIONS,
+ defaultParams: {
+ size: '1x1',
+ quality: 'standard',
+ style: 'vivid'
+ }
+ },
+ {
+ label: '豆包 Seedream 4.5',
+ key: 'doubao-seedream-4-5-251128',
+ provider: ['chatfire'], // 火宝渠道
+ sizes: SEEDREAM_SIZE_OPTIONS.map(s => s.key),
+ qualities: SEEDREAM_QUALITY_OPTIONS,
+ getSizesByQuality: (quality) => quality === '4k' ? SEEDREAM_4K_SIZE_OPTIONS : SEEDREAM_SIZE_OPTIONS,
+ defaultParams: {
+ size: '2048x2048',
+ quality: 'standard',
+ style: 'vivid'
+ }
+ },
+ {
+ label: 'Nano Banana',
+ key: 'nano-banana',
+ provider: ['chatfire'], // 火宝渠道
+ tips: '尺寸写在提示词中: 尺寸 9:16',
+ sizes: [],
+ defaultParams: {
+ quality: 'standard',
+ style: 'vivid'
+ }
+ },
+
]
+// Video ratio options | 视频比例选项
+export const VIDEO_RATIO_LIST = [
+ { label: '16:9 (横版)', key: '16x9' },
+ { label: '4:3', key: '4x3' },
+ { label: '1:1 (方形)', key: '1x1' },
+ { label: '3:4', key: '3x4' },
+ { label: '9:16 (竖版)', key: '9x16' }
+]
+
+// Video resolution options for Seedance | Seedance 分辨率选项
+export const SEEDANCE_RESOLUTION_OPTIONS = [
+ { label: '480p', key: '480p' },
+ { label: '720p', key: '720p' },
+ { label: '1080p', key: '1080p' }
+]
+
+// Video generation models | 视频生成模型
export const VIDEO_MODELS = [
- {
- label: 'Seedance',
- key: 'seedance',
- provider: ['skg'],
- type: 't2v+i2v',
- ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key),
- durs: [5, 8, 10, 12, 15].map(s => ({ label: `${s} 秒`, key: s })),
- resolutions: ['720p', '1080p'],
- defaultResolution: '1080p',
- defaultParams: { ratio: '720x1280', duration: 10, resolution: '1080p' }
- },
- {
- label: 'Kling',
- key: 'kling',
- provider: ['skg'],
- type: 't2v+i2v',
- ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key),
- durs: [4, 8, 12].map(s => ({ label: `${s} 秒`, key: s })),
- resolutions: ['720p'],
- defaultResolution: '720p',
- defaultParams: { ratio: '720x1280', duration: 8, resolution: '720p' }
- },
- {
- label: 'Veo 3',
- key: 'veo3',
- provider: ['skg'],
- type: 't2v+i2v',
- ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key),
- durs: [4, 8, 12].map(s => ({ label: `${s} 秒`, key: s })),
- resolutions: ['720p'],
- defaultResolution: '720p',
- defaultParams: { ratio: '720x1280', duration: 8, resolution: '720p' }
- }
+ // Seedance 模型 - 1.5 Pro
+ {
+ label: 'Seedance 1.5 Pro (图文视频)',
+ key: 'doubao-seedance-1-5-pro-251215',
+ provider: ['chatfire'],
+ type: 't2v+i2v',
+ ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
+ durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
+ resolutions: ['480p', '720p', '1080p'],
+ defaultResolution: '1080p',
+ defaultParams: { ratio: '16:9', duration: 10, resolution: '1080p' }
+ },
+ // Seedance 模型 - 文生视频
+ {
+ label: 'Seedance 1.0 Lite (文生视频)',
+ key: 'doubao-seedance-1-0-lite-t2v-250428',
+ provider: ['chatfire'],
+ type: 't2v', // 文生视频
+ ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
+ durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
+ resolutions: ['480p', '720p', '1080p'],
+ defaultResolution: '720p',
+ defaultParams: { ratio: '16:9', duration: 5, resolution: '720p' }
+ },
+ // Seedance 模型 - 图生视频
+ {
+ label: 'Seedance 1.0 Lite (图生视频)',
+ key: 'doubao-seedance-1-0-lite-i2v-250428',
+ provider: ['chatfire'],
+ type: 'i2v', // 图生视频
+ ratios: ['16:9'],
+ durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
+ resolutions: ['480p', '720p', '1080p'],
+ defaultResolution: '720p',
+ defaultParams: { ratio: '16:9', duration: 5, resolution: '720p' }
+ },
+ // Seedance 模型 - 图文视频 Pro
+ {
+ label: 'Seedance 1.0 Pro (图文视频)',
+ key: 'doubao-seedance-1-0-pro-250528',
+ provider: ['chatfire'],
+ type: 't2v+i2v', // 图文视频
+ ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9', '16:9'],
+ durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
+ resolutions: ['480p', '720p', '1080p'],
+ defaultResolution: '1080p',
+ defaultParams: { ratio: '16:9', duration: 5, resolution: '1080p' }
+ },
+
+ // Seedance 模型 - 1.0 Pro Fast
+ {
+ label: 'Seedance 1.0 Pro Fast (图文视频)',
+ key: 'doubao-seedance-1-0-pro-fast-251015',
+ provider: ['chatfire'],
+ type: 't2v+i2v',
+ ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
+ durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
+ resolutions: ['480p', '720p', '1080p'],
+ defaultResolution: '1080p',
+ defaultParams: { ratio: '16:9', duration: 5, resolution: '1080p' }
+ },
+ // 可灵 Kling
+ // {
+ // label: '可灵 Kling v2.5-turbo',
+ // key: 'kling-v2-1',
+ // provider: ['chatfire'], // 仅火宝渠道
+ // ratios: VIDEO_RATIO_LIST.map(s => s.key),
+ // durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
+ // defaultParams: { ratio: '9:16', duration: 10 }
+ // },
+ // {
+ // label: 'runway/gen4-turbo',
+ // key: 'runway/gen4-turbo',
+ // ratios: VIDEO_RATIO_LIST.map(s => s.key),
+ // durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
+ // defaultParams: { ratio: '16:9', duration: 5 }
+ // },
+ // {
+ // label: '可灵视频 O1',
+ // key: 'kling-video-o1',
+ // ratios: VIDEO_RATIO_LIST.map(s => s.key),
+ // durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
+ // defaultParams: { ratio: '16:9', duration: 5 }
+ // },
+ // {
+ // label: 'viduq2-pro_720p', key: 'viduq2-pro_720p',
+ // ratios: VIDEO_RATIO_LIST.map(s => s.key),
+ // durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
+ // defaultParams: { ratio: '16:9', duration: 5 }
+ // },
+ // {
+ // label: 'Sora 2', key: 'sora-2',
+ // ratios: VIDEO_RATIO_LIST.map(s => s.key),
+ // durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
+ // defaultParams: { ratio: '16:9', duration: 5 }
+ // }
]
+// Chat/LLM models | 对话模型
export const CHAT_MODELS = [
- { label: 'SKG 提示词助手', key: 'skg-copy', provider: ['skg'] }
+ { label: 'GPT-4o Mini', key: 'gpt-4o-mini', provider: ['openai'] },
+ { label: 'GPT-4o', key: 'gpt-4o', provider: ['openai'] },
+ { label: 'GPT-5.2', key: 'gpt-5.2', provider: ['openai'] },
+ { label: 'DeepSeek Chat', key: 'deepseek-chat', provider: ['openai', 'chatfire'] },
+ { label: '豆包 Seed Flash', key: 'doubao-seed-1-6-flash-250615', provider: ['chatfire'] },
+ { label: 'Gemini 3 Pro', key: 'gemini-3-pro', provider: ['openai'] }
]
-export const IMAGE_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS
-export const IMAGE_QUALITY_OPTIONS = SKG_IMAGE_QUALITY_OPTIONS
-export const IMAGE_STYLE_OPTIONS = [{ label: '商业营销', key: 'commercial' }]
-export const VIDEO_RATIO_OPTIONS = SKG_VIDEO_SIZE_OPTIONS
-export const VIDEO_DURATION_OPTIONS = [5, 8, 10, 12, 15].map(s => ({ label: `${s} 秒`, key: s }))
+// Image size options | 图片尺寸选项
+export const IMAGE_SIZE_OPTIONS = [
+ { label: '2048x2048', key: '2048x2048' },
+ { label: '1792x1024 (横版)', key: '1792x1024' },
+ { label: '1024x1792 (竖版)', key: '1024x1792' }
+]
-export const SEEDREAM_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS
-export const SEEDREAM_4K_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS
-export const SEEDREAM_QUALITY_OPTIONS = SKG_IMAGE_QUALITY_OPTIONS
+// Image quality options | 图片质量选项
+export const IMAGE_QUALITY_OPTIONS = [
+ { label: '标准', key: 'standard' },
+ { label: '高清', key: 'hd' }
+]
-export const DEFAULT_IMAGE_MODEL = 'auto'
-export const DEFAULT_VIDEO_MODEL = 'seedance'
-export const DEFAULT_CHAT_MODEL = 'skg-copy'
-export const DEFAULT_IMAGE_SIZE = '1024x1536'
-export const DEFAULT_VIDEO_RATIO = '720x1280'
-export const DEFAULT_VIDEO_DURATION = 10
+// Image style options | 图片风格选项
+export const IMAGE_STYLE_OPTIONS = [
+ { label: '生动', key: 'vivid' },
+ { label: '自然', key: 'natural' }
+]
+// Video ratio options | 视频比例选项
+export const VIDEO_RATIO_OPTIONS = VIDEO_RATIO_LIST
+
+// Video duration options | 视频时长选项
+export const VIDEO_DURATION_OPTIONS = [
+ { label: '5 秒', key: 5 },
+ { label: '10 秒', key: 10 }
+]
+
+// Default values | 默认值
+export const DEFAULT_IMAGE_MODEL = 'nano-banana-pro'
+export const DEFAULT_VIDEO_MODEL = 'doubao-seedance-1-5-pro-251215'
+export const DEFAULT_CHAT_MODEL = 'gpt-4o-mini'
+export const DEFAULT_IMAGE_SIZE = '2048x2048'
+export const DEFAULT_VIDEO_RATIO = '16:9'
+export const DEFAULT_VIDEO_DURATION = 5
+
+// Get model by key | 根据 key 获取模型
export const getModelByName = (key) => {
- const allModels = [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS]
- return allModels.find(m => m.key === key)
+ const allModels = [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS]
+ return allModels.find(m => m.key === key)
}
diff --git a/web/canvas-app/src/config/providers.js b/web/canvas-app/src/config/providers.js
index bd43324..03e02c8 100644
--- a/web/canvas-app/src/config/providers.js
+++ b/web/canvas-app/src/config/providers.js
@@ -1,40 +1,272 @@
/**
- * SKG internal provider config.
- * The browser never receives upstream model keys; all generation goes through /api.
+ * API Provider Adapters | API 渠道适配器
+ * 适配不同 API 提供商的请求参数和响应格式
*/
+// 渠道适配配置
export const PROVIDERS = {
- skg: {
- label: 'SKG 内部模型',
+ chatfire: {
+ label: 'SKG 内部',
defaultBaseUrl: '/api',
+ // 端点路径
endpoints: {
- chat: '/creative/copy',
- image: '/jobs/{jobId}/frames/{idx}/generate',
- video: '/jobs/{jobId}/frames/{idx}/storyboard/video',
- videoQuery: '/jobs/{jobId}'
+ chat: '/v1/chat/completions',
+ image: '/v1/images/generations',
+ video: '/v1/video/generations',
+ videoQuery: '/v1/video/task/{taskId}'
},
+ // 火宝渠道请求适配
requestAdapter: {
- chat: (params) => params,
- image: (params) => params,
- video: (params) => params
+ chat: (params) => {
+ const adapted = {
+ model: params.model,
+ messages: params.messages
+ }
+ if (params.temperature !== undefined) adapted.temperature = params.temperature
+ if (params.max_tokens !== undefined) adapted.max_tokens = params.max_tokens
+ if (params.stream !== undefined) adapted.stream = params.stream
+ return adapted
+ },
+ image: (params) => {
+ const adapted = {
+ model: params.model,
+ prompt: params.prompt
+ }
+ if (params.size) adapted.size = params.size
+ if (params.n) adapted.n = params.n
+ if (params.quality) adapted.quality = params.quality
+ if (params.style) adapted.style = params.style
+ if (params.image) adapted.image = params.image
+ return adapted
+ },
+ video: (params) => {
+ const model = params.model || ''
+
+ // Seedance 模型 - 使用 content 数组格式
+ if (model.includes('seedance')) {
+ const content = []
+
+ // 构建完整参数文本
+ // 格式: prompt --resolution 720p --ratio 16:9 --dur 5 --fps 24 --wm true --seed 11 --cf false
+ let textPrompt = params.prompt || ''
+
+ // 添加 resolution 参数
+ if (params.resolution) {
+ textPrompt += ` --resolution ${params.resolution}`
+ }
+
+ // 添加 ratio 参数 (图生视频用 16:9)
+ if (params.size) {
+ textPrompt += ` --ratio ${params.size}`
+ }
+
+ // 添加 duration 参数
+ if (params.seconds) {
+ textPrompt += ` --dur ${params.seconds}`
+ }
+
+ // 添加 fps (固定 24)
+ textPrompt += ` --fps 24`
+
+ // 添加水印参数 (默认 true)
+ textPrompt += ` --wm ${params.wm !== false ? 'true' : 'false'}`
+
+ // 添加 seed 参数 (可选)
+ if (params.seed !== undefined) {
+ textPrompt += ` --seed ${params.seed}`
+ }
+
+ // 添加 cf 参数 (默认 false)
+ textPrompt += ` --cf ${params.cf === true ? 'true' : 'false'}`
+
+ content.push({
+ type: 'text',
+ text: textPrompt
+ })
+
+ // 添加参考图(如果有)
+ if (params.first_frame_image) {
+ content.push({
+ type: 'image_url',
+ image_url: {
+ url: params.first_frame_image
+ }
+ })
+ }
+
+ const adapted = {
+ model: model,
+ content: content,
+ generate_audio: params.generateAudio !== false
+ }
+
+ return adapted
+ }
+
+ // Kling 模型 - 使用 kling 特定格式
+ if (model.includes('kling')) {
+ // 将 ratio 转换为 aspect_ratio 格式
+ const ratioMap = {
+ '16:9': '16:9',
+ '9:16': '9:16',
+ '1:1': '1:1',
+ '4:3': '4:3',
+ '3:4': '3:4'
+ }
+
+ const adapted = {
+ model_name: model,
+ mode: 'std',
+ prompt: params.prompt || '',
+ aspect_ratio: ratioMap[params.size] || '16:9',
+ duration: params.seconds || 5,
+ negative_prompt: '',
+ cfg_scale: 0.5
+ }
+
+ // 添加参考图(如果有)
+ if (params.first_frame_image) {
+ adapted.image = params.first_frame_image
+ }
+
+ return adapted
+ }
+
+ // 默认格式(veo 等)
+ const adapted = {
+ model: params.model,
+ prompt: params.prompt || ''
+ }
+ if (params.first_frame_image) adapted.first_frame_image = params.first_frame_image
+ if (params.last_frame_image) adapted.last_frame_image = params.last_frame_image
+ if (params.size) adapted.size = params.size
+ if (params.seconds) adapted.seconds = params.seconds
+
+ return adapted
+ }
},
+ // 火宝渠道响应格式
responseAdapter: {
- chat: (response) => response,
- image: (response) => response,
- video: (response) => response
+ chat: (response) => {
+ if (response.choices && response.choices.length > 0) {
+ return response.choices[0].message?.content || ''
+ }
+ return ''
+ },
+ image: (response) => {
+ const data = response.data || response
+ return (Array.isArray(data) ? data : [data]).map(item => ({
+ url: item.url || item.b64_json || '',
+ revisedPrompt: item.revised_prompt || ''
+ }))
+ },
+ video: (response) => {
+ return {
+ url: response.data?.url || response.url || response.data?.[0]?.url || '',
+ ...response
+ }
+ }
}
},
- default: 'skg'
+ openai: {
+ label: 'OpenAI',
+ defaultBaseUrl: 'https://api.openai.com',
+ // 端点路径
+ endpoints: {
+ chat: '/v1/chat/completions',
+ image: '/v1/images/generations',
+ video: '/v1/videos',
+ videoQuery: '/v1/videos/{taskId}'
+ },
+ // 请求参数适配
+ requestAdapter: {
+ chat: (params) => {
+ const adapted = {
+ model: params.model,
+ messages: params.messages
+ }
+ // 添加可选参数
+ if (params.temperature !== undefined) adapted.temperature = params.temperature
+ if (params.max_tokens !== undefined) adapted.max_tokens = params.max_tokens
+ if (params.stream !== undefined) adapted.stream = params.stream
+ return adapted
+ },
+ image: (params) => {
+ const adapted = {
+ model: params.model,
+ prompt: params.prompt
+ }
+ if (params.size) adapted.size = params.size
+ if (params.n) adapted.n = params.n
+ if (params.quality) adapted.quality = params.quality
+ if (params.style) adapted.style = params.style
+ if (params.image) adapted.image = params.image
+ return adapted
+ },
+ video: (params) => {
+ const adapted = {
+ model: params.model,
+ prompt: params.prompt || ''
+ }
+ if (params.first_frame_image) adapted.first_frame_image = params.first_frame_image
+ if (params.last_frame_image) adapted.last_frame_image = params.last_frame_image
+ if (params.size) adapted.size = params.size
+ if (params.seconds) adapted.seconds = params.seconds
+ return adapted
+ }
+ },
+ // 响应数据适配
+ responseAdapter: {
+ chat: (response) => {
+ if (response.choices && response.choices.length > 0) {
+ return response.choices[0].message?.content || ''
+ }
+ return ''
+ },
+ image: (response) => {
+ const data = response.data || response
+ return (Array.isArray(data) ? data : [data]).map(item => ({
+ url: item.url || item.b64_json || '',
+ revisedPrompt: item.revised_prompt || ''
+ }))
+ },
+ video: (response) => {
+ return {
+ url: response.data?.url || response.url || response.data?.[0]?.url || '',
+ ...response
+ }
+ }
+ }
+ },
+
+
+
+ // 默认使用 OpenAI 格式
+ default: 'chatfire'
}
-export const getProviderList = () => (
- Object.entries(PROVIDERS)
+// 获取渠道列表
+export const getProviderList = () => {
+ return Object.entries(PROVIDERS)
.filter(([key]) => key !== 'default')
- .map(([key, value]) => ({ key, label: value.label }))
-)
+ .map(([key, value]) => ({
+ key,
+ label: value.label
+ }))
+}
-export const getDefaultProvider = () => PROVIDERS.default || 'skg'
+// 获取默认渠道
+export const getDefaultProvider = () => {
+ return PROVIDERS.default || 'chatfire'
+}
-export const getProviderConfig = (provider) => PROVIDERS[provider] || PROVIDERS.skg
+// 获取渠道的默认 Base URL
+export const getDefaultBaseUrl = (providerKey) => {
+ const config = getProviderConfig(providerKey)
+ return config.defaultBaseUrl || ''
+}
-export const getDefaultBaseUrl = (provider) => getProviderConfig(provider).defaultBaseUrl
+// 获取渠道配置
+export const getProviderConfig = (providerKey) => {
+ return PROVIDERS[providerKey] || PROVIDERS[PROVIDERS.default]
+}
diff --git a/web/canvas-app/src/config/workflows.js b/web/canvas-app/src/config/workflows.js
index d76e948..d3a92bd 100644
--- a/web/canvas-app/src/config/workflows.js
+++ b/web/canvas-app/src/config/workflows.js
@@ -1,90 +1,1236 @@
/**
- * SKG internal workflow templates.
+ * Workflow Templates Configuration | 工作流模板配置
+ * 预设工作流模板,支持一键添加到画布
*/
+import workflowCover1 from '@/assets/workflow01.jpeg'
+import workflowCover2 from '@/assets/workflow02.jpeg'
-const makeId = (prefix) => `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`
+import scene01 from '@/assets/scene01.jpeg'
+import shot01 from '@/assets/shot01.jpeg'
+// Multi-angle prompts | 多角度提示词模板
export const MULTI_ANGLE_PROMPTS = {
front: {
- label: '正面',
+ label: '正视',
english: 'Front View',
- prompt: (subject) => `生成 SKG 营销图正面视角,主体清晰,产品佩戴或摆放关系准确,干净高级商业光线。\n主体参考: ${subject || '按上一张参考图保持一致'}`
+ prompt: (character) => `使用提供的图片,生成四宫格分镜,每张四宫格包括人物正面对着镜头的4个景别(远景、中景、近景、和局部特写),保持场景、产品、人物特征的一致性,宫格里的每一张照片保持和提供图片相同的比例。并在图片下方用英文标注这个景别
+
+角色参考: ${character}`
},
side: {
- label: '侧面',
+ label: '侧视',
english: 'Side View',
- prompt: (subject) => `生成 SKG 营销图侧面视角,保持主体和产品一致,肩颈或产品轮廓清楚,真实生活场景。\n主体参考: ${subject || '按上一张参考图保持一致'}`
+ prompt: (character) => `使用提供的图片,分别生成四宫格分镜,每张四宫格包括人物侧面角度的4个景别(远景、中景、近景、和局部特写),保持场景、产品、人物特征的一致性,宫格里的每一张照片保持和提供图片相同的比例。并在图片下方用英文标注这个景别
+
+角色参考: ${character}`
},
- detail: {
- label: '细节',
- english: 'Detail View',
- prompt: (subject) => `生成 SKG 产品细节视角,强调材质、佩戴方式、使用触点和高级感,无文字水印。\n主体参考: ${subject || '按上一张参考图保持一致'}`
+ back: {
+ label: '后视',
+ english: 'Back View',
+ prompt: (character) => `使用提供的图片,分别生成四宫格分镜,每张四宫格包括人物背影角度的4个景别(远景、中景、近景、和局部特写),保持场景、产品、人物特征的一致性,宫格里的每一张照片保持和提供图片相同的比例。并在图片下方用英文标注这个景别
+
+角色参考: ${character}`
+ },
+ top: {
+ label: '俯视',
+ english: 'Top/Bird\'s Eye View',
+ prompt: (character) => `使用提供的图片,分别生成四宫格分镜,每张四宫格包括俯视角度的4个景别(远景、中景、近景、和局部特写),保持场景、产品、人物特征的一致性,宫格里的每一张照片保持和提供图片相同的比例。并在图片下方用英文标注这个景别
+
+角色参考: ${character}`
}
}
+/**
+ * Workflow Templates | 工作流模板
+ */
export const WORKFLOW_TEMPLATES = [
{
- id: 'skg-text-image-video',
- name: '图文转视频',
- description: '提示词 → 营销图 → 视频候选',
- icon: 'VideocamOutline',
- category: 'skg',
- cover: '',
+ id: 'multi-angle-storyboard',
+ name: '多角度分镜',
+ description: '生成角色的正视、侧视、后视、俯视四宫格分镜图',
+ icon: 'GridOutline',
+ category: 'storyboard',
+ cover: workflowCover1,
+ // 节点配置
createNodes: (startPosition) => {
- const textId = makeId('text')
- const imageConfigId = makeId('image_config')
- const imageId = makeId('image')
- const videoConfigId = makeId('video_config')
- const videoId = makeId('video')
- const nodes = [
- {
- id: textId,
- type: 'text',
- position: { x: startPosition.x, y: startPosition.y },
- data: {
- label: '提示词',
- content: '竖屏 SKG 短视频广告,真实办公室午休场景,人物佩戴 SKG 颈部按摩仪放松,产品形状清晰稳定,镜头缓慢推进,高级干净光线'
- }
- },
- {
- id: imageConfigId,
- type: 'imageConfig',
- position: { x: startPosition.x + 380, y: startPosition.y },
- data: { label: '文生图', model: 'auto', size: '1024x1536' }
- },
- {
- id: imageId,
- type: 'image',
- position: { x: startPosition.x + 760, y: startPosition.y },
- data: { label: '营销图结果', url: '' }
- },
- {
- id: videoConfigId,
- type: 'videoConfig',
- position: { x: startPosition.x + 1140, y: startPosition.y },
- data: { label: '图生视频', model: 'seedance', ratio: '720x1280', dur: 10 }
- },
- {
- id: videoId,
- type: 'video',
- position: { x: startPosition.x + 1520, y: startPosition.y },
- data: { label: '视频结果', url: '' }
+ const nodeSpacing = 400
+ const rowSpacing = 280
+ const angles = ['front', 'side', 'back', 'top']
+
+ const nodes = []
+ const edges = []
+ let nodeIdCounter = 0
+ const getNodeId = () => `workflow_node_${Date.now()}_${nodeIdCounter++}`
+
+ // 主角色图:提示词 + 文生图配置
+ const characterTextId = getNodeId()
+ nodes.push({
+ id: characterTextId,
+ type: 'text',
+ position: { x: startPosition.x, y: startPosition.y + rowSpacing * 1.5 },
+ data: {
+ content: '',
+ label: '角色提示词'
}
- ]
- const edges = [
- { id: makeId('edge'), source: textId, target: imageConfigId, sourceHandle: 'right', targetHandle: 'left', type: 'promptOrder', data: { promptOrder: 1 } },
- { id: makeId('edge'), source: imageConfigId, target: imageId, sourceHandle: 'right', targetHandle: 'left' },
- { id: makeId('edge'), source: textId, target: videoConfigId, sourceHandle: 'right', targetHandle: 'left', type: 'promptOrder', data: { promptOrder: 1 } },
- { id: makeId('edge'), source: imageId, target: videoConfigId, sourceHandle: 'right', targetHandle: 'left', type: 'imageRole', data: { imageRole: 'first_frame_image' } },
- { id: makeId('edge'), source: videoConfigId, target: videoId, sourceHandle: 'right', targetHandle: 'left' }
- ]
+ })
+
+ const characterConfigId = getNodeId()
+ nodes.push({
+ id: characterConfigId,
+ type: 'imageConfig',
+ position: { x: startPosition.x + nodeSpacing, y: startPosition.y + rowSpacing * 1.5 },
+ data: {
+ label: '主角色图',
+ model: 'doubao-seedream-4-5-251128',
+ size: '2048x2048'
+ }
+ })
+
+ // 主角色图结果节点(空白图片节点)
+ const characterImageId = getNodeId()
+ nodes.push({
+ id: characterImageId,
+ type: 'image',
+ position: { x: startPosition.x + nodeSpacing * 2, y: startPosition.y + rowSpacing * 1.5 },
+ data: {
+ url: '',
+ label: '角色图结果'
+ }
+ })
+
+ // 连线:角色提示词 → 角色图配置
+ edges.push({
+ id: `edge_${characterTextId}_${characterConfigId}`,
+ source: characterTextId,
+ target: characterConfigId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // 连线:角色图配置 → 角色图结果
+ edges.push({
+ id: `edge_${characterConfigId}_${characterImageId}`,
+ source: characterConfigId,
+ target: characterImageId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // 创建4个角度的节点
+ const angleX = startPosition.x + nodeSpacing * 3 + 100
+
+ angles.forEach((angleKey, index) => {
+ const angleConfig = MULTI_ANGLE_PROMPTS[angleKey]
+ const angleY = startPosition.y + index * rowSpacing
+ let currentX = angleX
+
+ // 提示词节点(预填充默认提示词)
+ const textNodeId = getNodeId()
+ nodes.push({
+ id: textNodeId,
+ type: 'text',
+ position: { x: currentX, y: angleY },
+ data: {
+ content: angleConfig.prompt(''),
+ label: `${angleConfig.label}提示词`
+ }
+ })
+ currentX += nodeSpacing
+
+ // 图片配置节点
+ const configNodeId = getNodeId()
+ nodes.push({
+ id: configNodeId,
+ type: 'imageConfig',
+ position: { x: currentX, y: angleY },
+ data: {
+ label: `${angleConfig.label} (${angleConfig.english})`,
+ model: 'doubao-seedream-4-5-251128',
+ size: '2048x2048'
+ }
+ })
+
+ // 连线:提示词 → 配置
+ edges.push({
+ id: `edge_${textNodeId}_${configNodeId}`,
+ source: textNodeId,
+ target: configNodeId,
+ type: 'promptOrder',
+ data: { promptOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // 连线:角色图结果 → 角度配置(参考图)
+ edges.push({
+ id: `edge_${characterImageId}_${configNodeId}`,
+ source: characterImageId,
+ target: configNodeId,
+ type: 'imageOrder',
+ data: { imageOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ })
+
+ return { nodes, edges }
+ }
+ },
+ {
+ id: 'product-ecommerce-full-set',
+ name: '通用产品全套电商图',
+ description: '根据产品信息和图片,生成模特图、侧面展示图、俯瞰展示图',
+ icon: 'ShoppingOutline',
+ category: 'ecommerce',
+ cover: workflowCover2,
+ // 节点配置
+ createNodes: (startPosition) => {
+ const colSpacing = 500 // 列间距
+ const rowSpacing = 350 // 行间距
+
+ const nodes = []
+ const edges = []
+ let nodeIdCounter = 0
+ const getNodeId = () => `workflow_node_${Date.now()}_${nodeIdCounter++}`
+
+ // ========== 布局说明 ==========
+ // 第一列: A(产品信息), B(产品图片) - 输入节点
+ // 第二列: C, D, E - 提示词节点
+ // 第三列: 生成模特图, 侧面展示图, 俯瞰展示图 - 输出节点
+
+ // ========== 第一列:输入节点 ==========
+ // A: 产品信息文本节点
+ const nodeA_productInfoId = getNodeId()
+ nodes.push({
+ id: nodeA_productInfoId,
+ type: 'text',
+ position: { x: startPosition.x, y: startPosition.y },
+ data: {
+ content: 'Soundcore by Anker P20i真无线耳机,10mm驱动单元带来强劲低音,蓝牙5.3,30小时超长续航,防水,2个麦克风实现AI清晰通话,22种预设均衡器,可通过App定制 强劲低音:Soundcore P20i真无线耳机搭载超大10mm驱动单元,带来强劲音效和增强的低音,让您沉浸在喜爱的歌曲中。 个性化聆听体验:使用Soundcore App自定义控制选项,并从22种预设均衡器中进行选择。借助“Find My Earbuds”(查找我的耳机)功能,丢失的耳机可以发出声音,帮助您定位。 长续航,快速充电:单次充电可提供10小时电池续航,搭配充电盒则可延长至30小时。如果P20i真无线耳机电量不足,仅需10分钟快速充电即可提供2小时播放时间。 便携式设计:Soundcore P20i真无线耳机和充电盒小巧轻便,配有挂绳。其体积足够小,可轻松放入口袋,或挂在包或钥匙上,让您无需担心空间问题。 AI增强清晰通话:2个内置麦克风和AI算法协同工作,捕捉您的声音,让您无需在电话中大喊大叫。',
+ label: '产品信息'
+ }
+ })
+
+ // B: 产品图片节点
+ const nodeB_productImageId = getNodeId()
+ nodes.push({
+ id: nodeB_productImageId,
+ type: 'image',
+ position: { x: startPosition.x, y: startPosition.y + rowSpacing },
+ data: {
+ url: 'https://ffile.chatfire.site/image/covers/product01.jpg',
+ label: '产品图片'
+ }
+ })
+
+ // ========== 第二列:提示词节点 ==========
+ // C: 模特图提示词 (与生成模特图对齐)
+ const nodeC_modelPromptId = getNodeId()
+ nodes.push({
+ id: nodeC_modelPromptId,
+ type: 'text',
+ position: { x: startPosition.x + colSpacing, y: startPosition.y },
+ data: {
+ content: '根据产品特性,生成一个适合展示该产品且时尚富有高级感的模特图,彩色人像,背景是白底,人物居中,欧美人优先',
+ label: '模特图提示词'
+ }
+ })
+
+ // D: 侧面展示图提示词 (与侧面展示图对齐)
+ const nodeD_sidePromptId = getNodeId()
+ nodes.push({
+ id: nodeD_sidePromptId,
+ type: 'text',
+ position: { x: startPosition.x + colSpacing, y: startPosition.y + rowSpacing },
+ data: {
+ content: '根据产品图和产品信息,生成左侧侧面45度的展示图,高清展示侧面的产品形状和细节,保持产品不变形',
+ label: '侧面展示图提示词'
+ }
+ })
+
+ // E: 俯瞰展示图提示词 (与俯瞰展示图对齐)
+ const nodeE_topPromptId = getNodeId()
+ nodes.push({
+ id: nodeE_topPromptId,
+ type: 'text',
+ position: { x: startPosition.x + colSpacing, y: startPosition.y + rowSpacing * 2 },
+ data: {
+ content: '根据产品图和产品信息,生成从上往下俯瞰的产品展示图,高清展示俯瞰角度的产品形状和细节,保持产品不变形',
+ label: '俯瞰展示图提示词'
+ }
+ })
+
+ // F: 拆解图提示词 (与拆解图对齐)
+ const nodeF_explodedPromptId = getNodeId()
+ nodes.push({
+ id: nodeF_explodedPromptId,
+ type: 'text',
+ position: { x: startPosition.x + colSpacing, y: startPosition.y + rowSpacing * 3 },
+ data: {
+ content: '根据产品材质功能,生成一张产品核心部件的结构示意图,要展现出产品核心部件的内部构造,画面清晰呈现产品关键部件,背景为简洁的浅色调,同时包含核心卖点文案',
+ label: '拆解图提示词'
+ }
+ })
+
+ // ========== 第三列:生成节点 ==========
+ // B+C = 生成模特图
+ const modelConfigId = getNodeId()
+ nodes.push({
+ id: modelConfigId,
+ type: 'imageConfig',
+ position: { x: startPosition.x + colSpacing * 2, y: startPosition.y },
+ data: {
+ label: '生成模特图',
+ model: 'doubao-seedream-4-5-251128',
+ size: '2048x2048'
+ }
+ })
+
+ // B+D = 生成侧面展示图
+ const sideConfigId = getNodeId()
+ nodes.push({
+ id: sideConfigId,
+ type: 'imageConfig',
+ position: { x: startPosition.x + colSpacing * 2, y: startPosition.y + rowSpacing },
+ data: {
+ label: '侧面展示图',
+ model: 'doubao-seedream-4-5-251128',
+ size: '2048x2048'
+ }
+ })
+
+ // B+E = 生成俯瞰展示图
+ const topConfigId = getNodeId()
+ nodes.push({
+ id: topConfigId,
+ type: 'imageConfig',
+ position: { x: startPosition.x + colSpacing * 2, y: startPosition.y + rowSpacing * 2 },
+ data: {
+ label: '俯瞰展示图',
+ model: 'doubao-seedream-4-5-251128',
+ size: '2048x2048'
+ }
+ })
+
+ // AB+F = 生成拆解图
+ const explodedConfigId = getNodeId()
+ nodes.push({
+ id: explodedConfigId,
+ type: 'imageConfig',
+ position: { x: startPosition.x + colSpacing * 2, y: startPosition.y + rowSpacing * 3 },
+ data: {
+ label: '拆解图',
+ model: 'doubao-seedream-4-5-251128',
+ size: '2048x2048'
+ }
+ })
+
+ // ========== 连线 ==========
+ // AB+C → 生成模特图
+ edges.push({
+ id: `edge_${nodeA_productInfoId}_${modelConfigId}`,
+ source: nodeA_productInfoId,
+ target: modelConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ edges.push({
+ id: `edge_${nodeB_productImageId}_${modelConfigId}`,
+ source: nodeB_productImageId,
+ target: modelConfigId,
+ type: 'imageOrder',
+ data: { imageOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ edges.push({
+ id: `edge_${nodeC_modelPromptId}_${modelConfigId}`,
+ source: nodeC_modelPromptId,
+ target: modelConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 2 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // AB+D → 生成侧面展示图
+ edges.push({
+ id: `edge_${nodeA_productInfoId}_${sideConfigId}`,
+ source: nodeA_productInfoId,
+ target: sideConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ edges.push({
+ id: `edge_${nodeB_productImageId}_${sideConfigId}`,
+ source: nodeB_productImageId,
+ target: sideConfigId,
+ type: 'imageOrder',
+ data: { imageOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ edges.push({
+ id: `edge_${nodeD_sidePromptId}_${sideConfigId}`,
+ source: nodeD_sidePromptId,
+ target: sideConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 2 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // AB+E → 生成俯瞰展示图
+ edges.push({
+ id: `edge_${nodeA_productInfoId}_${topConfigId}`,
+ source: nodeA_productInfoId,
+ target: topConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ edges.push({
+ id: `edge_${nodeB_productImageId}_${topConfigId}`,
+ source: nodeB_productImageId,
+ target: topConfigId,
+ type: 'imageOrder',
+ data: { imageOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ edges.push({
+ id: `edge_${nodeE_topPromptId}_${topConfigId}`,
+ source: nodeE_topPromptId,
+ target: topConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 2 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // AB+F → 生成拆解图
+ edges.push({
+ id: `edge_${nodeA_productInfoId}_${explodedConfigId}`,
+ source: nodeA_productInfoId,
+ target: explodedConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ edges.push({
+ id: `edge_${nodeB_productImageId}_${explodedConfigId}`,
+ source: nodeB_productImageId,
+ target: explodedConfigId,
+ type: 'imageOrder',
+ data: { imageOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ edges.push({
+ id: `edge_${nodeF_explodedPromptId}_${explodedConfigId}`,
+ source: nodeF_explodedPromptId,
+ target: explodedConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 2 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ return { nodes, edges }
+ }
+ },
+ // ========== 短剧生图工作流 ==========
+ {
+ id: 'drama-character-design',
+ name: '短剧角色设计',
+ description: '根据角色描述生成一致性角色形象,后续多角度图依赖正面图保持一致性',
+ icon: 'PersonOutline',
+ category: 'drama',
+ cover: shot01,
+ createNodes: (startPosition) => {
+ const colSpacing = 400
+ const rowSpacing = 280
+
+ const nodes = []
+ const edges = []
+ let nodeIdCounter = 0
+ const getNodeId = () => `workflow_node_${Date.now()}_${nodeIdCounter++}`
+
+ // ========== 第一阶段:生成正面角色图 ==========
+ // 角色描述
+ const characterDescId = getNodeId()
+ nodes.push({
+ id: characterDescId,
+ type: 'text',
+ position: { x: startPosition.x, y: startPosition.y },
+ data: {
+ content: '角色名称:林小雨\n性别:女\n年龄:22岁\n外貌特征:长发及腰,眼睛明亮有神,皮肤白皙,身材高挑\n服装风格:现代都市风,白色连衣裙\n性格特点:温柔善良,内心坚强',
+ label: '角色描述'
+ }
+ })
+
+ // 风格参考图(可选)
+ const styleRefId = getNodeId()
+ nodes.push({
+ id: styleRefId,
+ type: 'image',
+ position: { x: startPosition.x, y: startPosition.y + rowSpacing },
+ data: {
+ url: '',
+ label: '风格参考图(可选)'
+ }
+ })
+
+ // 正面全身提示词
+ const frontPromptId = getNodeId()
+ nodes.push({
+ id: frontPromptId,
+ type: 'text',
+ position: { x: startPosition.x + colSpacing, y: startPosition.y },
+ data: {
+ content: '根据角色描述,生成角色的正面全身照,人物居中,白色简洁背景,高清写实风格,电影级画质',
+ label: '正面全身提示词'
+ }
+ })
+
+ // 正面全身生成配置
+ const frontConfigId = getNodeId()
+ nodes.push({
+ id: frontConfigId,
+ type: 'imageConfig',
+ position: { x: startPosition.x + colSpacing * 2, y: startPosition.y },
+ data: {
+ label: '生成正面全身图',
+ model: 'doubao-seedream-4-5-251128',
+ size: '1440x2560'
+ }
+ })
+
+ // 正面全身图结果(作为后续生成的参考)
+ const frontResultId = getNodeId()
+ nodes.push({
+ id: frontResultId,
+ type: 'image',
+ position: { x: startPosition.x + colSpacing * 3, y: startPosition.y },
+ data: {
+ url: '',
+ label: '正面角色图(参考基准)'
+ }
+ })
+
+ // ========== 第二阶段:基于正面图生成多角度 ==========
+ // 侧面半身提示词
+ const sidePromptId = getNodeId()
+ nodes.push({
+ id: sidePromptId,
+ type: 'text',
+ position: { x: startPosition.x + colSpacing * 3 + 100, y: startPosition.y + rowSpacing },
+ data: {
+ content: '参考提供的角色正面图,保持人物外貌、服装完全一致,生成角色的侧面半身照,45度角侧脸,展示五官轮廓,白色简洁背景,高清写实风格',
+ label: '侧面半身提示词'
+ }
+ })
+
+ // 表情特写提示词
+ const closeupPromptId = getNodeId()
+ nodes.push({
+ id: closeupPromptId,
+ type: 'text',
+ position: { x: startPosition.x + colSpacing * 3 + 100, y: startPosition.y + rowSpacing * 2 },
+ data: {
+ content: '参考提供的角色正面图,保持人物五官、发型完全一致,生成角色的面部特写,展示多种表情(微笑、严肃、惊讶、悲伤),四宫格布局,高清写实风格',
+ label: '表情特写提示词'
+ }
+ })
+
+ // 背面全身提示词
+ const backPromptId = getNodeId()
+ nodes.push({
+ id: backPromptId,
+ type: 'text',
+ position: { x: startPosition.x + colSpacing * 3 + 100, y: startPosition.y + rowSpacing * 3 },
+ data: {
+ content: '参考提供的角色正面图,保持人物发型、服装、身材完全一致,生成角色的背面全身照,展示背影,白色简洁背景,高清写实风格',
+ label: '背面全身提示词'
+ }
+ })
+
+ // 侧面生成配置
+ const sideConfigId = getNodeId()
+ nodes.push({
+ id: sideConfigId,
+ type: 'imageConfig',
+ position: { x: startPosition.x + colSpacing * 4 + 100, y: startPosition.y + rowSpacing },
+ data: {
+ label: '侧面半身图',
+ model: 'doubao-seedream-4-5-251128',
+ size: '2048x2048'
+ }
+ })
+
+ // 表情特写生成配置
+ const closeupConfigId = getNodeId()
+ nodes.push({
+ id: closeupConfigId,
+ type: 'imageConfig',
+ position: { x: startPosition.x + colSpacing * 4 + 100, y: startPosition.y + rowSpacing * 2 },
+ data: {
+ label: '表情特写图',
+ model: 'doubao-seedream-4-5-251128',
+ size: '2048x2048'
+ }
+ })
+
+ // 背面生成配置
+ const backConfigId = getNodeId()
+ nodes.push({
+ id: backConfigId,
+ type: 'imageConfig',
+ position: { x: startPosition.x + colSpacing * 4 + 100, y: startPosition.y + rowSpacing * 3 },
+ data: {
+ label: '背面全身图',
+ model: 'doubao-seedream-4-5-251128',
+ size: '1440x2560'
+ }
+ })
+
+ // ========== 连线:第一阶段 ==========
+ // 角色描述 → 正面生成
+ edges.push({
+ id: `edge_${characterDescId}_${frontConfigId}`,
+ source: characterDescId,
+ target: frontConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ // 风格参考 → 正面生成
+ edges.push({
+ id: `edge_${styleRefId}_${frontConfigId}`,
+ source: styleRefId,
+ target: frontConfigId,
+ type: 'imageOrder',
+ data: { imageOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ // 正面提示词 → 正面生成
+ edges.push({
+ id: `edge_${frontPromptId}_${frontConfigId}`,
+ source: frontPromptId,
+ target: frontConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 2 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ // 正面生成 → 正面结果
+ edges.push({
+ id: `edge_${frontConfigId}_${frontResultId}`,
+ source: frontConfigId,
+ target: frontResultId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // ========== 连线:第二阶段(依赖正面图) ==========
+ // 正面结果 → 侧面生成(作为参考图)
+ edges.push({
+ id: `edge_${frontResultId}_${sideConfigId}`,
+ source: frontResultId,
+ target: sideConfigId,
+ type: 'imageOrder',
+ data: { imageOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ // 正面结果 → 表情生成(作为参考图)
+ edges.push({
+ id: `edge_${frontResultId}_${closeupConfigId}`,
+ source: frontResultId,
+ target: closeupConfigId,
+ type: 'imageOrder',
+ data: { imageOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ // 正面结果 → 背面生成(作为参考图)
+ edges.push({
+ id: `edge_${frontResultId}_${backConfigId}`,
+ source: frontResultId,
+ target: backConfigId,
+ type: 'imageOrder',
+ data: { imageOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // 提示词 → 各生成节点
+ edges.push({
+ id: `edge_${sidePromptId}_${sideConfigId}`,
+ source: sidePromptId,
+ target: sideConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ edges.push({
+ id: `edge_${closeupPromptId}_${closeupConfigId}`,
+ source: closeupPromptId,
+ target: closeupConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ edges.push({
+ id: `edge_${backPromptId}_${backConfigId}`,
+ source: backPromptId,
+ target: backConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ return { nodes, edges }
+ }
+ },
+ {
+ id: 'drama-scene-background',
+ name: '多时段场景背景',
+ description: '先生成基础场景,再基于基础场景生成多时段变体,保持场景一致性',
+ icon: 'ImageOutline',
+ category: 'drama',
+ cover: scene01,
+ createNodes: (startPosition) => {
+ const colSpacing = 400
+ const rowSpacing = 260
+
+ const nodes = []
+ const edges = []
+ let nodeIdCounter = 0
+ const getNodeId = () => `workflow_node_${Date.now()}_${nodeIdCounter++}`
+
+ // ========== 第一阶段:生成基础场景 ==========
+ // 场景描述
+ const sceneDescId = getNodeId()
+ nodes.push({
+ id: sceneDescId,
+ type: 'text',
+ position: { x: startPosition.x, y: startPosition.y },
+ data: {
+ content: '场景名称:现代都市街道\n位置:繁华商业区主街道\n环境特征:高楼大厦林立,霓虹灯招牌,车水马龙\n氛围:都市繁华、现代感强\n特殊元素:咖啡店、书店、商场入口',
+ label: '场景描述'
+ }
+ })
+
+ // 基础场景提示词
+ const basePromptId = getNodeId()
+ nodes.push({
+ id: basePromptId,
+ type: 'text',
+ position: { x: startPosition.x + colSpacing, y: startPosition.y },
+ data: {
+ content: '根据场景描述,生成白天正午时段的场景背景作为基准,阳光明媚,光线充足均匀,展示场景全貌和所有环境元素,纯背景无人物,电影级画质,宽屏构图',
+ label: '基础场景提示词'
+ }
+ })
+
+ // 基础场景生成配置
+ const baseConfigId = getNodeId()
+ nodes.push({
+ id: baseConfigId,
+ type: 'imageConfig',
+ position: { x: startPosition.x + colSpacing * 2, y: startPosition.y },
+ data: {
+ label: '生成基础场景',
+ model: 'doubao-seedream-4-5-251128',
+ size: '2560x1440'
+ }
+ })
+
+ // 基础场景结果(作为后续生成的参考)
+ const baseResultId = getNodeId()
+ nodes.push({
+ id: baseResultId,
+ type: 'image',
+ position: { x: startPosition.x + colSpacing * 3, y: startPosition.y },
+ data: {
+ url: '',
+ label: '基础场景图(参考基准)'
+ }
+ })
+
+ // ========== 第二阶段:基于基础场景生成多时段变体 ==========
+ // 傍晚场景提示词
+ const eveningPromptId = getNodeId()
+ nodes.push({
+ id: eveningPromptId,
+ type: 'text',
+ position: { x: startPosition.x + colSpacing * 3 + 100, y: startPosition.y + rowSpacing },
+ data: {
+ content: '参考提供的基础场景图,保持场景构图、建筑、环境元素完全一致,仅改变光照为傍晚时段:夕阳西下,天空呈橙红色渐变,光线柔和温暖,建筑投射长影',
+ label: '傍晚场景提示词'
+ }
+ })
+
+ // 夜晚场景提示词
+ const nightPromptId = getNodeId()
+ nodes.push({
+ id: nightPromptId,
+ type: 'text',
+ position: { x: startPosition.x + colSpacing * 3 + 100, y: startPosition.y + rowSpacing * 2 },
+ data: {
+ content: '参考提供的基础场景图,保持场景构图、建筑、环境元素完全一致,仅改变光照为夜晚时段:霓虹灯亮起,城市灯光璀璨,天空深蓝或黑色,窗户透出暖光',
+ label: '夜晚场景提示词'
+ }
+ })
+
+ // 雨天场景提示词
+ const rainPromptId = getNodeId()
+ nodes.push({
+ id: rainPromptId,
+ type: 'text',
+ position: { x: startPosition.x + colSpacing * 3 + 100, y: startPosition.y + rowSpacing * 3 },
+ data: {
+ content: '参考提供的基础场景图,保持场景构图、建筑、环境元素完全一致,仅改变天气为雨天:细雨绵绵,地面湿润有倒影,天空阴沉灰暗,氛围忧郁',
+ label: '雨天场景提示词'
+ }
+ })
+
+ // 傍晚生成配置
+ const eveningConfigId = getNodeId()
+ nodes.push({
+ id: eveningConfigId,
+ type: 'imageConfig',
+ position: { x: startPosition.x + colSpacing * 4 + 100, y: startPosition.y + rowSpacing },
+ data: {
+ label: '傍晚场景',
+ model: 'doubao-seedream-4-5-251128',
+ size: '2560x1440'
+ }
+ })
+
+ // 夜晚生成配置
+ const nightConfigId = getNodeId()
+ nodes.push({
+ id: nightConfigId,
+ type: 'imageConfig',
+ position: { x: startPosition.x + colSpacing * 4 + 100, y: startPosition.y + rowSpacing * 2 },
+ data: {
+ label: '夜晚场景',
+ model: 'doubao-seedream-4-5-251128',
+ size: '2560x1440'
+ }
+ })
+
+ // 雨天生成配置
+ const rainConfigId = getNodeId()
+ nodes.push({
+ id: rainConfigId,
+ type: 'imageConfig',
+ position: { x: startPosition.x + colSpacing * 4 + 100, y: startPosition.y + rowSpacing * 3 },
+ data: {
+ label: '雨天场景',
+ model: 'doubao-seedream-4-5-251128',
+ size: '2560x1440'
+ }
+ })
+
+ // ========== 连线:第一阶段 ==========
+ // 场景描述 → 基础场景生成
+ edges.push({
+ id: `edge_${sceneDescId}_${baseConfigId}`,
+ source: sceneDescId,
+ target: baseConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ // 基础提示词 → 基础场景生成
+ edges.push({
+ id: `edge_${basePromptId}_${baseConfigId}`,
+ source: basePromptId,
+ target: baseConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 2 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ // 基础场景生成 → 基础场景结果
+ edges.push({
+ id: `edge_${baseConfigId}_${baseResultId}`,
+ source: baseConfigId,
+ target: baseResultId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // ========== 连线:第二阶段(依赖基础场景图) ==========
+ // 基础场景结果 → 各时段生成(作为参考图)
+ const variantConfigs = [eveningConfigId, nightConfigId, rainConfigId]
+ variantConfigs.forEach(configId => {
+ edges.push({
+ id: `edge_${baseResultId}_${configId}`,
+ source: baseResultId,
+ target: configId,
+ type: 'imageOrder',
+ data: { imageOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ })
+
+ // 提示词 → 各生成节点
+ edges.push({
+ id: `edge_${eveningPromptId}_${eveningConfigId}`,
+ source: eveningPromptId,
+ target: eveningConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ edges.push({
+ id: `edge_${nightPromptId}_${nightConfigId}`,
+ source: nightPromptId,
+ target: nightConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+ edges.push({
+ id: `edge_${rainPromptId}_${rainConfigId}`,
+ source: rainPromptId,
+ target: rainConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ return { nodes, edges }
+ }
+ },
+ // {
+ // id: 'drama-storyboard-shot',
+ // name: '短剧分镜图',
+ // description: '根据角色、场景和剧情描述生成分镜画面',
+ // icon: 'FilmOutline',
+ // category: 'drama',
+ // cover: workflowCover1,
+ // createNodes: (startPosition) => {
+ // const colSpacing = 400
+ // const rowSpacing = 250
+
+ // const nodes = []
+ // const edges = []
+ // let nodeIdCounter = 0
+ // const getNodeId = () => `workflow_node_${Date.now()}_${nodeIdCounter++}`
+
+ // // ========== 输入节点 ==========
+ // // 角色参考图
+ // const characterRefId = getNodeId()
+ // nodes.push({
+ // id: characterRefId,
+ // type: 'image',
+ // position: { x: startPosition.x, y: startPosition.y },
+ // data: {
+ // url: '',
+ // label: '角色参考图'
+ // }
+ // })
+
+ // // 场景背景图
+ // const sceneRefId = getNodeId()
+ // nodes.push({
+ // id: sceneRefId,
+ // type: 'image',
+ // position: { x: startPosition.x, y: startPosition.y + rowSpacing },
+ // data: {
+ // url: '',
+ // label: '场景背景图'
+ // }
+ // })
+
+ // // 分镜描述
+ // const shotDescId = getNodeId()
+ // nodes.push({
+ // id: shotDescId,
+ // type: 'text',
+ // position: { x: startPosition.x, y: startPosition.y + rowSpacing * 2 },
+ // data: {
+ // content: '分镜编号:001\n景别:中景\n镜头角度:平视\n画面描述:女主角站在咖啡店门口,手持一杯咖啡,微微低头看着手机,若有所思\n人物动作:站立,单手持咖啡,另一手拿手机\n表情:略带忧郁,眉头微蹙\n光线:自然光,侧逆光',
+ // label: '分镜描述'
+ // }
+ // })
+
+ // // ========== 生成提示词 ==========
+ // const shotPromptId = getNodeId()
+ // nodes.push({
+ // id: shotPromptId,
+ // type: 'text',
+ // position: { x: startPosition.x + colSpacing, y: startPosition.y + rowSpacing },
+ // data: {
+ // content: '根据角色参考图、场景背景和分镜描述,生成电影级分镜画面,保持角色外貌一致,场景融合自然,光影效果符合描述,16:9宽屏比例,电影调色',
+ // label: '分镜生成提示词'
+ // }
+ // })
+
+ // // ========== 生成节点 ==========
+ // const shotConfigId = getNodeId()
+ // nodes.push({
+ // id: shotConfigId,
+ // type: 'imageConfig',
+ // position: { x: startPosition.x + colSpacing * 2, y: startPosition.y + rowSpacing },
+ // data: {
+ // label: '分镜画面',
+ // model: 'doubao-seedream-4-5-251128',
+ // size: '2560x1440'
+ // }
+ // })
+
+ // // ========== 连线 ==========
+ // edges.push({
+ // id: `edge_${characterRefId}_${shotConfigId}`,
+ // source: characterRefId,
+ // target: shotConfigId,
+ // sourceHandle: 'right',
+ // targetHandle: 'left'
+ // })
+ // edges.push({
+ // id: `edge_${sceneRefId}_${shotConfigId}`,
+ // source: sceneRefId,
+ // target: shotConfigId,
+ // sourceHandle: 'right',
+ // targetHandle: 'left'
+ // })
+ // edges.push({
+ // id: `edge_${shotDescId}_${shotConfigId}`,
+ // source: shotDescId,
+ // target: shotConfigId,
+ // type: 'promptOrder',
+ // data: { promptOrder: 1 },
+ // sourceHandle: 'right',
+ // targetHandle: 'left'
+ // })
+ // edges.push({
+ // id: `edge_${shotPromptId}_${shotConfigId}`,
+ // source: shotPromptId,
+ // target: shotConfigId,
+ // type: 'promptOrder',
+ // data: { promptOrder: 2 },
+ // sourceHandle: 'right',
+ // targetHandle: 'left'
+ // })
+
+ // return { nodes, edges }
+ // }
+ // },
+ // ========== 儿童绘本工作流 ==========
+ {
+ id: 'picture-book-generator',
+ name: '儿童绘本生成',
+ description: '角色生成 → 剧情文字 → 绘本插画,支持角色一致性',
+ icon: 'BookOutline',
+ category: 'creative',
+ cover: "https://ffile.chatfire.site/image/covers/workflow03.jpeg",
+ createNodes: (startPosition) => {
+ const colSpacing = 420
+ const rowSpacing = 280
+ const pageRowSpacing = 240
+
+ const nodes = []
+ const edges = []
+ let nodeIdCounter = 0
+ const getNodeId = () => `workflow_node_${Date.now()}_${nodeIdCounter++}`
+
+ // ========== 第一阶段:故事输入 ==========
+ const storyInputId = getNodeId()
+ nodes.push({
+ id: storyInputId,
+ type: 'text',
+ position: { x: startPosition.x, y: startPosition.y },
+ data: {
+ content: `【绘本名称】小兔子的冒险之旅
+
+【故事主题】勇气与友谊
+
+【主要角色】
+1. 小白兔米米 - 主角,白色毛发,粉红色耳朵内侧,穿蓝色背带裤,性格勇敢好奇
+2. 小狐狸橙橙 - 伙伴,橙色毛发,白色尾巴尖,戴绿色围巾,聪明机智
+
+【故事梗概】
+小白兔米米住在森林边的小木屋里,有一天她发现了一张神秘的藏宝图。在好朋友小狐狸橙橙的陪伴下,她们踏上了寻宝之旅。途中遇到各种挑战,最后发现真正的宝藏是友谊和勇气。
+
+【画风要求】
+温馨治愈的水彩绘本风格,色彩明亮柔和,适合3-6岁儿童阅读`,
+ label: '故事大纲'
+ }
+ })
+
+ // ========== 第二阶段:LLM 角色设计 ==========
+ const characterLLMId = getNodeId()
+ nodes.push({
+ id: characterLLMId,
+ type: 'llmConfig',
+ position: { x: startPosition.x + colSpacing, y: startPosition.y - rowSpacing },
+ data: {
+ label: '角色设计生成',
+ systemPrompt: `你是专业的绘本角色设计师。根据故事大纲提取所有角色,为每个角色生成适合图像生成的详细提示词。
+
+输出格式(用换行分隔每个角色):
+[角色名]
+[角色图像生成提示词]
+---
+
+输出要求:
+1. 识别故事中的所有角色(主角、配角等)
+2. 提示词包含:外貌特征、服装、表情、姿态、场景
+3. 使用绘本水彩风格描述
+4. 末尾加上"白色简洁背景,儿童绘本水彩风格,温馨治愈,色彩明亮柔和"
+5. 直接输出,不要编号、标题或其他格式标记`,
+ model: 'gpt-4o-mini',
+ outputFormat: 'text'
+ }
+ })
+
+ // 故事大纲 → 角色设计LLM
+ edges.push({
+ id: `edge_${storyInputId}_${characterLLMId}`,
+ source: storyInputId,
+ target: characterLLMId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // 角色参考图配置
+ const characterConfigId = getNodeId()
+ nodes.push({
+ id: characterConfigId,
+ type: 'imageConfig',
+ position: { x: startPosition.x + colSpacing * 2, y: startPosition.y - rowSpacing },
+ data: {
+ label: '角色参考图',
+ model: 'doubao-seedream-4-5-251128',
+ size: '2048x2048'
+ }
+ })
+
+ // LLM → 角色图配置
+ edges.push({
+ id: `edge_${characterLLMId}_${characterConfigId}`,
+ source: characterLLMId,
+ target: characterConfigId,
+ type: 'promptOrder',
+ data: { promptOrder: 1 },
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // 角色参考图结果
+ const characterImageId = getNodeId()
+ nodes.push({
+ id: characterImageId,
+ type: 'image',
+ position: { x: startPosition.x + colSpacing * 3, y: startPosition.y - rowSpacing },
+ data: {
+ url: '',
+ label: '角色参考图结果'
+ }
+ })
+
+ // 角色配置 → 角色图结果
+ edges.push({
+ id: `edge_${characterConfigId}_${characterImageId}`,
+ source: characterConfigId,
+ target: characterImageId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // ========== 第三阶段:LLM 剧情拆分 ==========
+ const storyLLMId = getNodeId()
+ nodes.push({
+ id: storyLLMId,
+ type: 'llmConfig',
+ position: { x: startPosition.x + colSpacing, y: startPosition.y + rowSpacing * 0.5 },
+ data: {
+ label: '剧情拆分',
+ systemPrompt: `你是专业的绘本编剧。将故事拆分成绘本页面内容。
+
+输出格式(严格按此格式,换行分割每页):
+第1页:[故事配文] | [插画描述提示词]
+第2页:[故事配文] | [插画描述提示词]
+...
+
+要求:
+1. 根据故事复杂度拆分为4-8页
+2. 故事配文简洁温馨,适合3-6岁儿童(每页不超过30字)
+3. 插画描述要详细,包含角色外貌特征、动作、场景、色调
+4. 每页插画描述末尾加上画风说明以保持一致
+5. 故事节奏:开场→发展→高潮→温馨结局`,
+ model: 'gpt-4o',
+ outputFormat: 'text'
+ }
+ })
+
+ // 故事大纲 → 剧情拆分LLM
+ edges.push({
+ id: `edge_${storyInputId}_${storyLLMId}`,
+ source: storyInputId,
+ target: storyLLMId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // ========== 第四阶段:绘本页面(由 LLM 拆分动态生成) ==========
+ // 操作提示节点
+ const hintId = getNodeId()
+ nodes.push({
+ id: hintId,
+ type: 'text',
+ position: { x: startPosition.x + colSpacing * 2.5, y: startPosition.y + rowSpacing * 0.5 },
+ data: {
+ content: `操作步骤:
+1. 先点击「角色设计生成」的【执行生成】,等待生成所有角色参考图
+2. 再点击「剧情拆分」的【执行生成】,等待 LLM 输出剧本
+3. 在剧情拆分节点中点击【拆分为绘本页】按钮
+4. 系统将自动创建每页的故事文字、插画描述和图片生成节点
+5. 每页图片会自动关联角色参考图,保持角色一致性
+6. 点击各页的【立即生成】按钮生成绘本插画`,
+ label: '📖 操作指南'
+ }
+ })
+
return { nodes, edges }
}
}
]
-export const getWorkflowById = (id) => WORKFLOW_TEMPLATES.find(w => w.id === id)
+/**
+ * Get workflow template by ID | 根据ID获取工作流模板
+ */
+export const getWorkflowById = (id) => {
+ return WORKFLOW_TEMPLATES.find(w => w.id === id)
+}
-export const getWorkflowsByCategory = (category) => WORKFLOW_TEMPLATES.filter(w => w.category === category)
+/**
+ * Get workflows by category | 根据分类获取工作流
+ */
+export const getWorkflowsByCategory = (category) => {
+ return WORKFLOW_TEMPLATES.filter(w => w.category === category)
+}
export default WORKFLOW_TEMPLATES
diff --git a/web/canvas-app/src/hooks/useModelConfig.js b/web/canvas-app/src/hooks/useModelConfig.js
index 09ad4e7..989b40f 100644
--- a/web/canvas-app/src/hooks/useModelConfig.js
+++ b/web/canvas-app/src/hooks/useModelConfig.js
@@ -84,7 +84,7 @@ const customChatModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, []))
const customImageModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, []))
const customVideoModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, []))
-// 按渠道存储的自定义模型 | 结构: { 'skg': [{key, label}] }
+// 按渠道存储的自定义模型 | 结构: { 'openai': [{key, label}], 'chatfire': [{key, label}] }
const customChatModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER || 'custom-chat-models-by-provider', {}))
const customImageModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER || 'custom-image-models-by-provider', {}))
const customVideoModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER || 'custom-video-models-by-provider', {}))
@@ -411,7 +411,7 @@ export const useModelConfig = () => {
getImageModel,
getVideoModel,
- // Get models by provider
+ // Get models by provider (for ApiSettings)
getModelsByProvider,
// Custom models by provider
diff --git a/web/canvas-app/src/hooks/useWorkflowOrchestrator.js b/web/canvas-app/src/hooks/useWorkflowOrchestrator.js
index d76cfde..4ccc852 100644
--- a/web/canvas-app/src/hooks/useWorkflowOrchestrator.js
+++ b/web/canvas-app/src/hooks/useWorkflowOrchestrator.js
@@ -1,85 +1,1049 @@
-import { ref } from 'vue'
-import { addNode, addEdge, updateNode } from '@/stores/canvas'
+/**
+ * Workflow Orchestrator Hook | 工作流编排 Hook
+ * 使用回调串行结构编排节点执行
+ *
+ * 依赖关系:
+ * - imageConfig 执行后产生 image 节点
+ * - videoConfig 依赖 image 节点作为输入
+ * - 串行执行:等待上一步完成后再执行下一步
+ */
+import { ref, watch } from 'vue'
+import { streamChatCompletions } from '@/api'
+import {
+ nodes,
+ addNode,
+ addEdge,
+ updateNode
+} from '@/stores/canvas'
+
+// Workflow types | 工作流类型
const WORKFLOW_TYPES = {
TEXT_TO_IMAGE: 'text_to_image',
TEXT_TO_IMAGE_TO_VIDEO: 'text_to_image_to_video',
- STORYBOARD: 'storyboard'
+ STORYBOARD: 'storyboard', // 分镜工作流
+ MULTI_ANGLE_STORYBOARD: 'multi_angle_storyboard', // 多角度分镜工作流
+ PICTURE_BOOK: 'picture_book', // 儿童绘本工作流
}
+// Multi-angle prompts | 多角度提示词模板
+const MULTI_ANGLE_PROMPTS = {
+ front: {
+ label: '正视',
+ english: 'Front View',
+ prompt: (character) => `使用提供的图片,生成四宫格分镜,每张四宫格包括人物正面对着镜头的4个景别(远景、中景、近景、和局部特写),保持场景、产品、人物特征的一致性,宫格里的每一张照片保持和提供图片相同的比例。并在图片下方用英文标注这个景别
+
+角色参考: ${character}`
+ },
+ side: {
+ label: '侧视',
+ english: 'Side View',
+ prompt: (character) => `使用提供的图片,分别生成四宫格分镜,每张四宫格包括人物侧面角度的4个景别(远景、中景、近景、和局部特写),保持场景、产品、人物特征的一致性,宫格里的每一张照片保持和提供图片相同的比例。并在图片下方用英文标注这个景别
+
+角色参考: ${character}`
+ },
+ back: {
+ label: '后视',
+ english: 'Back View',
+ prompt: (character) => `使用提供的图片,分别生成四宫格分镜,每张四宫格包括人物背影角度的4个景别(远景、中景、近景、和局部特写),保持场景、产品、人物特征的一致性,宫格里的每一张照片保持和提供图片相同的比例。并在图片下方用英文标注这个景别
+
+角色参考: ${character}`
+ },
+ top: {
+ label: '俯视',
+ english: 'Top/Bird\'s Eye View',
+ prompt: (character) => `使用提供的图片,分别生成四宫格分镜,每张四宫格包括俯视角度的4个景别(远景、中景、近景、和局部特写),保持场景、产品、人物特征的一致性,宫格里的每一张照片保持和提供图片相同的比例。并在图片下方用英文标注这个景别
+
+角色参考: ${character}`
+ }
+}
+
+// System prompt for intent analysis | 意图分析系统提示词
+const INTENT_ANALYSIS_PROMPT = `你是一个工作流分析助手。根据用户输入判断需要的工作流类型,并生成对应的提示词。
+
+工作流类型:
+1. text_to_image - 用户想要生成单张图片(默认)
+2. text_to_image_to_video - 用户想要生成图片并转成视频(包含"视频"、"动画"、"动起来"等关键词)
+3. storyboard - 用户想要生成分镜/多场景图片(包含"分镜"、"场景一"、"镜头"等关键词,或描述多个连续场景)
+4. multi_angle_storyboard - 用户想要生成多角度分镜(包含"多角度"、"正视"、"侧视"、"后视"、"俯视"、"四宫格"、"景别"等关键词)
+5. picture_book - 用户想要生成儿童绘本(包含"绘本"、"故事书"、"童话"、"儿童故事"、"picture book"等关键词)
+
+返回 JSON:
+{
+ "workflow_type": "text_to_image | text_to_image_to_video | storyboard | multi_angle_storyboard | picture_book",
+ "description": "简短描述",
+
+ // text_to_image 和 text_to_image_to_video 使用:
+ "image_prompt": "优化后的图片生成提示词",
+ "video_prompt": "视频生成提示词(仅 text_to_image_to_video)",
+
+ // storyboard 分镜工作流使用:
+ "character": {
+ "name": "角色名称",
+ "description": "角色外观描述,用于生成参考图"
+ },
+ "shots": [
+ {
+ "title": "分镜标题",
+ "prompt": "该分镜的详细画面描述,包含角色动作、场景、光影等"
+ }
+ ],
+
+ // multi_angle_storyboard 多角度分镜工作流使用:
+ "multi_angle": {
+ "character_description": "角色的详细外观描述,包括服装、发型、体型、特征等"
+ },
+
+ // picture_book 儿童绘本工作流使用:
+ "picture_book": {
+ "title": "绘本标题",
+ "style": "插画风格描述,如水彩、蜡笔、扁平插画等",
+ "character": {
+ "name": "主角名称",
+ "description": "主角外观详细描述"
+ },
+ "pages": [
+ {
+ "page_number": 1,
+ "story_text": "该页的故事文字(给孩子读的)",
+ "illustration_prompt": "该页插画的详细描述,包含角色动作、场景、色彩、构图等,需保持风格一致"
+ }
+ ]
+ }
+}
+
+提示词优化要求:
+- image_prompt: 基于用户输入扩展,添加画面细节、艺术风格、光影效果等
+- video_prompt: 描述画面如何动起来,如镜头移动、主体动作、氛围变化等
+- character.description: 详细描述角色外观特征,便于后续分镜保持一致性
+- shots[].prompt: 每个分镜的完整画面描述,需包含角色名以保持一致性
+- multi_angle.character_description: 详细描述角色外观,用于生成多角度四宫格分镜
+- picture_book.style: 明确的插画风格,如"温暖水彩风"、"彩色蜡笔风"、"扁平矢量插画"等
+- picture_book.character.description: 详细描述主角外观特征,确保每页插画角色一致
+- picture_book.pages[].story_text: 简洁温馨的儿童故事文字,适合3-8岁孩子阅读
+- picture_book.pages[].illustration_prompt: 详细的插画描述,必须包含角色名和外观特征、场景、动作、色调,并注明插画风格以保持全书一致
+
+示例1 - 分镜工作流:
+输入: "蜡笔小新去上学。分镜一:清晨的战争;分镜二:出发的风姿"
+输出:
+{
+ "workflow_type": "storyboard",
+ "description": "蜡笔小新上学分镜",
+ "character": {
+ "name": "蜡笔小新",
+ "description": "5岁男孩,黑色蘑菇头发型,粗眉毛,穿红色T恤和黄色短裤,卡通动漫风格"
+ },
+ "shots": [
+ {"title": "清晨的战争", "prompt": "蜡笔小新在卧室赖床,妈妈美伢在旁边生气催促..."},
+ {"title": "出发的风姿", "prompt": "蜡笔小新背着黄色书包,在阳光下昂首阔步走出家门..."}
+ ]
+}
+
+示例2 - 多角度分镜工作流:
+输入: "生成一个穿红裙子的女孩的多角度分镜"
+输出:
+{
+ "workflow_type": "multi_angle_storyboard",
+ "description": "红裙女孩多角度分镜",
+ "multi_angle": {
+ "character_description": "年轻女孩,长发飘逸,穿着优雅的红色连衣裙,白皙皮肤,精致五官,现代时尚风格"
+ }
+}
+
+示例3 - 儿童绘本工作流:
+输入: "小兔子找妈妈的绘本故事"
+输出:
+{
+ "workflow_type": "picture_book",
+ "description": "小兔子找妈妈绘本",
+ "picture_book": {
+ "title": "小兔子找妈妈",
+ "style": "温暖水彩风,柔和色调,圆润线条,儿童绘本插画风格",
+ "character": {
+ "name": "小兔子",
+ "description": "一只白色小兔子,圆圆的大眼睛,粉色的长耳朵,穿着蓝色小背带裤,毛茸茸的短尾巴"
+ },
+ "pages": [
+ {"page_number": 1, "story_text": "清晨,小兔子醒来发现妈妈不在身边。", "illustration_prompt": "温暖水彩风,一只穿蓝色背带裤的白色小兔子坐在小床上揉眼睛,阳光从窗户洒进温馨的小房间,柔和的暖色调,儿童绘本插画风格"},
+ {"page_number": 2, "story_text": "小兔子来到花园里,问蝴蝶姐姐:'你看到我妈妈了吗?'", "illustration_prompt": "温暖水彩风,穿蓝色背带裤的白色小兔子站在五彩缤纷的花园中,仰头看着一只彩色蝴蝶,绿草如茵,鲜花盛开,明亮温暖的色调,儿童绘本插画风格"},
+ {"page_number": 3, "story_text": "小兔子终于在胡萝卜地里找到了妈妈,开心地扑进妈妈怀里。", "illustration_prompt": "温暖水彩风,穿蓝色背带裤的白色小兔子扑进兔妈妈怀里,兔妈妈穿着围裙温柔地抱着小兔子,周围是橙色的胡萝卜地,温馨幸福的画面,柔和暖色调,儿童绘本插画风格"}
+ ]
+ }
+}
+
+返回纯 JSON,不要其他内容。`
+
+const splitStoryboardShots = (text) => {
+ const parts = String(text || '')
+ .split(/(?:分镜|镜头|场景)[一二三四五六七八九十\d]*[::、\s]*/g)
+ .map(item => item.trim())
+ .filter(Boolean)
+ const source = parts.length > 1 ? parts.slice(1) : parts
+ const shots = (source.length ? source : [text]).slice(0, 6)
+ return shots.map((shot, index) => ({
+ title: `分镜 ${index + 1}`,
+ prompt: shot || text
+ }))
+}
+
+const buildLocalIntent = (userInput) => {
+ const text = String(userInput || '').trim()
+ if (/绘本|故事书|童话|儿童故事|picture\s*book/i.test(text)) {
+ return {
+ workflow_type: WORKFLOW_TYPES.PICTURE_BOOK,
+ description: '绘本工作流',
+ picture_book: {
+ title: text.slice(0, 18) || '绘本故事',
+ style: '温暖水彩风,柔和色调,儿童绘本插画风格',
+ character: {
+ name: '主角',
+ description: text || '可爱的儿童绘本主角'
+ },
+ pages: [
+ { page_number: 1, story_text: text || '故事开始了。', illustration_prompt: text || '温暖水彩风儿童绘本开场画面' },
+ { page_number: 2, story_text: '主角遇到了新的变化。', illustration_prompt: `${text},故事发展场景,保持同一角色和插画风格` },
+ { page_number: 3, story_text: '故事有了温暖的结尾。', illustration_prompt: `${text},温暖结尾画面,保持同一角色和插画风格` }
+ ]
+ }
+ }
+ }
+
+ if (/多角度|正视|侧视|后视|俯视|四宫格|景别/i.test(text)) {
+ return {
+ workflow_type: WORKFLOW_TYPES.MULTI_ANGLE_STORYBOARD,
+ description: '多角度分镜工作流',
+ multi_angle: {
+ character_description: text || '保持主体一致的多角度角色'
+ }
+ }
+ }
+
+ if (/分镜|场景|镜头/i.test(text)) {
+ return {
+ workflow_type: WORKFLOW_TYPES.STORYBOARD,
+ description: '分镜工作流',
+ character: {
+ name: '主体',
+ description: text || '保持一致的主体'
+ },
+ shots: splitStoryboardShots(text)
+ }
+ }
+
+ if (/视频|动画|动起来|运动|镜头运动|video|motion/i.test(text)) {
+ return {
+ workflow_type: WORKFLOW_TYPES.TEXT_TO_IMAGE_TO_VIDEO,
+ description: '图文转视频工作流',
+ image_prompt: text,
+ video_prompt: text
+ }
+ }
+
+ return {
+ workflow_type: WORKFLOW_TYPES.TEXT_TO_IMAGE,
+ description: '文生图工作流',
+ image_prompt: text
+ }
+}
+
+/**
+ * Workflow Orchestrator Composable
+ */
export const useWorkflowOrchestrator = () => {
+ // State | 状态
const isAnalyzing = ref(false)
const isExecuting = ref(false)
const currentStep = ref(0)
const totalSteps = ref(0)
const executionLog = ref([])
+ // Active watchers | 活跃的监听器
+ const activeWatchers = []
+
+ /**
+ * Add log entry | 添加日志
+ */
const addLog = (type, message) => {
executionLog.value.push({ type, message, timestamp: Date.now() })
+ console.log(`[Workflow ${type}] ${message}`)
}
+ /**
+ * Clear all watchers | 清除所有监听器
+ */
+ const clearWatchers = () => {
+ activeWatchers.forEach(stop => stop())
+ activeWatchers.length = 0
+ }
+
+ /**
+ * Wait for config node to complete and return output node ID
+ * 等待配置节点完成并返回输出节点 ID
+ */
+ const waitForConfigComplete = (configNodeId) => {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error('执行超时'))
+ }, 5 * 60 * 1000)
+
+ let stopWatcher = null
+
+ const checkNode = (node) => {
+ if (!node) return false
+
+ // Check for error | 检查错误
+ if (node.data?.error) {
+ clearTimeout(timeout)
+ if (stopWatcher) stopWatcher()
+ reject(new Error(node.data.error))
+ return true
+ }
+
+ // Config node completed with output node ID | 配置节点完成并返回输出节点 ID
+ if (node.data?.executed && node.data?.outputNodeId) {
+ clearTimeout(timeout)
+ if (stopWatcher) stopWatcher()
+ addLog('success', `节点 ${configNodeId} 完成,输出节点: ${node.data.outputNodeId}`)
+ resolve(node.data.outputNodeId)
+ return true
+ }
+ return false
+ }
+
+ // Check immediately first | 先立即检查一次
+ const node = nodes.value.find(n => n.id === configNodeId)
+ if (checkNode(node)) return
+
+ // Then watch for changes | 然后监听变化
+ stopWatcher = watch(
+ () => nodes.value.find(n => n.id === configNodeId),
+ (node) => checkNode(node),
+ { deep: true }
+ )
+
+ activeWatchers.push(stopWatcher)
+ })
+ }
+
+ /**
+ * Wait for output node (image/video) to be ready
+ * 等待输出节点准备好
+ */
+ const waitForOutputReady = (outputNodeId) => {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error('输出节点超时'))
+ }, 5 * 60 * 1000)
+
+ let stopWatcher = null
+
+ const checkNode = (node) => {
+ if (!node) return false
+
+ if (node.data?.error) {
+ clearTimeout(timeout)
+ if (stopWatcher) stopWatcher()
+ reject(new Error(node.data.error))
+ return true
+ }
+
+ // Output node ready when has URL and not loading
+ if (node.data?.url && !node.data?.loading) {
+ clearTimeout(timeout)
+ if (stopWatcher) stopWatcher()
+ addLog('success', `输出节点 ${outputNodeId} 已就绪`)
+ resolve(node)
+ return true
+ }
+ return false
+ }
+
+ // Check immediately first | 先立即检查一次
+ const node = nodes.value.find(n => n.id === outputNodeId)
+ if (checkNode(node)) return
+
+ // Then watch for changes | 然后监听变化
+ stopWatcher = watch(
+ () => nodes.value.find(n => n.id === outputNodeId),
+ (node) => checkNode(node),
+ { deep: true }
+ )
+
+ activeWatchers.push(stopWatcher)
+ })
+ }
+
+ /**
+ * Analyze user intent | 分析用户意图
+ */
const analyzeIntent = async (userInput) => {
- const text = String(userInput || '')
- const wantsVideo = /视频|动起来|镜头|运动|video|motion/i.test(text)
- return {
- workflow_type: wantsVideo ? WORKFLOW_TYPES.TEXT_TO_IMAGE_TO_VIDEO : WORKFLOW_TYPES.TEXT_TO_IMAGE,
- description: wantsVideo ? 'SKG 文生视频' : 'SKG 文生图',
- image_prompt: text,
- video_prompt: text
+ isAnalyzing.value = true
+
+ try {
+ let response = ''
+ for await (const chunk of streamChatCompletions({
+ model: 'gpt-4o',
+ messages: [
+ { role: 'system', content: INTENT_ANALYSIS_PROMPT },
+ { role: 'user', content: userInput }
+ ]
+ })) {
+ response += chunk
+ }
+
+ const jsonMatch = response.match(/\{[\s\S]*\}/)
+ if (!jsonMatch) {
+ return buildLocalIntent(userInput)
+ }
+
+ return JSON.parse(jsonMatch[0])
+ } catch (err) {
+ addLog('error', `分析失败: ${err.message}`)
+ return buildLocalIntent(userInput)
+ } finally {
+ isAnalyzing.value = false
}
}
- const executeWorkflow = async (params, position = { x: 100, y: 100 }) => {
- isExecuting.value = true
+ /**
+ * Execute text-to-image workflow | 执行文生图工作流
+ * text → imageConfig (autoExecute) → image
+ */
+ const executeTextToImage = async (imagePrompt, position) => {
+ const nodeSpacing = 400
+ let x = position.x
+
+ addLog('info', '开始执行文生图工作流')
currentStep.value = 1
- totalSteps.value = params.workflow_type === WORKFLOW_TYPES.TEXT_TO_IMAGE_TO_VIDEO ? 3 : 2
+ totalSteps.value = 2
+
+ // Step 1: Create text node for image | 创建图片提示词节点
+ const textNodeId = addNode('text', { x, y: position.y }, {
+ content: imagePrompt,
+ label: '图片提示词'
+ })
+ addLog('info', `创建图片提示词节点: ${textNodeId}`)
+ x += nodeSpacing
+
+ // Step 2: Create imageConfig with autoExecute | 创建图片配置节点并自动执行
+ currentStep.value = 2
+ const imageConfigId = addNode('imageConfig', { x, y: position.y }, {
+ label: '文生图',
+ autoExecute: true
+ })
+ addLog('info', `创建图片配置节点: ${imageConfigId}`)
+
+ // Connect text → imageConfig
+ addEdge({
+ source: textNodeId,
+ target: imageConfigId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ addLog('success', '文生图工作流已启动')
+ return { textNodeId, imageConfigId }
+ }
+
+ /**
+ * Execute text-to-image-to-video workflow | 执行文生图生视频工作流
+ * imageText → imageConfig → image
+ * videoText → videoConfig → video
+ * image → videoConfig
+ */
+ const executeTextToImageToVideo = async (imagePrompt, videoPrompt, position) => {
+ const nodeSpacing = 400
+ const rowSpacing = 200
+ let x = position.x
+
+ addLog('info', '开始执行文生图生视频工作流')
+ currentStep.value = 1
+ totalSteps.value = 5
+
+ // Step 1: Create image prompt text node | 创建图片提示词节点
+ const imageTextNodeId = addNode('text', { x, y: position.y }, {
+ content: imagePrompt,
+ label: '图片提示词'
+ })
+ addLog('info', `创建图片提示词节点: ${imageTextNodeId}`)
+
+ // Step 2: Create video prompt text node (below image prompt) | 创建视频提示词节点
+ currentStep.value = 2
+ const videoTextNodeId = addNode('text', { x, y: position.y + rowSpacing }, {
+ content: videoPrompt,
+ label: '视频提示词'
+ })
+ addLog('info', `创建视频提示词节点: ${videoTextNodeId}`)
+ x += nodeSpacing
+
+ // Step 3: Create imageConfig with autoExecute | 创建图片配置节点
+ currentStep.value = 3
+ const imageConfigId = addNode('imageConfig', { x, y: position.y }, {
+ label: '文生图',
+ autoExecute: true
+ })
+ addLog('info', `创建图片配置节点: ${imageConfigId}`)
+
+ // Connect imageText → imageConfig
+ addEdge({
+ source: imageTextNodeId,
+ target: imageConfigId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // Step 3: Wait for imageConfig to complete and get image node ID
+ // 等待图片配置完成并获取图片节点 ID
+ currentStep.value = 3
+ addLog('info', '等待图片生成完成...')
+
try {
- const textId = addNode('text', position, {
- label: '提示词',
- content: params.image_prompt || params.video_prompt || ''
+ const imageNodeId = await waitForConfigComplete(imageConfigId)
+
+ // Wait for image to be ready | 等待图片准备好
+ await waitForOutputReady(imageNodeId)
+
+ // Get image node position | 获取图片节点位置
+ const imageNode = nodes.value.find(n => n.id === imageNodeId)
+ x = (imageNode?.position?.x || x) + nodeSpacing
+
+ // Step 4: Create videoConfig connected to videoText and image nodes
+ // 创建视频配置节点,连接视频提示词和图片节点
+ currentStep.value = 4
+ const videoConfigId = addNode('videoConfig', { x, y: position.y + rowSpacing }, {
+ label: '图生视频',
+ autoExecute: true
})
- if (params.workflow_type === WORKFLOW_TYPES.TEXT_TO_IMAGE_TO_VIDEO) {
- const videoConfigId = addNode('videoConfig', { x: position.x + 400, y: position.y }, {
- label: '文生视频',
+ addLog('info', `创建视频配置节点: ${videoConfigId}`)
+
+ // Connect videoText → videoConfig (for video prompt)
+ addEdge({
+ source: videoTextNodeId,
+ target: videoConfigId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // Connect image → videoConfig (for image input)
+ addEdge({
+ source: imageNodeId,
+ target: videoConfigId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ addLog('success', '文生图生视频工作流已启动')
+ return { imageTextNodeId, videoTextNodeId, imageConfigId, imageNodeId, videoConfigId }
+ } catch (err) {
+ addLog('error', `工作流执行失败: ${err.message}`)
+ throw err
+ }
+ }
+
+ /**
+ * Execute storyboard workflow | 执行分镜工作流
+ *
+ * 布局结构:
+ * [角色描述] → [imageConfig] → [角色参考图]
+ * ↓
+ * [分镜1文本] → [imageConfig] → [分镜1图片]
+ * [分镜2文本] → [imageConfig] → [分镜2图片]
+ * ...
+ */
+ const executeStoryboard = async (character, shots, position) => {
+ const nodeSpacing = 400
+ const rowSpacing = 250
+ let x = position.x
+ let y = position.y
+
+ const shotCount = shots?.length || 0
+ addLog('info', `开始执行分镜工作流: ${character?.name || '未知角色'}, ${shotCount} 个分镜`)
+ currentStep.value = 1
+ totalSteps.value = 2 + shotCount * 2 // 角色生成 + 每个分镜(文本+生成)
+
+ const createdNodes = {
+ characterTextId: null,
+ characterConfigId: null,
+ characterImageId: null,
+ shots: []
+ }
+
+ try {
+ // Step 1: Create character description text node | 创建角色描述文本节点
+ const characterDesc = `${character?.name || '角色'}: ${character?.description || ''}`
+ createdNodes.characterTextId = addNode('text', { x, y }, {
+ content: characterDesc,
+ label: `角色: ${character?.name || '参考'}`
+ })
+ addLog('info', `创建角色描述节点: ${createdNodes.characterTextId}`)
+ x += nodeSpacing
+
+ // Step 2: Create character imageConfig with autoExecute | 创建角色参考图配置
+ currentStep.value = 2
+ createdNodes.characterConfigId = addNode('imageConfig', { x, y }, {
+ label: '角色参考图',
+ autoExecute: true
+ })
+ addLog('info', `创建角色配置节点: ${createdNodes.characterConfigId}`)
+
+ // Connect character text → imageConfig
+ addEdge({
+ source: createdNodes.characterTextId,
+ target: createdNodes.characterConfigId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // Wait for character image to complete | 等待角色参考图完成
+ addLog('info', '等待角色参考图生成...')
+ createdNodes.characterImageId = await waitForConfigComplete(createdNodes.characterConfigId)
+ await waitForOutputReady(createdNodes.characterImageId)
+ addLog('success', '角色参考图已生成')
+
+ // Get character image position for layout | 获取角色图位置用于布局
+ const charImageNode = nodes.value.find(n => n.id === createdNodes.characterImageId)
+ x = (charImageNode?.position?.x || x) + nodeSpacing
+
+ // Step 3+: Create each shot | 创建每个分镜
+ for (let i = 0; i < shotCount; i++) {
+ const shot = shots[i]
+ const shotY = y + (i + 1) * rowSpacing
+ let shotX = position.x
+
+ currentStep.value = 3 + i * 2
+
+ // Create shot text node | 创建分镜文本节点
+ const shotTextId = addNode('text', { x: shotX, y: shotY }, {
+ content: shot.prompt,
+ label: `分镜${i + 1}: ${shot.title}`
+ })
+ addLog('info', `创建分镜${i + 1}文本节点: ${shotTextId}`)
+ shotX += nodeSpacing
+
+ // Create shot imageConfig | 创建分镜配置节点
+ currentStep.value = 4 + i * 2
+ const shotConfigId = addNode('imageConfig', { x: shotX, y: shotY }, {
+ label: `分镜${i + 1}`,
autoExecute: true
})
- addEdge({ source: textId, target: videoConfigId, sourceHandle: 'right', targetHandle: 'left', type: 'promptOrder', data: { promptOrder: 1 } })
- updateNode(videoConfigId, { autoExecute: true })
- } else {
- const imageConfigId = addNode('imageConfig', { x: position.x + 400, y: position.y }, {
- label: '文生图',
- autoExecute: true
+ addLog('info', `创建分镜${i + 1}配置节点: ${shotConfigId}`)
+
+ // Connect shot text → imageConfig
+ addEdge({
+ source: shotTextId,
+ target: shotConfigId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // Connect character image → shot imageConfig (as reference)
+ addEdge({
+ source: createdNodes.characterImageId,
+ target: shotConfigId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // Wait for this shot to complete before next | 等待当前分镜完成
+ addLog('info', `等待分镜${i + 1}生成...`)
+ const shotImageId = await waitForConfigComplete(shotConfigId)
+ await waitForOutputReady(shotImageId)
+ addLog('success', `分镜${i + 1}已生成`)
+
+ createdNodes.shots.push({
+ textId: shotTextId,
+ configId: shotConfigId,
+ imageId: shotImageId,
+ title: shot.title
})
- addEdge({ source: textId, target: imageConfigId, sourceHandle: 'right', targetHandle: 'left', type: 'promptOrder', data: { promptOrder: 1 } })
- updateNode(imageConfigId, { autoExecute: true })
}
- addLog('success', 'SKG 工作流已启动')
+
+ addLog('success', `分镜工作流完成,共生成 ${shotCount} 个分镜`)
+ return createdNodes
+ } catch (err) {
+ addLog('error', `分镜工作流执行失败: ${err.message}`)
+ throw err
+ }
+ }
+
+ /**
+ * Execute multi-angle storyboard workflow | 执行多角度分镜工作流
+ *
+ * 布局结构:
+ * [主角色图] ──┬──> [正视提示词] → [imageConfig] → [正视四宫格]
+ * ├──> [侧视提示词] → [imageConfig] → [侧视四宫格]
+ * ├──> [后视提示词] → [imageConfig] → [后视四宫格]
+ * └──> [俯视提示词] → [imageConfig] → [俯视四宫格]
+ *
+ * @param {object} multiAngle - 多角度参数 { character_description }
+ * @param {object} position - 起始位置
+ */
+ const executeMultiAngleStoryboard = async (multiAngle, position) => {
+ const nodeSpacing = 400
+ const rowSpacing = 300
+ let x = position.x
+ let y = position.y
+
+ const characterDesc = multiAngle?.character_description || ''
+ const angles = ['front', 'side', 'back', 'top']
+
+ addLog('info', `开始执行多角度分镜工作流: ${characterDesc.slice(0, 30)}...`)
+ currentStep.value = 1
+ totalSteps.value = 2 + angles.length * 2 // 角色图 + 每个角度(提示词+生成)
+
+ const createdNodes = {
+ characterImageId: null,
+ angles: []
+ }
+
+ try {
+ // Step 1: Create character image node (user uploads or existing)
+ // 创建角色图节点(用户上传或已有)
+ const characterImageId = addNode('image', { x, y }, {
+ url: '',
+ label: '主角色图(请上传)',
+ isCharacterRef: true
+ })
+ createdNodes.characterImageId = characterImageId
+ addLog('info', `创建主角色图节点: ${characterImageId}`)
+
+ // Step 2: Create 4 angle nodes in parallel layout
+ // 创建4个角度的节点(并行布局)
+ const angleX = x + nodeSpacing + 100
+
+ for (let i = 0; i < angles.length; i++) {
+ const angleKey = angles[i]
+ const angleConfig = MULTI_ANGLE_PROMPTS[angleKey]
+ const angleY = y + i * rowSpacing
+ let currentX = angleX
+
+ currentStep.value = 2 + i * 2
+
+ // Create angle prompt text node | 创建角度提示词节点
+ const promptContent = angleConfig.prompt(characterDesc)
+ const textNodeId = addNode('text', { x: currentX, y: angleY }, {
+ content: promptContent,
+ label: `${angleConfig.label}提示词`
+ })
+ addLog('info', `创建${angleConfig.label}提示词节点: ${textNodeId}`)
+ currentX += nodeSpacing
+
+ // Create imageConfig node | 创建图片配置节点
+ currentStep.value = 3 + i * 2
+ const configNodeId = addNode('imageConfig', { x: currentX, y: angleY }, {
+ label: `${angleConfig.label} (${angleConfig.english})`,
+ autoExecute: false // 不自动执行,等待用户上传角色图
+ })
+ addLog('info', `创建${angleConfig.label}配置节点: ${configNodeId}`)
+
+ // Connect text → imageConfig
+ addEdge({
+ source: textNodeId,
+ target: configNodeId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // Connect character image → imageConfig (as reference)
+ addEdge({
+ source: characterImageId,
+ target: configNodeId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ createdNodes.angles.push({
+ key: angleKey,
+ label: angleConfig.label,
+ english: angleConfig.english,
+ textId: textNodeId,
+ configId: configNodeId,
+ imageId: null
+ })
+ }
+
+ addLog('success', `多角度分镜工作流已创建,请上传主角色图后点击各节点的"立即生成"按钮`)
+ window.$message?.info('请先上传主角色图,然后点击各角度节点的"立即生成"按钮')
+
+ return createdNodes
+ } catch (err) {
+ addLog('error', `多角度分镜工作流执行失败: ${err.message}`)
+ throw err
+ }
+ }
+
+ /**
+ * Wait for LLM config node to complete (outputContent ready)
+ * 等待 LLM 配置节点完成(outputContent 就绪)
+ */
+ const waitForLLMComplete = (llmNodeId) => {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error('LLM 执行超时'))
+ }, 5 * 60 * 1000)
+
+ let stopWatcher = null
+
+ const checkNode = (node) => {
+ if (!node) return false
+
+ if (node.data?.error) {
+ clearTimeout(timeout)
+ if (stopWatcher) stopWatcher()
+ reject(new Error(node.data.error))
+ return true
+ }
+
+ if (node.data?.executed && node.data?.outputContent) {
+ clearTimeout(timeout)
+ if (stopWatcher) stopWatcher()
+ addLog('success', `LLM 节点 ${llmNodeId} 完成`)
+ resolve(node.data.outputContent)
+ return true
+ }
+ return false
+ }
+
+ const node = nodes.value.find(n => n.id === llmNodeId)
+ if (checkNode(node)) return
+
+ stopWatcher = watch(
+ () => nodes.value.find(n => n.id === llmNodeId),
+ (node) => checkNode(node),
+ { deep: true }
+ )
+
+ activeWatchers.push(stopWatcher)
+ })
+ }
+
+ /**
+ * Execute picture book workflow | 执行儿童绘本工作流
+ *
+ * 布局结构:
+ * [故事主题] → [LLM角色生成] → [角色参考图 imageConfig] → [角色图]
+ * ↓
+ * [LLM页面1提示词] → [imageConfig] → [绘本页1]
+ * [LLM页面2提示词] → [imageConfig] → [绘本页2]
+ * ...
+ *
+ * 每页同时生成故事文字(text节点)和插画
+ */
+ const executePictureBook = async (pictureBook, position) => {
+ const nodeSpacing = 420
+ const rowSpacing = 280
+ let x = position.x
+ let y = position.y
+
+ const { title, style, character, pages } = pictureBook
+ const pageCount = pages?.length || 0
+
+ addLog('info', `开始执行儿童绘本工作流: ${title}, ${pageCount} 页`)
+ currentStep.value = 1
+ totalSteps.value = 2 + pageCount * 2
+
+ const createdNodes = {
+ characterLLMId: null,
+ characterConfigId: null,
+ characterImageId: null,
+ pages: []
+ }
+
+ try {
+ // Step 1: Create LLM node for character description → generate character reference image
+ // 创建角色描述 LLM 节点 → 生成角色参考图
+ const characterPrompt = `${character?.name || '角色'}: ${character?.description || ''}\n\n插画风格: ${style || '儿童绘本插画'}`
+ const characterTextId = addNode('text', { x, y }, {
+ content: characterPrompt,
+ label: `角色设定: ${character?.name || '主角'}`
+ })
+ addLog('info', `创建角色描述节点: ${characterTextId}`)
+ x += nodeSpacing
+
+ // Create imageConfig for character reference | 创建角色参考图配置
+ currentStep.value = 2
+ createdNodes.characterConfigId = addNode('imageConfig', { x, y }, {
+ label: `${character?.name || '角色'}参考图`,
+ autoExecute: true
+ })
+ addLog('info', `创建角色参考图配置: ${createdNodes.characterConfigId}`)
+
+ addEdge({
+ source: characterTextId,
+ target: createdNodes.characterConfigId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // Wait for character image | 等待角色参考图
+ addLog('info', '等待角色参考图生成...')
+ createdNodes.characterImageId = await waitForConfigComplete(createdNodes.characterConfigId)
+ await waitForOutputReady(createdNodes.characterImageId)
+ addLog('success', '角色参考图已生成')
+
+ // Step 3+: Create each page | 创建每一页
+ for (let i = 0; i < pageCount; i++) {
+ const page = pages[i]
+ const pageY = y + (i + 1) * rowSpacing
+ let pageX = position.x
+
+ currentStep.value = 3 + i * 2
+
+ // Create story text node for this page | 创建该页故事文字节点
+ const storyTextId = addNode('text', { x: pageX, y: pageY - 80 }, {
+ content: page.story_text,
+ label: `第${page.page_number}页 故事文字`
+ })
+ addLog('info', `创建第${page.page_number}页故事文字: ${storyTextId}`)
+
+ // Create illustration prompt text node | 创建插画提示词节点
+ const illustrationPromptId = addNode('text', { x: pageX, y: pageY + 40 }, {
+ content: page.illustration_prompt,
+ label: `第${page.page_number}页 插画提示词`
+ })
+ addLog('info', `创建第${page.page_number}页插画提示词: ${illustrationPromptId}`)
+ pageX += nodeSpacing
+
+ // Create imageConfig for this page | 创建该页图片配置
+ currentStep.value = 4 + i * 2
+ const pageConfigId = addNode('imageConfig', { x: pageX, y: pageY }, {
+ label: `绘本第${page.page_number}页`,
+ autoExecute: true
+ })
+ addLog('info', `创建第${page.page_number}页图片配置: ${pageConfigId}`)
+
+ // Connect illustration prompt → imageConfig
+ addEdge({
+ source: illustrationPromptId,
+ target: pageConfigId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // Connect character image → imageConfig (as reference for consistency)
+ addEdge({
+ source: createdNodes.characterImageId,
+ target: pageConfigId,
+ sourceHandle: 'right',
+ targetHandle: 'left'
+ })
+
+ // Wait for page image | 等待该页插画完成
+ addLog('info', `等待第${page.page_number}页插画生成...`)
+ const pageImageId = await waitForConfigComplete(pageConfigId)
+ await waitForOutputReady(pageImageId)
+ addLog('success', `第${page.page_number}页插画已生成`)
+
+ createdNodes.pages.push({
+ storyTextId,
+ illustrationPromptId,
+ configId: pageConfigId,
+ imageId: pageImageId,
+ pageNumber: page.page_number
+ })
+ }
+
+ addLog('success', `绘本工作流完成: ${title},共 ${pageCount} 页`)
+ return createdNodes
+ } catch (err) {
+ addLog('error', `绘本工作流执行失败: ${err.message}`)
+ throw err
+ }
+ }
+
+ /**
+ * 根据工作流类型执行
+ * @param {object} params - 工作流参数
+ * @param {object} position - 起始位置
+ */
+ const executeWorkflow = async (params, position) => {
+ isExecuting.value = true
+ clearWatchers()
+ executionLog.value = []
+
+ const { workflow_type, image_prompt, video_prompt, character, shots, multi_angle, picture_book } = params
+
+ try {
+ switch (workflow_type) {
+ case WORKFLOW_TYPES.PICTURE_BOOK:
+ return await executePictureBook(picture_book, position)
+ case WORKFLOW_TYPES.MULTI_ANGLE_STORYBOARD:
+ return await executeMultiAngleStoryboard(multi_angle, position)
+ case WORKFLOW_TYPES.STORYBOARD:
+ return await executeStoryboard(character, shots, position)
+ case WORKFLOW_TYPES.TEXT_TO_IMAGE_TO_VIDEO:
+ return await executeTextToImageToVideo(image_prompt, video_prompt, position)
+ case WORKFLOW_TYPES.TEXT_TO_IMAGE:
+ default:
+ return await executeTextToImage(image_prompt, position)
+ }
} finally {
isExecuting.value = false
+ clearWatchers()
}
}
- const createTextToImageWorkflow = (imagePrompt, position) => executeWorkflow({
- workflow_type: WORKFLOW_TYPES.TEXT_TO_IMAGE,
- image_prompt: imagePrompt
- }, position)
+ /**
+ * Convenience method for simple text-to-image | 简便方法
+ */
+ const createTextToImageWorkflow = (imagePrompt, position) => {
+ return executeWorkflow({
+ workflow_type: WORKFLOW_TYPES.TEXT_TO_IMAGE,
+ image_prompt: imagePrompt
+ }, position)
+ }
- const createMultiAngleStoryboard = (characterDescription, position) => executeWorkflow({
- workflow_type: WORKFLOW_TYPES.STORYBOARD,
- image_prompt: `SKG 多角度营销分镜,主体要求:${characterDescription || '保持主体和产品一致'}`
- }, position)
+ /**
+ * Convenience method for multi-angle storyboard | 多角度分镜简便方法
+ */
+ const createMultiAngleStoryboard = (characterDescription, position) => {
+ return executeWorkflow({
+ workflow_type: WORKFLOW_TYPES.MULTI_ANGLE_STORYBOARD,
+ multi_angle: { character_description: characterDescription }
+ }, position)
+ }
+
+ /**
+ * Convenience method for picture book | 儿童绘本简便方法
+ */
+ const createPictureBook = (pictureBookParams, position) => {
+ return executeWorkflow({
+ workflow_type: WORKFLOW_TYPES.PICTURE_BOOK,
+ picture_book: pictureBookParams
+ }, position)
+ }
+
+ /**
+ * Reset state | 重置状态
+ */
+ const reset = () => {
+ isAnalyzing.value = false
+ isExecuting.value = false
+ currentStep.value = 0
+ totalSteps.value = 0
+ executionLog.value = []
+ clearWatchers()
+ }
return {
+ // State
isAnalyzing,
isExecuting,
currentStep,
totalSteps,
executionLog,
+
+ // Methods
analyzeIntent,
executeWorkflow,
createTextToImageWorkflow,
createMultiAngleStoryboard,
- WORKFLOW_TYPES
+ createPictureBook,
+ reset,
+
+ // Constants
+ WORKFLOW_TYPES,
+ MULTI_ANGLE_PROMPTS
}
}
diff --git a/web/canvas-app/src/stores/pinia/models.js b/web/canvas-app/src/stores/pinia/models.js
index 33fbbf1..cd9a09c 100644
--- a/web/canvas-app/src/stores/pinia/models.js
+++ b/web/canvas-app/src/stores/pinia/models.js
@@ -57,6 +57,14 @@ const setStored = (key, value) => {
}
}
+const removeStored = (key) => {
+ try {
+ localStorage.removeItem(key)
+ } catch {
+ // ignore
+ }
+}
+
/**
* Get stored JSON value from localStorage
*/
@@ -94,7 +102,8 @@ export const useModelStore = defineStore('model', () => {
// ============ Provider 状态 | Provider State ============
// 当前选中的渠道
- const currentProvider = ref(getStored(STORAGE_KEYS.PROVIDER) || getDefaultProvider())
+ const storedProvider = getStored(STORAGE_KEYS.PROVIDER)
+ const currentProvider = ref(PROVIDERS[storedProvider] ? storedProvider : getDefaultProvider())
// 渠道列表
const providerList = computed(() => getProviderList())
diff --git a/web/canvas-app/src/views/Canvas.vue b/web/canvas-app/src/views/Canvas.vue
index 2e2e753..4bec2a0 100644
--- a/web/canvas-app/src/views/Canvas.vue
+++ b/web/canvas-app/src/views/Canvas.vue
@@ -4,7 +4,7 @@
-
-
+
@@ -52,7 +60,7 @@
class="canvas-flow"
>
-