fix: restore upstream canvas capabilities
This commit is contained in:
2
RULES.md
2
RULES.md
@@ -12,7 +12,7 @@
|
||||
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
|
||||
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
|
||||
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
|
||||
- 当前产品方向(2026-05-25 三模式 + logo-only 根域名画布版):默认首页彻底从“信息流广告复刻管线”切换为多人通用的 SKG 营销内容生产平台入口,服务公司内部成员同时使用。`https://marketing.skg.com` 登录后直接进入个人生成画布,`/canvas/` 只作为旧链接兼容跳转到根域名。终端可见品牌位只放 SKG logo,不再把“生图生视频”“SKG 生成画布”或长系统名放在主界面上。画布底部输入框只保留三个用户能直接理解的入口:文生图、文生视频、图生视频;不再把“首帧生视频 / 首尾帧生视频”这类模型实现概念作为主入口。图生视频只显示“上传图片”,内部仍用后端 first_image 能力提交。用户选择生成方式、必要时上传图片、手写提示词并点击生成;图片模式显示尺寸选择,视频模式显示画幅和真实可用时长选择。后端 `/health` 向前端返回可选图片 / 视频模型、图片尺寸、视频画幅和视频时长,首页允许用户选择图片模型(自动、GPT Image 2、Gemini 图片兜底)和视频模型(Seedance、Kling、Veo 3 等别名;实际可用模型以环境变量映射为准)。当前 Doubao / Seedance 生产链路单条视频最长按 15 秒暴露,不在 UI 显示 30 秒;如后续要 30 秒,需要改成多段生成后合成。用户登录后仍只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离;生成调用本项目后端 `/api`,每个浏览器的画布项目先保存在本地 localStorage,图片/视频资产仍按登录用户写入后端 job。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。
|
||||
- 当前产品方向(2026-05-25 上游画布能力恢复版):默认入口是多人通用的 SKG 营销内容生产平台,`https://marketing.skg.com` 登录后直接进入个人生成画布,`/canvas/` 只作为旧链接兼容跳转到根域名。终端可见品牌位只放 SKG logo,不在主界面展示“生图生视频”“SKG 生成画布”或长系统名。画布本体尽量恢复 `chatfire-AI/huobao-canvas` 的成熟交互,不再削成三模式单输入框:保留首页推荐词、画布底部推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载等上游能力;多角度分镜、故事板、图转视频、绘本等工作流按上游结构创建节点。API 接入是例外:生成调用继续走本项目后端 `/api` 和当前登录 Cookie,不要求员工在浏览器配置个人 API Key;API 设置弹窗只保留模型/端点配置外观和本地模型管理,不能出现上游注册链接或外部品牌。用户登录后仍只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离;每个浏览器的画布项目先保存在本地 localStorage,图片/视频资产按登录用户写入后端 job。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。
|
||||
|
||||
## 部署事实
|
||||
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
|
||||
|
||||
@@ -7,6 +7,6 @@ Portions of the internal SKG canvas module are adapted from `chatfire-AI/huobao-
|
||||
- Source: https://github.com/chatfire-AI/huobao-canvas
|
||||
- License note: the upstream README declares MIT licensing and links to a `LICENSE` file, but the cloned snapshot used for this integration did not include that file.
|
||||
- Local integration path: `web/canvas-app/`
|
||||
- SKG changes: branding, visible product text, model options, auth behavior, and API calls were changed for SKG internal use.
|
||||
- SKG changes: branding, visible product text, routing, auth behavior, and API calls were changed for SKG internal use; visible upstream registration links and external provider branding are removed from the product UI.
|
||||
|
||||
This notice is kept in the repository for engineering traceability and is not shown in the product UI.
|
||||
|
||||
@@ -579,16 +579,17 @@
|
||||
<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>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>
|
||||
<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="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">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">04</div><h3>上传图片 / 空白任务</h3><p><code>POST /creative/jobs/image</code> 创建轻量任务;文生图和文生视频可空白创建,图生视频上传一张图片作为视频参考。</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">07</div><h3>结果沉淀</h3><p>首页只在对话框下方显示最新图片或视频;视频会显示排队位置、生成进度、完成播放或失败可重试状态;所有图片/视频缩略图继续复用 <code>MediaAssetTile</code>。</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>可通过底部 prompt、AI 润色、自动执行、手动添加节点或工作流模板创建文本、图片、视频、LLM、配置和参考图节点。</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">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>生成图、视频 URL、任务状态和下载入口回填到画布节点;完整任务结果仍可进入 <code>/detail/?job=</code> 查看。</p></div>
|
||||
<div class="step"><div class="num">08</div><h3>详情页</h3><p><code>/detail/?job=<id></code> 展示参考图、全量生成图、视频候选、提示词和营销图文,并支持继续生成。</p></div>
|
||||
<div class="step"><div class="num">09</div><h3>高级复刻</h3><p>旧 <code>AdRecreationBoard</code> 与 <code>/agent/</code> 作为高级入口保留,不再是默认路径。</p></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/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/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/src/views/Canvas.vue</code></td><td>画布主交互:底部悬浮 prompt composer 吸附在画布下方,提供文生图、文生视频、图生视频三种模式;图生视频只显示“上传图片”,底部不再常驻推荐提示词 chips,避免遮挡画布操作。提交后自动创建文本节点、参考图节点、图片配置节点或视频配置节点,并用 <code>autoExecute</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、<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/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=<id></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>
|
||||
<td><span class="tag blue">内容生产画布</span></td>
|
||||
<td>承载个人自由排列的创作空间:用户在画布上用对话框生成文本、图片和视频节点,结果按节点位置沉淀,不和默认首页的单条结果卡互相挤压。画布项目先保存在浏览器本地,生成资产进入后端个人 job。</td>
|
||||
<td>当前不做团队共享画布、管理员总览、多人协同编辑或跨浏览器同步;也不让员工在浏览器里配置上游 API Key。</td>
|
||||
<td>承载个人自由排列的创作空间:用户在画布上通过提示词、推荐词、AI 润色、自动执行、工作流模板或手动节点连接生成文本、图片和视频节点,结果按节点位置沉淀。画布项目先保存在浏览器本地,生成资产进入后端个人 job。</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>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -1205,6 +1206,19 @@ ProductRefStateItem {
|
||||
<h2>变更记录</h2>
|
||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||
<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">
|
||||
<header>
|
||||
<h3>2026-05-25 · 根域名直接进入个人生成画布</h3>
|
||||
|
||||
396
web/canvas-app/src/components/ApiSettings.vue
Normal file
396
web/canvas-app/src/components/ApiSettings.vue
Normal 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>
|
||||
@@ -55,8 +55,8 @@ const props = defineProps({
|
||||
|
||||
// Image role options | 图片角色选项
|
||||
const imageRoleOptions = [
|
||||
{ label: '图片', key: 'first_frame_image' },
|
||||
{ label: '结束图', key: 'last_frame_image' },
|
||||
{ label: '首帧', key: 'first_frame_image' },
|
||||
{ label: '尾帧', key: 'last_frame_image' },
|
||||
{ label: '参考图', key: 'input_reference' }
|
||||
]
|
||||
|
||||
@@ -66,7 +66,7 @@ const currentRole = computed(() => props.data?.imageRole || 'first_frame_image')
|
||||
// Current role label | 当前角色标签
|
||||
const currentRoleLabel = computed(() => {
|
||||
const option = imageRoleOptions.find(o => o.key === currentRole.value)
|
||||
return option?.label || '图片'
|
||||
return option?.label || '首帧'
|
||||
})
|
||||
|
||||
// Calculate bezier path | 计算贝塞尔路径
|
||||
@@ -95,7 +95,7 @@ const edgeStyle = computed(() => ({
|
||||
|
||||
// Handle role selection | 处理角色选择
|
||||
const handleRoleSelect = (role) => {
|
||||
// Keep endpoint image roles unique when advanced users edit edge roles.
|
||||
// If selecting first_frame or last_frame, ensure uniqueness | 如果选择首帧或尾帧,确保唯一性
|
||||
if (role === 'first_frame_image' || role === 'last_frame_image') {
|
||||
// Find other edges connected to the same target with the same role | 查找连接到同一目标且具有相同角色的其他边
|
||||
const sameTargetEdges = edges.value.filter(edge =>
|
||||
|
||||
@@ -586,7 +586,7 @@ const handleGenerate = async (mode = 'auto') => {
|
||||
}
|
||||
|
||||
if (!isConfigured.value) {
|
||||
window.$message?.warning('登录状态异常,请重新进入工作台')
|
||||
window.$message?.warning('生成接口未就绪,请稍后重试')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -932,7 +932,7 @@ const handleVideoGen = () => {
|
||||
sourceHandle: 'right',
|
||||
targetHandle: 'left',
|
||||
type: 'imageRole',
|
||||
data: { imageRole: 'first_frame_image' } // Default reference image | 默认参考图
|
||||
data: { imageRole: 'first_frame_image' } // Default to first frame | 默认首帧
|
||||
})
|
||||
|
||||
// Connect text node to config node | 连接文本节点到配置节点
|
||||
|
||||
@@ -764,7 +764,7 @@ const getInputFromConnections = () => {
|
||||
// Handle generate | 处理生成
|
||||
const handleGenerate = async () => {
|
||||
if (!isApiConfigured.value) {
|
||||
window.$message?.warning('登录状态异常,请重新进入工作台')
|
||||
window.$message?.warning('生成接口未就绪,请稍后重试')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -623,7 +623,7 @@ const handlePolish = async () => {
|
||||
|
||||
// Check API configuration | 检查 API 配置
|
||||
if (!isApiConfigured.value) {
|
||||
window.$message?.warning('登录状态异常,请重新进入工作台')
|
||||
window.$message?.warning('生成接口未就绪,请稍后重试')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -82,8 +82,16 @@
|
||||
提示词 {{ connectedPrompt ? '✓' : '○' }}
|
||||
</span>
|
||||
<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'">
|
||||
图片 {{ connectedImages.length > 0 ? `✓ ${connectedImages.length}` : '○' }}
|
||||
:class="imagesByRole.firstFrame ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
|
||||
首帧 {{ imagesByRole.firstFrame ? '✓' : '○' }}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -187,7 +195,7 @@ const connectedImages = computed(() => {
|
||||
edgeId: edge.id,
|
||||
url: sourceNode.data.url,
|
||||
base64: sourceNode.data.base64,
|
||||
role: edge.data?.imageRole || 'first_frame_image' // Default reference image | 默认参考图
|
||||
role: edge.data?.imageRole || 'first_frame_image' // Default to first frame | 默认首帧
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -334,7 +342,7 @@ const handleGenerate = async () => {
|
||||
}
|
||||
|
||||
if (!isConfigured.value) {
|
||||
window.$message?.warning('登录状态异常,请重新进入工作台')
|
||||
window.$message?.warning('生成接口未就绪,请稍后重试')
|
||||
isGenerating.value = false
|
||||
return
|
||||
}
|
||||
@@ -377,12 +385,12 @@ const handleGenerate = async () => {
|
||||
params.prompt = prompt
|
||||
}
|
||||
|
||||
// Add primary reference image | 添加主参考图
|
||||
// Add first frame image | 添加首帧图片
|
||||
if (first_frame_image) {
|
||||
params.first_frame_image = first_frame_image
|
||||
}
|
||||
|
||||
// Add optional ending reference image | 添加可选结束参考图
|
||||
// Add last frame image | 添加尾帧图片
|
||||
if (last_frame_image) {
|
||||
params.last_frame_image = last_frame_image
|
||||
}
|
||||
|
||||
@@ -1,118 +1,269 @@
|
||||
/**
|
||||
* SKG model and size configuration.
|
||||
* These values mirror the backend /health capabilities and keep the canvas UI simple.
|
||||
* Models Configuration | 模型配置
|
||||
* Centralized model configuration | 集中模型配置
|
||||
*/
|
||||
|
||||
export const SKG_IMAGE_SIZE_OPTIONS = [
|
||||
{ label: '自动', key: 'auto' },
|
||||
{ label: '竖图 2:3', key: '1024x1536' },
|
||||
{ label: '方图 1:1', key: '1024x1024' },
|
||||
{ label: '横图 3:2', key: '1536x1024' }
|
||||
// Seedream image size options | 豆包图片尺寸选项
|
||||
export const SEEDREAM_SIZE_OPTIONS = [
|
||||
{ label: '21:9', key: '3024x1296' },
|
||||
{ label: '16:9', key: '2560x1440' },
|
||||
{ label: '4:3', key: '2304x1728' },
|
||||
{ label: '3:2', key: '2496x1664' },
|
||||
{ label: '1:1', key: '2048x2048' },
|
||||
{ label: '2:3', key: '1664x2496' },
|
||||
{ label: '3:4', key: '1728x2304' },
|
||||
{ label: '9:16', key: '1440x2560' },
|
||||
{ label: '9:21', key: '1296x3024' }
|
||||
]
|
||||
|
||||
export const SKG_IMAGE_QUALITY_OPTIONS = [
|
||||
{ label: '标准', key: 'standard' }
|
||||
// Seedream 4K image size options | 豆包4K图片尺寸选项
|
||||
export const SEEDREAM_4K_SIZE_OPTIONS = [
|
||||
{ label: '21:9', key: '6198x2656' },
|
||||
{ label: '16:9', key: '5404x3040' },
|
||||
{ label: '4:3', key: '4694x3520' },
|
||||
{ label: '3:2', key: '4992x3328' },
|
||||
{ label: '1:1', key: '4096x4096' },
|
||||
{ label: '2:3', key: '3328x4992' },
|
||||
{ label: '3:4', key: '3520x4694' },
|
||||
{ label: '9:16', key: '3040x5404' },
|
||||
{ label: '9:21', key: '2656x6198' }
|
||||
]
|
||||
|
||||
export const SKG_VIDEO_SIZE_OPTIONS = [
|
||||
{ label: '竖屏 9:16', key: '720x1280' },
|
||||
{ label: '横屏 16:9', key: '1280x720' },
|
||||
{ label: '方形 1:1', key: '1024x1024' },
|
||||
{ label: '竖屏 3:4', key: '960x1280' }
|
||||
// Seedream quality options | 豆包画质选项
|
||||
export const SEEDREAM_QUALITY_OPTIONS = [
|
||||
{ label: '标准画质', key: 'standard' },
|
||||
{ label: '4K 高清', key: '4k' }
|
||||
]
|
||||
|
||||
export const VIDEO_RATIO_LIST = SKG_VIDEO_SIZE_OPTIONS
|
||||
|
||||
export const SEEDANCE_RESOLUTION_OPTIONS = [
|
||||
{ label: '720p', key: '720p' },
|
||||
{ label: '1080p', key: '1080p' }
|
||||
export const BANANA_SIZE_OPTIONS = [
|
||||
{ label: '16:9', key: '16x9' },
|
||||
{ label: '4:3', key: '4x3' },
|
||||
{ label: '3:2', key: '3x2' },
|
||||
{ label: '1:1', key: '1x1' },
|
||||
{ label: '2:3', key: '2x3' },
|
||||
{ label: '3:4', key: '3x4' },
|
||||
{ label: '9:16', key: '9x16' },
|
||||
]
|
||||
|
||||
// Image generation models | 图片生成模型
|
||||
export const IMAGE_MODELS = [
|
||||
{
|
||||
label: '自动',
|
||||
key: 'auto',
|
||||
provider: ['skg'],
|
||||
sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key),
|
||||
qualities: SKG_IMAGE_QUALITY_OPTIONS,
|
||||
defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' }
|
||||
},
|
||||
{
|
||||
label: 'GPT Image 2',
|
||||
key: 'gpt-image-2',
|
||||
provider: ['skg'],
|
||||
sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key),
|
||||
qualities: SKG_IMAGE_QUALITY_OPTIONS,
|
||||
defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' }
|
||||
},
|
||||
{
|
||||
label: 'Gemini 图片备用',
|
||||
key: 'gemini-3-pro-image-preview',
|
||||
provider: ['skg'],
|
||||
sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key),
|
||||
qualities: SKG_IMAGE_QUALITY_OPTIONS,
|
||||
defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' }
|
||||
}
|
||||
{
|
||||
label: 'Nano Banana 2',
|
||||
key: 'nano-banana-2',
|
||||
provider: ['chatfire'], // 火宝渠道
|
||||
sizes: BANANA_SIZE_OPTIONS.map(s => s.key),
|
||||
// qualities: SEEDREAM_QUALITY_OPTIONS,
|
||||
// getSizesByQuality: (quality) => quality === '4k' ? SEEDREAM_4K_SIZE_OPTIONS : SEEDREAM_SIZE_OPTIONS,
|
||||
defaultParams: {
|
||||
size: '1x1',
|
||||
quality: 'standard',
|
||||
style: 'vivid'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Nano Banana Pro',
|
||||
key: 'nano-banana-pro',
|
||||
provider: ['chatfire'], // 火宝渠道
|
||||
sizes: BANANA_SIZE_OPTIONS.map(s => s.key),
|
||||
// qualities: SEEDREAM_QUALITY_OPTIONS,
|
||||
// getSizesByQuality: (quality) => quality === '4k' ? SEEDREAM_4K_SIZE_OPTIONS : SEEDREAM_SIZE_OPTIONS,
|
||||
defaultParams: {
|
||||
size: '1x1',
|
||||
quality: 'standard',
|
||||
style: 'vivid'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '豆包 Seedream 4.5',
|
||||
key: 'doubao-seedream-4-5-251128',
|
||||
provider: ['chatfire'], // 火宝渠道
|
||||
sizes: SEEDREAM_SIZE_OPTIONS.map(s => s.key),
|
||||
qualities: SEEDREAM_QUALITY_OPTIONS,
|
||||
getSizesByQuality: (quality) => quality === '4k' ? SEEDREAM_4K_SIZE_OPTIONS : SEEDREAM_SIZE_OPTIONS,
|
||||
defaultParams: {
|
||||
size: '2048x2048',
|
||||
quality: 'standard',
|
||||
style: 'vivid'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Nano Banana',
|
||||
key: 'nano-banana',
|
||||
provider: ['chatfire'], // 火宝渠道
|
||||
tips: '尺寸写在提示词中: 尺寸 9:16',
|
||||
sizes: [],
|
||||
defaultParams: {
|
||||
quality: 'standard',
|
||||
style: 'vivid'
|
||||
}
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
// Video ratio options | 视频比例选项
|
||||
export const VIDEO_RATIO_LIST = [
|
||||
{ label: '16:9 (横版)', key: '16x9' },
|
||||
{ label: '4:3', key: '4x3' },
|
||||
{ label: '1:1 (方形)', key: '1x1' },
|
||||
{ label: '3:4', key: '3x4' },
|
||||
{ label: '9:16 (竖版)', key: '9x16' }
|
||||
]
|
||||
|
||||
// Video resolution options for Seedance | Seedance 分辨率选项
|
||||
export const SEEDANCE_RESOLUTION_OPTIONS = [
|
||||
{ label: '480p', key: '480p' },
|
||||
{ label: '720p', key: '720p' },
|
||||
{ label: '1080p', key: '1080p' }
|
||||
]
|
||||
|
||||
// Video generation models | 视频生成模型
|
||||
export const VIDEO_MODELS = [
|
||||
{
|
||||
label: 'Seedance',
|
||||
key: 'seedance',
|
||||
provider: ['skg'],
|
||||
type: 't2v+i2v',
|
||||
ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key),
|
||||
durs: [5, 8, 10, 12, 15].map(s => ({ label: `${s} 秒`, key: s })),
|
||||
resolutions: ['720p', '1080p'],
|
||||
defaultResolution: '1080p',
|
||||
defaultParams: { ratio: '720x1280', duration: 10, resolution: '1080p' }
|
||||
},
|
||||
{
|
||||
label: 'Kling',
|
||||
key: 'kling',
|
||||
provider: ['skg'],
|
||||
type: 't2v+i2v',
|
||||
ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key),
|
||||
durs: [4, 8, 12].map(s => ({ label: `${s} 秒`, key: s })),
|
||||
resolutions: ['720p'],
|
||||
defaultResolution: '720p',
|
||||
defaultParams: { ratio: '720x1280', duration: 8, resolution: '720p' }
|
||||
},
|
||||
{
|
||||
label: 'Veo 3',
|
||||
key: 'veo3',
|
||||
provider: ['skg'],
|
||||
type: 't2v+i2v',
|
||||
ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key),
|
||||
durs: [4, 8, 12].map(s => ({ label: `${s} 秒`, key: s })),
|
||||
resolutions: ['720p'],
|
||||
defaultResolution: '720p',
|
||||
defaultParams: { ratio: '720x1280', duration: 8, resolution: '720p' }
|
||||
}
|
||||
// Seedance 模型 - 1.5 Pro
|
||||
{
|
||||
label: 'Seedance 1.5 Pro (图文视频)',
|
||||
key: 'doubao-seedance-1-5-pro-251215',
|
||||
provider: ['chatfire'],
|
||||
type: 't2v+i2v',
|
||||
ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||
durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
|
||||
resolutions: ['480p', '720p', '1080p'],
|
||||
defaultResolution: '1080p',
|
||||
defaultParams: { ratio: '16:9', duration: 10, resolution: '1080p' }
|
||||
},
|
||||
// Seedance 模型 - 文生视频
|
||||
{
|
||||
label: 'Seedance 1.0 Lite (文生视频)',
|
||||
key: 'doubao-seedance-1-0-lite-t2v-250428',
|
||||
provider: ['chatfire'],
|
||||
type: 't2v', // 文生视频
|
||||
ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||
durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
|
||||
resolutions: ['480p', '720p', '1080p'],
|
||||
defaultResolution: '720p',
|
||||
defaultParams: { ratio: '16:9', duration: 5, resolution: '720p' }
|
||||
},
|
||||
// Seedance 模型 - 图生视频
|
||||
{
|
||||
label: 'Seedance 1.0 Lite (图生视频)',
|
||||
key: 'doubao-seedance-1-0-lite-i2v-250428',
|
||||
provider: ['chatfire'],
|
||||
type: 'i2v', // 图生视频
|
||||
ratios: ['16:9'],
|
||||
durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
|
||||
resolutions: ['480p', '720p', '1080p'],
|
||||
defaultResolution: '720p',
|
||||
defaultParams: { ratio: '16:9', duration: 5, resolution: '720p' }
|
||||
},
|
||||
// Seedance 模型 - 图文视频 Pro
|
||||
{
|
||||
label: 'Seedance 1.0 Pro (图文视频)',
|
||||
key: 'doubao-seedance-1-0-pro-250528',
|
||||
provider: ['chatfire'],
|
||||
type: 't2v+i2v', // 图文视频
|
||||
ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9', '16:9'],
|
||||
durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
|
||||
resolutions: ['480p', '720p', '1080p'],
|
||||
defaultResolution: '1080p',
|
||||
defaultParams: { ratio: '16:9', duration: 5, resolution: '1080p' }
|
||||
},
|
||||
|
||||
// Seedance 模型 - 1.0 Pro Fast
|
||||
{
|
||||
label: 'Seedance 1.0 Pro Fast (图文视频)',
|
||||
key: 'doubao-seedance-1-0-pro-fast-251015',
|
||||
provider: ['chatfire'],
|
||||
type: 't2v+i2v',
|
||||
ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||
durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
|
||||
resolutions: ['480p', '720p', '1080p'],
|
||||
defaultResolution: '1080p',
|
||||
defaultParams: { ratio: '16:9', duration: 5, resolution: '1080p' }
|
||||
},
|
||||
// 可灵 Kling
|
||||
// {
|
||||
// label: '可灵 Kling v2.5-turbo',
|
||||
// key: 'kling-v2-1',
|
||||
// provider: ['chatfire'], // 仅火宝渠道
|
||||
// ratios: VIDEO_RATIO_LIST.map(s => s.key),
|
||||
// durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
|
||||
// defaultParams: { ratio: '9:16', duration: 10 }
|
||||
// },
|
||||
// {
|
||||
// label: 'runway/gen4-turbo',
|
||||
// key: 'runway/gen4-turbo',
|
||||
// ratios: VIDEO_RATIO_LIST.map(s => s.key),
|
||||
// durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
|
||||
// defaultParams: { ratio: '16:9', duration: 5 }
|
||||
// },
|
||||
// {
|
||||
// label: '可灵视频 O1',
|
||||
// key: 'kling-video-o1',
|
||||
// ratios: VIDEO_RATIO_LIST.map(s => s.key),
|
||||
// durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
|
||||
// defaultParams: { ratio: '16:9', duration: 5 }
|
||||
// },
|
||||
// {
|
||||
// label: 'viduq2-pro_720p', key: 'viduq2-pro_720p',
|
||||
// ratios: VIDEO_RATIO_LIST.map(s => s.key),
|
||||
// durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
|
||||
// defaultParams: { ratio: '16:9', duration: 5 }
|
||||
// },
|
||||
// {
|
||||
// label: 'Sora 2', key: 'sora-2',
|
||||
// ratios: VIDEO_RATIO_LIST.map(s => s.key),
|
||||
// durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
|
||||
// defaultParams: { ratio: '16:9', duration: 5 }
|
||||
// }
|
||||
]
|
||||
|
||||
// Chat/LLM models | 对话模型
|
||||
export const CHAT_MODELS = [
|
||||
{ label: 'SKG 提示词助手', key: 'skg-copy', provider: ['skg'] }
|
||||
{ label: 'GPT-4o Mini', key: 'gpt-4o-mini', provider: ['openai'] },
|
||||
{ label: 'GPT-4o', key: 'gpt-4o', provider: ['openai'] },
|
||||
{ label: 'GPT-5.2', key: 'gpt-5.2', provider: ['openai'] },
|
||||
{ label: 'DeepSeek Chat', key: 'deepseek-chat', provider: ['openai', 'chatfire'] },
|
||||
{ label: '豆包 Seed Flash', key: 'doubao-seed-1-6-flash-250615', provider: ['chatfire'] },
|
||||
{ label: 'Gemini 3 Pro', key: 'gemini-3-pro', provider: ['openai'] }
|
||||
]
|
||||
|
||||
export const IMAGE_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS
|
||||
export const IMAGE_QUALITY_OPTIONS = SKG_IMAGE_QUALITY_OPTIONS
|
||||
export const IMAGE_STYLE_OPTIONS = [{ label: '商业营销', key: 'commercial' }]
|
||||
export const VIDEO_RATIO_OPTIONS = SKG_VIDEO_SIZE_OPTIONS
|
||||
export const VIDEO_DURATION_OPTIONS = [5, 8, 10, 12, 15].map(s => ({ label: `${s} 秒`, key: s }))
|
||||
// Image size options | 图片尺寸选项
|
||||
export const IMAGE_SIZE_OPTIONS = [
|
||||
{ label: '2048x2048', key: '2048x2048' },
|
||||
{ label: '1792x1024 (横版)', key: '1792x1024' },
|
||||
{ label: '1024x1792 (竖版)', key: '1024x1792' }
|
||||
]
|
||||
|
||||
export const SEEDREAM_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS
|
||||
export const SEEDREAM_4K_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS
|
||||
export const SEEDREAM_QUALITY_OPTIONS = SKG_IMAGE_QUALITY_OPTIONS
|
||||
// Image quality options | 图片质量选项
|
||||
export const IMAGE_QUALITY_OPTIONS = [
|
||||
{ label: '标准', key: 'standard' },
|
||||
{ label: '高清', key: 'hd' }
|
||||
]
|
||||
|
||||
export const DEFAULT_IMAGE_MODEL = 'auto'
|
||||
export const DEFAULT_VIDEO_MODEL = 'seedance'
|
||||
export const DEFAULT_CHAT_MODEL = 'skg-copy'
|
||||
export const DEFAULT_IMAGE_SIZE = '1024x1536'
|
||||
export const DEFAULT_VIDEO_RATIO = '720x1280'
|
||||
export const DEFAULT_VIDEO_DURATION = 10
|
||||
// Image style options | 图片风格选项
|
||||
export const IMAGE_STYLE_OPTIONS = [
|
||||
{ label: '生动', key: 'vivid' },
|
||||
{ label: '自然', key: 'natural' }
|
||||
]
|
||||
|
||||
// Video ratio options | 视频比例选项
|
||||
export const VIDEO_RATIO_OPTIONS = VIDEO_RATIO_LIST
|
||||
|
||||
// Video duration options | 视频时长选项
|
||||
export const VIDEO_DURATION_OPTIONS = [
|
||||
{ label: '5 秒', key: 5 },
|
||||
{ label: '10 秒', key: 10 }
|
||||
]
|
||||
|
||||
// Default values | 默认值
|
||||
export const DEFAULT_IMAGE_MODEL = 'nano-banana-pro'
|
||||
export const DEFAULT_VIDEO_MODEL = 'doubao-seedance-1-5-pro-251215'
|
||||
export const DEFAULT_CHAT_MODEL = 'gpt-4o-mini'
|
||||
export const DEFAULT_IMAGE_SIZE = '2048x2048'
|
||||
export const DEFAULT_VIDEO_RATIO = '16:9'
|
||||
export const DEFAULT_VIDEO_DURATION = 5
|
||||
|
||||
// Get model by key | 根据 key 获取模型
|
||||
export const getModelByName = (key) => {
|
||||
const allModels = [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS]
|
||||
return allModels.find(m => m.key === key)
|
||||
const allModels = [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS]
|
||||
return allModels.find(m => m.key === key)
|
||||
}
|
||||
|
||||
@@ -1,40 +1,272 @@
|
||||
/**
|
||||
* SKG internal provider config.
|
||||
* The browser never receives upstream model keys; all generation goes through /api.
|
||||
* API Provider Adapters | API 渠道适配器
|
||||
* 适配不同 API 提供商的请求参数和响应格式
|
||||
*/
|
||||
|
||||
// 渠道适配配置
|
||||
export const PROVIDERS = {
|
||||
skg: {
|
||||
label: 'SKG 内部模型',
|
||||
chatfire: {
|
||||
label: 'SKG 内部',
|
||||
defaultBaseUrl: '/api',
|
||||
// 端点路径
|
||||
endpoints: {
|
||||
chat: '/creative/copy',
|
||||
image: '/jobs/{jobId}/frames/{idx}/generate',
|
||||
video: '/jobs/{jobId}/frames/{idx}/storyboard/video',
|
||||
videoQuery: '/jobs/{jobId}'
|
||||
chat: '/v1/chat/completions',
|
||||
image: '/v1/images/generations',
|
||||
video: '/v1/video/generations',
|
||||
videoQuery: '/v1/video/task/{taskId}'
|
||||
},
|
||||
// 火宝渠道请求适配
|
||||
requestAdapter: {
|
||||
chat: (params) => params,
|
||||
image: (params) => params,
|
||||
video: (params) => params
|
||||
chat: (params) => {
|
||||
const adapted = {
|
||||
model: params.model,
|
||||
messages: params.messages
|
||||
}
|
||||
if (params.temperature !== undefined) adapted.temperature = params.temperature
|
||||
if (params.max_tokens !== undefined) adapted.max_tokens = params.max_tokens
|
||||
if (params.stream !== undefined) adapted.stream = params.stream
|
||||
return adapted
|
||||
},
|
||||
image: (params) => {
|
||||
const adapted = {
|
||||
model: params.model,
|
||||
prompt: params.prompt
|
||||
}
|
||||
if (params.size) adapted.size = params.size
|
||||
if (params.n) adapted.n = params.n
|
||||
if (params.quality) adapted.quality = params.quality
|
||||
if (params.style) adapted.style = params.style
|
||||
if (params.image) adapted.image = params.image
|
||||
return adapted
|
||||
},
|
||||
video: (params) => {
|
||||
const model = params.model || ''
|
||||
|
||||
// Seedance 模型 - 使用 content 数组格式
|
||||
if (model.includes('seedance')) {
|
||||
const content = []
|
||||
|
||||
// 构建完整参数文本
|
||||
// 格式: prompt --resolution 720p --ratio 16:9 --dur 5 --fps 24 --wm true --seed 11 --cf false
|
||||
let textPrompt = params.prompt || ''
|
||||
|
||||
// 添加 resolution 参数
|
||||
if (params.resolution) {
|
||||
textPrompt += ` --resolution ${params.resolution}`
|
||||
}
|
||||
|
||||
// 添加 ratio 参数 (图生视频用 16:9)
|
||||
if (params.size) {
|
||||
textPrompt += ` --ratio ${params.size}`
|
||||
}
|
||||
|
||||
// 添加 duration 参数
|
||||
if (params.seconds) {
|
||||
textPrompt += ` --dur ${params.seconds}`
|
||||
}
|
||||
|
||||
// 添加 fps (固定 24)
|
||||
textPrompt += ` --fps 24`
|
||||
|
||||
// 添加水印参数 (默认 true)
|
||||
textPrompt += ` --wm ${params.wm !== false ? 'true' : 'false'}`
|
||||
|
||||
// 添加 seed 参数 (可选)
|
||||
if (params.seed !== undefined) {
|
||||
textPrompt += ` --seed ${params.seed}`
|
||||
}
|
||||
|
||||
// 添加 cf 参数 (默认 false)
|
||||
textPrompt += ` --cf ${params.cf === true ? 'true' : 'false'}`
|
||||
|
||||
content.push({
|
||||
type: 'text',
|
||||
text: textPrompt
|
||||
})
|
||||
|
||||
// 添加参考图(如果有)
|
||||
if (params.first_frame_image) {
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: params.first_frame_image
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const adapted = {
|
||||
model: model,
|
||||
content: content,
|
||||
generate_audio: params.generateAudio !== false
|
||||
}
|
||||
|
||||
return adapted
|
||||
}
|
||||
|
||||
// Kling 模型 - 使用 kling 特定格式
|
||||
if (model.includes('kling')) {
|
||||
// 将 ratio 转换为 aspect_ratio 格式
|
||||
const ratioMap = {
|
||||
'16:9': '16:9',
|
||||
'9:16': '9:16',
|
||||
'1:1': '1:1',
|
||||
'4:3': '4:3',
|
||||
'3:4': '3:4'
|
||||
}
|
||||
|
||||
const adapted = {
|
||||
model_name: model,
|
||||
mode: 'std',
|
||||
prompt: params.prompt || '',
|
||||
aspect_ratio: ratioMap[params.size] || '16:9',
|
||||
duration: params.seconds || 5,
|
||||
negative_prompt: '',
|
||||
cfg_scale: 0.5
|
||||
}
|
||||
|
||||
// 添加参考图(如果有)
|
||||
if (params.first_frame_image) {
|
||||
adapted.image = params.first_frame_image
|
||||
}
|
||||
|
||||
return adapted
|
||||
}
|
||||
|
||||
// 默认格式(veo 等)
|
||||
const adapted = {
|
||||
model: params.model,
|
||||
prompt: params.prompt || ''
|
||||
}
|
||||
if (params.first_frame_image) adapted.first_frame_image = params.first_frame_image
|
||||
if (params.last_frame_image) adapted.last_frame_image = params.last_frame_image
|
||||
if (params.size) adapted.size = params.size
|
||||
if (params.seconds) adapted.seconds = params.seconds
|
||||
|
||||
return adapted
|
||||
}
|
||||
},
|
||||
// 火宝渠道响应格式
|
||||
responseAdapter: {
|
||||
chat: (response) => response,
|
||||
image: (response) => response,
|
||||
video: (response) => response
|
||||
chat: (response) => {
|
||||
if (response.choices && response.choices.length > 0) {
|
||||
return response.choices[0].message?.content || ''
|
||||
}
|
||||
return ''
|
||||
},
|
||||
image: (response) => {
|
||||
const data = response.data || response
|
||||
return (Array.isArray(data) ? data : [data]).map(item => ({
|
||||
url: item.url || item.b64_json || '',
|
||||
revisedPrompt: item.revised_prompt || ''
|
||||
}))
|
||||
},
|
||||
video: (response) => {
|
||||
return {
|
||||
url: response.data?.url || response.url || response.data?.[0]?.url || '',
|
||||
...response
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
default: 'skg'
|
||||
openai: {
|
||||
label: 'OpenAI',
|
||||
defaultBaseUrl: 'https://api.openai.com',
|
||||
// 端点路径
|
||||
endpoints: {
|
||||
chat: '/v1/chat/completions',
|
||||
image: '/v1/images/generations',
|
||||
video: '/v1/videos',
|
||||
videoQuery: '/v1/videos/{taskId}'
|
||||
},
|
||||
// 请求参数适配
|
||||
requestAdapter: {
|
||||
chat: (params) => {
|
||||
const adapted = {
|
||||
model: params.model,
|
||||
messages: params.messages
|
||||
}
|
||||
// 添加可选参数
|
||||
if (params.temperature !== undefined) adapted.temperature = params.temperature
|
||||
if (params.max_tokens !== undefined) adapted.max_tokens = params.max_tokens
|
||||
if (params.stream !== undefined) adapted.stream = params.stream
|
||||
return adapted
|
||||
},
|
||||
image: (params) => {
|
||||
const adapted = {
|
||||
model: params.model,
|
||||
prompt: params.prompt
|
||||
}
|
||||
if (params.size) adapted.size = params.size
|
||||
if (params.n) adapted.n = params.n
|
||||
if (params.quality) adapted.quality = params.quality
|
||||
if (params.style) adapted.style = params.style
|
||||
if (params.image) adapted.image = params.image
|
||||
return adapted
|
||||
},
|
||||
video: (params) => {
|
||||
const adapted = {
|
||||
model: params.model,
|
||||
prompt: params.prompt || ''
|
||||
}
|
||||
if (params.first_frame_image) adapted.first_frame_image = params.first_frame_image
|
||||
if (params.last_frame_image) adapted.last_frame_image = params.last_frame_image
|
||||
if (params.size) adapted.size = params.size
|
||||
if (params.seconds) adapted.seconds = params.seconds
|
||||
return adapted
|
||||
}
|
||||
},
|
||||
// 响应数据适配
|
||||
responseAdapter: {
|
||||
chat: (response) => {
|
||||
if (response.choices && response.choices.length > 0) {
|
||||
return response.choices[0].message?.content || ''
|
||||
}
|
||||
return ''
|
||||
},
|
||||
image: (response) => {
|
||||
const data = response.data || response
|
||||
return (Array.isArray(data) ? data : [data]).map(item => ({
|
||||
url: item.url || item.b64_json || '',
|
||||
revisedPrompt: item.revised_prompt || ''
|
||||
}))
|
||||
},
|
||||
video: (response) => {
|
||||
return {
|
||||
url: response.data?.url || response.url || response.data?.[0]?.url || '',
|
||||
...response
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
// 默认使用 OpenAI 格式
|
||||
default: 'chatfire'
|
||||
}
|
||||
|
||||
export const getProviderList = () => (
|
||||
Object.entries(PROVIDERS)
|
||||
// 获取渠道列表
|
||||
export const getProviderList = () => {
|
||||
return Object.entries(PROVIDERS)
|
||||
.filter(([key]) => key !== 'default')
|
||||
.map(([key, value]) => ({ key, label: value.label }))
|
||||
)
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: value.label
|
||||
}))
|
||||
}
|
||||
|
||||
export const getDefaultProvider = () => PROVIDERS.default || 'skg'
|
||||
// 获取默认渠道
|
||||
export const getDefaultProvider = () => {
|
||||
return PROVIDERS.default || 'chatfire'
|
||||
}
|
||||
|
||||
export const getProviderConfig = (provider) => PROVIDERS[provider] || PROVIDERS.skg
|
||||
// 获取渠道的默认 Base URL
|
||||
export const getDefaultBaseUrl = (providerKey) => {
|
||||
const config = getProviderConfig(providerKey)
|
||||
return config.defaultBaseUrl || ''
|
||||
}
|
||||
|
||||
export const getDefaultBaseUrl = (provider) => getProviderConfig(provider).defaultBaseUrl
|
||||
// 获取渠道配置
|
||||
export const getProviderConfig = (providerKey) => {
|
||||
return PROVIDERS[providerKey] || PROVIDERS[PROVIDERS.default]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -84,7 +84,7 @@ const customChatModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, []))
|
||||
const customImageModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, []))
|
||||
const customVideoModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, []))
|
||||
|
||||
// 按渠道存储的自定义模型 | 结构: { 'skg': [{key, label}] }
|
||||
// 按渠道存储的自定义模型 | 结构: { 'openai': [{key, label}], 'chatfire': [{key, label}] }
|
||||
const customChatModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER || 'custom-chat-models-by-provider', {}))
|
||||
const customImageModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER || 'custom-image-models-by-provider', {}))
|
||||
const customVideoModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER || 'custom-video-models-by-provider', {}))
|
||||
@@ -411,7 +411,7 @@ export const useModelConfig = () => {
|
||||
getImageModel,
|
||||
getVideoModel,
|
||||
|
||||
// Get models by provider
|
||||
// Get models by provider (for ApiSettings)
|
||||
getModelsByProvider,
|
||||
|
||||
// Custom models by provider
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,14 @@ const setStored = (key, value) => {
|
||||
}
|
||||
}
|
||||
|
||||
const removeStored = (key) => {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored JSON value from localStorage
|
||||
*/
|
||||
@@ -94,7 +102,8 @@ export const useModelStore = defineStore('model', () => {
|
||||
// ============ Provider 状态 | Provider State ============
|
||||
|
||||
// 当前选中的渠道
|
||||
const currentProvider = ref(getStored(STORAGE_KEYS.PROVIDER) || getDefaultProvider())
|
||||
const storedProvider = getStored(STORAGE_KEYS.PROVIDER)
|
||||
const currentProvider = ref(PROVIDERS[storedProvider] ? storedProvider : getDefaultProvider())
|
||||
|
||||
// 渠道列表
|
||||
const providerList = computed(() => getProviderList())
|
||||
|
||||
@@ -26,6 +26,14 @@
|
||||
>
|
||||
<n-icon :size="20"><DownloadOutline /></n-icon>
|
||||
</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>
|
||||
</AppHeader>
|
||||
|
||||
@@ -142,7 +150,7 @@
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm text-[var(--accent-color)] mb-2">
|
||||
<n-spin :size="14" />
|
||||
<span>正在创建生成任务...</span>
|
||||
<span>正在生成提示词...</span>
|
||||
</div>
|
||||
<div v-if="currentResponse" class="text-sm text-[var(--text-primary)] whitespace-pre-wrap">
|
||||
{{ currentResponse }}
|
||||
@@ -150,31 +158,6 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-model="chatInput"
|
||||
:placeholder="inputPlaceholder"
|
||||
@@ -186,14 +169,23 @@
|
||||
/>
|
||||
<div class="flex items-center justify-between mt-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)]">
|
||||
<img :src="firstFramePreview" alt="参考图片" class="h-full w-full object-cover" />
|
||||
</span>
|
||||
<button
|
||||
@click="handlePolish"
|
||||
: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 class="flex items-center gap-3">
|
||||
<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"
|
||||
: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"
|
||||
>
|
||||
<n-spin v-if="isProcessing" :size="16" />
|
||||
@@ -203,9 +195,27 @@
|
||||
</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>
|
||||
|
||||
<!-- API Settings Modal | API 设置弹窗 -->
|
||||
<ApiSettings v-model:show="showApiSettings" />
|
||||
|
||||
<!-- Rename Modal | 重命名弹窗 -->
|
||||
<n-modal v-model:show="showRenameModal" preset="dialog" title="重命名项目">
|
||||
<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 { Background } from '@vue-flow/background'
|
||||
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 {
|
||||
ChevronBackOutline,
|
||||
ChevronDownOutline,
|
||||
SettingsOutline,
|
||||
AddOutline,
|
||||
ImageOutline,
|
||||
SendOutline,
|
||||
RefreshOutline,
|
||||
TextOutline,
|
||||
VideocamOutline,
|
||||
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 { loadAllModels } from '../stores/models'
|
||||
import { useChat, useWorkflowOrchestrator } from '../hooks'
|
||||
import { useModelStore } from '../stores/pinia'
|
||||
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 WorkflowPanel from '../components/WorkflowPanel.vue'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
|
||||
// API Config state | API 配置状态
|
||||
const modelStore = useModelStore()
|
||||
const isApiConfigured = computed(() => !!modelStore.currentApiKey)
|
||||
|
||||
// Initialize models on page load | 页面加载时初始化模型
|
||||
onMounted(() => {
|
||||
loadAllModels()
|
||||
@@ -355,25 +374,12 @@ const edgeTypes = {
|
||||
// UI state | UI状态
|
||||
const showNodeMenu = ref(false)
|
||||
const chatInput = ref('')
|
||||
const autoExecute = ref(false)
|
||||
const isMobile = ref(false)
|
||||
const showGrid = ref(true)
|
||||
const showApiSettings = 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
|
||||
const flowKey = ref(Date.now())
|
||||
|
||||
@@ -426,42 +432,15 @@ const nodeTypeOptions = [
|
||||
]
|
||||
|
||||
// Input placeholder | 输入占位符
|
||||
const inputPlaceholder = computed(() => {
|
||||
if (creationMode.value === 'text-image') return '写清楚画面、主体、构图、光线、比例和 SKG 产品露出方式'
|
||||
if (creationMode.value === 'image-video') return '上传图片后,写人物动作、镜头运动、产品细节保持和视频节奏'
|
||||
return '写清楚画面、动作、镜头、产品出现方式、视频比例和时长'
|
||||
})
|
||||
const inputPlaceholder = '你可以试着说"帮我生成一个二次元的卡通角色"'
|
||||
|
||||
const setCreationMode = (mode) => {
|
||||
creationMode.value = mode
|
||||
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 = ''
|
||||
}
|
||||
// Quick suggestions | 快捷建议
|
||||
const suggestions = [
|
||||
'像个魔法森林',
|
||||
'三只不同的小猫',
|
||||
'生成多角度分镜',
|
||||
'夏日田野环绕漫步'
|
||||
]
|
||||
|
||||
// Add new node | 添加新节点
|
||||
const addNewNode = async (type) => {
|
||||
@@ -549,7 +528,7 @@ const onConnect = (params) => {
|
||||
addEdge({
|
||||
...params,
|
||||
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') {
|
||||
// Use promptOrder edge type | 使用提示词顺序边类型
|
||||
@@ -703,10 +682,48 @@ const handleEnterKey = (e) => {
|
||||
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 | 发送消息
|
||||
const sendMessage = async () => {
|
||||
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
|
||||
const content = chatInput.value
|
||||
@@ -721,63 +738,54 @@ const sendMessage = async () => {
|
||||
const baseX = 100
|
||||
const baseY = maxY + 200
|
||||
|
||||
const textNodeId = addNode('text', { x: baseX, y: baseY }, {
|
||||
content,
|
||||
label: '提示词'
|
||||
})
|
||||
if (autoExecute.value) {
|
||||
// Auto-execute mode: analyze intent and execute workflow | 自动执行模式:分析意图并执行工作流
|
||||
window.$message?.info('正在分析工作流...')
|
||||
|
||||
if (creationMode.value === 'text-image') {
|
||||
const imageConfigNodeId = addNode('imageConfig', { x: baseX + 400, y: baseY }, {
|
||||
label: '文生图',
|
||||
autoExecute: true
|
||||
try {
|
||||
// Analyze user intent | 分析用户意图
|
||||
const result = await analyzeIntent(content)
|
||||
|
||||
// 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({
|
||||
source: textNodeId,
|
||||
target: imageConfigNodeId,
|
||||
sourceHandle: 'right',
|
||||
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) {
|
||||
window.$message?.error(err.message || '创建失败')
|
||||
@@ -850,7 +858,6 @@ onMounted(() => {
|
||||
// Cleanup on unmount | 卸载时清理
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
clearFrameFiles()
|
||||
// Save project before leaving | 离开前保存项目
|
||||
saveProject()
|
||||
})
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
<div class="min-h-screen h-screen overflow-y-auto bg-[var(--bg-primary)]">
|
||||
<!-- Header | 顶部导航 -->
|
||||
<AppHeader>
|
||||
<template #left>
|
||||
<div class="flex h-8 items-center rounded-full bg-white px-3 shadow-sm">
|
||||
<img src="/skg-logo-black.svg" alt="SKG" class="h-6 w-auto dark:invert" />
|
||||
</div>
|
||||
<template #right>
|
||||
<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>
|
||||
</AppHeader>
|
||||
|
||||
@@ -14,8 +19,8 @@
|
||||
<main class="max-w-5xl mx-auto px-4 py-8 md:py-16">
|
||||
<!-- Welcome section | 欢迎区域 -->
|
||||
<section class="text-center mb-12">
|
||||
<div class="flex items-center justify-center mb-8">
|
||||
<img src="/skg-logo-black.svg" alt="SKG" class="h-12 w-auto dark:invert" />
|
||||
<div class="flex items-center justify-center gap-4 mb-8">
|
||||
<img src="/skg-logo-black.svg" alt="SKG" class="h-12 w-auto md:h-16 dark:invert" />
|
||||
<h1 class="sr-only">SKG</h1>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +29,7 @@
|
||||
<div class="bg-[var(--bg-secondary)] rounded-2xl border border-[var(--border-color)] p-4 shadow-sm">
|
||||
<textarea
|
||||
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]"
|
||||
@keydown.enter.ctrl="handleCreateWithInput"
|
||||
/>
|
||||
@@ -48,6 +53,21 @@
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -160,6 +180,9 @@
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<!-- API Settings Modal | API 设置弹窗 -->
|
||||
<ApiSettings v-model:show="showApiSettings" @saved="refreshApiConfig" />
|
||||
|
||||
<!-- Rename modal | 重命名弹窗 -->
|
||||
<n-modal v-model:show="showRenameModal" preset="dialog" title="重命名项目">
|
||||
<n-input v-model:value="renameValue" placeholder="请输入项目名称" />
|
||||
@@ -176,17 +199,20 @@
|
||||
* Home view component | 首页视图组件
|
||||
* 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 { NIcon, NDropdown, NModal, NInput, NButton, useDialog } from 'naive-ui'
|
||||
import {
|
||||
AddOutline,
|
||||
ImageOutline,
|
||||
SendOutline,
|
||||
RefreshOutline,
|
||||
DocumentOutline,
|
||||
FolderOutline,
|
||||
EllipsisHorizontalOutline,
|
||||
CreateOutline,
|
||||
CopyOutline,
|
||||
SettingsOutline,
|
||||
TrashOutline
|
||||
} from '@vicons/ionicons5'
|
||||
import {
|
||||
@@ -197,10 +223,23 @@ import {
|
||||
duplicateProject,
|
||||
renameProject
|
||||
} from '../stores/projects'
|
||||
import { useModelStore } from '../stores/pinia'
|
||||
import ApiSettings from '../components/ApiSettings.vue'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
|
||||
const router = useRouter()
|
||||
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 | 视频引用用于悬停播放
|
||||
const videoRefs = new Map()
|
||||
|
||||
@@ -238,6 +277,14 @@ const showRenameModal = ref(false)
|
||||
const renameValue = ref('')
|
||||
const renameTargetId = ref(null)
|
||||
|
||||
// Suggestions tags | 建议标签
|
||||
const suggestions = [
|
||||
'雨中魔法森林',
|
||||
'日式街面美食摄影',
|
||||
'瀑布水流飞溅',
|
||||
'雨天富声旁边花语'
|
||||
]
|
||||
|
||||
// Format date | 格式化日期
|
||||
const formatDate = (date) => {
|
||||
if (!date) return ''
|
||||
@@ -305,24 +352,46 @@ const confirmRename = () => {
|
||||
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 | 创建新项目
|
||||
const createNewProject = () => {
|
||||
const id = createProject('未命名项目')
|
||||
router.push(`/p/${id}`)
|
||||
checkApiKeyAndNavigate(() => {
|
||||
const id = createProject('未命名项目')
|
||||
router.push(`/p/${id}`)
|
||||
})
|
||||
}
|
||||
|
||||
// Create project with input text | 使用输入文本创建项目
|
||||
const handleCreateWithInput = () => {
|
||||
const name = inputText.value.trim() || '未命名项目'
|
||||
const id = createProject(name)
|
||||
sessionStorage.setItem('ai-canvas-initial-prompt', inputText.value.trim())
|
||||
inputText.value = ''
|
||||
router.push(`/p/${id}`)
|
||||
checkApiKeyAndNavigate(() => {
|
||||
const name = inputText.value.trim() || '未命名项目'
|
||||
const id = createProject(name)
|
||||
// Store the input text to be used as initial prompt
|
||||
sessionStorage.setItem('ai-canvas-initial-prompt', inputText.value.trim())
|
||||
inputText.value = ''
|
||||
router.push(`/p/${id}`)
|
||||
})
|
||||
}
|
||||
|
||||
// Open existing project | 打开已有项目
|
||||
const openProject = (project) => {
|
||||
router.push(`/p/${project.id}`)
|
||||
checkApiKeyAndNavigate(() => {
|
||||
router.push(`/p/${project.id}`)
|
||||
})
|
||||
}
|
||||
|
||||
// Check if URL is a video | 检查 URL 是否为视频
|
||||
|
||||
Reference in New Issue
Block a user