fix: restore upstream canvas capabilities

This commit is contained in:
2026-05-25 18:28:11 +08:00
parent 8bb4c96556
commit cce9779a8a
18 changed files with 3459 additions and 463 deletions

View File

@@ -12,7 +12,7 @@
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解 - 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md` - 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`
- 第一冲刺:步骤 1-4下载 / 拆轨 / 关键帧 / ASR+翻译) - 第一冲刺:步骤 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 KeyAPI 设置弹窗只保留模型/端点配置外观和本地模型管理,不能出现上游注册链接或外部品牌。用户登录后仍只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离;每个浏览器的画布项目先保存在本地 localStorage图片/视频资产按登录用户写入后端 job。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。
## 部署事实 ## 部署事实
- 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik - 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik

View File

@@ -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 - 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. - 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/` - 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. This notice is kept in the repository for engineering traceability and is not shown in the product UI.

View File

@@ -579,16 +579,17 @@
<p><strong>2026-05-25 即梦 generate 式简化:</strong>默认首页进一步压缩为窄导航栏、会话侧栏和中央 prompt composer不再把四入口、参考图、我的任务和结果区平铺成三栏。图片 / 视频 / 图文模式、自动设置和参考上传都收进 composer 底部的小按钮;参考图是输入框左侧倾斜上传卡;结果只用右下角浮层提示,完整沉淀交给详情页。</p> <p><strong>2026-05-25 即梦 generate 式简化:</strong>默认首页进一步压缩为窄导航栏、会话侧栏和中央 prompt composer不再把四入口、参考图、我的任务和结果区平铺成三栏。图片 / 视频 / 图文模式、自动设置和参考上传都收进 composer 底部的小按钮;参考图是输入框左侧倾斜上传卡;结果只用右下角浮层提示,完整沉淀交给详情页。</p>
<p><strong>2026-05-25 三模式版:</strong>默认首页再收敛为一个中央对话框,首页和画布底部输入框只让用户选文生图、文生视频、图生视频,然后手写提示词生成。图生视频只显示“上传图片”,不再把首帧 / 首尾帧这类模型实现概念作为主入口;营销图文不再作为首页默认入口。后端 <code>/health</code> 返回可选图片 / 视频模型、图片尺寸、视频画幅和真实可用视频时长,首页按返回值显示模型和规格选择;当前 Doubao / Seedance 生产链路单条最长 15 秒,不向用户暴露 30 秒按钮。</p> <p><strong>2026-05-25 三模式版:</strong>默认首页再收敛为一个中央对话框,首页和画布底部输入框只让用户选文生图、文生视频、图生视频,然后手写提示词生成。图生视频只显示“上传图片”,不再把首帧 / 首尾帧这类模型实现概念作为主入口;营销图文不再作为首页默认入口。后端 <code>/health</code> 返回可选图片 / 视频模型、图片尺寸、视频画幅和真实可用视频时长,首页按返回值显示模型和规格选择;当前 Doubao / Seedance 生产链路单条最长 15 秒,不向用户暴露 30 秒按钮。</p>
<p><strong>2026-05-25 根域名画布版:</strong><code>https://marketing.skg.com</code> 登录后直接进入个人生成画布,不再先进入 React 单对话框首页再点画布;<code>/canvas/</code> 只保留为旧链接兼容跳转。后续优先少改成熟画布结构,只在必要时改模式文案、生成接入和结果/队列显示。</p> <p><strong>2026-05-25 根域名画布版:</strong><code>https://marketing.skg.com</code> 登录后直接进入个人生成画布,不再先进入 React 单对话框首页再点画布;<code>/canvas/</code> 只保留为旧链接兼容跳转。后续优先少改成熟画布结构,只在必要时改模式文案、生成接入和结果/队列显示。</p>
<p><strong>2026-05-25 上游能力恢复版:</strong>用户明确要求“API 没关系,其他恢复,别削弱”。因此根域名画布恢复 <code>chatfire-AI/huobao-canvas</code> 的成熟节点和工作流结构推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、图片/视频/LLM 配置、多角度分镜、故事板、绘本和批量下载都保留;只继续替换品牌、路由和 API 接入。生成请求仍走 SKG 后端 <code>/api</code> 与登录 Cookie员工不需要个人 API Key。</p>
</div> </div>
<p>当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 在画布底部选择生成方式 → 选择模型和规格 → 图生视频时上传图片 → 手写提示词 → 在画布生成图片或视频节点 → 进入详情页继续沉淀”。默认只做三件事:文生图、生视频、图生视频。底层仍复用既有 <code>/creative/jobs/image</code><code>/jobs/{id}/frames/{idx}/generate</code><code>/jobs/{id}/frames/{idx}/storyboard/video</code>;图生视频把上传图片作为 <code>first_image</code> 提交给视频接口,但 UI 不展示“首帧”概念。生图接口现在按前端 <code>model</code><code>size</code> 字段走 <code>auto / gpt-image-2 / gemini-3-pro-image-preview</code><code>1024x1536 / 1024x1024 / 1536x1024</code> 等图片尺寸;视频接口继续按 <code>model</code> 字段走 <code>seedance / kling / veo3 / veo</code> 别名映射,并按后端返回的 <code>video_size_options</code><code>video_duration_options</code> 提交画幅和时长,实际模型和上限以服务器环境变量为准。多人互不影响依赖后端 <code>owner_id</code> 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。</p> <p>当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 用提示词、推荐词、AI 润色或工作流模板创建节点 → 画布自动执行或手动连接图片/视频/文本节点 → 生成结果沉淀在当前个人画布 → 需要时进入详情页继续编辑”。画布不再被削成三模式入口;首帧、尾帧、参考图、生视频、多角度分镜、故事板和绘本等上游概念按节点能力保留。底层生成仍由 <code>web/canvas-app/src/hooks/useApi.js</code> 适配到本项目 <code>/creative/jobs/image</code><code>/jobs/{id}/frames/{idx}/generate</code><code>/jobs/{id}/frames/{idx}/storyboard/video</code>,并按当前登录用户写入个人 job。多人互不影响依赖后端 <code>owner_id</code> 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。</p>
<div class="pipeline"> <div class="pipeline">
<div class="step"><div class="num">01</div><h3>个人任务</h3><p><code>GET /jobs</code> 按当前登录用户过滤;旧无 owner 任务只对备用账号可见。</p></div> <div class="step"><div class="num">01</div><h3>个人任务</h3><p><code>GET /jobs</code> 按当前登录用户过滤;旧无 owner 任务只对备用账号可见。</p></div>
<div class="step"><div class="num">02</div><h3>选择方式</h3><p>首页对话框只提供文生图、文生视频、图生视频三个按钮</p></div> <div class="step"><div class="num">02</div><h3>进入画布</h3><p>用户直接在根域名个人画布里操作,上游项目列表、推荐词、节点菜单、工作流模板和批量下载能力保留</p></div>
<div class="step"><div class="num">03</div><h3>选择模型和规格</h3><p><code>GET /health</code> 返回 <code>image_options</code><code>image_size_options</code><code>video_options</code><code>video_size_options</code><code>video_duration_options</code>;首页按当前生成方式切换模型、图片尺寸、视频画幅和视频时长</p></div> <div class="step"><div class="num">03</div><h3>组织节点</h3><p>可通过底部 prompt、AI 润色、自动执行、手动添加节点或工作流模板创建文本、图片、视频、LLM、配置和参考图节点</p></div>
<div class="step"><div class="num">04</div><h3>上传图片 / 空白任务</h3><p><code>POST /creative/jobs/image</code> 创建轻量任务;文生图和文生视频可空白创建,图生视频上传一张图片作为视频参考。</p></div> <div class="step"><div class="num">04</div><h3>参考素材</h3><p>首帧、尾帧、参考图和图片节点按上游节点语义保留;提交到后端时由 <code>useApi.js</code> 转成 <code>first_image</code><code>last_image</code> 或图片编辑参考。</p></div>
<div class="step"><div class="num">05</div><h3>手写提示词</h3><p>首页不再生成营销文案或自动展开产品 / 人群配置,用户直接写图片或视频提示词</p></div> <div class="step"><div class="num">05</div><h3>工作流执行</h3><p>自动执行会根据提示词创建文生图、图转视频、故事板、多角度分镜或绘本等节点组;手动模式下用户可自行连接节点</p></div>
<div class="step"><div class="num">06</div><h3>生成图片 / 视频</h3><p><code>generateImage</code> <code>mode=text</code>、图片模型和图片尺寸;<code>generateStoryboardVideo</code> 提交文本、模型、画幅、时长,图生视频额外提交 <code>first_image</code>视频提交后先写入 <code>queued</code> 占位,再由后端队列按并发上限启动。</p></div> <div class="step"><div class="num">06</div><h3>生成图片 / 视频</h3><p><code>generateImage</code> <code>generateStoryboardVideo</code> 继续走 SKG 后端 <code>/api</code>视频提交后先写入 <code>queued</code> 占位,再由后端队列按并发上限启动。</p></div>
<div class="step"><div class="num">07</div><h3>结果沉淀</h3><p>首页只在对话框下方显示最新图片或视频;视频会显示排队位置、生成进度、完成播放或失败可重试状态;所有图片/视频缩略图继续复用 <code>MediaAssetTile</code></p></div> <div class="step"><div class="num">07</div><h3>结果沉淀</h3><p>生成图、视频 URL、任务状态和下载入口回填到画布节点完整任务结果仍可进入 <code>/detail/?job=</code> 查看</p></div>
<div class="step"><div class="num">08</div><h3>详情页</h3><p><code>/detail/?job=&lt;id&gt;</code> 展示参考图、全量生成图、视频候选、提示词和营销图文,并支持继续生成。</p></div> <div class="step"><div class="num">08</div><h3>详情页</h3><p><code>/detail/?job=&lt;id&gt;</code> 展示参考图、全量生成图、视频候选、提示词和营销图文,并支持继续生成。</p></div>
<div class="step"><div class="num">09</div><h3>高级复刻</h3><p><code>AdRecreationBoard</code><code>/agent/</code> 作为高级入口保留,不再是默认路径。</p></div> <div class="step"><div class="num">09</div><h3>高级复刻</h3><p><code>AdRecreationBoard</code><code>/agent/</code> 作为高级入口保留,不再是默认路径。</p></div>
</div> </div>
@@ -604,8 +605,8 @@
<tr><td><code>web/next.config.mjs</code></td><td>Next.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator并移除 Next 16 已不支持的 <code>eslint</code> 顶层配置,避免本地 dev 出现配置 Issue 提示。</td></tr> <tr><td><code>web/next.config.mjs</code></td><td>Next.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator并移除 Next 16 已不支持的 <code>eslint</code> 顶层配置,避免本地 dev 出现配置 Issue 提示。</td></tr>
<tr><td><code>web/app/globals.css</code></td><td>全局主题变量、登录页视觉样式、信息流工作台玻璃拟态 token、ReactFlow 样式引用,以及本地开发态 <code>nextjs-portal</code> 遮挡隐藏规则。工作台在 <code>skg-board-theme</code> 内按 Figma 本地 MCP 参考改成黑灰玻璃系统:深灰背景、<code>#383838</code> 胶囊侧栏、<code>rgba(255,255,255,.1)</code> 玻璃面、<code>backdrop-filter: blur(5px)</code><code>20px</code> 圆角、<code>10px 10px 10px rgba(0,0,0,.3)</code> 阴影和绿黄状态色;新增 <code>skg-board-shell</code><code>skg-board-rail</code><code>skg-glass-card</code><code>skg-glass-card--flat</code><code>skg-status-orb</code> 等样式。侧栏改为跟随视口拉满工作台可用高度的悬停胶囊,桌面最小 600px展开时在同一侧栏内承载素材输入抽屉。明暗主题已分开维护 shell、panel、glass、stat、action 和音频波形 token暗色压低灰雾和面板底色明亮模式改为暖白工作台避免指标卡、按钮和波形继续残留黑底/白线;顶部指标卡增加紫、黄绿、琥珀、青绿、绿色光斑变量,接近原版多色玻璃卡效果。主/次按钮、指标卡和空状态继续走统一类,避免各板块散写不同玻璃效果。</td></tr> <tr><td><code>web/app/globals.css</code></td><td>全局主题变量、登录页视觉样式、信息流工作台玻璃拟态 token、ReactFlow 样式引用,以及本地开发态 <code>nextjs-portal</code> 遮挡隐藏规则。工作台在 <code>skg-board-theme</code> 内按 Figma 本地 MCP 参考改成黑灰玻璃系统:深灰背景、<code>#383838</code> 胶囊侧栏、<code>rgba(255,255,255,.1)</code> 玻璃面、<code>backdrop-filter: blur(5px)</code><code>20px</code> 圆角、<code>10px 10px 10px rgba(0,0,0,.3)</code> 阴影和绿黄状态色;新增 <code>skg-board-shell</code><code>skg-board-rail</code><code>skg-glass-card</code><code>skg-glass-card--flat</code><code>skg-status-orb</code> 等样式。侧栏改为跟随视口拉满工作台可用高度的悬停胶囊,桌面最小 600px展开时在同一侧栏内承载素材输入抽屉。明暗主题已分开维护 shell、panel、glass、stat、action 和音频波形 token暗色压低灰雾和面板底色明亮模式改为暖白工作台避免指标卡、按钮和波形继续残留黑底/白线;顶部指标卡增加紫、黄绿、琥珀、青绿、绿色光斑变量,接近原版多色玻璃卡效果。主/次按钮、指标卡和空状态继续走统一类,避免各板块散写不同玻璃效果。</td></tr>
<tr><td><code>web/app/page.tsx</code></td><td>旧 React 单对话框生成台源码仍保留,便于以后回滚或抽能力;当前生产根域名已经由 <code>web/canvas-app/</code> 画布产物覆盖,不再把这个 React 首页作为默认首屏。该页面里的模式也已收敛为文生图、文生视频、图生视频;图生视频只显示“上传图片”,不把“首帧/首尾帧”作为用户入口。旧 TK 复刻工作台组件仍保留在 <code>web/components/ad-recreation-board.tsx</code>,但不再作为默认首页渲染。</td></tr> <tr><td><code>web/app/page.tsx</code></td><td>旧 React 单对话框生成台源码仍保留,便于以后回滚或抽能力;当前生产根域名已经由 <code>web/canvas-app/</code> 画布产物覆盖,不再把这个 React 首页作为默认首屏。该页面里的模式也已收敛为文生图、文生视频、图生视频;图生视频只显示“上传图片”,不把“首帧/首尾帧”作为用户入口。旧 TK 复刻工作台组件仍保留在 <code>web/components/ad-recreation-board.tsx</code>,但不再作为默认首页渲染。</td></tr>
<tr><td><code>web/canvas-app/</code></td><td>SKG 内部画布应用:从 <code>chatfire-AI/huobao-canvas</code> 交互逻辑改造而来,保留 Vue Flow 节点画布、项目列表、节点连接和批量下载等核心画布能力移除可见原品牌、GitHub 链接、本地 API Key 设置和第三方 base URL 配置,终端可见品牌收敛为 SKG logo。生产路径固定为根域名 <code>/</code>,内部路由用 <code>/p/:id?</code>;来源说明保存在 <code>THIRD_PARTY_NOTICES.md</code>,不展示给终端用户。</td></tr> <tr><td><code>web/canvas-app/</code></td><td>SKG 内部画布应用:从 <code>chatfire-AI/huobao-canvas</code> 交互逻辑改造而来。当前策略是“保留成熟画布能力,替换品牌/路由/API”Vue Flow 节点画布、项目列表、推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载都保留;可见品牌收敛为 SKG logo,不展示上游注册链接或外部品牌。生产路径固定为根域名 <code>/</code>,内部路由用 <code>/p/:id?</code>;来源说明保存在 <code>THIRD_PARTY_NOTICES.md</code>,不展示给终端用户。</td></tr>
<tr><td><code>web/canvas-app/src/views/Canvas.vue</code></td><td>画布主交互:底部悬浮 prompt composer 吸附在画布下方,提供文生图、文生视频、图生视频三种模式;图生视频只显示“上传图片”,底部不再常驻推荐提示词 chips避免遮挡画布操作。提交后自动创建文本节点、参考图节点、图片配置节点或视频配置节点并用 <code>autoExecute</code> 触发生成;图片连到视频节点时默认作为视频参考图</td></tr> <tr><td><code>web/canvas-app/src/views/Canvas.vue</code></td><td>画布主交互:恢复上游底部 prompt composer<code>AI 润色</code><code>自动执行</code>、推荐词、节点菜单、工作流面板、API/模型设置入口和批量下载入口。自动执行会调用 <code>useWorkflowOrchestrator</code> 分析提示词,创建文生图、图转视频、故事板、多角度分镜或绘本节点组;手动模式只创建文本节点,用户自行连接节点</td></tr>
<tr><td><code>web/canvas-app/src/hooks/useApi.js</code></td><td>画布到本项目后端的适配层:不再读取浏览器 API Key而是使用当前登录会话 Cookie 调用 <code>/api</code>。文生图 / 图生图先创建轻量 creative job再调用 <code>/frames/0/generate</code>;文生视频 / 图生视频调用 <code>/storyboard/video</code> 并轮询 <code>/jobs/{id}</code>,完成后把图片或 mp4 URL 写回画布节点。</td></tr> <tr><td><code>web/canvas-app/src/hooks/useApi.js</code></td><td>画布到本项目后端的适配层:不再读取浏览器 API Key而是使用当前登录会话 Cookie 调用 <code>/api</code>。文生图 / 图生图先创建轻量 creative job再调用 <code>/frames/0/generate</code>;文生视频 / 图生视频调用 <code>/storyboard/video</code> 并轮询 <code>/jobs/{id}</code>,完成后把图片或 mp4 URL 写回画布节点。</td></tr>
<tr><td><code>web/scripts/sync-canvas-root.mjs</code></td><td>构建桥接脚本:在 <code>next build</code> 静态导出完成后,把 Vite 画布产物 <code>web/canvas-app/dist</code> 覆盖到 <code>web/out</code> 根目录,使 <code>https://marketing.skg.com</code> 登录后直接进入画布;旧 <code>web/scripts/sync-canvas-dist.mjs</code> 保留但不再由生产构建调用。</td></tr> <tr><td><code>web/scripts/sync-canvas-root.mjs</code></td><td>构建桥接脚本:在 <code>next build</code> 静态导出完成后,把 Vite 画布产物 <code>web/canvas-app/dist</code> 覆盖到 <code>web/out</code> 根目录,使 <code>https://marketing.skg.com</code> 登录后直接进入画布;旧 <code>web/scripts/sync-canvas-dist.mjs</code> 保留但不再由生产构建调用。</td></tr>
<tr><td><code>web/app/detail/page.tsx</code></td><td>任务详情页:静态导出路由 <code>/detail/?job=&lt;id&gt;</code>,通过 query 读取 job id调用 <code>getJob</code> 恢复同一任务。页面展示参考图、全部生成图、视频候选、营销图文方案和历史提示词,可继续调用 <code>generateImage</code><code>generateStoryboardVideo</code><code>generateCreativeCopy</code>,并支持删除图片/视频。该页继续依赖后端 owner 过滤,用户不能通过切换 URL 读取别人的任务。</td></tr> <tr><td><code>web/app/detail/page.tsx</code></td><td>任务详情页:静态导出路由 <code>/detail/?job=&lt;id&gt;</code>,通过 query 读取 job id调用 <code>getJob</code> 恢复同一任务。页面展示参考图、全部生成图、视频候选、营销图文方案和历史提示词,可继续调用 <code>generateImage</code><code>generateStoryboardVideo</code><code>generateCreativeCopy</code>,并支持删除图片/视频。该页继续依赖后端 owner 过滤,用户不能通过切换 URL 读取别人的任务。</td></tr>
@@ -1114,8 +1115,8 @@ ProductRefStateItem {
</tr> </tr>
<tr> <tr>
<td><span class="tag blue">内容生产画布</span></td> <td><span class="tag blue">内容生产画布</span></td>
<td>承载个人自由排列的创作空间:用户在画布上用对话框生成文本、图片和视频节点,结果按节点位置沉淀,不和默认首页的单条结果卡互相挤压。画布项目先保存在浏览器本地,生成资产进入后端个人 job。</td> <td>承载个人自由排列的创作空间:用户在画布上通过提示词、推荐词、AI 润色、自动执行、工作流模板或手动节点连接生成文本、图片和视频节点,结果按节点位置沉淀。画布项目先保存在浏览器本地,生成资产进入后端个人 job。</td>
<td>当前不做团队共享画布、管理员总览、多人协同编辑或跨浏览器同步;也不让员工在浏览器里配置上游 API Key</td> <td>当前不做团队共享画布、管理员总览、多人协同编辑或跨浏览器同步;API 设置不能接回上游外部注册链接,生成调用必须继续走本项目后端 <code>/api</code> 和登录会话</td>
<td><code>web/canvas-app/</code><code>deploy/nginx.conf</code><code>web/scripts/sync-canvas-root.mjs</code></td> <td><code>web/canvas-app/</code><code>deploy/nginx.conf</code><code>web/scripts/sync-canvas-root.mjs</code></td>
</tr> </tr>
<tr> <tr>
@@ -1205,6 +1206,19 @@ ProductRefStateItem {
<h2>变更记录</h2> <h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p> <p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog"> <div class="changelog">
<article class="change">
<header>
<h3>2026-05-25 · 恢复上游画布能力API 保持 SKG 接入</h3>
<span class="tag rose">UI</span>
<span class="tag green">Product</span>
<span class="tag blue">Docs</span>
</header>
<div class="body">
<p><strong>问题:</strong>三模式精简版把成熟画布削弱过多,用户明确要求 <code>api 没关系</code>,其他上游画布能力直接恢复,不再把早先的 SKG 简化想法强行融入当前成熟版面。</p>
<p><strong>改动:</strong><code>chatfire-AI/huobao-canvas</code> 恢复 <code>ApiSettings.vue</code>、模型/渠道/工作流配置、<code>useWorkflowOrchestrator</code>、首页、画布和图片/视频/文本/LLM 节点。保留 SKG logo-only 品牌、根路径 <code>/</code>、内部路由 <code>/p/:id?</code><code>useApi.js</code> 的 SKG 后端适配API 设置弹窗去掉上游注册链接和外部品牌文案。</p>
<p><strong>影响:</strong>画布重新具备推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、多角度分镜、故事板、绘本和批量下载等成熟能力;生成请求仍使用当前登录会话调用本项目 <code>/api</code>,员工不需要个人 API Key。</p>
</div>
</article>
<article class="change"> <article class="change">
<header> <header>
<h3>2026-05-25 · 根域名直接进入个人生成画布</h3> <h3>2026-05-25 · 根域名直接进入个人生成画布</h3>

View File

@@ -0,0 +1,396 @@
<template>
<!-- API Settings Modal | API 设置弹窗 -->
<n-modal v-model:show="showModal" preset="card" title="API 设置" style="width: 560px;">
<n-tabs type="line" animated>
<!-- API 配置标签 -->
<n-tab-pane name="api" tab="API 配置">
<n-form ref="formRef" :model="formData" label-placement="left" label-width="80">
<n-form-item label="渠道" path="provider">
<n-select
v-model:value="formData.provider"
:options="providerOptions"
placeholder="选择 API 渠道"
/>
</n-form-item>
<n-form-item label="Base URL" path="baseUrl">
<n-input
v-model:value="formData.baseUrl"
placeholder="/api"
/>
</n-form-item>
<n-form-item label="API Key" path="apiKey">
<n-input
v-model:value="formData.apiKey"
type="password"
show-password-on="click"
placeholder="内部接口无需填写"
/>
</n-form-item>
<n-divider title-placement="left" class="!my-3">
<span class="text-xs text-[var(--text-secondary)]">端点路径</span>
</n-divider>
<div class="endpoint-list">
<div class="endpoint-item">
<span class="endpoint-label">问答</span>
<n-tag size="small" type="info" class="endpoint-tag">{{ currentEndpoints.chat }}</n-tag>
</div>
<div class="endpoint-item">
<span class="endpoint-label">生图</span>
<n-tag size="small" type="success" class="endpoint-tag">{{ currentEndpoints.image }}</n-tag>
</div>
<div class="endpoint-item">
<span class="endpoint-label">视频生成</span>
<n-tag size="small" type="warning" class="endpoint-tag">{{ currentEndpoints.video }}</n-tag>
</div>
<div class="endpoint-item">
<span class="endpoint-label">视频查询</span>
<n-tag size="small" type="warning" class="endpoint-tag">{{ currentEndpoints.videoQuery }}</n-tag>
</div>
</div>
<n-alert v-if="!isConfigured" type="warning" title="未配置" class="mb-4">
<div class="flex flex-col gap-2">
<p>当前使用 SKG 内部登录会话调用生成接口</p>
</div>
</n-alert>
<n-alert v-else type="success" title="已配置" class="mb-4">
API 已就绪可以使用 AI 功能
</n-alert>
</n-form>
</n-tab-pane>
<!-- 模型配置标签 -->
<n-tab-pane name="models" tab="模型配置">
<div class="model-config-section">
<!-- 问答模型 -->
<div class="model-group">
<div class="model-group-header">
<span class="model-group-title">问答模型</span>
<n-tag size="tiny" type="info">{{ allChatModels.length }} </n-tag>
</div>
<div class="model-input-row">
<n-input
v-model:value="newChatModel"
placeholder="输入模型名称,如 gpt-4o"
size="small"
@keyup.enter="handleAddChatModel"
/>
<n-button size="small" type="primary" @click="handleAddChatModel" :disabled="!newChatModel">
添加
</n-button>
</div>
<div class="model-tags">
<n-tag
v-for="model in allChatModels"
:key="model.key"
size="small"
:closable="model.isCustom"
:type="model.isCustom ? 'info' : 'default'"
@close="handleRemoveChatModel(model.key)"
>
{{ model.label }}
</n-tag>
</div>
</div>
<!-- 图片模型 -->
<div class="model-group">
<div class="model-group-header">
<span class="model-group-title">图片模型</span>
<n-tag size="tiny" type="success">{{ allImageModels.length }} </n-tag>
</div>
<div class="model-input-row">
<n-input
v-model:value="newImageModel"
placeholder="输入模型名称,如 dall-e-3"
size="small"
@keyup.enter="handleAddImageModel"
/>
<n-button size="small" type="primary" @click="handleAddImageModel" :disabled="!newImageModel">
添加
</n-button>
</div>
<div class="model-tags">
<n-tag
v-for="model in allImageModels"
:key="model.key"
size="small"
:closable="model.isCustom"
:type="model.isCustom ? 'success' : 'default'"
@close="handleRemoveImageModel(model.key)"
>
{{ model.label }}
</n-tag>
</div>
</div>
<!-- 视频模型 -->
<div class="model-group">
<div class="model-group-header">
<span class="model-group-title">视频模型</span>
<n-tag size="tiny" type="warning">{{ allVideoModels.length }} </n-tag>
</div>
<div class="model-input-row">
<n-input
v-model:value="newVideoModel"
placeholder="输入模型名称,如 sora-2"
size="small"
@keyup.enter="handleAddVideoModel"
/>
<n-button size="small" type="primary" @click="handleAddVideoModel" :disabled="!newVideoModel">
添加
</n-button>
</div>
<div class="model-tags">
<n-tag
v-for="model in allVideoModels"
:key="model.key"
size="small"
:closable="model.isCustom"
:type="model.isCustom ? 'warning' : 'default'"
@close="handleRemoveVideoModel(model.key)"
>
{{ model.label }}
</n-tag>
</div>
</div>
</div>
</n-tab-pane>
</n-tabs>
<template #footer>
<div class="flex justify-between items-center">
<span class="text-xs text-[var(--text-secondary)]">生成调用走当前登录会话无需个人 API Key</span>
<div class="flex gap-2">
<n-button @click="handleClear" tertiary>清除配置</n-button>
<n-button @click="showModal = false">取消</n-button>
<n-button type="primary" @click="handleSave">保存</n-button>
</div>
</div>
</template>
</n-modal>
</template>
<script setup>
/**
* API Settings Component | API 设置组件
* Modal for configuring API key, base URL, and custom models
*/
import { ref, reactive, watch, computed } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, NAlert, NDivider, NTag, NTabs, NTabPane, NSelect } from 'naive-ui'
import { useModelStore } from '../stores/pinia'
import { getProviderConfig } from '../config/providers'
// Props | 属性
const props = defineProps({
show: {
type: Boolean,
default: false
}
})
// Emits | 事件
const emit = defineEmits(['update:show', 'saved'])
// API Config 状态
const isConfigured = computed(() => !!modelStore.currentApiKey)
// Model Store (Pinia) | 模型配置 Store
const modelStore = useModelStore()
// Provider options for select | 渠道下拉选项
const providerOptions = modelStore.providerList.map(p => ({
label: p.label,
value: p.key
}))
// 当前渠道的端点路径
const currentEndpoints = computed(() => {
const config = getProviderConfig(formData.provider)
return config.endpoints || {
chat: '/chat/completions',
image: '/v1/images/generations',
video: '/v1/videos',
videoQuery: '/v1/videos/{taskId}'
}
})
// 全局模型列表(不区分渠道)
const allChatModels = computed(() => modelStore.allChatModels)
const allImageModels = computed(() => modelStore.allImageModels)
const allVideoModels = computed(() => modelStore.allVideoModels)
// Modal visibility | 弹窗可见性
const showModal = ref(props.show)
// Form data | 表单数据
const formData = reactive({
provider: modelStore.currentProvider,
apiKey: '',
baseUrl: ''
})
// New model inputs | 新模型输入
const newChatModel = ref('')
const newImageModel = ref('')
const newVideoModel = ref('')
// 初始化或切换渠道时,更新 API 配置
const updateFormApiConfig = () => {
const provider = formData.provider
const config = getProviderConfig(provider)
formData.apiKey = modelStore.apiKeysByProvider[provider] || ''
formData.baseUrl = modelStore.baseUrlsByProvider[provider] || config.defaultBaseUrl || ''
}
// Watch prop changes | 监听属性变化
watch(() => props.show, (val) => {
showModal.value = val
if (val) {
formData.provider = modelStore.currentProvider
updateFormApiConfig()
}
})
// 监听渠道变化,更新表单中的 API 配置
watch(() => formData.provider, () => {
updateFormApiConfig()
})
// Watch modal changes | 监听弹窗变化
watch(showModal, (val) => {
emit('update:show', val)
})
// Handle add models | 处理添加模型
const handleAddChatModel = () => {
if (newChatModel.value.trim()) {
modelStore.addCustomChatModel(newChatModel.value.trim())
newChatModel.value = ''
}
}
const handleAddImageModel = () => {
if (newImageModel.value.trim()) {
modelStore.addCustomImageModel(newImageModel.value.trim())
newImageModel.value = ''
}
}
const handleAddVideoModel = () => {
if (newVideoModel.value.trim()) {
modelStore.addCustomVideoModel(newVideoModel.value.trim())
newVideoModel.value = ''
}
}
// Handle remove models | 处理删除模型
const handleRemoveChatModel = (modelKey) => {
modelStore.removeCustomChatModel(modelKey)
}
const handleRemoveImageModel = (modelKey) => {
modelStore.removeCustomImageModel(modelKey)
}
const handleRemoveVideoModel = (modelKey) => {
modelStore.removeCustomVideoModel(modelKey)
}
// Handle save | 处理保存
const handleSave = () => {
if (formData.provider) {
modelStore.setProvider(formData.provider)
}
if (formData.apiKey) {
modelStore.setApiKeyByProvider(formData.provider, formData.apiKey)
}
if (formData.baseUrl) {
modelStore.setBaseUrlByProvider(formData.provider, formData.baseUrl)
}
showModal.value = false
emit('saved')
}
// Handle clear | 处理清除
const handleClear = () => {
modelStore.clearApiConfigByProvider(formData.provider)
modelStore.clearCustomModels()
formData.apiKey = ''
formData.baseUrl = ''
}
</script>
<style scoped>
.endpoint-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
padding: 12px;
background: var(--bg-secondary, #f5f5f5);
border-radius: 6px;
}
.endpoint-item {
display: flex;
align-items: center;
justify-content: space-between;
}
.endpoint-label {
font-size: 13px;
color: var(--text-secondary, #666);
min-width: 70px;
}
.endpoint-tag {
font-family: monospace;
font-size: 12px;
}
.model-config-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.model-group {
padding: 12px;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
}
.model-group-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.model-group-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary, #333);
}
.model-input-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.model-input-row .n-input {
flex: 1;
}
.model-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
</style>

View File

@@ -55,8 +55,8 @@ const props = defineProps({
// Image role options | 图片角色选项 // Image role options | 图片角色选项
const imageRoleOptions = [ const imageRoleOptions = [
{ label: '图片', key: 'first_frame_image' }, { label: '首帧', key: 'first_frame_image' },
{ label: '结束图', key: 'last_frame_image' }, { label: '尾帧', key: 'last_frame_image' },
{ label: '参考图', key: 'input_reference' } { label: '参考图', key: 'input_reference' }
] ]
@@ -66,7 +66,7 @@ const currentRole = computed(() => props.data?.imageRole || 'first_frame_image')
// Current role label | 当前角色标签 // Current role label | 当前角色标签
const currentRoleLabel = computed(() => { const currentRoleLabel = computed(() => {
const option = imageRoleOptions.find(o => o.key === currentRole.value) const option = imageRoleOptions.find(o => o.key === currentRole.value)
return option?.label || '图片' return option?.label || '首帧'
}) })
// Calculate bezier path | 计算贝塞尔路径 // Calculate bezier path | 计算贝塞尔路径
@@ -95,7 +95,7 @@ const edgeStyle = computed(() => ({
// Handle role selection | 处理角色选择 // Handle role selection | 处理角色选择
const handleRoleSelect = (role) => { 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') { if (role === 'first_frame_image' || role === 'last_frame_image') {
// Find other edges connected to the same target with the same role | 查找连接到同一目标且具有相同角色的其他边 // Find other edges connected to the same target with the same role | 查找连接到同一目标且具有相同角色的其他边
const sameTargetEdges = edges.value.filter(edge => const sameTargetEdges = edges.value.filter(edge =>

View File

@@ -586,7 +586,7 @@ const handleGenerate = async (mode = 'auto') => {
} }
if (!isConfigured.value) { if (!isConfigured.value) {
window.$message?.warning('登录状态异常,请重新进入工作台') window.$message?.warning('生成接口未就绪,请稍后重试')
return return
} }

View File

@@ -932,7 +932,7 @@ const handleVideoGen = () => {
sourceHandle: 'right', sourceHandle: 'right',
targetHandle: 'left', targetHandle: 'left',
type: 'imageRole', 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 | 连接文本节点到配置节点 // Connect text node to config node | 连接文本节点到配置节点

View File

@@ -764,7 +764,7 @@ const getInputFromConnections = () => {
// Handle generate | 处理生成 // Handle generate | 处理生成
const handleGenerate = async () => { const handleGenerate = async () => {
if (!isApiConfigured.value) { if (!isApiConfigured.value) {
window.$message?.warning('登录状态异常,请重新进入工作台') window.$message?.warning('生成接口未就绪,请稍后重试')
return return
} }

View File

@@ -623,7 +623,7 @@ const handlePolish = async () => {
// Check API configuration | 检查 API 配置 // Check API configuration | 检查 API 配置
if (!isApiConfigured.value) { if (!isApiConfigured.value) {
window.$message?.warning('登录状态异常,请重新进入工作台') window.$message?.warning('生成接口未就绪,请稍后重试')
return return
} }

View File

@@ -82,8 +82,16 @@
提示词 {{ connectedPrompt ? '✓' : '○' }} 提示词 {{ connectedPrompt ? '✓' : '○' }}
</span> </span>
<span class="px-2 py-0.5 rounded-full" <span class="px-2 py-0.5 rounded-full"
:class="connectedImages.length > 0 ? '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'"> :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'">
图片 {{ connectedImages.length > 0 ? `${connectedImages.length}` : '○' }} 首帧 {{ imagesByRole.firstFrame ? '✓' : '○' }}
</span>
<span class="px-2 py-0.5 rounded-full"
:class="imagesByRole.lastFrame ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
尾帧 {{ imagesByRole.lastFrame ? '✓' : '○' }}
</span>
<span class="px-2 py-0.5 rounded-full"
:class="imagesByRole.referenceImages.length > 0 ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
参考图 {{ imagesByRole.referenceImages.length > 0 ? `${imagesByRole.referenceImages.length}` : '○' }}
</span> </span>
</div> </div>
@@ -187,7 +195,7 @@ const connectedImages = computed(() => {
edgeId: edge.id, edgeId: edge.id,
url: sourceNode.data.url, url: sourceNode.data.url,
base64: sourceNode.data.base64, 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) { if (!isConfigured.value) {
window.$message?.warning('登录状态异常,请重新进入工作台') window.$message?.warning('生成接口未就绪,请稍后重试')
isGenerating.value = false isGenerating.value = false
return return
} }
@@ -377,12 +385,12 @@ const handleGenerate = async () => {
params.prompt = prompt params.prompt = prompt
} }
// Add primary reference image | 添加主参考图 // Add first frame image | 添加首帧图片
if (first_frame_image) { if (first_frame_image) {
params.first_frame_image = first_frame_image params.first_frame_image = first_frame_image
} }
// Add optional ending reference image | 添加可选结束参考图 // Add last frame image | 添加尾帧图片
if (last_frame_image) { if (last_frame_image) {
params.last_frame_image = last_frame_image params.last_frame_image = last_frame_image
} }

View File

@@ -1,118 +1,269 @@
/** /**
* SKG model and size configuration. * Models Configuration | 模型配置
* These values mirror the backend /health capabilities and keep the canvas UI simple. * Centralized model configuration | 集中模型配置
*/ */
export const SKG_IMAGE_SIZE_OPTIONS = [ // Seedream image size options | 豆包图片尺寸选项
{ label: '自动', key: 'auto' }, export const SEEDREAM_SIZE_OPTIONS = [
{ label: '竖图 2:3', key: '1024x1536' }, { label: '21:9', key: '3024x1296' },
{ label: '方图 1:1', key: '1024x1024' }, { label: '16:9', key: '2560x1440' },
{ label: '横图 3:2', key: '1536x1024' } { 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 = [ // Seedream 4K image size options | 豆包4K图片尺寸选项
{ label: '标准', key: 'standard' } 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 = [ // Seedream quality options | 豆包画质选项
{ label: '竖屏 9:16', key: '720x1280' }, export const SEEDREAM_QUALITY_OPTIONS = [
{ label: '横屏 16:9', key: '1280x720' }, { label: '标准画质', key: 'standard' },
{ label: '方形 1:1', key: '1024x1024' }, { label: '4K 高清', key: '4k' }
{ label: '竖屏 3:4', key: '960x1280' }
] ]
export const VIDEO_RATIO_LIST = SKG_VIDEO_SIZE_OPTIONS export const BANANA_SIZE_OPTIONS = [
{ label: '16:9', key: '16x9' },
export const SEEDANCE_RESOLUTION_OPTIONS = [ { label: '4:3', key: '4x3' },
{ label: '720p', key: '720p' }, { label: '3:2', key: '3x2' },
{ label: '1080p', key: '1080p' } { 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 = [ export const IMAGE_MODELS = [
{ {
label: '自动', label: 'Nano Banana 2',
key: 'auto', key: 'nano-banana-2',
provider: ['skg'], provider: ['chatfire'], // 火宝渠道
sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key), sizes: BANANA_SIZE_OPTIONS.map(s => s.key),
qualities: SKG_IMAGE_QUALITY_OPTIONS, // qualities: SEEDREAM_QUALITY_OPTIONS,
defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' } // getSizesByQuality: (quality) => quality === '4k' ? SEEDREAM_4K_SIZE_OPTIONS : SEEDREAM_SIZE_OPTIONS,
}, defaultParams: {
{ size: '1x1',
label: 'GPT Image 2', quality: 'standard',
key: 'gpt-image-2', style: 'vivid'
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 Pro',
}, key: 'nano-banana-pro',
{ provider: ['chatfire'], // 火宝渠道
label: 'Gemini 图片备用', sizes: BANANA_SIZE_OPTIONS.map(s => s.key),
key: 'gemini-3-pro-image-preview', // qualities: SEEDREAM_QUALITY_OPTIONS,
provider: ['skg'], // getSizesByQuality: (quality) => quality === '4k' ? SEEDREAM_4K_SIZE_OPTIONS : SEEDREAM_SIZE_OPTIONS,
sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key), defaultParams: {
qualities: SKG_IMAGE_QUALITY_OPTIONS, size: '1x1',
defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' } 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 = [ export const VIDEO_MODELS = [
{ // Seedance 模型 - 1.5 Pro
label: 'Seedance', {
key: 'seedance', label: 'Seedance 1.5 Pro (图文视频)',
provider: ['skg'], key: 'doubao-seedance-1-5-pro-251215',
type: 't2v+i2v', provider: ['chatfire'],
ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key), type: 't2v+i2v',
durs: [5, 8, 10, 12, 15].map(s => ({ label: `${s}`, key: s })), ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
resolutions: ['720p', '1080p'], durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
defaultResolution: '1080p', resolutions: ['480p', '720p', '1080p'],
defaultParams: { ratio: '720x1280', duration: 10, resolution: '1080p' } defaultResolution: '1080p',
}, defaultParams: { ratio: '16:9', duration: 10, resolution: '1080p' }
{ },
label: 'Kling', // Seedance 模型 - 文生视频
key: 'kling', {
provider: ['skg'], label: 'Seedance 1.0 Lite (文生视频)',
type: 't2v+i2v', key: 'doubao-seedance-1-0-lite-t2v-250428',
ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key), provider: ['chatfire'],
durs: [4, 8, 12].map(s => ({ label: `${s}`, key: s })), type: 't2v', // 文生视频
resolutions: ['720p'], ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
defaultResolution: '720p', durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
defaultParams: { ratio: '720x1280', duration: 8, resolution: '720p' } resolutions: ['480p', '720p', '1080p'],
}, defaultResolution: '720p',
{ defaultParams: { ratio: '16:9', duration: 5, resolution: '720p' }
label: 'Veo 3', },
key: 'veo3', // Seedance 模型 - 图生视频
provider: ['skg'], {
type: 't2v+i2v', label: 'Seedance 1.0 Lite (图生视频)',
ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key), key: 'doubao-seedance-1-0-lite-i2v-250428',
durs: [4, 8, 12].map(s => ({ label: `${s}`, key: s })), provider: ['chatfire'],
resolutions: ['720p'], type: 'i2v', // 图生视频
defaultResolution: '720p', ratios: ['16:9'],
defaultParams: { ratio: '720x1280', duration: 8, resolution: '720p' } 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 = [ 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 // Image size options | 图片尺寸选项
export const IMAGE_QUALITY_OPTIONS = SKG_IMAGE_QUALITY_OPTIONS export const IMAGE_SIZE_OPTIONS = [
export const IMAGE_STYLE_OPTIONS = [{ label: '商业营销', key: 'commercial' }] { label: '2048x2048', key: '2048x2048' },
export const VIDEO_RATIO_OPTIONS = SKG_VIDEO_SIZE_OPTIONS { label: '1792x1024 (横版)', key: '1792x1024' },
export const VIDEO_DURATION_OPTIONS = [5, 8, 10, 12, 15].map(s => ({ label: `${s}`, key: s })) { label: '1024x1792 (竖版)', key: '1024x1792' }
]
export const SEEDREAM_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS // Image quality options | 图片质量选项
export const SEEDREAM_4K_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS export const IMAGE_QUALITY_OPTIONS = [
export const SEEDREAM_QUALITY_OPTIONS = SKG_IMAGE_QUALITY_OPTIONS { label: '标准', key: 'standard' },
{ label: '高清', key: 'hd' }
]
export const DEFAULT_IMAGE_MODEL = 'auto' // Image style options | 图片风格选项
export const DEFAULT_VIDEO_MODEL = 'seedance' export const IMAGE_STYLE_OPTIONS = [
export const DEFAULT_CHAT_MODEL = 'skg-copy' { label: '生动', key: 'vivid' },
export const DEFAULT_IMAGE_SIZE = '1024x1536' { label: '自然', key: 'natural' }
export const DEFAULT_VIDEO_RATIO = '720x1280' ]
export const DEFAULT_VIDEO_DURATION = 10
// 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) => { export const getModelByName = (key) => {
const allModels = [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS] const allModels = [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS]
return allModels.find(m => m.key === key) return allModels.find(m => m.key === key)
} }

View File

@@ -1,40 +1,272 @@
/** /**
* SKG internal provider config. * API Provider Adapters | API 渠道适配器
* The browser never receives upstream model keys; all generation goes through /api. * 适配不同 API 提供商的请求参数和响应格式
*/ */
// 渠道适配配置
export const PROVIDERS = { export const PROVIDERS = {
skg: { chatfire: {
label: 'SKG 内部模型', label: 'SKG 内部',
defaultBaseUrl: '/api', defaultBaseUrl: '/api',
// 端点路径
endpoints: { endpoints: {
chat: '/creative/copy', chat: '/v1/chat/completions',
image: '/jobs/{jobId}/frames/{idx}/generate', image: '/v1/images/generations',
video: '/jobs/{jobId}/frames/{idx}/storyboard/video', video: '/v1/video/generations',
videoQuery: '/jobs/{jobId}' videoQuery: '/v1/video/task/{taskId}'
}, },
// 火宝渠道请求适配
requestAdapter: { requestAdapter: {
chat: (params) => params, chat: (params) => {
image: (params) => params, const adapted = {
video: (params) => params 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: { responseAdapter: {
chat: (response) => response, chat: (response) => {
image: (response) => response, if (response.choices && response.choices.length > 0) {
video: (response) => response 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') .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]
}

File diff suppressed because it is too large Load Diff

View File

@@ -84,7 +84,7 @@ const customChatModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, []))
const customImageModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, [])) const customImageModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, []))
const customVideoModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_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 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 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', {})) const customVideoModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER || 'custom-video-models-by-provider', {}))
@@ -411,7 +411,7 @@ export const useModelConfig = () => {
getImageModel, getImageModel,
getVideoModel, getVideoModel,
// Get models by provider // Get models by provider (for ApiSettings)
getModelsByProvider, getModelsByProvider,
// Custom models by provider // Custom models by provider

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,14 @@ const setStored = (key, value) => {
} }
} }
const removeStored = (key) => {
try {
localStorage.removeItem(key)
} catch {
// ignore
}
}
/** /**
* Get stored JSON value from localStorage * Get stored JSON value from localStorage
*/ */
@@ -94,7 +102,8 @@ export const useModelStore = defineStore('model', () => {
// ============ Provider 状态 | Provider State ============ // ============ 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()) const providerList = computed(() => getProviderList())

View File

@@ -4,7 +4,7 @@
<!-- Header | 顶部导航 --> <!-- Header | 顶部导航 -->
<AppHeader class="bg-[var(--bg-secondary)]"> <AppHeader class="bg-[var(--bg-secondary)]">
<template #left> <template #left>
<button <button
@click="goBack" @click="goBack"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors" class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
> >
@@ -18,7 +18,7 @@
</n-dropdown> </n-dropdown>
</template> </template>
<template #right> <template #right>
<button <button
@click="showDownloadModal = true" @click="showDownloadModal = true"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors" class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
:class="{ 'text-[var(--accent-color)]': hasDownloadableAssets }" :class="{ 'text-[var(--accent-color)]': hasDownloadableAssets }"
@@ -26,6 +26,14 @@
> >
<n-icon :size="20"><DownloadOutline /></n-icon> <n-icon :size="20"><DownloadOutline /></n-icon>
</button> </button>
<button
@click="showApiSettings = true"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
:class="{ 'text-[var(--accent-color)]': isApiConfigured }"
title="API 设置"
>
<n-icon :size="20"><SettingsOutline /></n-icon>
</button>
</template> </template>
</AppHeader> </AppHeader>
@@ -52,7 +60,7 @@
class="canvas-flow" class="canvas-flow"
> >
<Background v-if="showGrid" :gap="20" :size="1" /> <Background v-if="showGrid" :gap="20" :size="1" />
<MiniMap <MiniMap
v-if="!isMobile" v-if="!isMobile"
position="bottom-right" position="bottom-right"
:pannable="true" :pannable="true"
@@ -62,14 +70,14 @@
<!-- Left toolbar | 左侧工具栏 --> <!-- Left toolbar | 左侧工具栏 -->
<aside class="absolute left-4 top-1/2 -translate-y-1/2 flex flex-col gap-1 p-2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-lg z-10"> <aside class="absolute left-4 top-1/2 -translate-y-1/2 flex flex-col gap-1 p-2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-lg z-10">
<button <button
@click="showNodeMenu = !showNodeMenu" @click="showNodeMenu = !showNodeMenu"
class="w-10 h-10 flex items-center justify-center rounded-xl bg-[var(--accent-color)] text-white hover:bg-[var(--accent-hover)] transition-colors" class="w-10 h-10 flex items-center justify-center rounded-xl bg-[var(--accent-color)] text-white hover:bg-[var(--accent-hover)] transition-colors"
title="添加节点" title="添加节点"
> >
<n-icon :size="20"><AddOutline /></n-icon> <n-icon :size="20"><AddOutline /></n-icon>
</button> </button>
<button <button
@click="showWorkflowPanel = true" @click="showWorkflowPanel = true"
class="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-[var(--bg-tertiary)] transition-colors" class="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-[var(--bg-tertiary)] transition-colors"
title="工作流模板" title="工作流模板"
@@ -77,8 +85,8 @@
<n-icon :size="20"><AppsOutline /></n-icon> <n-icon :size="20"><AppsOutline /></n-icon>
</button> </button>
<div class="w-full h-px bg-[var(--border-color)] my-1"></div> <div class="w-full h-px bg-[var(--border-color)] my-1"></div>
<button <button
v-for="tool in tools" v-for="tool in tools"
:key="tool.id" :key="tool.id"
@click="tool.action" @click="tool.action"
:disabled="tool.disabled && tool.disabled()" :disabled="tool.disabled && tool.disabled()"
@@ -90,12 +98,12 @@
</aside> </aside>
<!-- Node menu popup | 节点菜单弹窗 --> <!-- Node menu popup | 节点菜单弹窗 -->
<div <div
v-if="showNodeMenu" v-if="showNodeMenu"
class="absolute left-20 top-1/2 -translate-y-1/2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-lg p-2 z-20" class="absolute left-20 top-1/2 -translate-y-1/2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-lg p-2 z-20"
> >
<button <button
v-for="nodeType in nodeTypeOptions" v-for="nodeType in nodeTypeOptions"
:key="nodeType.type" :key="nodeType.type"
@click="addNewNode(nodeType.type)" @click="addNewNode(nodeType.type)"
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors text-left" class="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors text-left"
@@ -107,16 +115,16 @@
<!-- Bottom controls | 底部控制 --> <!-- Bottom controls | 底部控制 -->
<div class="absolute bottom-4 left-4 flex items-center gap-2 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)] p-1"> <div class="absolute bottom-4 left-4 flex items-center gap-2 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)] p-1">
<!-- <button <!-- <button
@click="showGrid = !showGrid" @click="showGrid = !showGrid"
:class="showGrid ? 'bg-[var(--accent-color)] text-white' : 'hover:bg-[var(--bg-tertiary)]'" :class="showGrid ? 'bg-[var(--accent-color)] text-white' : 'hover:bg-[var(--bg-tertiary)]'"
class="p-2 rounded transition-colors" class="p-2 rounded transition-colors"
title="切换网格" title="切换网格"
> >
<n-icon :size="16"><GridOutline /></n-icon> <n-icon :size="16"><GridOutline /></n-icon>
</button> --> </button> -->
<button <button
@click="fitView({ padding: 0.2 })" @click="fitView({ padding: 0.2 })"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded transition-colors" class="p-2 hover:bg-[var(--bg-tertiary)] rounded transition-colors"
title="适应视图" title="适应视图"
> >
@@ -136,13 +144,13 @@
<!-- Bottom input panel (floating) | 底部输入面板悬浮 --> <!-- Bottom input panel (floating) | 底部输入面板悬浮 -->
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 w-full max-w-2xl px-4 z-20"> <div class="absolute bottom-4 left-1/2 -translate-x-1/2 w-full max-w-2xl px-4 z-20">
<!-- Processing indicator | 处理中指示器 --> <!-- Processing indicator | 处理中指示器 -->
<div <div
v-if="isProcessing" v-if="isProcessing"
class="mb-3 p-3 bg-[var(--bg-primary)] rounded-xl border border-[var(--accent-color)] animate-pulse" class="mb-3 p-3 bg-[var(--bg-primary)] rounded-xl border border-[var(--accent-color)] animate-pulse"
> >
<div class="flex items-center gap-2 text-sm text-[var(--accent-color)] mb-2"> <div class="flex items-center gap-2 text-sm text-[var(--accent-color)] mb-2">
<n-spin :size="14" /> <n-spin :size="14" />
<span>正在创建生成任务...</span> <span>正在生成提示词...</span>
</div> </div>
<div v-if="currentResponse" class="text-sm text-[var(--text-primary)] whitespace-pre-wrap"> <div v-if="currentResponse" class="text-sm text-[var(--text-primary)] whitespace-pre-wrap">
{{ currentResponse }} {{ currentResponse }}
@@ -150,31 +158,6 @@
</div> </div>
<div class="bg-[var(--bg-primary)] rounded-xl border border-[var(--border-color)] p-3"> <div class="bg-[var(--bg-primary)] rounded-xl border border-[var(--border-color)] p-3">
<div class="mb-2 flex flex-wrap items-center gap-2">
<button
v-for="modeItem in creationModes"
:key="modeItem.id"
@click="setCreationMode(modeItem.id)"
class="px-3 py-1.5 text-xs rounded-lg border transition-colors"
:class="creationMode === modeItem.id ? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)]' : 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'"
>
{{ modeItem.label }}
</button>
<label
v-if="needsFirstFrame"
class="px-3 py-1.5 text-xs rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-secondary)] cursor-pointer hover:text-[var(--text-primary)]"
>
{{ firstFrameFile ? `图片 · ${firstFrameFile.name}` : '上传图片' }}
<input type="file" accept="image/*" class="hidden" @change="event => handleFrameFile('first', event)" />
</label>
<button
v-if="firstFrameFile"
@click="clearFrameFiles"
class="px-2 py-1.5 text-xs rounded-lg text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]"
>
清空图片
</button>
</div>
<textarea <textarea
v-model="chatInput" v-model="chatInput"
:placeholder="inputPlaceholder" :placeholder="inputPlaceholder"
@@ -186,14 +169,23 @@
/> />
<div class="flex items-center justify-between mt-2"> <div class="flex items-center justify-between mt-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span v-if="firstFramePreview" class="h-8 w-8 overflow-hidden rounded-md border border-[var(--border-color)] bg-[var(--bg-secondary)]"> <button
<img :src="firstFramePreview" alt="参考图片" class="h-full w-full object-cover" /> @click="handlePolish"
</span> :disabled="isProcessing || !chatInput.trim()"
class="px-3 py-1.5 text-xs rounded-lg bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="AI 润色提示词"
>
AI 润色
</button>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <label class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<n-switch v-model:value="autoExecute" size="small" />
自动执行
</label>
<button
@click="sendMessage" @click="sendMessage"
:disabled="isProcessing || !canSubmit" :disabled="isProcessing"
class="w-8 h-8 rounded-xl bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] flex items-center justify-center transition-colors disabled:opacity-50 disabled:cursor-not-allowed" class="w-8 h-8 rounded-xl bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] flex items-center justify-center transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
<n-spin v-if="isProcessing" :size="16" /> <n-spin v-if="isProcessing" :size="16" />
@@ -202,10 +194,28 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Quick suggestions | 快捷建议 -->
<div class="flex flex-wrap items-center justify-center gap-2 mt-2">
<span class="text-xs text-[var(--text-secondary)]">推荐</span>
<button
v-for="tag in suggestions"
:key="tag"
@click="chatInput = tag"
class="px-2 py-0.5 text-xs rounded-full bg-[var(--bg-secondary)]/80 border border-[var(--border-color)] hover:border-[var(--accent-color)] transition-colors"
>
{{ tag }}
</button>
<button class="p-1 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
<n-icon :size="14"><RefreshOutline /></n-icon>
</button>
</div>
</div> </div>
</div> </div>
<!-- API Settings Modal | API 设置弹窗 -->
<ApiSettings v-model:show="showApiSettings" />
<!-- Rename Modal | 重命名弹窗 --> <!-- Rename Modal | 重命名弹窗 -->
<n-modal v-model:show="showRenameModal" preset="dialog" title="重命名项目"> <n-modal v-model:show="showRenameModal" preset="dialog" title="重命名项目">
<n-input v-model:value="renameValue" placeholder="请输入项目名称" /> <n-input v-model:value="renameValue" placeholder="请输入项目名称" />
@@ -242,13 +252,15 @@ import { useRouter, useRoute } from 'vue-router'
import { VueFlow, useVueFlow } from '@vue-flow/core' import { VueFlow, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background' import { Background } from '@vue-flow/background'
import { MiniMap } from '@vue-flow/minimap' import { MiniMap } from '@vue-flow/minimap'
import { NIcon, NDropdown, NSpin, NModal, NInput, NButton } from 'naive-ui' import { NIcon, NSwitch, NDropdown, NMessageProvider, NSpin, NModal, NInput, NButton } from 'naive-ui'
import { import {
ChevronBackOutline, ChevronBackOutline,
ChevronDownOutline, ChevronDownOutline,
SettingsOutline,
AddOutline, AddOutline,
ImageOutline, ImageOutline,
SendOutline, SendOutline,
RefreshOutline,
TextOutline, TextOutline,
VideocamOutline, VideocamOutline,
ColorPaletteOutline, ColorPaletteOutline,
@@ -265,12 +277,19 @@ import {
import { nodes, edges, addNode, addNodes, addEdge, addEdges, updateNode, initSampleData, loadProject, saveProject, clearCanvas, canvasViewport, updateViewport, undo, redo, canUndo, canRedo, manualSaveHistory, startBatchOperation, endBatchOperation } from '../stores/canvas' import { nodes, edges, addNode, addNodes, addEdge, addEdges, updateNode, initSampleData, loadProject, saveProject, clearCanvas, canvasViewport, updateViewport, undo, redo, canUndo, canRedo, manualSaveHistory, startBatchOperation, endBatchOperation } from '../stores/canvas'
import { loadAllModels } from '../stores/models' import { loadAllModels } from '../stores/models'
import { useChat, useWorkflowOrchestrator } from '../hooks' import { useChat, useWorkflowOrchestrator } from '../hooks'
import { useModelStore } from '../stores/pinia'
import { projects, initProjectsStore, updateProject, renameProject, currentProject } from '../stores/projects' import { projects, initProjectsStore, updateProject, renameProject, currentProject } from '../stores/projects'
// API Settings component | API 设置组件
import ApiSettings from '../components/ApiSettings.vue'
import DownloadModal from '../components/DownloadModal.vue' import DownloadModal from '../components/DownloadModal.vue'
import WorkflowPanel from '../components/WorkflowPanel.vue' import WorkflowPanel from '../components/WorkflowPanel.vue'
import AppHeader from '../components/AppHeader.vue' import AppHeader from '../components/AppHeader.vue'
// API Config state | API 配置状态
const modelStore = useModelStore()
const isApiConfigured = computed(() => !!modelStore.currentApiKey)
// Initialize models on page load | 页面加载时初始化模型 // Initialize models on page load | 页面加载时初始化模型
onMounted(() => { onMounted(() => {
loadAllModels() loadAllModels()
@@ -294,11 +313,11 @@ const CHAT_TEMPLATES = {
const currentTemplate = ref('imagePrompt') const currentTemplate = ref('imagePrompt')
// Chat hook with image prompt template | 问答 hook // Chat hook with image prompt template | 问答 hook
const { const {
loading: chatLoading, loading: chatLoading,
status: chatStatus, status: chatStatus,
currentResponse, currentResponse,
send: sendChat send: sendChat
} = useChat({ } = useChat({
systemPrompt: CHAT_TEMPLATES.imagePrompt.systemPrompt, systemPrompt: CHAT_TEMPLATES.imagePrompt.systemPrompt,
model: CHAT_TEMPLATES.imagePrompt.model model: CHAT_TEMPLATES.imagePrompt.model
@@ -355,25 +374,12 @@ const edgeTypes = {
// UI state | UI状态 // UI state | UI状态
const showNodeMenu = ref(false) const showNodeMenu = ref(false)
const chatInput = ref('') const chatInput = ref('')
const autoExecute = ref(false)
const isMobile = ref(false) const isMobile = ref(false)
const showGrid = ref(true) const showGrid = ref(true)
const showApiSettings = ref(false)
const isProcessing = ref(false) const isProcessing = ref(false)
const creationModes = [
{ id: 'text-image', label: '文生图' },
{ id: 'text-video', label: '文生视频' },
{ id: 'image-video', label: '图生视频' }
]
const creationMode = ref('text-image')
const firstFrameFile = ref(null)
const firstFramePreview = ref('')
const needsFirstFrame = computed(() => creationMode.value === 'image-video')
const canSubmit = computed(() => {
if (!chatInput.value.trim()) return false
if (needsFirstFrame.value && !firstFrameFile.value) return false
return true
})
// Flow key for forcing re-render on project switch | 项目切换时强制重新渲染的 key // Flow key for forcing re-render on project switch | 项目切换时强制重新渲染的 key
const flowKey = ref(Date.now()) const flowKey = ref(Date.now())
@@ -386,7 +392,7 @@ const renameValue = ref('')
// Check if has downloadable assets | 检查是否有可下载素材 // Check if has downloadable assets | 检查是否有可下载素材
const hasDownloadableAssets = computed(() => { const hasDownloadableAssets = computed(() => {
return nodes.value.some(n => return nodes.value.some(n =>
(n.type === 'image' || n.type === 'video') && n.data?.url (n.type === 'image' || n.type === 'video') && n.data?.url
) )
}) })
@@ -426,61 +432,34 @@ const nodeTypeOptions = [
] ]
// Input placeholder | 输入占位符 // Input placeholder | 输入占位符
const inputPlaceholder = computed(() => { const inputPlaceholder = '你可以试着说"帮我生成一个二次元的卡通角色"'
if (creationMode.value === 'text-image') return '写清楚画面、主体、构图、光线、比例和 SKG 产品露出方式'
if (creationMode.value === 'image-video') return '上传图片后,写人物动作、镜头运动、产品细节保持和视频节奏'
return '写清楚画面、动作、镜头、产品出现方式、视频比例和时长'
})
const setCreationMode = (mode) => { // Quick suggestions | 快捷建议
creationMode.value = mode const suggestions = [
if (mode !== 'image-video') { '像个魔法森林',
clearFrameFiles() '三只不同的小猫',
} '生成多角度分镜',
} '夏日田野环绕漫步'
]
const fileToDataUrl = (file) => new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(reader.error || new Error('读取图片失败'))
reader.readAsDataURL(file)
})
const handleFrameFile = (slot, event) => {
const file = event?.target?.files?.[0]
if (!file) return
const url = URL.createObjectURL(file)
if (slot === 'first') {
if (firstFramePreview.value) URL.revokeObjectURL(firstFramePreview.value)
firstFrameFile.value = file
firstFramePreview.value = url
}
}
const clearFrameFiles = () => {
if (firstFramePreview.value) URL.revokeObjectURL(firstFramePreview.value)
firstFrameFile.value = null
firstFramePreview.value = ''
}
// Add new node | 添加新节点 // Add new node | 添加新节点
const addNewNode = async (type) => { const addNewNode = async (type) => {
// Calculate viewport center position | 计算视口中心位置 // Calculate viewport center position | 计算视口中心位置
const viewportCenterX = -viewport.value.x / viewport.value.zoom + (window.innerWidth / 2) / viewport.value.zoom const viewportCenterX = -viewport.value.x / viewport.value.zoom + (window.innerWidth / 2) / viewport.value.zoom
const viewportCenterY = -viewport.value.y / viewport.value.zoom + (window.innerHeight / 2) / viewport.value.zoom const viewportCenterY = -viewport.value.y / viewport.value.zoom + (window.innerHeight / 2) / viewport.value.zoom
// Add node at viewport center | 在视口中心添加节点 // Add node at viewport center | 在视口中心添加节点
const nodeId = addNode(type, { x: viewportCenterX - 100, y: viewportCenterY - 100 }) const nodeId = addNode(type, { x: viewportCenterX - 100, y: viewportCenterY - 100 })
// Set highest z-index | 设置最高层级 // Set highest z-index | 设置最高层级
const maxZIndex = Math.max(0, ...nodes.value.map(n => n.zIndex || 0)) const maxZIndex = Math.max(0, ...nodes.value.map(n => n.zIndex || 0))
updateNode(nodeId, { zIndex: maxZIndex + 1 }) updateNode(nodeId, { zIndex: maxZIndex + 1 })
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸 // Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
setTimeout(() => { setTimeout(() => {
updateNodeInternals(nodeId) updateNodeInternals(nodeId)
}, 50) }, 50)
showNodeMenu.value = false showNodeMenu.value = false
} }
@@ -543,22 +522,22 @@ const onConnect = (params) => {
// Check connection types | 检查连接类型 // Check connection types | 检查连接类型
const sourceNode = nodes.value.find(n => n.id === params.source) const sourceNode = nodes.value.find(n => n.id === params.source)
const targetNode = nodes.value.find(n => n.id === params.target) const targetNode = nodes.value.find(n => n.id === params.target)
if (sourceNode?.type === 'image' && targetNode?.type === 'videoConfig') { if (sourceNode?.type === 'image' && targetNode?.type === 'videoConfig') {
// Use imageRole edge type | 使用图片角色边类型 // Use imageRole edge type | 使用图片角色边类型
addEdge({ addEdge({
...params, ...params,
type: 'imageRole', type: 'imageRole',
data: { imageRole: 'first_frame_image' } // Default reference image | 默认参考图 data: { imageRole: 'first_frame_image' } // Default to first frame | 默认首帧
}) })
} else if (sourceNode?.type === 'text' && targetNode?.type === 'imageConfig') { } else if (sourceNode?.type === 'text' && targetNode?.type === 'imageConfig') {
// Use promptOrder edge type | 使用提示词顺序边类型 // Use promptOrder edge type | 使用提示词顺序边类型
// Calculate next order number | 计算下一个顺序号 // Calculate next order number | 计算下一个顺序号
const existingTextEdges = edges.value.filter(e => const existingTextEdges = edges.value.filter(e =>
e.target === params.target && e.type === 'promptOrder' e.target === params.target && e.type === 'promptOrder'
) )
const nextOrder = existingTextEdges.length + 1 const nextOrder = existingTextEdges.length + 1
addEdge({ addEdge({
...params, ...params,
type: 'promptOrder', type: 'promptOrder',
@@ -625,7 +604,7 @@ const onNodeClick = (event) => {
// nodes.value.forEach(node => { // nodes.value.forEach(node => {
// updateNode(node.id, { selected: false }) // updateNode(node.id, { selected: false })
// }) // })
// // Select clicked node | 选中的节点 // // Select clicked node | 选中的节点
// const clickedNode = nodes.value.find(n => n.id === event.node.id) // const clickedNode = nodes.value.find(n => n.id === event.node.id)
// if (clickedNode) { // if (clickedNode) {
@@ -642,7 +621,7 @@ const handleViewportChange = (newViewport) => {
const onEdgesChange = (changes) => { const onEdgesChange = (changes) => {
// Check if any edge is being removed | 检查是否有边被删除 // Check if any edge is being removed | 检查是否有边被删除
const hasRemoval = changes.some(change => change.type === 'remove') const hasRemoval = changes.some(change => change.type === 'remove')
if (hasRemoval) { if (hasRemoval) {
// Trigger history save after edge removal | 边删除后触发历史保存 // Trigger history save after edge removal | 边删除后触发历史保存
nextTick(() => { nextTick(() => {
@@ -703,10 +682,48 @@ const handleEnterKey = (e) => {
sendMessage() sendMessage()
} }
// Handle AI polish | 处理 AI 润色
const handlePolish = async () => {
const input = chatInput.value.trim()
if (!input) return
// Check API configuration | 检查 API 配置
if (!isApiConfigured.value) {
window.$message?.warning('生成接口未就绪,请稍后重试')
showApiSettings.value = true
return
}
isProcessing.value = true
const originalInput = chatInput.value
try {
// Call chat API to polish the prompt | 调用 AI 润色提示词
const result = await sendChat(input, true)
if (result) {
chatInput.value = result
window.$message?.success('提示词已润色')
}
} catch (err) {
chatInput.value = originalInput
window.$message?.error(err.message || '润色失败')
} finally {
isProcessing.value = false
}
}
// Send message | 发送消息 // Send message | 发送消息
const sendMessage = async () => { const sendMessage = async () => {
const input = chatInput.value.trim() const input = chatInput.value.trim()
if (!input || !canSubmit.value) return if (!input) return
// Check API configuration | 检查 API 配置
if (!isApiConfigured.value) {
window.$message?.warning('生成接口未就绪,请稍后重试')
showApiSettings.value = true
return
}
isProcessing.value = true isProcessing.value = true
const content = chatInput.value const content = chatInput.value
@@ -721,63 +738,54 @@ const sendMessage = async () => {
const baseX = 100 const baseX = 100
const baseY = maxY + 200 const baseY = maxY + 200
const textNodeId = addNode('text', { x: baseX, y: baseY }, { if (autoExecute.value) {
content, // Auto-execute mode: analyze intent and execute workflow | 自动执行模式:分析意图并执行工作流
label: '提示词' window.$message?.info('正在分析工作流...')
})
if (creationMode.value === 'text-image') { try {
const imageConfigNodeId = addNode('imageConfig', { x: baseX + 400, y: baseY }, { // Analyze user intent | 分析用户意图
label: '文生图', const result = await analyzeIntent(content)
autoExecute: true
// Ensure we have valid workflow params | 确保有效的工作流参数
const workflowParams = {
workflow_type: result?.workflow_type || WORKFLOW_TYPES.TEXT_TO_IMAGE,
image_prompt: result?.image_prompt || content,
video_prompt: result?.video_prompt || content,
character: result?.character,
shots: result?.shots,
multi_angle: result?.multi_angle,
picture_book: result?.picture_book
}
window.$message?.info(`执行工作流: ${result?.description || '文生图'}`)
// Execute the workflow | 执行工作流
await executeWorkflow(workflowParams, { x: baseX, y: baseY })
window.$message?.success('工作流已启动')
} catch (err) {
console.error('Workflow error:', err)
// Fallback to simple text-to-image | 回退到文生图
window.$message?.warning('使用默认文生图工作流')
await createTextToImageWorkflow(content, { x: baseX, y: baseY })
}
} else {
// Manual mode: just create nodes | 手动模式:仅创建节点
const textNodeId = addNode('text', { x: baseX, y: baseY }, {
content: content,
label: '提示词'
}) })
const imageConfigNodeId = addNode('imageConfig', { x: baseX + 400, y: baseY }, {
label: '文生图'
})
addEdge({ addEdge({
source: textNodeId, source: textNodeId,
target: imageConfigNodeId, target: imageConfigNodeId,
sourceHandle: 'right', sourceHandle: 'right',
targetHandle: 'left' targetHandle: 'left'
}) })
} else {
let videoX = baseX + 400
let promptY = baseY
const imageNodeIds = []
if (needsFirstFrame.value && firstFrameFile.value) {
const dataUrl = await fileToDataUrl(firstFrameFile.value)
const firstId = addNode('image', { x: baseX, y: baseY + 160 }, {
url: dataUrl,
base64: dataUrl,
label: '参考图'
})
imageNodeIds.push({ id: firstId, role: 'first_frame_image' })
promptY = baseY - 140
updateNode(textNodeId, { zIndex: 5 })
}
const videoConfigNodeId = addNode('videoConfig', { x: videoX, y: promptY }, {
label: creationMode.value === 'text-video' ? '文生视频' : '图生视频',
autoExecute: true
})
addEdge({
source: textNodeId,
target: videoConfigNodeId,
sourceHandle: 'right',
targetHandle: 'left',
type: 'promptOrder',
data: { promptOrder: 1 }
})
for (const item of imageNodeIds) {
addEdge({
source: item.id,
target: videoConfigNodeId,
sourceHandle: 'right',
targetHandle: 'left',
type: 'imageRole',
data: { imageRole: item.role }
})
}
} }
} catch (err) { } catch (err) {
window.$message?.error(err.message || '创建失败') window.$message?.error(err.message || '创建失败')
@@ -800,7 +808,7 @@ const checkMobile = () => {
const loadProjectById = (projectId) => { const loadProjectById = (projectId) => {
// Update flow key to force VueFlow re-render | 更新 key 强制 VueFlow 重新渲染 // Update flow key to force VueFlow re-render | 更新 key 强制 VueFlow 重新渲染
flowKey.value = Date.now() flowKey.value = Date.now()
if (projectId && projectId !== 'new') { if (projectId && projectId !== 'new') {
loadProject(projectId) loadProject(projectId)
} else { } else {
@@ -828,13 +836,13 @@ watch(
onMounted(() => { onMounted(() => {
checkMobile() checkMobile()
window.addEventListener('resize', checkMobile) window.addEventListener('resize', checkMobile)
// Initialize projects store | 初始化项目存储 // Initialize projects store | 初始化项目存储
initProjectsStore() initProjectsStore()
// Load project data | 加载项目数据 // Load project data | 加载项目数据
loadProjectById(route.params.id) loadProjectById(route.params.id)
// Check for initial prompt from home page | 检查来自首页的初始提示词 // Check for initial prompt from home page | 检查来自首页的初始提示词
const initialPrompt = sessionStorage.getItem('ai-canvas-initial-prompt') const initialPrompt = sessionStorage.getItem('ai-canvas-initial-prompt')
if (initialPrompt) { if (initialPrompt) {
@@ -850,7 +858,6 @@ onMounted(() => {
// Cleanup on unmount | 卸载时清理 // Cleanup on unmount | 卸载时清理
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', checkMobile) window.removeEventListener('resize', checkMobile)
clearFrameFiles()
// Save project before leaving | 离开前保存项目 // Save project before leaving | 离开前保存项目
saveProject() saveProject()
}) })

View File

@@ -3,10 +3,15 @@
<div class="min-h-screen h-screen overflow-y-auto bg-[var(--bg-primary)]"> <div class="min-h-screen h-screen overflow-y-auto bg-[var(--bg-primary)]">
<!-- Header | 顶部导航 --> <!-- Header | 顶部导航 -->
<AppHeader> <AppHeader>
<template #left> <template #right>
<div class="flex h-8 items-center rounded-full bg-white px-3 shadow-sm"> <button
<img src="/skg-logo-black.svg" alt="SKG" class="h-6 w-auto dark:invert" /> @click="showApiSettings = true"
</div> class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
:class="{ 'text-[var(--accent-color)]': isApiConfigured }"
title="API 设置"
>
<n-icon :size="20"><SettingsOutline /></n-icon>
</button>
</template> </template>
</AppHeader> </AppHeader>
@@ -14,17 +19,17 @@
<main class="max-w-5xl mx-auto px-4 py-8 md:py-16"> <main class="max-w-5xl mx-auto px-4 py-8 md:py-16">
<!-- Welcome section | 欢迎区域 --> <!-- Welcome section | 欢迎区域 -->
<section class="text-center mb-12"> <section class="text-center mb-12">
<div class="flex items-center justify-center mb-8"> <div class="flex items-center justify-center gap-4 mb-8">
<img src="/skg-logo-black.svg" alt="SKG" class="h-12 w-auto dark:invert" /> <img src="/skg-logo-black.svg" alt="SKG" class="h-12 w-auto md:h-16 dark:invert" />
<h1 class="sr-only">SKG</h1> <h1 class="sr-only">SKG</h1>
</div> </div>
<!-- Input area | 输入区域 --> <!-- Input area | 输入区域 -->
<div class="max-w-2xl mx-auto"> <div class="max-w-2xl mx-auto">
<div class="bg-[var(--bg-secondary)] rounded-2xl border border-[var(--border-color)] p-4 shadow-sm"> <div class="bg-[var(--bg-secondary)] rounded-2xl border border-[var(--border-color)] p-4 shadow-sm">
<textarea <textarea
v-model="inputText" v-model="inputText"
placeholder="写提示词,生成图片或视频,结果会放进画布" placeholder="输入你的创意,开始新项目"
class="w-full bg-transparent resize-none outline-none text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] min-h-[80px]" class="w-full bg-transparent resize-none outline-none text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] min-h-[80px]"
@keydown.enter.ctrl="handleCreateWithInput" @keydown.enter.ctrl="handleCreateWithInput"
/> />
@@ -38,7 +43,7 @@
</button> --> </button> -->
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <button
@click="handleCreateWithInput" @click="handleCreateWithInput"
class="w-8 h-8 rounded-xl bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] flex items-center justify-center transition-colors" class="w-8 h-8 rounded-xl bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] flex items-center justify-center transition-colors"
> >
@@ -47,7 +52,22 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Quick suggestions | 快捷建议 -->
<div class="flex flex-wrap items-center justify-center gap-2 mt-4">
<span class="text-sm text-[var(--text-secondary)]">推荐</span>
<button
v-for="tag in suggestions"
:key="tag"
@click="inputText = tag"
class="px-3 py-1.5 text-sm rounded-full bg-[var(--bg-secondary)] border border-[var(--border-color)] hover:border-[var(--accent-color)] transition-colors"
>
{{ tag }}
</button>
<button class="p-1.5 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
<n-icon :size="16"><RefreshOutline /></n-icon>
</button>
</div>
</div> </div>
</section> </section>
@@ -55,7 +75,7 @@
<section ref="projectsSection"> <section ref="projectsSection">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-[var(--text-primary)]">我的项目</h2> <h2 class="text-lg font-semibold text-[var(--text-primary)]">我的项目</h2>
<button <button
@click="createNewProject" @click="createNewProject"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white transition-colors" class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white transition-colors"
> >
@@ -63,32 +83,32 @@
新建项目 新建项目
</button> </button>
</div> </div>
<!-- Empty state | 空状态 --> <!-- Empty state | 空状态 -->
<div v-if="projects.length === 0" class="text-center py-12 bg-[var(--bg-secondary)] rounded-xl border border-dashed border-[var(--border-color)]"> <div v-if="projects.length === 0" class="text-center py-12 bg-[var(--bg-secondary)] rounded-xl border border-dashed border-[var(--border-color)]">
<n-icon :size="48" class="text-[var(--text-secondary)] mb-4"><FolderOutline /></n-icon> <n-icon :size="48" class="text-[var(--text-secondary)] mb-4"><FolderOutline /></n-icon>
<p class="text-[var(--text-secondary)] mb-4">还没有项目创建一个开始吧</p> <p class="text-[var(--text-secondary)] mb-4">还没有项目创建一个开始吧</p>
<button <button
@click="createNewProject" @click="createNewProject"
class="px-4 py-2 text-sm rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white transition-colors" class="px-4 py-2 text-sm rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white transition-colors"
> >
创建第一个项目 创建第一个项目
</button> </button>
</div> </div>
<!-- Projects grid | 项目网格 --> <!-- Projects grid | 项目网格 -->
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-4"> <div v-else class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div <div
v-for="project in projects" v-for="project in projects"
:key="project.id" :key="project.id"
class="group relative" class="group relative"
> >
<!-- Project card | 项目卡片 --> <!-- Project card | 项目卡片 -->
<div <div
@click="openProject(project)" @click="openProject(project)"
class="cursor-pointer" class="cursor-pointer"
> >
<div <div
class="aspect-video rounded-xl overflow-hidden bg-[var(--bg-tertiary)] mb-2 border border-[var(--border-color)] relative" class="aspect-video rounded-xl overflow-hidden bg-[var(--bg-tertiary)] mb-2 border border-[var(--border-color)] relative"
@mouseenter="handleThumbnailHover(project, true)" @mouseenter="handleThumbnailHover(project, true)"
@mouseleave="handleThumbnailHover(project, false)" @mouseleave="handleThumbnailHover(project, false)"
@@ -96,7 +116,7 @@
<!-- Thumbnail or placeholder | 缩略图或占位 --> <!-- Thumbnail or placeholder | 缩略图或占位 -->
<template v-if="project.thumbnail"> <template v-if="project.thumbnail">
<!-- Video thumbnail | 视频缩略图 --> <!-- Video thumbnail | 视频缩略图 -->
<video <video
v-if="isVideoUrl(project.thumbnail)" v-if="isVideoUrl(project.thumbnail)"
:ref="el => setVideoRef(project.id, el)" :ref="el => setVideoRef(project.id, el)"
:src="project.thumbnail" :src="project.thumbnail"
@@ -106,9 +126,9 @@
playsinline playsinline
/> />
<!-- Image thumbnail | 图片缩略图 --> <!-- Image thumbnail | 图片缩略图 -->
<img <img
v-else v-else
:src="project.thumbnail" :src="project.thumbnail"
:alt="project.name" :alt="project.name"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/> />
@@ -116,7 +136,7 @@
<div v-else class="w-full h-full flex items-center justify-center"> <div v-else class="w-full h-full flex items-center justify-center">
<n-icon :size="32" class="text-[var(--text-secondary)]"><DocumentOutline /></n-icon> <n-icon :size="32" class="text-[var(--text-secondary)]"><DocumentOutline /></n-icon>
</div> </div>
<!-- Hover overlay | 悬浮遮罩 --> <!-- Hover overlay | 悬浮遮罩 -->
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"> <div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<span class="text-white text-sm">打开项目</span> <span class="text-white text-sm">打开项目</span>
@@ -125,11 +145,11 @@
<p class="text-sm text-[var(--text-primary)] truncate">{{ project.name }}</p> <p class="text-sm text-[var(--text-primary)] truncate">{{ project.name }}</p>
<p class="text-xs text-[var(--text-secondary)]">{{ formatDate(project.updatedAt) }}</p> <p class="text-xs text-[var(--text-secondary)]">{{ formatDate(project.updatedAt) }}</p>
</div> </div>
<!-- Project actions | 项目操作 --> <!-- Project actions | 项目操作 -->
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10"> <div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<n-dropdown :options="getProjectActions(project)" @select="(key) => handleProjectAction(key, project)" placement="bottom-end"> <n-dropdown :options="getProjectActions(project)" @select="(key) => handleProjectAction(key, project)" placement="bottom-end">
<button <button
@click.stop @click.stop
class="p-1.5 bg-white/90 dark:bg-gray-800/90 rounded-lg shadow hover:bg-white dark:hover:bg-gray-800 transition-colors" class="p-1.5 bg-white/90 dark:bg-gray-800/90 rounded-lg shadow hover:bg-white dark:hover:bg-gray-800 transition-colors"
> >
@@ -144,14 +164,14 @@
<!-- Left sidebar | 左侧边栏 --> <!-- Left sidebar | 左侧边栏 -->
<aside class="fixed left-4 top-1/2 -translate-y-1/2 hidden md:flex flex-col gap-2 p-2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-sm"> <aside class="fixed left-4 top-1/2 -translate-y-1/2 hidden md:flex flex-col gap-2 p-2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-sm">
<button <button
@click="createNewProject" @click="createNewProject"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors" class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
title="新建项目" title="新建项目"
> >
<n-icon :size="20"><DocumentOutline /></n-icon> <n-icon :size="20"><DocumentOutline /></n-icon>
</button> </button>
<button <button
@click="scrollToProjects" @click="scrollToProjects"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors" class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
title="我的项目" title="我的项目"
@@ -160,6 +180,9 @@
</button> </button>
</aside> </aside>
<!-- API Settings Modal | API 设置弹窗 -->
<ApiSettings v-model:show="showApiSettings" @saved="refreshApiConfig" />
<!-- Rename modal | 重命名弹窗 --> <!-- Rename modal | 重命名弹窗 -->
<n-modal v-model:show="showRenameModal" preset="dialog" title="重命名项目"> <n-modal v-model:show="showRenameModal" preset="dialog" title="重命名项目">
<n-input v-model:value="renameValue" placeholder="请输入项目名称" /> <n-input v-model:value="renameValue" placeholder="请输入项目名称" />
@@ -176,31 +199,47 @@
* Home view component | 首页视图组件 * Home view component | 首页视图组件
* Entry point with project list and creation input * Entry point with project list and creation input
*/ */
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { NIcon, NDropdown, NModal, NInput, NButton, useDialog } from 'naive-ui' import { NIcon, NDropdown, NModal, NInput, NButton, useDialog } from 'naive-ui'
import { import {
AddOutline, AddOutline,
ImageOutline,
SendOutline, SendOutline,
RefreshOutline,
DocumentOutline, DocumentOutline,
FolderOutline, FolderOutline,
EllipsisHorizontalOutline, EllipsisHorizontalOutline,
CreateOutline, CreateOutline,
CopyOutline, CopyOutline,
SettingsOutline,
TrashOutline TrashOutline
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import { import {
projects, projects,
initProjectsStore, initProjectsStore,
createProject, createProject,
deleteProject, deleteProject,
duplicateProject, duplicateProject,
renameProject renameProject
} from '../stores/projects' } from '../stores/projects'
import { useModelStore } from '../stores/pinia'
import ApiSettings from '../components/ApiSettings.vue'
import AppHeader from '../components/AppHeader.vue' import AppHeader from '../components/AppHeader.vue'
const router = useRouter() const router = useRouter()
const dialog = useDialog() const dialog = useDialog()
const modelStore = useModelStore()
// API Settings state | API 设置状态
const showApiSettings = ref(false)
const isApiConfigured = computed(() => !!modelStore.currentApiKey)
// Refresh API config state | 刷新 API 配置状态
const refreshApiConfig = () => {
// 通过 computed 自动更新,不需要手动刷新
}
// Video refs for hover play | 视频引用用于悬停播放 // Video refs for hover play | 视频引用用于悬停播放
const videoRefs = new Map() const videoRefs = new Map()
@@ -216,10 +255,10 @@ const setVideoRef = (projectId, el) => {
// Handle thumbnail hover | 处理缩略图悬停 // Handle thumbnail hover | 处理缩略图悬停
const handleThumbnailHover = (project, isHovering) => { const handleThumbnailHover = (project, isHovering) => {
if (!isVideoUrl(project.thumbnail)) return if (!isVideoUrl(project.thumbnail)) return
const video = videoRefs.get(project.id) const video = videoRefs.get(project.id)
if (!video) return if (!video) return
if (isHovering) { if (isHovering) {
video.play().catch(() => { video.play().catch(() => {
// Ignore play errors (e.g., autoplay policy) // Ignore play errors (e.g., autoplay policy)
@@ -238,13 +277,21 @@ const showRenameModal = ref(false)
const renameValue = ref('') const renameValue = ref('')
const renameTargetId = ref(null) const renameTargetId = ref(null)
// Suggestions tags | 建议标签
const suggestions = [
'雨中魔法森林',
'日式街面美食摄影',
'瀑布水流飞溅',
'雨天富声旁边花语'
]
// Format date | 格式化日期 // Format date | 格式化日期
const formatDate = (date) => { const formatDate = (date) => {
if (!date) return '' if (!date) return ''
const d = new Date(date) const d = new Date(date)
const now = new Date() const now = new Date()
const diff = now - d const diff = now - d
// Less than 1 minute | 小于1分钟 // Less than 1 minute | 小于1分钟
if (diff < 60000) return '刚刚' if (diff < 60000) return '刚刚'
// Less than 1 hour | 小于1小时 // Less than 1 hour | 小于1小时
@@ -305,24 +352,46 @@ const confirmRename = () => {
renameValue.value = '' renameValue.value = ''
} }
// Check internal API before navigation | 跳转前检查内部接口
const checkApiKeyAndNavigate = (callback) => {
if (!isApiConfigured.value) {
dialog.warning({
title: '生成接口未就绪',
content: '当前登录会话还不能使用生成接口,请稍后重试或联系管理员。',
positiveText: '知道了'
})
return false
}
callback()
return true
}
// Create new project | 创建新项目 // Create new project | 创建新项目
const createNewProject = () => { const createNewProject = () => {
const id = createProject('未命名项目') checkApiKeyAndNavigate(() => {
router.push(`/p/${id}`) const id = createProject('未命名项目')
router.push(`/p/${id}`)
})
} }
// Create project with input text | 使用输入文本创建项目 // Create project with input text | 使用输入文本创建项目
const handleCreateWithInput = () => { const handleCreateWithInput = () => {
const name = inputText.value.trim() || '未命名项目' checkApiKeyAndNavigate(() => {
const id = createProject(name) const name = inputText.value.trim() || '未命名项目'
sessionStorage.setItem('ai-canvas-initial-prompt', inputText.value.trim()) const id = createProject(name)
inputText.value = '' // Store the input text to be used as initial prompt
router.push(`/p/${id}`) sessionStorage.setItem('ai-canvas-initial-prompt', inputText.value.trim())
inputText.value = ''
router.push(`/p/${id}`)
})
} }
// Open existing project | 打开已有项目 // Open existing project | 打开已有项目
const openProject = (project) => { const openProject = (project) => {
router.push(`/p/${project.id}`) checkApiKeyAndNavigate(() => {
router.push(`/p/${project.id}`)
})
} }
// Check if URL is a video | 检查 URL 是否为视频 // Check if URL is a video | 检查 URL 是否为视频