Compare commits
286 Commits
backend-wo
...
3f216727bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f216727bb | |||
| b1aab451ef | |||
| ff0bfaa8b2 | |||
| d038f1b2f4 | |||
| e14acee2a7 | |||
| 538bfb8f59 | |||
| 22421eb117 | |||
| 3572ddebef | |||
| 6201ee9a7d | |||
| b56d5177e5 | |||
| 3ed3f721eb | |||
| 56ea8aef11 | |||
| 854947a239 | |||
| 4bcca76098 | |||
| 5c6476fe1a | |||
| 47b7073514 | |||
| fd5aefe1b9 | |||
| e97dc032d5 | |||
| 538b24a2fd | |||
| d7f72f6b42 | |||
| b6a7e7b4b8 | |||
| 0c30fb9091 | |||
| 13d9057318 | |||
| dab4bde28f | |||
| 6ac548a937 | |||
| fb939b8fcf | |||
| 9ab541796b | |||
| 8999fe0baf | |||
| ec38215dd5 | |||
| 685a6c4d64 | |||
| 52e7a01a7e | |||
| fdef7f77e1 | |||
| 3035efcceb | |||
| f3c0500b60 | |||
| 284296d3e9 | |||
| 3c146d64a0 | |||
| 22398c1483 | |||
| a699899323 | |||
| 5046e2304e | |||
| 934bdd1fa2 | |||
| e0df6a5d0f | |||
| 0eb775dff3 | |||
| 6d32b63eab | |||
| fe92c7943d | |||
| 8d5311c60a | |||
| ef9b8312ec | |||
| bdb7226642 | |||
| ffdb60c463 | |||
| 56a23847a1 | |||
| cb0659fa00 | |||
| 5d047af346 | |||
| 47300b8fa6 | |||
| f5be97b9e7 | |||
| 13fa5a08da | |||
| 5290812353 | |||
| bbd1f08f7c | |||
| 7f3a6cc429 | |||
| 054f082323 | |||
| d01fdc5508 | |||
| 97f617197c | |||
| daec523dec | |||
| a3a9ed90e2 | |||
| 509bd9b594 | |||
| c415cd0aba | |||
| 591bc37990 | |||
| 579e538aa7 | |||
| 836a33e85b | |||
| e0330bfb28 | |||
| 8aeeee6838 | |||
| 79696b7d3f | |||
| c5ddfedf03 | |||
| d803d65c0c | |||
| c9d8fa7139 | |||
| 4104bbe5d5 | |||
| 544087cf9d | |||
| 089a30d970 | |||
| 830afac720 | |||
| 327cd2b113 | |||
| 96f19a49d1 | |||
| 8278de44cb | |||
| 84d9de6b30 | |||
| 103907ca3a | |||
| cce9779a8a | |||
| 8bb4c96556 | |||
| e767d2b388 | |||
| fb9dc17b42 | |||
| 378d151b14 | |||
| 2a1ceeec3e | |||
| 7d98de0df3 | |||
| 2192f15beb | |||
| f21254fa82 | |||
| 2d19560dd3 | |||
| c425b82415 | |||
| 779e9b342b | |||
| f49d4b248c | |||
| b2d84dce5c | |||
| a82069f26a | |||
| 486a682320 | |||
| d246563dc1 | |||
| a02c5eb48c | |||
| a69ab8106b | |||
| e77e77fada | |||
| fa64f95911 | |||
| dcc8abc812 | |||
| 6ba84a7603 | |||
| 7b4351fe55 | |||
| eca5213dab | |||
| 976b318432 | |||
| 04d80c133a | |||
| 3b1d7645d1 | |||
| f8c51b5ef6 | |||
| 8e60c7dff9 | |||
| a27dcbda8d | |||
| 828b86d187 | |||
| c1eddda59e | |||
| 04a822ac79 | |||
| 90dde14ac3 | |||
| 3146266383 | |||
| 0e55945352 | |||
| d551c45006 | |||
| 0d5c32630f | |||
| 7ae92ae4d9 | |||
| 642793500f | |||
| eb4347a843 | |||
| 4efb2ce456 | |||
| cc12d7c6a7 | |||
| 77d23a06b3 | |||
| 775ad79661 | |||
| a3ddb05424 | |||
| 02a9999d8c | |||
| b6fec10371 | |||
| 7bb4f3ea9f | |||
| b82dad4aa8 | |||
| 68ecc8b97b | |||
| 8458dac4bf | |||
| 5c47ea37c9 | |||
| 36da23beb2 | |||
| a48c2965d9 | |||
| d83e56169d | |||
| 8421af2af8 | |||
| f2655e1418 | |||
| def4900c1d | |||
| c805012adc | |||
| 536b4d7f59 | |||
| 1f193e95f3 | |||
| 6597db312b | |||
| dbedabaae4 | |||
| 2b842fd106 | |||
| 9c05e0bd6e | |||
| ab31a98383 | |||
| 39b9d211aa | |||
| 215987aab6 | |||
| af7622586c | |||
| e1e9bf8ca1 | |||
| fc06816483 | |||
| 45b25d01f1 | |||
| 06f3eb0504 | |||
| 54f159b6ef | |||
| f1137cc006 | |||
| d1e2b1785e | |||
| 41e71d3865 | |||
| caa7b730a6 | |||
| d03b38d75a | |||
| 0db265f086 | |||
| 97cca8d855 | |||
| 5bffd6308e | |||
| f0f2203eed | |||
| f0f567bc16 | |||
| b4f56122f2 | |||
| 3e7c1653f4 | |||
| 5b13a5cdfc | |||
| e33463e99f | |||
| ddaa795c90 | |||
| f35bfe0c5b | |||
| 4a22ca0948 | |||
| 1d0a77b46a | |||
| ae0526955f | |||
| 54eaac0de2 | |||
| 9f57cbace1 | |||
| 64fef5ae60 | |||
| 2ce2d3ec2e | |||
| 40f1f284d3 | |||
| f495e8384b | |||
| 2c0e8a058d | |||
| ab78385633 | |||
| 5bdde89809 | |||
| fc3e64d32a | |||
| 10d955c6f2 | |||
| eeb7186d7a | |||
| 659ee10efb | |||
| b9c5511128 | |||
| 5ac48749df | |||
| c43c707cde | |||
| 85d365069b | |||
| 1618ac13f1 | |||
| 7e763cf51b | |||
| fd676c71f9 | |||
| f1c710edc2 | |||
| 52b839c614 | |||
| d82175f0f3 | |||
| b3cc0aa83c | |||
| e10b1a6e1a | |||
| 83b151df04 | |||
| bc46291afd | |||
| 35fc088375 | |||
| 3d198b024b | |||
| 5f37dd98df | |||
| 7acbfd5214 | |||
| e64bf40267 | |||
| 2366662d33 | |||
| 75666f151f | |||
| c245bff4b8 | |||
| b4a7968c1b | |||
| 3756259850 | |||
| 516d99ba8c | |||
| fd794e3e30 | |||
| 00df9d01fe | |||
| 47299396dc | |||
| 39ab443389 | |||
| 15c6f4d2fc | |||
| aabddef486 | |||
| fe60d5dc99 | |||
| bdadd71bcf | |||
| a5979bb0d7 | |||
| b9bf50f851 | |||
| ce5f3b448d | |||
| 7604ed1dfe | |||
| f574ab4775 | |||
| 818d785d14 | |||
| 97016dcc0f | |||
| 883e1d4de6 | |||
| cb991e7a17 | |||
| d461c65472 | |||
| 1f6eabc819 | |||
| 7a7a3b5e19 | |||
| 4f74fa8364 | |||
| 4753d5e8bf | |||
| 45395cfa3e | |||
| a9171e225a | |||
| b9ba8113f0 | |||
| a7aa280330 | |||
| 54979bc4e2 | |||
| e03c5db3fd | |||
| 64a9673fa1 | |||
| 980d252815 | |||
| 68ab3dab96 | |||
| e6d957fcab | |||
| ce4ff74b7d | |||
| f576875af2 | |||
| b5855fd457 | |||
| 6d950ef900 | |||
| f4d456cef0 | |||
| 3462758585 | |||
| ff7bf00f6d | |||
| 5b44d35316 | |||
| 2954e58740 | |||
| 9a4268281e | |||
| e5652c463c | |||
| 2fe3db8f88 | |||
| 49c998f66c | |||
| 3b7a06272d | |||
| e6004b3bb5 | |||
| 33b91a2b3e | |||
| 73e8ffecc6 | |||
| 32620af91d | |||
| 58fe17c5e0 | |||
| 2e2998c5df | |||
| b886e02746 | |||
| 69bb692a58 | |||
| bc0b010def | |||
| adf8b2ba0a | |||
| 33c3aef669 | |||
| 87ffa6bac7 | |||
| 06bf0ee57f | |||
| 095c6f1c00 | |||
| cc4c021074 | |||
| e19e5db595 | |||
| 92f04f1a7e | |||
| 1f600ae436 | |||
| 78bd294d57 | |||
| cdffc4ba08 | |||
| 48d4002cbd | |||
| d9b51348fe | |||
| c22bee4878 | |||
| 4991526bcc | |||
| 665a0efca6 |
@@ -13,6 +13,9 @@ web/.next
|
|||||||
web/out
|
web/out
|
||||||
|
|
||||||
api/.venv
|
api/.venv
|
||||||
|
api/.env
|
||||||
|
api/.env.local
|
||||||
|
api/.env.production
|
||||||
api/jobs
|
api/jobs
|
||||||
jobs
|
jobs
|
||||||
data
|
data
|
||||||
@@ -21,3 +24,5 @@ data
|
|||||||
.env.local
|
.env.local
|
||||||
.env.production
|
.env.production
|
||||||
deploy/.env.production
|
deploy/.env.production
|
||||||
|
deploy/.env.local
|
||||||
|
data-local
|
||||||
|
|||||||
13
.gitignore
vendored
@@ -15,12 +15,25 @@ __pycache__/
|
|||||||
.logs/
|
.logs/
|
||||||
.pids/
|
.pids/
|
||||||
deploy/.env.production
|
deploy/.env.production
|
||||||
|
deploy/.env.local
|
||||||
deploy/.htpasswd
|
deploy/.htpasswd
|
||||||
|
secrets/
|
||||||
|
.backups/
|
||||||
|
data-local/
|
||||||
|
|
||||||
# api
|
# api
|
||||||
api/.venv/
|
api/.venv/
|
||||||
api/jobs/
|
api/jobs/
|
||||||
|
asset_library/*
|
||||||
|
!asset_library/.gitkeep
|
||||||
|
prompt_library/*
|
||||||
|
!prompt_library/.gitkeep
|
||||||
|
_trash/
|
||||||
|
output/
|
||||||
|
.playwright-cli/
|
||||||
|
|
||||||
# web
|
# web
|
||||||
web/.next/
|
web/.next/
|
||||||
web/out/
|
web/out/
|
||||||
|
web/public/canvas/
|
||||||
|
.pnpm-store/
|
||||||
|
|||||||
@@ -1,103 +1,107 @@
|
|||||||
# 项目接力
|
# 项目接力
|
||||||
|
|
||||||
- 生成时间:May 16, 2026 at 16:38
|
- 生成时间:May 31, 2026 at 15:25
|
||||||
- 项目:SKG Marketing Studio / SKG 营销内容工作台
|
- 项目:SKG 营销内容生产平台
|
||||||
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||||
- 状态:active
|
- 状态:active
|
||||||
- 主链接:https://marketing.skg.com
|
- 主链接:https://marketing.skg.com
|
||||||
|
|
||||||
## 最近助手会话概览
|
## 最近助手会话概览
|
||||||
|
|
||||||
- Claude:be53a031-9311-4ee8-b822-d4cfb4f5e78b · 时间未知
|
- Claude:eb894273-808b-439c-b4b9-840ad2553d0b · 时间未知
|
||||||
- Codex:019e2274-9db5-71c1-85e7-2bb0519c8ba9 · 时间未知
|
- Codex:019e63ac-1984-7a42-8c65-ffd7ab146968 · 时间未知
|
||||||
- Cursor:未找到匹配当前项目的最近会话
|
|
||||||
|
|
||||||
## Claude 最近会话
|
## Claude 最近会话
|
||||||
|
|
||||||
- Session ID:be53a031-9311-4ee8-b822-d4cfb4f5e78b
|
- Session ID:eb894273-808b-439c-b4b9-840ad2553d0b
|
||||||
- Transcript:/Users/kangwan/.claude/projects/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/be53a031-9311-4ee8-b822-d4cfb4f5e78b.jsonl
|
- Transcript:/Users/kangwan/.claude/projects/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/eb894273-808b-439c-b4b9-840ad2553d0b.jsonl
|
||||||
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/api/jobs
|
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||||
- 分支:HEAD
|
- 分支:main
|
||||||
- 敏感字段:已对 token / key / password / secret 做脱敏
|
- 敏感字段:已对 token / key / password / secret 做脱敏
|
||||||
|
|
||||||
### 最近用户要求
|
### 最近用户要求
|
||||||
|
|
||||||
- 音频处理的卡片太大了也不美观你看下怎么调整 里面也不填写东西,你别弄没用的展示
|
- <task-notification> <task-id>b0600axey</task-id> <tool-use-id>toolu_01WmFAqcK9tAmbAo5pyeYMHE</tool-use-id> <output-file>/private/tmp/claude-501/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/eb894273-808b-439c-b4b9-840ad2553d0b/tasks/b0600axey.output</output-file> <status>completed</status> <summary>Background command "构建并启动本地 Docker" completed (exit code 0)</summary> </task-notification>
|
||||||
- 你也要稍微说一下这是干什么的,调用了什么模型
|
- 继续
|
||||||
- 缩略图下方的滑动条需要明显一点且便于点击拖动,不然我总是点击不到
|
- <task-notification> <task-id>b2merdsp4</task-id> <tool-use-id>toolu_012dVaF8oA6bNGWwNtnMP95D</tool-use-id> <output-file>/private/tmp/claude-501/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/eb894273-808b-439c-b4b9-840ad2553d0b/tasks/b2merdsp4.output</output-file> <status>completed</status> <summary>Background command "生产安全部署(备份+rsync+服务器构建+验证)" completed (exit code 0)</summary> </task-notification>
|
||||||
|
|
||||||
### Claude 最近回复
|
### Claude 最近回复
|
||||||
|
|
||||||
> 加个粗紫色滚动条,覆盖节点内所有横滚浮条:
|
> Deploy fact recorded. `.project.json` needs no change (no URL changes this round). Committing the RULES.md update and pushing:
|
||||||
|
|
||||||
> 滚动条加粗到 14px + 紫色加亮:
|
> 完成。全部上线并验证。
|
||||||
>
|
>
|
||||||
> | | 浮条 1 (InputNode) | 浮条 2 (VisualLab) |
|
> ## 最终交付
|
||||||
> |---|---|---|
|
|
||||||
> | 可视宽 | 1255 | 620 |
|
|
||||||
> | 内容总宽 | 2498 | 988 |
|
|
||||||
> | 需要横滚 | ✅ | ✅ |
|
|
||||||
>
|
>
|
||||||
> CSS 改动:所有 `.react-flow__node .overflow-x-auto` 的 webkit scrollbar 高度 14px、轨道半透明白边框、拖动滑块紫色(hover 加深、active 粉紫),最小宽度 48px 保证拖手够大。Firefox 自动用 native scrollbar-color。
|
> **已推送** (Gitea, `4bcca76 → 22421eb`):
|
||||||
|
> - `3ed3f72` fix(api) · `b56d517` fix(canvas) · `6201ee9` fix(web) · `3572dde` chore · `22421eb` docs(deploy fact)
|
||||||
>
|
>
|
||||||
> 去刷新看。如果还不够明显(被画布 zoom 缩太小),可以再加大到 18-20px,告诉我。
|
> **已部署生产** (`76.13.31.179:/opt/skg-marketing-studio`,`deploy-prod-safe.sh`):
|
||||||
|
> - 部署前备份:`skg-marketing-preserve-20260529181045.tgz`(+ pg_dump)
|
||||||
|
> - web/api 镜像在**服务器**构建成功(本地的 Google Fonts 失败纯属本机网络环境,与代码无关)
|
||||||
|
>
|
||||||
|
> **生产验证**(`verify-prod-docker.sh` + 容器内复验):
|
||||||
|
>
|
||||||
|
> | 检查 | 结果 |
|
||||||
|
> |---|---|
|
||||||
|
> | web/api/postgres 容器 | Up |
|
||||||
|
> | `/` 302 · `/login/` 200 · `_next` 404 · `/api/health` 401 | ✅ |
|
||||||
|
> | `web:no_local_api_refs` · `api:ytdlp_cookie_args []` | ✅ |
|
||||||
|
> | `api:health ok db connected` | ✅ |
|
||||||
|
> | `psycopg_pool 3.2.4` 生效 | ✅ |
|
||||||
|
> | SSRF:`file://`/`169.254.169.254`/`evil.com` → 400,`tiktok.com` 放行 | ✅ |
|
||||||
|
> | `run()` timeout=300 · `DOWNLOAD_TIMEOUT_SECONDS=600` | ✅ |
|
||||||
|
>
|
||||||
|
> **元数据**:`RULES.md` 部署事实已回写并推送;`.project.json` 无 URL 变化,无需改。
|
||||||
|
>
|
||||||
|
> ## 上线后留意
|
||||||
|
>
|
||||||
|
> - **新增 env**(都有安全默认值,可不配):`…
|
||||||
|
|
||||||
## Codex 最近会话
|
## Codex 最近会话
|
||||||
|
|
||||||
- Session ID:019e2274-9db5-71c1-85e7-2bb0519c8ba9
|
- Session ID:019e63ac-1984-7a42-8c65-ffd7ab146968
|
||||||
- Transcript:/Users/kangwan/.codex/sessions/2026/05/14/rollout-2026-05-14T01-48-48-019e2274-9db5-71c1-85e7-2bb0519c8ba9.jsonl
|
- Transcript:/Users/kangwan/.codex/sessions/2026/05/26/rollout-2026-05-26T17-44-43-019e63ac-1984-7a42-8c65-ffd7ab146968.jsonl
|
||||||
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||||
|
- 分支:main
|
||||||
- 敏感字段:已对 token / key / password / secret 做脱敏
|
- 敏感字段:已对 token / key / password / secret 做脱敏
|
||||||
|
|
||||||
### 最近用户要求
|
### 最近用户要求
|
||||||
|
|
||||||
- <image name=[Image #1]> </image> [Image #1] logo 和那个营销内容工作台 你把营销内容工作台和logo水平对齐
|
- # AGENTS.md instructions for /Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证 <INSTRUCTIONS> # SKG 营销内容工作台 Agent Rules ## Must Read First - `.project.json` 是机器真源:公网链接、快捷登录、凭证引用都以它为准 - `RULES.md` 是人工规则和部署事实:启动命令、平台、域名、注意事项都写这里 - 工作看板全局规则是本项目的开发收口默认规则;本地规则缺失时按 `/Users/kangwan/Projects/code/20260317-rules-dashboard/RULES.md`、`SCHEMA.md`、`rules/03-Git约定.md`、`rules/04-版本发布规则.md` 补齐 - 不允许编造不存在的域名、账号、密码;未知就保持空白并明确标记待补充 ## Development Ledger Contract - 开发任务结束前必须执行并汇报 `git status -sb` - 功能、修复、规则或部署元数据变更完成后,必须创建人工语义 commit;`auto-save` 只算安全快照 - 默认先在本地 Docker 完整验证:`./scripts/start-local…
|
||||||
- logo怎么没有在服务器上显现出来呢奇怪
|
- 你现在是在 Codex CLI 里的项目承接会话。 这不是对话立项台;项目目录已经存在,当前目标是接住上下文并等待用户下一步。 项目路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证 承接步骤: - 先读取 `.memory/assistant-handoff.md` - 再阅读 `.project.json`、`RULES.md`、`AGENTS.md` - 结合当前仓库状态建立上下文 要求: - 不要要求用户重新粘贴 Claude / Codex / Cursor 的总结 - 先理解最近进度、未完成项和下一步候选 - 不要立即修改代码、继续上一个任务或自行执行命令 - 等待用户给出下一条明确指令后再开始动手 - 如果后续需要 Cursor,提醒用户从看板打开 Cursor GUI 到当前项目目录
|
||||||
- 账号密码创建了么
|
|
||||||
|
|
||||||
### Codex 最近回复
|
### Codex 最近回复
|
||||||
|
|
||||||
> 原因找到了:不是 logo 文件没上去,而是生产 Nginx 把 `/skg-logo-black.svg` 当成受保护资源了,未登录访问会 `302` 到 `/login/`,所以登录页里的图片加载不到。
|
> 已读到项目交接核心:最近不是要继续执行旧任务,而是先接住上下文;当前工作区也只有交接/日志类未提交变更。再补一个只读的提交/远端/差异概览,避免把 handoff 里的仓库状态当成最新事实。
|
||||||
>
|
|
||||||
> 已修:
|
|
||||||
> - `deploy/nginx.conf` 加了 `/skg-logo-black.svg` 公开静态资源规则
|
|
||||||
> - 同步更新了 `RULES.md` 和 `docs/source-analysis.html`
|
|
||||||
> - 已重新构建并重启生产 web 容器
|
|
||||||
>
|
|
||||||
> 验证结果:
|
|
||||||
> - `https://marketing.skg.com/skg-logo-black.svg` 现在是 `200 image/svg+xml`
|
|
||||||
> - `https://marketing.skg.com/login/` 是 `200 OK`
|
|
||||||
> - 我截了线上图,logo 已经显示,和“营销内容工作台”也水平对齐了
|
|
||||||
>
|
|
||||||
> 你浏览器那边如果还看不到,强刷一下页面缓存即可。
|
|
||||||
|
|
||||||
> 创建好了,生产环境现在可用:
|
> 已完成只读承接。
|
||||||
>
|
>
|
||||||
> ```text
|
> 我已按顺序读了 [assistant-handoff.md](/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/.memory/assistant-handoff.md)、[.project.json](/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/.project.json)、[RULES.md](/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/RULES.md)、[AGENTS.md](/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/AGENTS.md),并补了只读仓库状态检查。没有改文件、没有启动服务、没有继续旧任务。
|
||||||
> 登录地址:https://marketing.skg.com/login/
|
|
||||||
> 账号:skg
|
|
||||||
> 密码:c413cdc5bbbf2ca042
|
|
||||||
> ```
|
|
||||||
>
|
>
|
||||||
> 密码备份位置按项目规则放在服务器:`/root/skg-marketing-studio-login.txt`。前面已验证登录接口返回成功。
|
> 当前状态:
|
||||||
|
> - 分支:`main`
|
||||||
## Cursor 最近会话
|
> - 远端:`origin -> ssh://git@git.kang-kang.com:22222/kangwan/20260512-skg-tk.git`
|
||||||
|
> - 状态:`main...origin/main [ahead 4]`
|
||||||
- 未在本机找到匹配这个项目的 Cursor transcript。
|
> - 未提交变更:`.memory/assistant-handoff.md`、`.memory/worklog.json`,看起来是承接/日志类变更
|
||||||
|
> - 最新提交:`0eb775d docs: compact user guide pdf layout`
|
||||||
|
>
|
||||||
|
> 我接住的上下文:
|
||||||
|
> - 当前项目是 SKG 营销内容生产平台,生产入口 `https://marketing.skg.com`,根域名登录后进个人画布。
|
||||||
|
> - 生产已收口到飞书免登录,密码登录停用;Postgres 已用于画布项目、用户索引、任务索引、资源索引等持久化。
|
||||||
|
> - 普通开发默认必须本地 Docker 验证并形成语义 commit;没有你明确说“可以推送 / 上推 / 部署”前,不推 Gitea、不跑生…
|
||||||
|
|
||||||
## 当前仓库状态
|
## 当前仓库状态
|
||||||
|
|
||||||
- 当前分支:HEAD
|
- 当前分支:main
|
||||||
- 未提交变更:2 项
|
- 未提交变更:1 项
|
||||||
- 最近提交:auto-save 2026-05-16 10:20 (~1)
|
- 最近提交:docs: record 2026-05-30 stability/security hardening deploy
|
||||||
- 变更文件:
|
- 变更文件:
|
||||||
- M .memory/worklog.json
|
- M .memory/worklog.json
|
||||||
|
|
||||||
## 统一接力要求
|
## 统一接力要求
|
||||||
|
|
||||||
- 对话立项只用 Claude / Codex;Cursor 只用于项目目录已经创建之后的 GUI 开发承接。
|
|
||||||
- Claude / Codex 终端承接:先阅读本文件,再结合 `.project.json`、`RULES.md`、`AGENTS.md` 和当前仓库状态理解项目进度。
|
- Claude / Codex 终端承接:先阅读本文件,再结合 `.project.json`、`RULES.md`、`AGENTS.md` 和当前仓库状态理解项目进度。
|
||||||
- Cursor GUI 承接:只打开当前项目根目录,不打开 `~/Projects`、`~/Projects/business` 或 `/Users/kangwan`。
|
- 不要要求用户重新手工粘贴 Claude / Codex 总结,缺口直接从代码、日志和 handoff 文件补。
|
||||||
- 不要要求用户重新手工粘贴 Claude / Codex / Cursor 总结,缺口直接从代码、日志和 handoff 文件补。
|
|
||||||
- 如果最近助手会话里有明确未完成项,只把它当作候选待办,不要自动继续执行。
|
- 如果最近助手会话里有明确未完成项,只把它当作候选待办,不要自动继续执行。
|
||||||
- 当前目标是建立上下文并等待用户下一条明确指令,不要自行开始修改。
|
- 当前目标是建立上下文并等待用户下一条明确指令,不要自行开始修改。
|
||||||
|
|||||||
BIN
.memory/screenshots/seedream-default-local.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
@@ -1,7 +1,7 @@
|
|||||||
# SKG TK 二创验证 — 当前状态(2026-05-13)
|
# SKG TK 二创验证 — 当前状态(2026-05-18)
|
||||||
|
|
||||||
## 一句话
|
## 一句话
|
||||||
SKG AI 素材生产管线第二条思路:TK 链接/上传 → 拆轨 → 抽关键帧(5 张+手动加)→ Vision 识别 → 改写文案 → 生图 → 生视频 → 合成。**MVP 通到生图,剩余 3 个节点占位**。
|
SKG 信息流广告快速复刻工作台:TK 链接/上传 → 下载源视频 → 并行音频解析与 12 张动作/节奏参考帧 → 相似主体 / 产品素材池 → 分镜口播改写 → 首尾帧审核 → 视频候选待开放。当前主流程不直接批量提交视频模型。
|
||||||
|
|
||||||
## 路径 / 端口
|
## 路径 / 端口
|
||||||
- 路径:`~/Projects/business/20260512-20260512-skg-tk-二创验证/`
|
- 路径:`~/Projects/business/20260512-20260512-skg-tk-二创验证/`
|
||||||
@@ -15,51 +15,62 @@ key 写在 `api/.env` 的 `LLM_API_KEY`
|
|||||||
|
|
||||||
| 端点 / 字段 | 状态 | 用途 |
|
| 端点 / 字段 | 状态 | 用途 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/v1/chat/completions` text-only | ✅ 通 | translate / rewrite |
|
| 远端 ASR | `ASR_MODEL=whisper-1` | 第一优先级音频转写;失败后进本机 ASR。 |
|
||||||
| `/v1/chat/completions` + image_url | ✅ **通**(之前误判为不通,是 dog.jpg 那张图损坏) | vision 识别图片(gemini-2.5-flash 推荐) |
|
| 本机 ASR | `LOCAL_ASR_MODEL=mlx-community/whisper-tiny` | 二级兜底,优先产出真实逐句时间轴。 |
|
||||||
| `/v1/chat/completions` + input_audio | ❌ 不通 | ASR 不能走这条 |
|
| ASR 兜底 / 音频分析 | `ASR_FALLBACK_MODEL=gemini-2.5-flash` | 远端和本机都失败后才做多模态 ASR;音频画像会读取 `audio.wav` + 转写时间轴,失败则本地估算。后端会拒绝假字幕、重复文本和覆盖率过低结果。 |
|
||||||
| `/v1/audio/transcriptions` (whisper) | ❌ 404 | 整个 audio 端点都没暴露 |
|
| 字幕翻译 | `TRANSLATE_MODEL=gemini-2.5-flash` | 按 ASR 段落补中文;失败时保留英文时间轴,中文可为空。 |
|
||||||
| `/v1/audio/speech` (tts) | ❌ 404 | |
|
| 画面理解 / brief | `VISION_MODEL=gpt-4o` | 关键帧 Vision 和相似主体非身份化 brief 已切 GPT;旧环境若写 `gemini-*` 会自动归一化到 `GPT_TEXT_MODEL`。 |
|
||||||
| `/v1/images/generations` (text→image) | ✅ 通 | 生图(gemini-3-pro-image-preview = nano-banana-pro) |
|
| 通用改写 / 分镜描述 | `REWRITE_MODEL=gpt-4o` | 已切 GPT;旧 Gemini 覆盖值会自动归一化。 |
|
||||||
| `/v1/images/generations` + image 参数 | ✅ **通**(image-to-image) | 实测能传 reference image,关键的发现 |
|
| 新口播改写 | `AUDIO_REWRITE_MODEL=gpt-4o` | 默认跟随 `REWRITE_MODEL`;失败后依次尝试 `ASR_FALLBACK_MODEL`、`TRANSLATE_MODEL`,再用本地模板兜底。 |
|
||||||
| `/v1/images/edits` | ❌ 404 | |
|
| 产品视角识别 | `PRODUCT_VIEW_MODEL=gpt-image-2` | 多图批量识别;失败后单图重试,再写本地默认视角和风险备注。 |
|
||||||
| `/v1/videos/*` (sora-2) | ❌ 404 | 视频生成需要 IT 开通或外部 key |
|
| 所有生图 / 修图 | `gpt-image-2` | 服务端硬锁,无其他图片模型 fallback;覆盖关键帧生图、水印清理、元素提取、主体资产包、产品补角度、首尾帧。 |
|
||||||
| `/v1/files` | ❌ 403 "必须指定渠道" | |
|
| 配音 | `VOICE_PROVIDER=azure_openai` + `AZURE_TTS_MODEL=gpt-4o-mini-tts` | 语音固定 Azure OpenAI TTS。后端会按 `AZURE_TTS_PATHS` 依次尝试路径,便于区分路径错误和整条语音服务不可用。 |
|
||||||
|
| 视频 | `VIDEO_MODEL=seedance`,别名支持 `kling-omni`、`veo-3.1-fast` | 当前主流程暂停直接提交;真实 ID 由 `VIDEO_MODEL_SEEDANCE` / `VIDEO_MODEL_KLING` / `VIDEO_MODEL_VEO3` 配置,入口按 `VIDEO_CREATE_PATHS`。 |
|
||||||
|
|
||||||
**网关后端 = one-hub 多渠道代理**。当前 key 分组叫「纯OpenAI+AWSClaude+Gemini官方」,缺 audio 渠道(`gpt-4o-audio-preview` 503 "无可用渠道")和 video 渠道。
|
**网关后端 = one-hub 多渠道代理**。当前 key 分组叫「纯OpenAI+AWSClaude+Gemini官方」,缺 audio 渠道(`gpt-4o-audio-preview` 503 "无可用渠道")和 video 渠道。
|
||||||
|
|
||||||
## 模型选型(已写入 api/.env)
|
## 模型选型(运行时默认 / 归一化后)
|
||||||
```
|
```
|
||||||
ASR_MODEL=whisper-1 # ⚠️ 端点 404,ASR 还没真跑通
|
ASR_MODEL=whisper-1
|
||||||
TRANSLATE_MODEL=gemini-2.5-flash # ✅ text 已通
|
LOCAL_ASR_MODEL=mlx-community/whisper-tiny
|
||||||
REWRITE_MODEL=gemini-2.5-pro # 占位
|
ASR_FALLBACK_MODEL=gemini-2.5-flash
|
||||||
VISION_MODEL=gemini-2.5-flash # ✅ 识别已通
|
TRANSLATE_MODEL=gemini-2.5-flash
|
||||||
IMAGE_MODEL=gemini-3-pro-image-preview # ✅ nano-banana-pro,i2i 已通
|
GPT_TEXT_MODEL=gpt-4o
|
||||||
|
VISION_MODEL=gpt-4o
|
||||||
|
REWRITE_MODEL=gpt-4o
|
||||||
|
AUDIO_REWRITE_MODEL=gpt-4o
|
||||||
|
IMAGE_MODEL=gpt-image-2
|
||||||
|
PRODUCT_VIEW_MODEL=gpt-image-2
|
||||||
|
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
|
||||||
|
VOICE_PROVIDER=azure_openai
|
||||||
|
AZURE_TTS_MODEL=gpt-4o-mini-tts
|
||||||
|
VIDEO_MODEL=seedance
|
||||||
|
VIDEO_MODEL_KLING=kling-omni
|
||||||
|
VIDEO_MODEL_VEO3=veo-3.1-fast
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pipeline 状态(8 节点合并版)
|
## Pipeline 状态(9 步工作台版)
|
||||||
原 10 节点已合并:input + download + split 合一;translate 合到 transcript;videogen 和 compose 占位。
|
当前主入口是信息流复刻工作表,不再是旧 ReactFlow 八节点主画布。
|
||||||
|
|
||||||
| 步 | 节点 | 状态 | 备注 |
|
| 步 | 节点 | 状态 | 备注 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | **输入·Input**(合并下载+拆分) | ✅ | yt-dlp 真下 + ffmpeg 拆 wav |
|
| 1 | **素材输入** | ✅ | TK 链接 / 上传视频;失败素材可重新下载。 |
|
||||||
| 2 | **关键帧·Keyframes** | ✅ | D 启发式:候选 30 张 → pHash 去重 + Laplacian variance 评分 + 时序分桶 → 5 张;手动加帧 OK |
|
| 2 | **源视频下载** | ✅ | yt-dlp + cookies 配置;上传视频直接进入 downloaded。 |
|
||||||
| 3 | **转录·ASR** | ❌ 阻塞 | SKG 网关 audio 不通;待 IT 开 audio 渠道 / 外部 key |
|
| 3 | **音频文案** | ✅ | 拆 `audio.wav`,ASR、翻译、讲话人 / 节奏 / 背景音画像。 |
|
||||||
| 4 | **翻译·Translate** | ❌ 阻塞 | 依赖 ASR |
|
| 4 | **抽帧参考** | ✅ | 下载完成后自动抽 12 张动作/节奏参考帧;支持当前播放点手动补帧。 |
|
||||||
| 5 | **改写·Rewrite** | ⏳ 占位 | 等用户给产品信息模板 |
|
| 5 | **相似主体** | ✅ | GPT 视觉 brief + `gpt-image-2` 文字生图,生成类似但不复刻的人物/透明骨架主体。 |
|
||||||
| 6 | **生图·Image Gen** | ✅ **刚做完** | nano-banana-pro i2i + 正负 prompt |
|
| 6 | **产品素材池** | ✅ | 不限量上传;`gpt-image-2` 识别视角 / 用途 / 风险,缺角度可补图。 |
|
||||||
| 7 | **生视频·Video Gen** | ⏳ 占位 | sora-2 端点不通 |
|
| 7 | **分镜文案** | ✅ | 按逐句时间轴生成行,可单段或整片 GPT 改写;保存后写入 storyboard action。 |
|
||||||
| 8 | **合成·Compose** | ⏳ 占位 | 本地 ffmpeg + 字幕 + TTS |
|
| 8 | **画面首尾帧** | ✅ | 用相似主体视图 + 产品素材池生成首帧/尾帧,审核后保存规划。 |
|
||||||
|
| 9 | **视频候选** | ⏸️ | 历史候选可看;主流程当前暂停直接提交视频模型。 |
|
||||||
|
|
||||||
## UI 架构(重要)
|
## UI 架构(重要)
|
||||||
- **左侧 sidebar**(108px 极窄):8 个 stage tile 竖排 + DAG 路径分叉表达
|
- 主入口:`web/components/ad-recreation-board.tsx`,左侧素材输入列 + 右侧信息流复刻工作表。
|
||||||
- **主区 ReactFlow**:8 节点 DAG(input → keyframe/asr → ... → compose)
|
- 工作流条:01 素材输入 → 02 源视频下载 → 03 音频文案 → 04 抽帧参考 → 05 相似主体 → 06 产品素材池 → 07 分镜文案 → 08 画面首尾帧 → 09 视频候选。
|
||||||
- **点 sidebar tile**:从左滑出 drawer panel(粉/紫/橙 Kanban 风格)
|
- 源视频工作区:左侧 9:16 原视频播放器,内置当前点抽帧;右侧音频波形 + 逐句时间轴 + 参考帧池。
|
||||||
- **关键帧 lightbox**:**embedded 嵌入到 keyframe drawer**(不全屏)—— `<FrameLightbox embedded ... />`,drawer 宽度有 expandedFrame 时 760,无时 400
|
- 相似主体:模板库 / 内置形象 / 源视频相似方向;生成结果统一用媒体素材卡,支持 hover 放大、删除、单张重生。
|
||||||
- **Input 节点上方**:多视频缩略图浮条 + 「+」加新视频
|
- 分镜工作台:产品素材池、逐句口播、画面规划、首尾帧和历史视频候选在同一纵向工作表里处理。
|
||||||
- **关键帧节点上方**:5+ 张缩略图按视频原比例(aspect-ratio: width/height)
|
- 旧 ReactFlow 节点、旧 lightbox、旧 storyboard workbench 底层保留,但当前不作为主入口。
|
||||||
- **缩略图 hover**:弹大图静态(关键帧是垫图素材,不放视频)
|
|
||||||
- **缩略图点击**:打开 keyframe drawer 内的 lightbox(左大图 + 右识别面板)
|
|
||||||
|
|
||||||
## 数据模型(关键 typescript / pydantic)
|
## 数据模型(关键 typescript / pydantic)
|
||||||
```typescript
|
```typescript
|
||||||
@@ -80,67 +91,55 @@ Job { frames: KeyFrame[] ... }
|
|||||||
**前端取帧必须用 `frames.find(x => x.index === activeIndex)` 不能用数组下标**(之前的 bug)。
|
**前端取帧必须用 `frames.find(x => x.index === activeIndex)` 不能用数组下标**(之前的 bug)。
|
||||||
|
|
||||||
## 关键文件
|
## 关键文件
|
||||||
- `web/app/page.tsx` — 多 job state 管理(jobs[] + activeJobId),8 节点 LAYOUT
|
- `web/app/page.tsx` — 多 job state 管理(jobs[] + activeJobId),开始后并行触发音频解析和 12 张视觉抽帧
|
||||||
- `web/components/dashboard.tsx` — sidebar + drawer + 9 个 Kanban section(input/keyframe/asr/translate/rewrite/imagegen/videogen/compose),含 `ImageGenCard` 子组件
|
- `web/components/ad-recreation-board.tsx` — 当前主工作台:素材输入、音频结果、参考帧池、相似主体、产品素材池、分镜规划和首尾帧
|
||||||
- `web/components/lightbox.tsx` — `FrameLightbox` 支持 `embedded` prop
|
- `web/components/media-asset-tile.tsx` — 图片 / 视频 / 抽帧 / 产品图 / 主体图 / 首尾帧 / 视频候选统一媒体卡
|
||||||
|
- `web/components/dashboard.tsx` — 旧 ReactFlow / Kanban 面板,底层保留但当前不作为主入口
|
||||||
|
- `web/components/lightbox.tsx` — 旧深度素材面板,底层保留
|
||||||
- `web/components/video-lightbox.tsx` — Input 节点点视频缩略图弹的播放器
|
- `web/components/video-lightbox.tsx` — Input 节点点视频缩略图弹的播放器
|
||||||
- `web/components/nodes/index.tsx` — ReactFlow 8 节点定义
|
- `web/components/nodes/index.tsx` — ReactFlow 8 节点定义
|
||||||
- `web/lib/api.ts` — API client
|
- `web/lib/api.ts` — API client
|
||||||
- `api/main.py` — FastAPI 所有端点,KeyFrame/GeneratedImage 模型
|
- `api/main.py` — FastAPI 所有端点,Job/KeyFrame/AudioScript/ProductRef/SubjectAsset/SceneAsset/GeneratedVideo 模型
|
||||||
|
|
||||||
## 已通的 API 端点
|
## 已通的 API 端点
|
||||||
```
|
```
|
||||||
POST /jobs 创建 job(链接)
|
POST /jobs 创建 job(链接)
|
||||||
|
POST /jobs/{id}/download/retry TK 链接下载失败后重新下载
|
||||||
POST /jobs/upload 上传视频
|
POST /jobs/upload 上传视频
|
||||||
GET /jobs/{id} job 状态
|
GET /jobs/{id} job 状态
|
||||||
POST /jobs/{id}/analyze?frames=5 拆轨+抽帧+ASR 自动一气呵成
|
POST /jobs/{id}/transcribe 音频提取 + ASR + 翻译 + 讲话人/节奏/背景音分析
|
||||||
|
POST /jobs/{id}/analyze?frames=12 动作/节奏参考帧抽取
|
||||||
POST /jobs/{id}/frames?t=<sec> 手动按时间戳加帧
|
POST /jobs/{id}/frames?t=<sec> 手动按时间戳加帧
|
||||||
POST /jobs/{id}/frames/{idx}/describe ✅ Vision 识别(3 次重试 + reasoning_content 兜底)
|
POST /jobs/{id}/frames/{idx}/describe ✅ Vision 识别(3 次重试 + reasoning_content 兜底)
|
||||||
POST /jobs/{id}/frames/{idx}/generate ✅ 生图(i2i / text-only, 含 negative_prompt)
|
POST /jobs/{id}/frames/{idx}/generate ✅ 生图(i2i / text-only, 含 negative_prompt)
|
||||||
GET /jobs/{id}/frames/{idx}/gen/{gen_id}.jpg 生成图二进制
|
GET /jobs/{id}/frames/{idx}/gen/{gen_id}.jpg 生成图二进制
|
||||||
POST /jobs/{id}/frames/{idx}/gen/{gen_id}/select 选用某 gen 给下游
|
POST /jobs/{id}/frames/{idx}/gen/{gen_id}/select 选用某 gen 给下游
|
||||||
|
POST /jobs/{id}/assets/product-views/analyze 产品视角 / 用途 / 风险识别
|
||||||
|
POST /jobs/{id}/assets/product-angle 缺产品角度补图
|
||||||
|
POST /jobs/{id}/script/rewrite 单段 / 整片新口播改写
|
||||||
|
POST /jobs/{id}/frames/{idx}/scene-asset 首帧 / 尾帧 / 场景资产生成
|
||||||
GET /jobs/{id}/video.mp4 原视频
|
GET /jobs/{id}/video.mp4 原视频
|
||||||
|
GET /jobs/{id}/audio.wav 原音频 wav
|
||||||
GET /jobs/{id}/frames/{idx}.jpg 关键帧 jpg
|
GET /jobs/{id}/frames/{idx}.jpg 关键帧 jpg
|
||||||
GET /health
|
GET /health
|
||||||
```
|
```
|
||||||
|
|
||||||
## 已知坑 / 不要再踩
|
## 当前约束 / 不要踩
|
||||||
1. **关键帧 index 不连续**:手动加帧后 frames 数组按 timestamp 排序,index 是稳定 ID。lightbox 必须用 `frames.find(x => x.index === activeIndex)`,**不要**用 `frames[activeIndex]`。
|
1. 图片 / 视频 / 抽帧 / 产品图 / 生成图 / 首尾帧 / 视频候选缩略图默认复用 `web/components/media-asset-tile.tsx`。
|
||||||
2. **SKG 网关 vision 之前测试结果错误**:用 `dog.jpg` 那张 wikipedia 200px 缩略图损坏 / metadata 异常,导致一直以为 image input 不通。用标准 PNG / 真实 jpeg 测就通了。
|
2. 所有生图入口服务端只允许 `gpt-image-2`,不要重新加 Gemini 图片模型或其他 fallback。
|
||||||
3. **Gemini 2.5 Flash 默认带 thinking**,`content` 字段经常为空(token 都给了 reasoning),要从 `reasoning_content` 正则挖 JSON 兜底。
|
3. 画面理解和文案改写默认归 GPT:`VISION_MODEL`、`REWRITE_MODEL`、`AUDIO_REWRITE_MODEL` 会拦截旧 `gemini-*` 覆盖值。
|
||||||
4. **缩略图 aspect-ratio**:必须用 `aspectRatio: ${job.width}/${job.height}` 自适应,不要强制 `aspect-video` 16:9(竖屏视频会被裁切)。
|
4. Gemini 仍保留在 ASR fallback / 音频分析 / 翻译链路,不要误删。
|
||||||
5. **ReactFlow `type="input"` / `"output"` 是 reserved**:自带白底默认样式,要 CSS 覆盖 `.react-flow .react-flow__node-input { background: transparent !important; ... }`。
|
5. 语音只走 Azure OpenAI TTS;不要新增或依赖其他配音通道配置。
|
||||||
6. **ReactFlow 12 colorMode 独立于 next-themes**:必须 `<ReactFlow colorMode={resolvedTheme}>` 联动,否则节点白底。
|
6. TikTok 受限下载遇到 `Log in for access` 不是后端没接到任务;需要 `YTDLP_COOKIES_FILE` 或 `YTDLP_COOKIES_FROM_BROWSER`,配置后可点“重新下载”。
|
||||||
7. **FastAPI BackgroundTasks 用法**:`bg.add_task(func, arg)` 不能传 coroutine。
|
7. 当前主流程不直接批量提交视频;先走“分镜规划 → 首尾帧 → 人工审核”。
|
||||||
8. **ffmpeg 8 mjpeg encoder 拒绝 yuv420p**:抽帧必须加 `-pix_fmt yuvj420p`,且 `-vsync` 改 `-fps_mode`。
|
8. 后端长任务不要用 `--reload`。
|
||||||
9. **抽帧速度**:场景切换检测(`select='gt(scene,0.4)'`)超慢(71s 视频要 30s+),换均匀采样 fast seek(5 张 < 3 秒)。
|
9. 关键帧 `index` 是稳定 ID,不等于数组下标;前端取帧用 `frames.find(x => x.index === idx)`。
|
||||||
|
|
||||||
## 待办(按优先级)
|
## 最近变更
|
||||||
1. **ASR 阻塞**:找 SKG IT 开 audio 渠道,或给一个外部 ASR key(Deepgram / 讯飞 / OpenAI 直连)
|
- 2026-05-18:前端模型链路弹窗、`.project.json`、`api/README.md` 和本状态文档已按真实后端链路重写:音频三级 ASR、翻译失败行为、音频画像兜底、产品识别重试、相似主体 GPT brief + gpt-image-2 文字生图、脚本改写本地模板兜底、视频主入口暂停。
|
||||||
2. **生图测试反馈**:刚做完,等用户在浏览器试 → 调 negative prompt / 模型选型
|
- 2026-05-18:清理个人语音通道残留,`/health`、前端类型、环境模板和文档不再暴露相关字段或配置。
|
||||||
3. **区域化修图(inpainting)**:用户讨论了,方案 A 纯 prompt / B 矩形框 / C 画笔 mask / D SAM;暂时搁置
|
- 2026-05-18:`VISION_MODEL`、`REWRITE_MODEL`、`AUDIO_REWRITE_MODEL` 切到 GPT 默认模型 `gpt-4o`,并加旧 Gemini 环境变量归一化保护。
|
||||||
4. **改写 Rewrite**:等用户给产品信息卡模板
|
- 2026-05-18:语音通道固定 Azure OpenAI TTS,并按 `AZURE_TTS_PATHS` 尝试语音路径。
|
||||||
5. **视频生成**:sora-2 走 SKG 端点不通;考虑外部 key (Runway/Kling/Veo3)
|
- 2026-05-18:TikTok 受限链接支持 cookies 配置和失败素材“重新下载”。
|
||||||
6. **合成 Compose**:全本地 ffmpeg + 字幕 + TTS
|
- 2026-05-18:媒体素材交互统一收口到 `MediaAssetTile`。
|
||||||
|
- 2026-05-18:产品图视角识别和产品缺角度补图收敛到 `gpt-image-2`。
|
||||||
## 操作流(开发会话)
|
|
||||||
```bash
|
|
||||||
# 1. 启动后端(如未跑)
|
|
||||||
cd ~/Projects/business/20260512-20260512-skg-tk-二创验证/api
|
|
||||||
source .venv/bin/activate
|
|
||||||
uvicorn main:app --port 4291 --reload
|
|
||||||
|
|
||||||
# 2. 启动前端(如未跑)
|
|
||||||
cd ../web
|
|
||||||
pnpm dev
|
|
||||||
|
|
||||||
# 3. 浏览器
|
|
||||||
open http://localhost:4290/?job=c6767f3a166b
|
|
||||||
```
|
|
||||||
|
|
||||||
## 用户偏好提醒(feedback memory)
|
|
||||||
- feedback_image-gen-model:生图统一用 nano-banana-pro ✅
|
|
||||||
- feedback_keep-scope-small:小需求小做
|
|
||||||
- feedback_flow-dont-stop:连续执行到交付,真分叉才问
|
|
||||||
- feedback_demand-before-infra:基建前先反问谁/痛点/频率
|
|
||||||
- feedback_no-guessing-ports:操作前先核实
|
|
||||||
|
|||||||
6412
.memory/worklog.json
145
.project.json
@@ -1,96 +1,113 @@
|
|||||||
{
|
{
|
||||||
"company": "SKG",
|
"company" : "SKG",
|
||||||
"created": "2026-05-12",
|
"created" : "2026-05-12",
|
||||||
"credentials": [
|
"credentials" : [
|
||||||
{
|
{
|
||||||
"description": "SKG AI 网关 API Key,生产只放服务器 deploy/.env.production 的 LLM_API_KEY,本地开发放 api/.env,不入库",
|
"description" : "SKG AI 网关 API Key,生产只放服务器 deploy\/.env.production 的 LLM_API_KEY,本地开发放 api\/.env,不入库",
|
||||||
"name": "LLM_API_KEY",
|
"name" : "LLM_API_KEY",
|
||||||
"storage": "api/.env / deploy/.env.production",
|
"storage" : "api\/.env \/ deploy\/.env.production",
|
||||||
"type": "api_key"
|
"type" : "api_key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "MiniMax T2A 配音 API Key,本地开发只放 api/.env 的 MINIMAX_API_KEY,不入库",
|
"description" : "OpenAI Audio Transcriptions 兼容 ASR Key;未单独配置 ASR_API_KEY 时复用 LLM_API_KEY,本地开发只放 api\/.env,不入库",
|
||||||
"name": "MINIMAX_API_KEY",
|
"name" : "ASR_API_KEY",
|
||||||
"storage": "api/.env",
|
"storage" : "api\/.env \/ deploy\/.env.production",
|
||||||
"type": "api_key"
|
"type" : "api_key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "OpenAI-compatible GPT 图片模型 Key;未单独配置 IMAGE_API_KEY 时复用 LLM_API_KEY,本地开发只放 api/.env,不入库",
|
"description" : "OpenAI-compatible GPT 图片模型 Key;未单独配置 IMAGE_API_KEY 时复用 LLM_API_KEY,本地开发只放 api\/.env,不入库",
|
||||||
"name": "IMAGE_API_KEY",
|
"name" : "IMAGE_API_KEY",
|
||||||
"storage": "api/.env / deploy/.env.production",
|
"storage" : "api\/.env \/ deploy\/.env.production",
|
||||||
"type": "api_key"
|
"type" : "api_key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Azure OpenAI 协议语音/配音 Key;未单独配置 AZURE_OPENAI_API_KEY 时复用 LLM_API_KEY,本地开发只放 api/.env,不入库",
|
"description" : "Azure OpenAI 协议语音\/配音 Key;未单独配置 AZURE_OPENAI_API_KEY 时复用 LLM_API_KEY,本地开发只放 api\/.env,不入库",
|
||||||
"name": "AZURE_OPENAI_API_KEY",
|
"name" : "AZURE_OPENAI_API_KEY",
|
||||||
"storage": "api/.env / deploy/.env.production",
|
"storage" : "api\/.env \/ deploy\/.env.production",
|
||||||
"type": "api_key"
|
"type" : "api_key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "SKG 豆包 / Seedance 视频生成 API Key,生产只放服务器 deploy/.env.production 的 VIDEO_API_KEY,本地开发放 api/.env,不入库",
|
"description" : "SKG 豆包 \/ Seedance 视频生成 API Key,生产只放服务器 deploy\/.env.production 的 VIDEO_API_KEY,本地开发放 api\/.env,不入库",
|
||||||
"name": "VIDEO_API_KEY",
|
"name" : "VIDEO_API_KEY",
|
||||||
"storage": "api/.env / deploy/.env.production",
|
"storage" : "api\/.env \/ deploy\/.env.production",
|
||||||
"type": "api_key"
|
"type" : "api_key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "生产网页登录;用户名写 RULES.md,密码只放服务器 /root/skg-marketing-studio-login.txt,后端会话密钥只放服务器 deploy/.env.production 的 WEB_AUTH_SESSION_SECRET",
|
"description" : "生产网页登录备用账号已停用,当前只允许飞书免登录;如需紧急恢复,需在服务器 deploy\/.env.production 显式开启 PASSWORD_AUTH_ENABLED=true。备用账号密码只放服务器 \/root\/skg-marketing-studio-login.txt,后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET",
|
||||||
"name": "WEB_LOGIN",
|
"name" : "WEB_LOGIN",
|
||||||
"storage": "/root/skg-marketing-studio-login.txt / deploy/.env.production",
|
"storage" : "\/root\/skg-marketing-studio-login.txt \/ deploy\/.env.production",
|
||||||
"type": "web_login"
|
"type" : "web_login"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description" : "飞书免登录 OAuth 应用配置;App ID 和 App Secret 只放服务器 deploy\/.env.production,本地开发放 api\/.env,不入库;回调地址为 https:\/\/marketing.skg.com\/api\/auth\/feishu\/callback",
|
||||||
|
"name" : "FEISHU_OAUTH",
|
||||||
|
"storage" : "api\/.env \/ deploy\/.env.production \/ 飞书开放平台",
|
||||||
|
"type" : "oauth_app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description" : "Postgres 服务端持久化配置,用于画布项目、用户索引、任务索引、资源索引和审计日志;生产密码只放服务器 deploy\/.env.production 的 POSTGRES_PASSWORD\/DATABASE_URL,不入库",
|
||||||
|
"name" : "POSTGRES_DATABASE",
|
||||||
|
"storage" : "deploy\/.env.production \/ docker-compose.prod.yml \/ 服务器 data\/postgres",
|
||||||
|
"type" : "database"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "SKG 信息流广告快速复刻第一步:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后优先解析原音频,提取原文案/字幕,分析讲话人、语速节奏、背景音乐/环境声/音效。抽帧、分镜、元素生成和视频合成暂保留为后续能力,不作为当前开始流程的默认动作。",
|
"description" : "SKG 营销内容生产平台:根域名 https:\/\/marketing.skg.com 登录后直接进入个人生成画布,终端可见品牌位只保留 SKG logo。主路径为文生图、文生视频、图生视频;每个登录用户只看到自己的任务和结果。画布项目已接入服务端 Postgres 持久化,浏览器 localStorage 只作为缓存和首次导入来源;图片\/视频资产继续写入当前用户自己的后端 job;旧 TK 复刻\/一键出片能力保留为高级入口。",
|
||||||
"kind": "app",
|
"kind" : "app",
|
||||||
"name": "SKG Marketing Studio / SKG 营销内容工作台",
|
"name" : "SKG 营销内容生产平台",
|
||||||
"ownership": "company",
|
"ownership" : "company",
|
||||||
"pin_order": 1778664997,
|
"pin_order" : 1778664997,
|
||||||
"pinned": true,
|
"pinned" : true,
|
||||||
"ports": [
|
"ports" : [
|
||||||
{
|
{
|
||||||
"fixed": true,
|
"fixed" : true,
|
||||||
"label": "web-dev",
|
"label" : "web-dev",
|
||||||
"port": 4290
|
"port" : 4290
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fixed": true,
|
"fixed" : true,
|
||||||
"label": "api-dev",
|
"label" : "api-dev",
|
||||||
"port": 4291
|
"port" : 4291
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"quick_login": {
|
"quick_login" : {
|
||||||
"label": "SKG Marketing Studio / SKG 营销内容工作台",
|
"label" : "SKG 营销内容生产平台",
|
||||||
"password": "c413cdc5bbbf2ca042",
|
"password" : "",
|
||||||
"url": "https://marketing.skg.com",
|
"url" : "https:\/\/marketing.skg.com\/login\/",
|
||||||
"username": "skg"
|
"username" : "仅飞书免登录;密码登录已停用"
|
||||||
},
|
},
|
||||||
"stack": [
|
"stack" : [
|
||||||
"Next.js + Python(yt-dlp/ffmpeg) + OpenAI-compatible LLM + GPT Image + Azure OpenAI TTS + Seedance"
|
"Next.js + Vue\/Vite canvas + FastAPI + Postgres + Python(yt-dlp\/ffmpeg) + OpenAI-compatible LLM + GPT Image 2 + Azure OpenAI TTS + Seedance\/Kling\/Veo video gateway"
|
||||||
],
|
],
|
||||||
"status": "active",
|
"status" : "active",
|
||||||
"urls": [
|
"urls" : [
|
||||||
{
|
{
|
||||||
"label": "production",
|
"label" : "production",
|
||||||
"type": "app",
|
"type" : "app",
|
||||||
"url": "https://marketing.skg.com"
|
"url" : "https:\/\/marketing.skg.com"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "production-api",
|
"label" : "production-api",
|
||||||
"type": "backend",
|
"type" : "backend",
|
||||||
"url": "https://marketing.skg.com/api"
|
"url" : "https:\/\/marketing.skg.com\/api"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "source-analysis",
|
"label" : "agent-cut-preview",
|
||||||
"type": "docs",
|
"type" : "app",
|
||||||
"url": "docs/source-analysis.html"
|
"url" : "http:\/\/2.24.28.41:4290\/agent\/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "repo",
|
"label" : "git",
|
||||||
"label": "git",
|
"type" : "repo",
|
||||||
"url": "https://git.kang-kang.com/kangwan/20260512-skg-tk"
|
"url" : "https:\/\/git.kang-kang.com\/kangwan\/20260512-skg-tk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label" : "git",
|
||||||
|
"type" : "repo",
|
||||||
|
"url" : "https:\/\/git.kang-kang.com"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"worklog": {
|
"worklog" : {
|
||||||
"auto": true,
|
"auto" : true,
|
||||||
"path": "/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/.memory/worklog.json"
|
"path" : "\/Users\/kangwan\/Projects\/business\/20260512-20260512-skg-tk-二创验证\/.memory\/worklog.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
AGENTS.md
@@ -1,4 +1,4 @@
|
|||||||
# SKG AI 素材管线 - TK 二创验证 Agent Rules
|
# SKG 营销内容工作台 Agent Rules
|
||||||
|
|
||||||
## Must Read First
|
## Must Read First
|
||||||
|
|
||||||
@@ -11,11 +11,21 @@
|
|||||||
|
|
||||||
- 开发任务结束前必须执行并汇报 `git status -sb`
|
- 开发任务结束前必须执行并汇报 `git status -sb`
|
||||||
- 功能、修复、规则或部署元数据变更完成后,必须创建人工语义 commit;`auto-save` 只算安全快照
|
- 功能、修复、规则或部署元数据变更完成后,必须创建人工语义 commit;`auto-save` 只算安全快照
|
||||||
- Gitea 是主远端,`origin` 必须指向 Gitea;能联网和鉴权时必须推送完成提交
|
- 默认先在本地 Docker 完整验证:`./scripts/start-local-docker.sh` 后运行 `./scripts/verify-local-docker.sh`;用户明确确认“可以推送 / 上推 / 部署”前,不要 `git push`,也不要运行生产部署脚本。
|
||||||
|
- 涉及模型接入、API Key、图片生成或视频生成链路修复时,如果用户已经提供本地可用的 base/key 且没有明确禁止真实调用,Agent 必须直接触发一次最小真实生成做端到端验证,不能只停在配置检查、dry-run 或让用户手动点测;汇报时说明实际触发的模型、入口、任务 ID/生成 URL 或失败原因,以及是否产生了真实调用消耗。
|
||||||
|
- Gitea 是主远端,`origin` 必须指向 Gitea;只有在用户明确确认推送后,才把已验证的人工语义 commit 推送到 Gitea。
|
||||||
- 当前主分支为 `main`,Gitea 仓库为 `https://git.kang-kang.com/kangwan/20260512-skg-tk`
|
- 当前主分支为 `main`,Gitea 仓库为 `https://git.kang-kang.com/kangwan/20260512-skg-tk`
|
||||||
- `.memory/worklog.json` 是辅助日志,不代替人工语义 commit 和 Gitea 远端记录
|
- `.memory/worklog.json` 是辅助日志,不代替人工语义 commit 和 Gitea 远端记录
|
||||||
- 不能推送时,必须说明当前分支、本地领先/落后数量、最新未推送 commit 和失败原因
|
- 不能推送时,必须说明当前分支、本地领先/落后数量、最新未推送 commit 和失败原因
|
||||||
|
|
||||||
|
## Product Baseline Contract
|
||||||
|
|
||||||
|
- 最终产品基线是 `https://marketing.skg.com` 登录后的个人生成画布;本地开发和验收必须尽量复刻这套线上运行形态。
|
||||||
|
- 根域名生产入口由 `web/canvas-app/` 的 Vue / Vite 画布产物承载;旧 React 首页、旧 TK 复刻工作台、Agent Cut 和详情页能力只能作为高级/兼容/回滚参考,不能当默认产品基线。
|
||||||
|
- 之后所有升级、修复和产品判断,优先围绕根域名画布、当前 `/api`、Postgres 持久化、飞书登录和 owner 隔离展开。
|
||||||
|
- 本地同构环境优先使用 Docker:`docker-compose.local.yml` + `deploy/.env.local` + `data-local/`;不要用本地 dev server 的偶然行为代替生产形态判断。
|
||||||
|
- 遇到线上用户 bug,先只读确认生产事实:用户身份、owner、job、canvas project、generated asset、日志时间线;必要时把最小可复现数据拉到本地 Docker 环境复现和修复,不能直接在生产库里试错。
|
||||||
|
|
||||||
## Deployment Metadata Contract
|
## Deployment Metadata Contract
|
||||||
|
|
||||||
- 任何任务只要新增、删除或修改公网地址,必须在同一次任务里更新 `.project.json`
|
- 任何任务只要新增、删除或修改公网地址,必须在同一次任务里更新 `.project.json`
|
||||||
@@ -26,6 +36,7 @@
|
|||||||
|
|
||||||
## Completion Gate
|
## Completion Gate
|
||||||
|
|
||||||
|
- 普通代码修改完成后,默认收口在本地 Docker 验证和本地 commit;生产推送 / 部署必须等用户明确确认。
|
||||||
- 部署完成后,不允许在 `.project.json` 缺少最新公网链接的状态下结束任务
|
- 部署完成后,不允许在 `.project.json` 缺少最新公网链接的状态下结束任务
|
||||||
- 部署完成后,必须同步更新 `RULES.md` 的部署事实
|
- 部署完成后,必须同步更新 `RULES.md` 的部署事实
|
||||||
- 如果只更新了代码但没回写部署元数据,这个任务不算完成
|
- 如果只更新了代码但没回写部署元数据,这个任务不算完成
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates curl \
|
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates curl libgomp1 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY api/requirements.txt /app/requirements.txt
|
COPY api/requirements.txt /app/requirements.txt
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ WORKDIR /app
|
|||||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||||
|
|
||||||
COPY web/package.json web/pnpm-lock.yaml ./
|
COPY web/package.json web/pnpm-lock.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
COPY web/canvas-app/package.json web/canvas-app/pnpm-lock.yaml ./canvas-app/
|
||||||
|
RUN pnpm install --frozen-lockfile && cd canvas-app && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY web ./
|
COPY web ./
|
||||||
|
|
||||||
|
|||||||
158
RULES.md
@@ -1,37 +1,123 @@
|
|||||||
# SKG AI 素材管线 - TK 二创验证
|
# SKG 营销内容生产平台
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
- 本地 Docker 启动:`./scripts/start-local-docker.sh`(默认 Web `http://localhost:4390`、API `http://localhost:4391`、Postgres 数据在 `data-local/postgres`;首次会从 `deploy/.env.local.example` 生成 gitignored 的 `deploy/.env.local`)
|
||||||
|
- 本地 Docker 验证:`./scripts/verify-local-docker.sh`(检查容器、`/login/`、未登录 `/api/health`、容器内 `/health` + Postgres)
|
||||||
|
- 本地真实生成验收:涉及模型接入、API Key、图片生成或视频生成链路修复时,已配置可用 base/key 且用户未明确禁止真实调用,就必须由 Agent 在本地 Docker / 当前画布中直接触发一次最小真实生成,记录模型、入口、任务 ID/生成 URL 或失败原因;不要只让用户自己手动点测。
|
||||||
|
- 本地 Docker 停止:`./scripts/stop-local-docker.sh`
|
||||||
- 后台启动(不弹 Terminal):`./scripts/start-dev-background.sh`(通过 macOS launchd 后台托管;前端 4290 + 后端 4291,日志写入 `.logs/`)
|
- 后台启动(不弹 Terminal):`./scripts/start-dev-background.sh`(通过 macOS launchd 后台托管;前端 4290 + 后端 4291,日志写入 `.logs/`)
|
||||||
- 后台停止:`./scripts/stop-dev-background.sh`
|
- 后台停止:`./scripts/stop-dev-background.sh`
|
||||||
- 前端 dev:`cd web && npm run dev`(Next.js 16,端口 4290)
|
- 前端 dev:`cd web && npm run dev`(Next.js 16,端口 4290)
|
||||||
|
- 画布 dev:`cd web && npm run dev:canvas`(Vue / Vite,端口 4292;生产构建会作为根域名工作台输出)
|
||||||
- 后端 dev:`cd api && uvicorn main:app --host 127.0.0.1 --port 4291`(FastAPI,端口 4291,重任务用)
|
- 后端 dev:`cd api && uvicorn main:app --host 127.0.0.1 --port 4291`(FastAPI,端口 4291,重任务用)
|
||||||
- 注意:后端不要带 `--reload` 跑长下载 / 抽帧 / 音频任务;reload 会等待后台任务结束,导致 4291 端口占用但新请求卡住。
|
- 注意:后端不要带 `--reload` 跑长下载 / 抽帧 / 音频任务;reload 会等待后台任务结束,导致 4291 端口占用但新请求卡住。
|
||||||
|
- 发布流程新规则(2026-05-26):所有修改默认先在本地 Docker 跑通并验证,确认可用后只保留本地 commit;只有用户明确说“可以推送 / 上推 / 部署”时,才允许 `git push` 或执行生产部署。
|
||||||
|
|
||||||
|
## 最终产品和排查基线
|
||||||
|
- 最终产品入口固定为 `https://marketing.skg.com`;登录后根路径进入个人生成画布,之后所有升级和修复都以这套线上画布为主线。
|
||||||
|
- 当前本地基线必须尽量和线上一致:优先用 `./scripts/start-local-docker.sh` 启动 Web/API/Postgres,用 `./scripts/verify-local-docker.sh` 验证;只在需要快速定位前端细节时才临时使用 `npm run dev` 或 `npm run dev:canvas`。
|
||||||
|
- 旧 React 单对话框首页、旧 TK 复刻工作台、Agent Cut 和详情页功能保留为高级能力、兼容入口或代码参考;除非明确要求恢复,否则不要把它们当作新需求默认落点。
|
||||||
|
- 用户线上 bug 的默认处理顺序:确认用户和时间线 → 只读查询生产 API 容器内 `DATABASE_URL` / Postgres 与相关 job state → 必要时复制最小可复现数据到本地 Docker → 本地修复和验证 → 本地 commit → 等用户确认后再推送或部署。
|
||||||
|
- 排查线上数据时不要手动写生产库,不要覆盖服务器 `deploy/.env.production`、`data/`、`secrets/` 或 `/data/jobs`;需要恢复用户数据时先备份并给出影响范围。
|
||||||
|
|
||||||
## 立项决策快索引
|
## 立项决策快索引
|
||||||
- 详见 `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-18 再确认):先解决信息流广告快速复刻的第一步,不再沿用“开始后线性完成抽帧、分镜、元素生成、合成”的旧做法。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧,供人工选择可用主体并生成相似主体白底视图。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴规划新口播、镜头类型、首帧/尾帧、人物需求和产品出现方式;当前暂停直接调视频模型,先逐条用“相似主体视图 + 产品素材池 + 首尾帧文字规划”生成并审核首帧/尾帧,保存规划后再决定哪些分镜进入单条视频候选。
|
- 当前产品方向(2026-05-26 Postgres 持久化版):默认入口是多人通用的 SKG 营销内容生产平台,`https://marketing.skg.com` 登录后直接进入个人生成画布,`/canvas/` 只作为旧链接兼容跳转到根域名。终端可见品牌位只放 SKG logo,不在主界面展示“生图生视频”“SKG 生成画布”或长系统名。画布本体尽量恢复 `chatfire-AI/huobao-canvas` 的成熟交互,不再削成三模式单输入框:保留首页推荐词、画布底部推荐词、AI 润色、自动执行、公共工作流、我的工作流、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载等上游能力;多角度分镜、故事板、图转视频、绘本等工作流按上游结构创建节点。API 接入是例外:生成调用继续走本项目后端 `/api` 和当前登录 Cookie,不要求员工在浏览器配置个人 API Key;AI 润色只扩写用户明确写出的主体、品牌、产品、平台、动作和镜头,用户没写 `SKG` 时绝不主动加入 SKG,也不能把未知主体润成人物或强行润成无人物;上传/生成的参考图如果本来就有人物,应在视频提示词里按 AI 生成的虚拟角色、非真人、非公众人物处理,继续允许 AI 人像素材参与图生视频;图片/视频模型选择只显示后端已经接通的媒体模型,不能让浏览器本地自定义或旧缓存模型进入生成下拉;生图配置恢复最初简单版,图片模型显示 `auto`、`gpt-image-2`、`gemini-3-pro-image-preview`,尺寸只显示 `auto`、`1024x1536`、`1024x1024`、`1536x1024`,画质只保留标准项。API 设置弹窗只保留模型/端点配置外观,不能出现上游注册链接或外部品牌。用户登录后仍只看到自己的任务、结果、详情页、画布项目和个人工作流模板,继续沿用后端 owner 隔离;画布项目和我的工作流以服务端 Postgres 为主持久化,浏览器 `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)
|
||||||
- 发布状态:已部署并验证(2026-05-15);`https://marketing.skg.com` 已启用应用内登录页,认证后首页 200,`/api/health` 返回 `ok:true`
|
- Agent Cut 独立预览服务器:`2.24.28.41`(Ubuntu 24.04 / Docker Compose / 裸端口 `4290`),部署目录 `/opt/skg-marketing-studio`,Compose 入口 `docker-compose.standalone.yml`,访问地址 `http://2.24.28.41:4290/agent/`。该入口用于“一分钟二创出片终端”预览:用户只提交 TikTok 链接和产品图,后端 `AgentRun` 状态机负责下载、抽帧、规划、生成、自动重跑、审片和合成。
|
||||||
|
- Agent Cut 独立预览验证(2026-05-21):已在 `2.24.28.41` 的 `/opt/skg-marketing-studio` 用 `docker-compose.standalone.yml` 启动 `skg-agent-api` / `skg-agent-web`;独立 compose 通过网络别名兼容 Nginx 的 `skg-marketing-api` upstream。该裸 IP HTTP 入口的服务器 `deploy/.env.production` 需要 `WEB_AUTH_COOKIE_SECURE=false`;本次已补齐 `WEB_AUTH_*` 后重启验证通过:未登录 `/agent/` 返回 302 到 `/login/`,登录后 `/agent/` 返回 200,`/api/agent-runs` 返回数组,容器内 `/health` 返回 `ok:true` 且 `auth_configured:true`。
|
||||||
|
- 稳定性/安全加固(2026-05-30):`3572dde`(含 `3ed3f72` fix(api) / `b56d517` fix(canvas) / `6201ee9` fix(web))已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260529181045.tgz`。本次后端:`run()` 子进程加超时(下载 `DOWNLOAD_TIMEOUT_SECONDS` 默认 600s、其余 300s,超时 kill 并标 failed)、新增 `validate_source_url()` SSRF 白名单(拒绝 `file://`/私有·环回·链路本地 IP,域名走 `SOURCE_URL_ALLOWED_HOSTS`,默认主流短视频平台)、per-job `RLock` 保护 `save_state`/`update`/`update_generated_video` 与 retry 的 check-and-set、`db.py` 改用 `psycopg_pool` 连接池且写失败 `logging.error` 暴露、只读媒体 GET 改用不创建目录的 `job_path()`、多处 `Image.open()` 改 `with` 防 fd 泄漏;新增后端依赖 `psycopg-pool`(未装自动回退)。前端:画布 VideoNode 上传改走后端 `/jobs/upload` 拿稳定 URL 并在 `cleanNodeForStorage` 剥 `blob:`、`useCachedMediaUrl` 用真实 `blob.size` 统计缓存并补 catch 竞态校验、读参考图补 credentials、删除与 Canvas 层重复的节点级视频轮询与 `api/video.js` 死代码、`request.js` timeout 改 60s+withCredentials;首页/详情页视频轮询改为容错(连续失败 10 次才停)、agent 页预览 objectURL 移入 effect、登录页 pointermove rAF 节流。飞书登录自动跳转行为按确认保留不动。本地 `python3 -m py_compile api/main.py api/db.py` 与 `cd web && pnpm build`(canvas + next)通过(本机 Docker web 镜像因 next/font 拉取 Google Fonts 受限未重建,生产服务器构建正常)。生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/api/postgres Up、`web:no_local_api_refs`、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、`api:ytdlp_cookie_args []`、`api:health ok db connected`);生产 API 容器内复验 `psycopg_pool 3.2.4` 生效、`validate_source_url` 对 `file://`/`169.254.169.254`/`evil.com` 返回 400 而 `tiktok.com` 放行、`run()` 默认 timeout=300、`DOWNLOAD_TIMEOUT_SECONDS=600`。新增可选 env:`DOWNLOAD_TIMEOUT_SECONDS`、`SOURCE_URL_ALLOWED_HOSTS`、`DB_POOL_MAX_SIZE`。
|
||||||
|
- 我的工作流云端模板(2026-05-26):`5290812` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526031841.tgz`。本次新增 Postgres 表 `canvas_workflows` 与 `/canvas-workflows` 个人模板接口,画布工作流面板“我的工作流”可保存当前节点结构、刷新列表、删除模板,并在插回画布时重新生成节点 ID、按视口重排、重连边;保存前会清理已生成图片/视频、任务进度、错误和 LLM 输出等运行态。本地验证 `python3 -m py_compile api/main.py api/db.py` 与 `cd web && npm run build` 通过;生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`),生产 web 静态 bundle 命中 `保存当前工作流` 和 `canvas-workflows`,API 容器查询 `to_regclass('public.canvas_workflows')` 返回 `canvas_workflows`。
|
||||||
|
- 生图配置恢复(2026-05-26):`bdb7226` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526060255.tgz`。本次按用户要求恢复最初简单生图配置:图片模型为 `auto,gpt-image-2,gemini-3-pro-image-preview`,尺寸只保留 `auto,1024x1536,1024x1024,1536x1024`,画质恢复为单一标准项,撤回低/中/高画质、自定义尺寸、Gemini 1K/2K/4K 长列表和取消自动模型的改动。脚本内首次验证在容器启动 4 秒时遇到根路径临时 500,随后复跑 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过(web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`);生产 API 容器确认 `auto -> ['gpt-image-2','gemini-3-pro-image-preview']`,生产 web 静态包未命中 `supportsCustomSize`、`1536×2752` 或 `自定义 1088`。
|
||||||
|
- 最近部署验证(2026-05-25):`84d9de6` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,画布图片/视频模型选择收口到当前后端真实可用媒体模型。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525105910.tgz`;生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。补验:外部访问 `https://marketing.skg.com/` 未登录返回 302 到 `/login/?next=/`,`https://marketing.skg.com/p/test` 未登录返回 302 到 `/login/?next=/p/test`;容器内 `/health` 返回 `image_options=auto,gpt-image-2,gemini-3-pro-image-preview`,`video_options=seedance:Seedance 2.0 Fast:doubao-seedance-2-0-fast-260128`,`video_duration_options=5,8,10,12,15`,图片尺寸为 `auto,1024x1536,1024x1024,1536x1024`,视频画幅为 `720x1280,1280x720,1024x1024,960x1280`;生产静态 bundle 命中 `GPT Image 2 / Gemini 图片 / Seedance 2.0 Fast / 1024x1536 / 720x1280`,未命中 `Nano Banana / Seedream / doubao-seedream / doubao-seedance-1 / sora-2 / Kling / Veo 3`。
|
||||||
|
- 生产配置验证(2026-05-25 23:49 CST):已在服务器 `/opt/skg-marketing-studio/deploy/.env.production` 补齐飞书 OAuth 应用配置,并仅重建 `skg-marketing-api` 使环境变量生效;敏感 App Secret 不入库。验证结果:`https://marketing.skg.com/api/auth/config` 返回 `feishu_enabled=true`、`password_enabled=true`、`data_isolation_enabled=true`;`GET https://marketing.skg.com/api/auth/feishu/start?next=/` 返回 302 跳转到飞书授权页;容器内 `/health` 返回 `auth_modes.feishu=True`。
|
||||||
|
- 最近部署验证(2026-05-26):`c9d8fa7` 对应 Postgres 持久化代码已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`。生产新增 `skg-marketing-postgres` 容器,数据库持久化在服务器 `./data/postgres`,`DATABASE_URL` / `POSTGRES_PASSWORD` 只写服务器 `deploy/.env.production`。部署前脚本备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525225145.tgz`;生产 Docker 重建后脚本内验证通过(web/API/Postgres 容器 Up、Postgres healthy、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok db connected`、`api:ytdlp_cookie_args []`)。文档/元数据同步后又执行 `./scripts/deploy-prod-safe.sh --no-build`,实际走过 Postgres `pg_dump` 备份路径并生成 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525230444.tgz`,复验同样通过。补验:容器内 `/health` 返回 `database.enabled=true`、`database.connected=true`,`/api/auth/config` 返回 `feishu_enabled=true`、`password_enabled=true`、`data_isolation_enabled=true`;画布项目 API 可创建、读取、软删除记录;数据库索引计数为 users=1、jobs=26、assets=129、canvas_active=0、canvas_deleted=1、audit=2。
|
||||||
|
- 生产登录收口(2026-05-26):已在服务器 `/opt/skg-marketing-studio/deploy/.env.production` 设置 `PASSWORD_AUTH_ENABLED=false` 并通过 `./scripts/deploy-prod-safe.sh` 重建生产。部署前脚本备份到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526003816.tgz`;脚本内首次验证在容器启动 3 秒时遇到根路径临时 500,随后复跑 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过(web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`)。公网复验:`/api/auth/config` 返回 `password_enabled=false`、`feishu_enabled=true`、`data_isolation_enabled=true`;`GET /api/auth/feishu/start?next=/` 返回 302 到飞书授权页;`POST /api/auth/login` 返回 503 `账号密码登录未配置`。
|
||||||
|
- 旧密码账号归属迁移(2026-05-26):已把旧共享密码账号 `password:skg` 下的 22 个 job、3 个画布项目和对应生成资产索引迁到飞书用户 `万康`(`feishu:ou_78276b4fd9dd818d8f70bc00d03ddbdf`)。迁移前已备份数据库和 `data/jobs` 到 `/opt/skg-marketing-studio-backups/skg-marketing-owner-migration-20260526010622.sql.gz` 与 `/opt/skg-marketing-studio-backups/skg-marketing-owner-migration-jobs-20260526010622.tgz`。复验:`job_index` 中该飞书用户 24 个 job,`canvas_projects` 中该飞书用户 3 个未删除私有画布,生成资产索引为 image completed=11、video completed=11、video failed=1;无 owner 的 4 个更早旧 job 保持未迁移,后续再确认归属。
|
||||||
|
- 视频错误提示收口(2026-05-26):`579e538` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526014111.tgz`。本次把 Seedance / Doubao 视频上游错误转换为员工可读中文后再写入 `GeneratedVideo.error`,例如 `InputImageSensitiveContentDetected.PrivacyInformation` 会提示参考图含清晰人物或疑似真实人脸,需要换无脸首帧、裁切或模糊人物脸;原始上游错误只保留在 API 日志。脚本内首次验证在容器启动 3 秒时遇到根路径临时 500,随后复跑 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过(web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`),并在生产 API 容器内确认隐私风控码会返回中文解释。
|
||||||
|
- AI 润色中性化(2026-05-26):`509bd9b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526020846.tgz`。本次把画布 `AI 润色`、LLM 节点和自动执行意图分析从 SKG 广告文案接口 `/creative/copy` 拆出,新增中性 `POST /prompt/polish`:只优化用户已经写明的主体、品牌、产品、地点、风格和镜头,不主动添加 SKG、按摩产品、TikTok/Reels 广告话术、标题或 hashtag;`/creative/copy` 继续保留给明确的 SKG 营销文案场景。脚本内验证通过(web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`),并在生产 API 容器内确认普通雨夜街头摊位提示词经 `/prompt/polish` 兜底输出不包含 SKG、massage 或 TikTok。
|
||||||
|
- AI 润色人物安全词分流(2026-05-26):`daec523` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526022320.tgz`。本次在 `/prompt/polish` 增加人物意图检测:原提示词没有人物语义或明确写无人时,润色只补“保持 object-only / scene-only / product-only 构图,不新增 people、faces、bodies、hands、avatars、characters、crowds”;原提示词明确有人像、模特、角色、数字人或脸时,才补“fully fictional synthetic AI character / virtual avatar / not based on any real person”。脚本内验证通过(web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`),并在生产 API 容器内确认雨夜章鱼烧摊位不会出现虚构角色安全词,年轻女生人像会出现虚构 AI 角色安全词。
|
||||||
|
- AI 润色意图校验和参考图人物提示(2026-05-26):`f5be97b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526035016.tgz`。本次把 `/prompt/polish` 改为清理旧模板尾巴、分类人物/无人/物体/场景/动物/未知主体并做冲突修复:不主动加入 SKG、产品、平台、广告语境或人物,也不把未知主体强行润成无人物;同时 `/storyboard/video` 最终入队前会给参考图请求追加条件提示,说明参考图里若有人物、脸、身体、手、头像或角色,应按 AI 生成的虚拟角色、非真人、非公众人物处理,允许员工继续用 AI 人像素材做图生视频。部署脚本内首次验证在容器启动 3 秒时遇到根路径临时 500,随后复跑 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过(web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`),并在生产 API 容器内确认参考图提示词追加和 `InputImageSensitiveContentDetected.PrivacyInformation` 中文错误解释已生效。
|
||||||
|
- 推荐词轮换(2026-05-26):`d01fdc5` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526023923.tgz`。本次把画布和首页推荐词从固定数组改为 4 个一组的短词池,刷新按钮绑定为切换下一组;推荐栏固定单行高度并截断过长 chip,避免把底部输入框顶高。脚本内验证通过(web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`),并在生产 web 容器静态 bundle 中确认命中 `换一组推荐`、`魔法森林`、`无人物街景` 等新文案。
|
||||||
|
- 推荐词扩展(2026-05-26):`7f3a6cc` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526024847.tgz`。本次新增 `web/canvas-app/src/config/suggestions.js`,把首页和画布推荐词统一为 30 组 / 120 个短词共享池,每次仍显示 4 个并按组轮换,保持单行不顶起 composer。本地验证 `groups=30`、`items=120`、最长词 5 个字符;本地 `npm run build` 和生产 Docker 构建通过,`./scripts/verify-prod-docker.sh` 复验通过(web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`),并在生产 web 容器静态 bundle 中确认命中 `银河帐篷`。
|
||||||
|
- 最近部署验证(2026-05-25):`cce9779` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,恢复 `chatfire-AI/huobao-canvas` 上游画布能力但保留 SKG 后端 `/api` 接入。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525102857.tgz`;生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。补验:外部访问 `https://marketing.skg.com/` 未登录返回 302 到 `/login/?next=/`,`https://marketing.skg.com/canvas/` 返回 308 到 `/`,`https://marketing.skg.com/p/test` 未登录返回 302 到 `/login/?next=/p/test`;容器内静态 bundle 命中 `AI 润色 / 自动执行 / 推荐: / 首帧 / 尾帧 / 多角度分镜 / 儿童绘本 / 工作流模板 / 批量下载素材`,未命中上游注册链接、火宝欢迎文案、GitHub 入口或 `/huobao-canvas`。
|
||||||
|
- 最近部署验证(2026-05-25):`e767d2b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,生产根域名改为直接进入个人生成画布,`/canvas/` 仅作为旧链接 308 跳转到 `/`。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525095839.tgz`;生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。补验:容器内 `/usr/share/nginx/html/index.html` 为 Vue 画布产物,引用 `/assets/index-CioZwOvT.js` 且 title 为 `SKG`;静态 bundle 命中 `文生图 / 文生视频 / 图生视频`,未命中 `首帧生视频 / 首尾帧生视频 / 上传首帧 / 上传尾帧 / 推荐:`;外部访问 `https://marketing.skg.com/` 未登录返回 302 到 `/login/?next=/`,`https://marketing.skg.com/canvas/` 返回 308 到 `/`,`/p/test` 未登录返回 302 到 `/login/?next=/p/test`。
|
||||||
|
- 最近部署验证(2026-05-25):`2a1ceee` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,可见品牌位从文字命名收敛为 logo-only:首页、登录页和画布首页只显示 SKG logo,网页 title 和画布 title 为 `SKG`,首页入口按钮文案为“画布”。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525092749.tgz`;生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。容器内静态产物复验:`index.html` 包含 `<title>SKG</title>` 和 `/skg-logo-black.svg`,首页入口包含“画布”,登录页只保留 logo;当前 `_next` 与 `/canvas` 产物未再命中 `SKG 生图生视频`、`SKG 生成画布`、`营销内容生产平台` 或 `内容生产画布` 等旧可见文案。
|
||||||
|
- 发布状态:已部署并验证(2026-05-20,主体元素按套图文件夹分组展示,主体生成接口提交后立即返回 queued 占位并后台逐视角生成、逐张回填;工作台外层取消 1800x1000 固定画布和应用层 `zoom` 缩放,改为正常流式桌面容器,最低操作宽度 1280px;源视频工作区主体链路为上方竖向参考帧池 + 宽幅对话式转换层、下方主体元素结果栏;转换层通过参考帧 `+` 加入、参考图分析、生图对话,英文 prompt 就绪后由发送区主按钮切换为确认生成,点击后才触发主体套图生成;转换层不再固定 640px 长高,按内容自然高度显示,仅以 560px 最大高度兜底内部滚动;下方主体元素结果栏的套图输出、轮询、文件夹分组、单张重生、删除和 hover 预览逻辑保持不变;胶片双击/拖拽加入参考帧池 + 胶片缓存复用 + 音频解析失败可重试,参考帧缩略图保持小尺寸 9:16 比例 + hover 左侧紧凑预览,旧主体模板区移出主路径 + 逐句时间轴移到原版视频下方并支持双行文案 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片上下错落 + body 顶层原位大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401,认证后首页 200;容器内 `/health` 返回 `ok:true`
|
||||||
|
- 最近部署验证(2026-05-25):`2192f15` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,可见命名从“营销内容工作台 / 无限画布”改为“SKG 生图生视频 / 生成画布”。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525091127.tgz`;生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。容器内静态产物复验:首页标题和 header 包含 `SKG 生图生视频`,首页按钮包含 `生成画布`,`/canvas/index.html` 标题为 `SKG 生成画布`,当前 `_next` 与 `/canvas` 产物未再命中旧可见命名。
|
||||||
|
- 最近部署验证(2026-05-25):`2d19560` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,新增登录保护下的 SKG 内部生成画布入口 `https://marketing.skg.com/canvas/`,并把首页“生成画布”按钮接到该路径。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525085342.tgz`;生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。补验:未登录访问 `/canvas` 返回 308 到 `/canvas/`,未登录访问 `/canvas/` 返回 302 到 `/login/?next=/canvas/`;容器内确认 `/usr/share/nginx/html/canvas/index.html` 和 `canvas/assets` 已存在。
|
||||||
|
- 最近部署验证(2026-05-25):`779e9b3` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,视频生成改为个人公平队列:全局默认同时 2 个视频、单用户同时 1 个视频,同一用户连续提交会显示排队且不会占满所有生成通道。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525075706.tgz`;生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。复验静态 bundle 已包含 `queue_message` 和“排队中”文案;API 容器确认 `VIDEO_QUEUE_MAX_CONCURRENT=2`、`VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1`。
|
||||||
|
- 最近部署验证(2026-05-25):`b2d84dc` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,修复首页生成视频完成后结果卡点击无反馈的问题:`MediaAssetTile` 新增可选原生视频 controls,首页仅在视频 `completed` 后开启播放控件,失败状态展示错误说明。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525071823.tgz`;生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。复验静态 bundle 已包含 `videoControls` 和 `controls:`;生产容器内最近完成视频文件存在:`/data/jobs/16b984e804f5/storyboard_videos/ac96d8eba342/video.mp4`,大小 3687229 bytes。
|
||||||
|
- 最近部署验证(2026-05-25):`486a682` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,登录页新增飞书客户端 UA 自动发起 `/api/auth/feishu/start`,Nginx 未登录跳转改为 `/login/?next=$request_uri` 以保留回跳页面。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525070905.tgz`;生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。复验静态 bundle 已包含 `skg-feishu-auto-login` 和 `auth/feishu/start?next`,未登录访问 `/detail/?job=test` 返回 `Location: /login/?next=/detail/?job=test`。该部署当时生产 `auth_config()` 仍显示 `feishu_enabled=false`;2026-05-25 23:49 CST 已在服务器环境补齐飞书 OAuth 配置并重建 API,当前 `feishu_enabled=true`。
|
||||||
|
- 最近部署验证(2026-05-25):`a02c5eb` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,修复无首帧文生图 / 文生视频创建空白创作任务时的 `createCreativeImageJob 400 There was an error parsing the body`。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525064659.tgz`;生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。云端容器确认图片 / 视频密钥均已配置:`image_configured=True`、`video_configured=True`、`image_base_url=https://ai.skg.com/ezlink/v1`、`video_base_url=https://ai.skg.com/doubao`;同一个缺 boundary 的空 multipart 探针已从旧版 400 变为认证层 401,说明请求体解析问题已消除。
|
||||||
|
- 最近部署验证(2026-05-25):`e77e77f` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,生产入口新增图片尺寸、视频画幅和按真实能力返回的视频时长选择。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525062614.tgz`;脚本内首次验证在容器刚启动 3 秒时遇到 `/` 500,随后复跑 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。容器内能力复验:`image_sizes=auto,1024x1536,1024x1024,1536x1024`,`video_sizes=720x1280,1280x720,1024x1024,960x1280`,`video_durations=5,8,10,12,15`,`video_max=15`;当前 Doubao / Seedance 单条不暴露 30 秒。
|
||||||
|
- 最近部署验证(2026-05-25):`dcc8abc` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,生产入口为单对话框四模式生成页,并接入图片 / 视频模型选择。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525030237.tgz`;首次脚本校验在容器刚启动时遇到 `/` 500,经日志确认是 Nginx auth 子请求早于 API 就绪导致的临时连接拒绝,随后复跑 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。容器内模型选项复验:`image_options=auto,gpt-image-2,gemini-3-pro-image-preview`,`video_options=seedance,kling,veo3,veo`,`video_configured=True`。
|
||||||
|
- 最近部署验证(2026-05-24):`828b86d` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,生产入口切换为多人通用营销内容创作平台首页,并保留 `/agent/` 作为高级复刻入口、`/detail/?job=<id>` 作为任务详情页。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260523175306.tgz`;生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。
|
||||||
|
- 最近部署验证(2026-05-22):`6427935` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260522012756.tgz`,生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`)。部署后已把生产私有 `deploy/.env.production` 明确固定为多语言本地 ASR 路径并重启 API:`ASR_LANGUAGE=auto`、`FASTER_WHISPER_MODEL=base`、`ASR_REMOTE_ENABLED=false`、`ASR_LOCAL_FALLBACK_ENABLED=true`、`ASR_AUDIO_FALLBACK_ENABLED=false`;复验 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过,容器内 `/health` 确认 `asr_language=auto`、`faster_whisper=base`。
|
||||||
|
- 最近部署验证(2026-05-21):`8458dac` 已按“先本地 Docker、再上传部署”流程上线。上线前在本机 Docker 构建 `skg-marketing-studio-web:latest` / `skg-marketing-studio-api:latest`,并用本地 Compose 容器验证通过:`web:/ 302`、`web:/login/ 200`、`web:/_next/does-not-exist.js 404`、`web:/api/health 401`、`api:health ok`、`api:ytdlp_cookie_args []`、静态 bundle 包含 `未来健康 · 营销内容工作台` 和 `信息流广告复刻生产`,且未发现本地 API/dev URL 泄漏。随后通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260521070327.tgz`,生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。
|
||||||
|
- 最近部署验证(2026-05-20):`6597db3` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520151033.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/c48f07b9aef1cd29.js` 已包含 `min-w-[1280px]` 和 `max-w-[1920px]`,未再命中旧的 `h-[1000px]`、`w-[1800px]`、`BOARD_SCALE_PRESETS` 或 `boardScale`;对应工作台取消固定画布缩放,按浏览器正常流式布局渲染。
|
||||||
|
- 最近部署验证(2026-05-20):`2b842fd` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520145223.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/743b82648dfa9db9.js` 已包含 `h-32`、`maxHeight:560`、`提示词就绪` 和 `确认生成`,且未再命中旧的 `height:640` / `h-40`;对应转换层取消固定长高,生成要求输入区回到 128px,底部仍由发送区主按钮确认生成。
|
||||||
|
- 最近部署验证(2026-05-20):`ab31a98` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520144227.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/5bbecb6cf31316cb.js` 已包含 `h-40`、`提示词就绪` 和 `确认生成`,对应生成要求输入框加高到 160px,出图提示词生成后不再自动弹窗,底部主按钮直接切换为确认生成。
|
||||||
|
- 最近部署验证(2026-05-20):`215987a` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520142849.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/54e1ee55c5019be8.js` 已包含 `height:640`,对应转换层固定高度从 560px 扩到 640px。
|
||||||
|
- 最近部署验证(2026-05-20):`e1e9bf8` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520142145.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测生成要求 composer:文本输入区实际高约 119px,张数控件和发送按钮实际高约 42px,页面无客户端异常,验证截图 `/tmp/skg-generation-composer-expanded.png`。
|
||||||
|
- 最近部署验证(2026-05-20):`45b25d0` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520140706.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测转换层:生成要求区不再渲染“当前要求”、“提示词已生成”和“对话记录已收起”摘要,保留元素副本也已移除;该区只保留文本输入、张数控件和发送按钮,页面无客户端异常,验证截图 `/tmp/skg-generation-composer-simplified.png`。
|
||||||
|
- 最近部署验证(2026-05-20):`54f159b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520135509.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测转换层:转换层和参考帧池共用高度从 500px 拉到 560px,转换层内部改为 gap 堆叠并让主要板块 `shrink-0`,超出由转换层自身滚动承接;页面无客户端异常,验证截图 `/tmp/skg-conversion-stretched.png`。
|
||||||
|
- 最近部署验证(2026-05-20):`d1e2b17` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520134529.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测转换层:内嵌“待确认提示词”卡和黑色“确认并生成 N 张”按钮均不再渲染,页面无客户端异常;有待确认 prompt 时只在“生成要求”标题右侧显示小型“待确认 · N 张”入口,验证截图 `/tmp/skg-conversion-no-inline-confirm.png`。
|
||||||
|
- 最近部署验证(2026-05-20):`caa7b73` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520132820.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测:页面无客户端异常,源视频工作区已撤销“布局调节”按钮和 `localStorage["skg-source-workspace-layout:v1"]` 布局读写,固定为左侧原视频列 380px、9:16 视频高 500px、逐句时间轴最大高 270px、参考帧池 140px、转换层 500px 内部滚动、主体空态 78px;验证截图 `/tmp/skg-layout-fixed-no-tuning.png`。
|
||||||
|
- 最近部署验证(2026-05-20):`0db265f` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520131649.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 打开历史任务并展开“布局调节”:面板显示左列宽、视频高、时间轴高、参考池宽、转换层高、主体空态 6 个滑杆,调参值写入 `localStorage["skg-source-workspace-layout:v1"]`,供用户先在线试比例再固化默认值。
|
||||||
|
- 最近部署验证(2026-05-20):`5bffd63` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520123949.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 复测 1440x900 与 2048x1060:历史任务加载后转换层占据主操作宽度,主体元素下移为转换层下方的紧凑结果栏,未再出现右侧三栏挤压;滚动到主体元素位置后仍能看到下方分镜工作台承接。
|
||||||
|
- 最近部署验证(2026-05-20):`f0f567b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520120958.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 截图复测 1440x900、1728x1117、2048x1060、2560x1440:缩放后的工作台在 1440/1728/2560 这类高度有余量的窗口上下居中,2048x1060 保持顶部对齐并承接纵向内容,未出现先前的底部黑边失衡。
|
||||||
|
- 最近部署验证(2026-05-20):`3e7c165` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520114759.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测 1366x768、1440x900、1728x1117、1920x1080、2048x1060、2200x1400、2560x1440:缩放档位分别为 0.72、0.8、0.92、1.06、1.06、1.16、1.34;2048x1060 保留左右 70px 呼吸感且无横向溢出,浏览器 `pageerror` 为空。
|
||||||
|
- 最近部署验证(2026-05-20):`e33463e` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520113414.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测转换层:页面显示“生成要求”和“对话记录已收起”,不再显示旧标题“生图对话”,也不再渲染“我们将不再强制...”这类模型确认消息;最终英文 prompt 仍保留在“待确认提示词”区域,浏览器 `pageerror` 为空。
|
||||||
|
- 最近部署验证(2026-05-20):`f35bfe0` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520111824.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测 1440x900、2048x1060、2200x1400 三种窗口,工作台仍按可见宽度优先铺满且外层 wrapper 左右间隙均为 0;内层画布已改用 CSS `zoom` 渲染,三个窗口分别为 `zoom=0.8/1.138/1.222`,`transform` 均为 `none`,避免整屏 transform 小数缩放造成文字发虚,浏览器 `pageerror` 为空。
|
||||||
|
- 最近部署验证(2026-05-20):`1d0a77b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520105846.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测 1440x900、2048x1060、2200x1400 三种窗口,工作台按可见宽度优先缩放,外层 wrapper 左右间隙均为 0;三个窗口分别缩放到 0.8、1.138、1.222,2048x1060 这类高度不足场景通过纵向滚动承接,不再为了完整高度留下左右空白,浏览器 `pageerror` 为空。
|
||||||
|
- 最近部署验证(2026-05-20):`54eaac0` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520104155.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测 1440x900、2048x1060、2200x1400 三种窗口,工作台以 1800x1000 为基准分别缩放到 0.8、1.06、1.222,主网格列宽、源视频区列宽和三栏主体管线列宽保持一致,浏览器 `pageerror` 为空。
|
||||||
|
- 最近部署验证(2026-05-20):`64fef5a` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520102354.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 以 1440x900 与 2200x1400 两种窗口复测,工作台内部画布固定为 1800x1000,主网格列宽、源视频区列宽和三栏主体管线列宽一致,浏览器 `pageerror` 为空。
|
||||||
|
- 最近部署验证(2026-05-20):`40f1f28` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520095941.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测首页正常渲染,浏览器 `pageerror` 为空。转换层不再显示固定快捷需求按钮,生图对话空态和输入框改为中性“保留识别元素 / 补充调整要求”,由识别结果 chip 和自然语言对话承接用户意图。
|
||||||
|
- 最近部署验证(2026-05-20):`2c0e8a0` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520094923.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测首页正常渲染到工作台,浏览器 `pageerror` 为空;本次修复 `selectedAgentTraitsDirty` 残留变量名导致的客户端 `ReferenceError`,恢复转换层页面首屏渲染。
|
||||||
|
- 最近部署验证(2026-05-20):`5bdde89` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520092721.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、无本地 API 地址泄漏)。线上转换层识别结果 chip 改为本地即时切换:点亮表示保留元素、再次点击取消、清空按钮取消全部;点击 chip 不再触发 `/subject-agent/message`,保留元素随下一条“发送消息”一次性提交,避免每点一个特征都等待模型导致卡顿。
|
||||||
|
- 最近部署验证(2026-05-20):`10d955c` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520090750.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、无本地 API 地址泄漏)。线上转换层已移除可见快捷需求 chip,复刻/创新/卡通/人物占比作为对话默认意图写入输入提示;生成张数控件移到发送消息旁边,默认 6 张、当前支持 1-10;参考输入空态和已选参考图缩略图压小并继续复用 `MediaAssetTile` hover 放大预览。
|
||||||
|
- 最近部署验证(2026-05-20):`b9c5511` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520085513.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、无本地 API 地址泄漏)。线上转换层已更新为参考输入区支持左侧 `+`、参考帧拖拽、胶片拖拽和本地图片拖入,下方为生图对话消息 composer,右侧主体元素套图输出逻辑保持不变。
|
||||||
|
- 最近部署验证(2026-05-19):`fd794e3` 已推送并部署到 `/opt/skg-marketing-studio`;生产 `/health` 显示 `image=gpt-image-2`、`subject_image=gpt-image-2`、`image_request_timeout_seconds=60`、`image_base_url=https://ai.skg.com/ezlink/v1`。容器内最小文字生图探针在 20 秒限制下返回 `ReadTimeout`,说明当前阻塞点是 `https://ai.skg.com/ezlink/v1` 的 `gpt-image-2` 上游通道超时,服务端不会更换图片模型。
|
||||||
|
- 最近部署验证(2026-05-19):`3756259` 已推送并部署到 `/opt/skg-marketing-studio`;生产 `/health` 显示 `image=gpt-image-2`、`image_fallbacks=['gemini-3-pro-image-preview']`、`subject_image_fallbacks=['gpt-image-2','gemini-3-pro-image-preview']`、短时熔断阈值 2 次 / 600 秒。线上真实探针确认 `gpt-image-2` 读超时后同次调用可自动兜底到 `gemini-3-pro-image-preview` 并返回图片;模拟探针确认连续 2 次主模型失败后第三次直接走 Gemini。
|
||||||
|
- 最近部署验证(2026-05-20):`c245bff` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py` 和 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web 容器 Up、API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。容器内模型偏好探针确认转换层 `image_model_preference` 路由:`auto -> ['gpt-image-2','gemini-3-pro-image-preview']`、`gpt-image-2 -> ['gpt-image-2']`、`gemini-3-pro-image-preview -> ['gemini-3-pro-image-preview']`。
|
||||||
|
- 最近部署验证(2026-05-20):`2366662` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py`、`web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过。容器内后处理探针确认白底小主体保存为 `1152x2048` 时有效主体高度占比从约 0.60 可放大到 `0.906`,主体 6 视图 prompt 已注入同一份 pack bible。
|
||||||
|
- 最近部署验证(2026-05-20):`7acbfd5` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py`、`web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层主体提示词记忆和生图模型偏好改为按 `job.id` 隔离;有参考帧的 `reconstruction_mode=similar` 会先生成 source brief,再把参考帧作为 `/images/edits` 的 `image[]` 参考提交;自主描述空文本切到 `reconstruction_mode=same` 源形象锁定路径。
|
||||||
|
- 最近部署验证(2026-05-20):`e10b1a6` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py`、`web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层改为项目内生图对话智能体,新增 `Job.subject_agent` 和 `/subject-agent/analyze`、`/subject-agent/message`,GPT / Gemini 改为成套控制分析、对话和生图模型,数量与要求修改进入对话状态后再调用主体套图生成;Pydantic `model_bundle` protected namespace warning 已消除。
|
||||||
|
- 最近部署验证(2026-05-20):`d82175f` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py`、`web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层去掉方向卡片、卡通风格下拉和独立数量按钮,保留单一参考区 + 生图对话;后端 `/subject-agent/message` 从对话中识别 `selected_mode` 和 `quantity` 后再驱动主体套图生成。
|
||||||
|
- 最近部署验证(2026-05-20):`f1c710e` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层中间栏先清空为待重构占位,不再接收拖拽或触发 subject-agent / subject-assets;右侧主体元素输出逻辑保持不变。
|
||||||
|
- 最近部署验证(2026-05-20):`7e763cf` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层改为参考帧分析 + 对话生成提示词 + 弹窗确认后再生成主体套图;右侧主体元素输出逻辑保持不变。部署时发现服务器 `WEB_AUTH_*` 环境变量缺失导致 `/auth/check` 503,已从 `/root/skg-marketing-studio-login.txt` 和新 session secret 恢复服务器 `deploy/.env.production` 后重启验证通过;后续同步生产代码必须继续排除服务器真实 `deploy/.env.production`。
|
||||||
- 主站 / 前端:`https://marketing.skg.com`
|
- 主站 / 前端:`https://marketing.skg.com`
|
||||||
|
- 旧画布路径:`https://marketing.skg.com/canvas/`(仅兼容跳转到根域名)
|
||||||
- API / 后端:`https://marketing.skg.com/api`
|
- API / 后端:`https://marketing.skg.com/api`
|
||||||
- 代码仓库 / Gitea:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
|
- 代码仓库 / Gitea:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
|
||||||
- 文档 / 解析:`docs/source-analysis.html`(项目内独立文档,不公开挂主应用路由)
|
- 文档 / 解析:`docs/source-analysis.html`(项目内独立文档,不公开挂主应用路由)
|
||||||
- 管理后台:待定
|
- 管理后台:待定
|
||||||
- 服务器目录:`/opt/skg-marketing-studio`
|
- 服务器目录:`/opt/skg-marketing-studio`
|
||||||
- 生产启动:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build`
|
- 本地 Docker 是生产前默认验收口径:`./scripts/start-local-docker.sh` 构建本地 Web/API/Postgres,`./scripts/verify-local-docker.sh` 通过后才允许进入推送/部署讨论。本地 Docker 使用 `docker-compose.local.yml`、`deploy/.env.local` 和 `data-local/`,不能读取或覆盖生产 `deploy/.env.production`、服务器 `data/` 或 `secrets/`。
|
||||||
- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出;`/login/`、`/_next/`、`/assets/`、`/skg-logo-black.svg`、`/oasis-source/` 等登录页必需静态资源公开访问;未登录访问工作台跳转 `/login/`,`/api/` 通过 Nginx `auth_request` 校验 FastAPI 会话 Cookie 后反代到 `skg-marketing-api:4291`;Traefik 通过 `coolify` 外部网络接入 80/443
|
- 生产部署唯一入口:`./scripts/deploy-prod-safe.sh`(先在服务器备份 `deploy/.env.production`、`data/jobs`、资源库和 `secrets`,如 Postgres 容器存在则额外导出 `pg_dump`,再用受保护 rsync 同步代码,最后 Docker 重建并运行 `verify-prod-docker.sh`)
|
||||||
- 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`
|
- 生产容器重建命令:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build`;只允许脚本内部或明确只重启容器时使用,不允许再用裸 `rsync --delete` 手动同步。
|
||||||
- 登录凭证:用户名写下方快捷登录;密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt`,生产环境变量 `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`
|
- 独立预览容器重建命令:服务器 `/opt/skg-marketing-studio` 下执行 `docker compose -f docker-compose.standalone.yml --env-file deploy/.env.production up -d --build`;Web 暴露 `0.0.0.0:4290->80`,后端仅在 compose 内部网络暴露,`/api/` 由 Web 容器 Nginx 反代并复用应用内登录校验。
|
||||||
|
- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出与根域名 Vue / Vite 画布静态应用;构建时先生成画布,再 Next 静态导出,最后用画布产物覆盖 `web/out/index.html` 和 `/assets/`,使登录后的 `/` 直接进入画布;`/canvas/` 只做 308 兼容跳转到 `/`。`/login/`、`/_next/`、`/assets/`、`/skg-logo-black.svg`、`/oasis-source/` 等登录页必需静态资源公开访问;未登录访问工作台跳转 `/login/`;`/api/` 通过 Nginx `auth_request` 校验 FastAPI 会话 Cookie 后反代到 `skg-marketing-api:4291`;FastAPI 通过内网 `DATABASE_URL` 连接 `skg-marketing-postgres:5432`,Postgres 不对公网暴露;Traefik 通过 `coolify` 外部网络接入 80/443
|
||||||
|
- Web 验收必须以生产 Docker 形态为准:前端是 `next export` 静态产物 + Nginx,不是 `next dev` / `next start`。任何 Web 改动部署后必须运行 `./scripts/verify-prod-docker.sh`,确认 `/login/`、`/_next/`、`/api/health`、本地 API 地址泄漏和 API 镜像 `.env` 污染检查通过;不能只用本地 `npm run build` 作为上线依据。
|
||||||
|
- 未经用户明确确认,不允许推送 Gitea 或部署生产;完成开发任务时报告本地 Docker 验证结果、当前分支、本地领先数量和待推送 commit。
|
||||||
|
- 当前音频解析:`https://ai.skg.com/azure/v1` 的 `gpt-4o-transcribe` 当前返回 `DeploymentNotFound`,且官方 Azure OpenAI transcription 路径探测也未返回可用部署;生产临时复制本地成功策略,直接使用容器内多语言 `faster-whisper` 真实转写,默认语种为 `auto`,支持中文、英文和其他多语言原文识别,关闭 Gemini 多模态音频兜底。拿到真实 Azure ASR deployment 名后再恢复 `ASR_REMOTE_ENABLED=true`,并保持 `ASR_LANGUAGE` 为空或 `auto`,除非明确只想强制单一语种。
|
||||||
|
- 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`;全局资源中心持久化在 `./data/asset_library`、`./data/prompt_library` 和 `./data/_trash`;Postgres 数据目录为服务器 `./data/postgres`,部署脚本通过 `pg_dump` 产出 `/opt/skg-marketing-studio-backups/skg-marketing-postgres-*.sql.gz`
|
||||||
|
- TikTok 下载登录态:公开视频默认不带 cookies 直接下载,生产环境变量必须显式保持 `YTDLP_COOKIES_FILE=`、`YTDLP_COOKIES_FROM_BROWSER=` 为空,防止容器读取不存在的浏览器 cookies。只有 TikTok 明确要求登录态时,才使用服务器私有 cookies 文件 `./secrets/tiktok_cookies.txt` 挂载到 API 容器 `/run/secrets/tiktok_cookies.txt` 并配置 `YTDLP_COOKIES_FILE=/run/secrets/tiktok_cookies.txt`;`yt-dlp` 会在任务结束时回写 cookies,因此不要把该挂载设为只读;不要使用云端浏览器读取方案,也不要把 cookies 入库。生产容器严禁使用 `YTDLP_COOKIES_FROM_BROWSER=chrome`。
|
||||||
|
- 登录凭证:生产入口只允许飞书免登录;飞书 OAuth 的 `FEISHU_APP_ID` / `FEISHU_APP_SECRET` 只放服务器 `deploy/.env.production`,回调地址固定为 `https://marketing.skg.com/api/auth/feishu/callback` 并需要在飞书开放平台应用安全设置中登记。登录页读取 `/api/auth/config` 后,如果检测到飞书客户端并且 `feishu_enabled=true`,会自动跳转 `/api/auth/feishu/start`,普通浏览器显示“飞书免登录”按钮;生产 `PASSWORD_AUTH_ENABLED=false` 时账号密码表单不展示,`POST /auth/login` 不可用,旧密码 Cookie 会失效。原账号密码只作为紧急备用配置保留在服务器 `/root/skg-marketing-studio-login.txt` 和 `deploy/.env.production`,如需临时恢复必须显式改为 `PASSWORD_AUTH_ENABLED=true` 并重启 API。开启 `AUTH_DATA_ISOLATION_ENABLED=true` 后,新建任务、素材任务和一键出片记录按登录用户隔离;历史无 owner 的旧任务不再通过密码账号访问,后续应走迁移/认领。
|
||||||
|
- 禁止手动裸 `rsync --delete` 到服务器;必须使用 `./scripts/deploy-prod-safe.sh`。如遇极端情况必须手动同步,命令必须同时包含 protect/exclude:`.git`、`.memory`、`.logs`、`.pids`、`data`、`jobs`、`secrets`、`api/jobs`、`api/.env`、`api/.env.local`、`api/.env.production`、`deploy/.env.production`、`web/node_modules`、`web/.next`、`web/out`。不要把本地 `api/.env` 或 `deploy/.env.production` 覆盖到 `/opt/skg-marketing-studio`,也不要删除服务器 `data/jobs`,否则会清空案例、登录和模型配置。
|
||||||
|
|
||||||
## 快捷登录
|
## 快捷登录
|
||||||
- 登录地址:`https://marketing.skg.com/login/`
|
- 登录地址:`https://marketing.skg.com/login/`
|
||||||
- 用户名:`skg`
|
- 主路径:飞书免登录
|
||||||
- 密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
|
- 密码登录:生产已停用
|
||||||
- 说明:当前是生产入口应用内登录页;数据库密码、API Key、服务器 root 密码不要写这里
|
- 备用用户名:`skg`
|
||||||
|
- 备用密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
|
||||||
|
- 说明:当前是生产入口应用内登录页;飞书 App Secret、数据库密码、API Key、服务器 root 密码不要写这里
|
||||||
|
|
||||||
## 元数据回写清单
|
## 元数据回写清单
|
||||||
- 新增或变更公网地址后,必须同步更新 `.project.json.urls`
|
- 新增或变更公网地址后,必须同步更新 `.project.json.urls`
|
||||||
@@ -47,32 +133,50 @@
|
|||||||
- Gitea 网页仓库:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
|
- Gitea 网页仓库:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
|
||||||
- 每次开发结束前必须执行并汇报 `git status -sb` 和变更范围
|
- 每次开发结束前必须执行并汇报 `git status -sb` 和变更范围
|
||||||
- 代码、规则、部署或元数据变更必须形成 `feat:`、`fix:`、`docs:`、`chore:`、`release:` 等人工语义 commit;`auto-save` 只算安全快照
|
- 代码、规则、部署或元数据变更必须形成 `feat:`、`fix:`、`docs:`、`chore:`、`release:` 等人工语义 commit;`auto-save` 只算安全快照
|
||||||
- 能联网和鉴权时必须 `git push origin main`;如果不能推送,最终回复必须写清楚当前分支、领先/落后数量、最新未推送 commit 和失败原因
|
- 用户明确确认“可以推送 / 上推 / 部署”前,不允许 `git push` 或生产部署;用户确认后,能联网和鉴权时再 `git push origin main`,如果届时不能推送,最终回复必须写清楚当前分支、领先/落后数量、最新未推送 commit 和失败原因
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
- `LLM_BASE_URL` / `LLM_API_KEY`:OpenAI 兼容网关,用于 ASR、翻译、文案改写、音频分析等文本/音频理解模型调用
|
- `LLM_BASE_URL` / `LLM_API_KEY`:OpenAI 兼容网关,用于翻译、文案改写、音频分析等文本/多模态理解模型调用
|
||||||
- `ASR_MODEL`:OpenAI Audio Transcriptions 音频转写模型,默认 `whisper-1`
|
- `ASR_BASE_URL` / `ASR_API_KEY`:OpenAI Audio Transcriptions 兼容网关,用于上传 `audio.wav` 做真实转写;未配置 `ASR_API_KEY` 时复用 `LLM_API_KEY`,生产默认指向 `https://ai.skg.com/azure/v1`
|
||||||
- `ASR_FALLBACK_MODEL`:远端 ASR 和本机 ASR 都不可用时才尝试的多模态兜底,默认 `gemini-2.5-flash`;如果模型不能真实听到音频或返回疑似逐秒假字幕,后端必须拒绝写入时间轴
|
- `ASR_MODEL`:OpenAI Audio Transcriptions 音频转写模型;微软通道使用 Azure OpenAI 部署名 `gpt-4o-transcribe`,如果 Azure 侧实际部署名不同必须同步改这里
|
||||||
- `ASR_TIMEOUT_SECONDS`:远端 ASR / 音频分析单次请求超时,默认 45 秒,避免第一步长时间停在转录中
|
- `ASR_LANGUAGE`:远端和本地 ASR 的可选输入语言提示,默认空值/`auto`,由模型自动识别中文、英文和其他多语言;只有明确知道素材固定语种时才填写 ISO-639-1 代码强制识别。
|
||||||
|
- `ASR_REMOTE_ENABLED`:是否启用远端 OpenAI Audio Transcriptions;微软 ASR 验收时必须为 `true`。当前生产因 `https://ai.skg.com/azure/v1` 下 `gpt-4o-transcribe` 返回 `DeploymentNotFound`,临时设为 `false`,直接走容器内 `faster-whisper`,等真实 Azure deployment 名补齐后再恢复。
|
||||||
|
- `ASR_LOCAL_FALLBACK_ENABLED`:是否允许远端 ASR 失败后落到本机 / 容器内 ASR;当前生产为 `true`,复制本地成功路径的“本机真实转写”策略,云端用 CPU 版 `faster-whisper` 替代本机 Mac 的 `mlx_whisper`。
|
||||||
|
- `ASR_AUDIO_FALLBACK_ENABLED`:是否允许远端和本机 ASR 失败后落到多模态音频兜底;生产微软 ASR 验收设为 `false`,避免静默使用 Gemini 音频
|
||||||
|
- `FASTER_WHISPER_MODEL` / `FASTER_WHISPER_DEVICE` / `FASTER_WHISPER_COMPUTE_TYPE`:容器内本地 ASR 兜底,仅在 `ASR_LOCAL_FALLBACK_ENABLED=true` 时启用;默认用多语言 `base`,不要改回 `*.en` 英文专用模型,否则中文和多语言识别会退化。
|
||||||
|
- `ASR_FALLBACK_MODEL`:多模态音频兜底模型,仅在 `ASR_AUDIO_FALLBACK_ENABLED=true` 时用于兜底或音频画像,默认 `gemini-2.5-flash`;如果模型不能真实听到音频或返回疑似逐秒假字幕,后端必须拒绝写入时间轴
|
||||||
|
- `ASR_TIMEOUT_SECONDS`:远端 ASR / 翻译 / 音频分析单次请求超时;当前生产本地转写模式设为 45 秒,微软 ASR 重新启用时可按素材长度提高。
|
||||||
- `LOCAL_ASR_BIN` / `LOCAL_ASR_MODEL` / `LOCAL_ASR_TIMEOUT_SECONDS`:本机 ASR 兜底,默认使用 `/opt/homebrew/bin/mlx_whisper` + `mlx-community/whisper-tiny`,用于当前 SKG 网关 `/audio/transcriptions` 不可用时生成真实逐句时间轴
|
- `LOCAL_ASR_BIN` / `LOCAL_ASR_MODEL` / `LOCAL_ASR_TIMEOUT_SECONDS`:本机 ASR 兜底,默认使用 `/opt/homebrew/bin/mlx_whisper` + `mlx-community/whisper-tiny`,用于当前 SKG 网关 `/audio/transcriptions` 不可用时生成真实逐句时间轴
|
||||||
- `TRANSLATE_MODEL`:字幕翻译模型,默认 `gemini-2.5-flash`
|
- `TRANSLATE_MODEL`:字幕翻译模型,默认 `gemini-2.5-flash`
|
||||||
- `REWRITE_MODEL`:通用改写/分镜描述模型,默认 `gemini-2.5-pro`
|
- `GPT_TEXT_MODEL`:GPT 文本 / 视觉默认模型,默认 `gpt-4o`;用于兜底修正旧 Gemini 覆盖值
|
||||||
- `AUDIO_REWRITE_MODEL`:后续音频口播改写模型,默认跟随 `REWRITE_MODEL`;当前第一步不默认调用口播改写,只保留原文案和声音分析
|
- `REWRITE_MODEL`:通用改写/分镜描述模型,默认 `gpt-4o`;如果旧环境仍写 `gemini-*`,后端会自动改用 `GPT_TEXT_MODEL`
|
||||||
|
- `VISION_MODEL`:关键帧画面理解模型,默认 `gpt-4o`;如果旧环境仍写 `gemini-*`,后端会自动改用 `GPT_TEXT_MODEL`
|
||||||
|
- `AUDIO_REWRITE_MODEL`:后续音频口播改写模型,默认跟随 `REWRITE_MODEL`;如果旧环境仍写 `gemini-*`,后端会自动改用 `REWRITE_MODEL`
|
||||||
- `AUDIO_PRODUCT_BRIEF`:音频口播改写时注入的 SKG 产品卖点
|
- `AUDIO_PRODUCT_BRIEF`:音频口播改写时注入的 SKG 产品卖点
|
||||||
- `PRODUCT_VIEW_MODEL`:同一产品素材池的视角标注/自动识别模型;当前按项目要求强制使用 `gpt-image-2`
|
- `PRODUCT_VIEW_MODEL`:同一产品素材池的视角标注/自动识别模型;当前按项目要求强制使用 `gpt-image-2`
|
||||||
- `IMAGE_BASE_URL` / `IMAGE_API_KEY` / `IMAGE_MODEL`:OpenAI 兼容生图网关;当前所有生图入口一律强制使用 `gpt-image-2`,不做其他图片模型 fallback
|
- `IMAGE_BASE_URL` / `IMAGE_API_KEY` / `IMAGE_MODEL`:OpenAI 兼容生图网关;当前所有生图入口主模型仍为 `gpt-image-2`
|
||||||
- `GPT_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODELS`:保留兼容旧环境变量名,但服务端会强制主体 6 视图和所有其他生图入口都只使用 `gpt-image-2`
|
- `IMAGE_REQUEST_TIMEOUT_SECONDS`:单次图片网关请求超时,默认 60 秒;超时会直接把该视图标失败并继续下一张,避免主体 6 视图整包长时间无反馈
|
||||||
- `AI_HTTP_PROXY` / `IMAGE_HTTP_PROXY`:可选的 AI 网关出站代理;本地 launchd 后台进程不一定继承 shell 的 `http_proxy/https_proxy`,如生图报 DNS / ConnectError,可在本地 `api/.env` 配置后重启后端。`/health` 只回传是否配置代理,不回传代理地址。
|
- `IMAGE_FALLBACK_ENABLED` / `IMAGE_FALLBACK_MODEL`:图片主模型故障兜底;当前允许在 `gpt-image-2` 超时、429、5xx 或网络错误时临时使用 `gemini-3-pro-image-preview`,400/401/403/404 和参数错误不兜底
|
||||||
- `VOICE_PROVIDER`:配音通道,当前固定使用 `azure_openai`
|
- `IMAGE_CIRCUIT_FAILURE_THRESHOLD` / `IMAGE_CIRCUIT_COOLDOWN_SECONDS`:短时熔断配置,默认 `gpt-image-2` 连续 2 次上游类失败后 600 秒内直接走 Gemini 兜底;成功恢复后自动清空失败计数
|
||||||
|
- `GPT_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODELS`:保留兼容旧环境变量名;主体 6 视图在转换层默认自动使用 `gpt-image-2`,同一套图内一旦触发 Gemini 兜底,后续视图沿用 Gemini,避免一张张等待主模型超时;用户显式选择 GPT 或 Gemini 时,`image_model_preference` 会让主体套图只走所选模型
|
||||||
|
- `AI_HTTP_PROXY` / `IMAGE_HTTP_PROXY`:可选的 AI 网关出站代理;本地 launchd 后台进程不一定继承 shell 的 `http_proxy/https_proxy`,如生图或生视频 MP4 下载报 DNS / ConnectError / SSL 握手异常,可在本地 `api/.env` 配置后重启后端。本地 Docker 使用 `deploy/.env.local`,宿主机代理要写成 `http://host.docker.internal:端口`,不能写容器内的 `127.0.0.1`。`/health` 只回传是否配置代理,不回传代理地址。
|
||||||
|
- `YTDLP_COOKIES_FILE` / `YTDLP_COOKIES_FROM_BROWSER`:可选 TikTok 下载登录态;生产云端固定使用 cookies 文件 `/run/secrets/tiktok_cookies.txt`(宿主机 `./secrets/tiktok_cookies.txt` 挂载进容器),本地开发可临时用浏览器 cookies。cookies 文件属于敏感登录态,只能放本机或服务器私有路径,不允许入库。
|
||||||
|
- `VOICE_PROVIDER`:配音通道,服务端固定使用 `azure_openai`;旧环境若写 `minimax` 会被忽略
|
||||||
- `AZURE_OPENAI_BASE_URL` / `AZURE_OPENAI_API_KEY`:微软 Azure OpenAI 协议配音网关;本地未单独配置 Key 时回退复用 `LLM_API_KEY`
|
- `AZURE_OPENAI_BASE_URL` / `AZURE_OPENAI_API_KEY`:微软 Azure OpenAI 协议配音网关;本地未单独配置 Key 时回退复用 `LLM_API_KEY`
|
||||||
- `AZURE_TTS_MODEL` / `AZURE_TTS_VOICE_ID` / `AZURE_TTS_VOICE_POOL` / `AZURE_TTS_PATH`:Azure OpenAI TTS 模型、默认音色、音色池和 OpenAI 协议语音路径
|
- `AZURE_TTS_MODEL` / `AZURE_TTS_VOICE_ID` / `AZURE_TTS_VOICE_POOL` / `AZURE_TTS_PATH` / `AZURE_TTS_PATHS`:Azure OpenAI TTS 模型、默认音色、音色池和 OpenAI 协议语音路径;后端会按 `AZURE_TTS_PATHS` 依次尝试,便于区分路径不对和整条语音服务不可用
|
||||||
- `MINIMAX_API_KEY`:MiniMax T2A 配音 Key,只能放本地 `api/.env`,不能入库;当前第一步暂不默认调用
|
- `POE_API_KEY` / `VIDEO_API_KEY`:默认视频生成通道 Key,只能放本地环境变量;如果显式配置了 `VIDEO_API_BASE_URL`,必须同时配置 `VIDEO_API_KEY` 才会在 `/health` 暴露该默认视频通道,不能用通用 `LLM_API_KEY` 冒充视频 key。
|
||||||
- `MINIMAX_TTS_BASE_URL` / `MINIMAX_TTS_MODEL` / `MINIMAX_TTS_VOICE_ID`:MiniMax 旧配音端点、模型和兜底音色配置,仅作为保留兼容;当前不作为默认语音通道
|
- `XAI_VIDEO_API_BASE_URL` / `XAI_VIDEO_API_KEY` / `VIDEO_MODEL_XAI`:xAI / Grok Imagine Video 独立视频通道;默认 base 为 `https://ai.skg.com/ezlink/xai`,模型为 `grok-imagine-video`,真实 key 只放本地 `api/.env`、本地 Docker `deploy/.env.local` 或服务器 `deploy/.env.production`,不入库。未配置 `XAI_VIDEO_API_KEY` 时 `/health` 会标记 xAI 视频不可用,画布不显示该模型;已配置时即使默认 Doubao/Seedance 视频 key 为空,也可以独立显示和生成 Grok Imagine Video。
|
||||||
- `MINIMAX_TTS_VOICE_POOL`:MiniMax 英文随机音色池;当前默认男声 `English_magnetic_voiced_man`、女声 `English_Upbeat_Woman`、成熟声 `English_MaturePartner`,供后续新配音阶段使用
|
- `PASSWORD_AUTH_ENABLED`:生产密码登录总开关;当前固定为 `false`,只允许飞书免登录。若应急恢复密码入口,必须显式改成 `true` 并重启 API。
|
||||||
- `POE_API_KEY` / `VIDEO_API_KEY`:视频生成通道 Key,只能放本地环境变量
|
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产备用网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库。当前密码入口被 `PASSWORD_AUTH_ENABLED=false` 禁用;即使只开飞书免登录,也必须配置 `WEB_AUTH_SESSION_SECRET` 用于签名会话 Cookie。
|
||||||
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库
|
- `FEISHU_APP_ID` / `FEISHU_APP_SECRET`:飞书免登录 OAuth 应用凭证;只放服务器 `deploy/.env.production` 或本地 `api/.env`,不入库。
|
||||||
|
- `FEISHU_REDIRECT_URI`:飞书 OAuth 回调地址,生产固定为 `https://marketing.skg.com/api/auth/feishu/callback`。
|
||||||
|
- `FEISHU_OAUTH_SCOPE`:飞书 OAuth 授权范围;默认空值,按飞书应用后台已开权限执行。
|
||||||
|
- `FEISHU_ALLOWED_EMAIL_DOMAINS` / `FEISHU_ALLOWED_EMAILS` / `FEISHU_ALLOWED_TENANT_KEYS`:可选飞书账号白名单;留空时由飞书应用可见范围控制。
|
||||||
|
- `AUTH_DATA_ISOLATION_ENABLED`:多用户数据隔离开关,生产保持 `true`;新建 `Job` / `AgentRun` 会写入当前登录用户 owner,列表和详情访问只返回本人数据。
|
||||||
|
- `VIDEO_QUEUE_MAX_CONCURRENT` / `VIDEO_QUEUE_MAX_CONCURRENT_PER_USER`:视频生成进程内队列并发上限,生产默认全局同时 2 个、单用户同时 1 个;同一用户连续提交会排队,其他用户仍可获得执行机会。当前队列不依赖 Redis,API 容器重启会把未完成视频标记为失败并提示重新生成。
|
||||||
- `FFMPEG_BIN` / `FFPROBE_BIN`:可选本地媒体二进制路径;本机 Homebrew ffmpeg 动态库损坏时,后端会自动跳过不可用的 PATH 版本并尝试本机静态 ffmpeg 备选,生产仍建议使用系统 ffmpeg/ffprobe
|
- `FFMPEG_BIN` / `FFPROBE_BIN`:可选本地媒体二进制路径;本机 Homebrew ffmpeg 动态库损坏时,后端会自动跳过不可用的 PATH 版本并尝试本机静态 ffmpeg 备选,生产仍建议使用系统 ffmpeg/ffprobe
|
||||||
- 生产环境变量:服务器只使用 `deploy/.env.production`,模板为 `deploy/.env.production.example`;真实 Key 不入库
|
- 生产环境变量:服务器只使用 `deploy/.env.production`,模板为 `deploy/.env.production.example`;真实 Key 不入库
|
||||||
|
- 同步生产代码时必须排除服务器真实 `deploy/.env.production`,只同步 `deploy/.env.production.example`;网页登录密码、session secret、ASR/API Key 只保留在服务器环境文件和 `/root/skg-marketing-studio-login.txt`
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
- 不允许编造不存在的部署域名、账号、密码
|
- 不允许编造不存在的部署域名、账号、密码
|
||||||
|
|||||||
12
THIRD_PARTY_NOTICES.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Third Party Notices
|
||||||
|
|
||||||
|
## huobao-canvas
|
||||||
|
|
||||||
|
Portions of the internal SKG canvas module are adapted from `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.
|
||||||
|
- Local integration path: `web/canvas-app/`
|
||||||
|
- 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.
|
||||||
@@ -8,6 +8,18 @@ WEB_AUTH_PASSWORD=
|
|||||||
WEB_AUTH_SESSION_SECRET=
|
WEB_AUTH_SESSION_SECRET=
|
||||||
WEB_AUTH_COOKIE_NAME=skg_marketing_session
|
WEB_AUTH_COOKIE_NAME=skg_marketing_session
|
||||||
WEB_AUTH_COOKIE_SECURE=false
|
WEB_AUTH_COOKIE_SECURE=false
|
||||||
|
AUTH_DATA_ISOLATION_ENABLED=true
|
||||||
|
VIDEO_QUEUE_MAX_CONCURRENT=2
|
||||||
|
VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1
|
||||||
|
|
||||||
|
# 飞书免登录(OAuth)。生产回调地址需同步配置到飞书开放平台应用安全设置。
|
||||||
|
FEISHU_APP_ID=
|
||||||
|
FEISHU_APP_SECRET=
|
||||||
|
FEISHU_REDIRECT_URI=https://marketing.skg.com/api/auth/feishu/callback
|
||||||
|
FEISHU_OAUTH_SCOPE=
|
||||||
|
FEISHU_ALLOWED_EMAIL_DOMAINS=
|
||||||
|
FEISHU_ALLOWED_EMAILS=
|
||||||
|
FEISHU_ALLOWED_TENANT_KEYS=
|
||||||
|
|
||||||
# 模型分工
|
# 模型分工
|
||||||
ASR_MODEL=whisper-1
|
ASR_MODEL=whisper-1
|
||||||
@@ -17,24 +29,47 @@ LOCAL_ASR_BIN=/opt/homebrew/bin/mlx_whisper
|
|||||||
LOCAL_ASR_MODEL=mlx-community/whisper-tiny
|
LOCAL_ASR_MODEL=mlx-community/whisper-tiny
|
||||||
LOCAL_ASR_TIMEOUT_SECONDS=180
|
LOCAL_ASR_TIMEOUT_SECONDS=180
|
||||||
TRANSLATE_MODEL=gemini-2.5-flash
|
TRANSLATE_MODEL=gemini-2.5-flash
|
||||||
REWRITE_MODEL=gemini-2.5-pro
|
GPT_TEXT_MODEL=gpt-4o
|
||||||
|
REWRITE_MODEL=gpt-4o
|
||||||
|
VISION_MODEL=gpt-4o
|
||||||
PRODUCT_VIEW_MODEL=gpt-image-2
|
PRODUCT_VIEW_MODEL=gpt-image-2
|
||||||
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
|
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||||
IMAGE_API_KEY=
|
IMAGE_API_KEY=
|
||||||
IMAGE_MODEL=gpt-image-2
|
IMAGE_MODEL=gpt-image-2
|
||||||
|
IMAGE_REQUEST_TIMEOUT_SECONDS=60
|
||||||
|
IMAGE_FALLBACK_ENABLED=true
|
||||||
|
IMAGE_FALLBACK_MODEL=gemini-3-pro-image-preview
|
||||||
|
# 多备用模型用逗号分隔;未设置时兼容 IMAGE_FALLBACK_MODEL。
|
||||||
|
IMAGE_FALLBACK_MODELS=gemini-3-pro-image-preview
|
||||||
|
# 可选:把其它 OpenAI-compatible 图片模型加入 /health 和前端白名单,默认走 IMAGE_BASE_URL/IMAGE_API_KEY。
|
||||||
|
IMAGE_EXTRA_MODELS=
|
||||||
|
# 可选:JSON 覆盖/扩展模型配置,建议只写 api_key_env,不把真实 key 写入 JSON。
|
||||||
|
# IMAGE_MODEL_CONFIGS_JSON={"custom-model":{"label":"Custom Image","base_url_env":"CUSTOM_IMAGE_BASE_URL","api_key_env":"CUSTOM_IMAGE_API_KEY","provider":"openai","sizes":["1024x1024"],"default_size":"1024x1024"}}
|
||||||
|
IMAGE_CIRCUIT_FAILURE_THRESHOLD=2
|
||||||
|
IMAGE_CIRCUIT_COOLDOWN_SECONDS=600
|
||||||
GPT_IMAGE_MODEL=gpt-image-2
|
GPT_IMAGE_MODEL=gpt-image-2
|
||||||
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
|
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
|
||||||
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2
|
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2,gemini-3-pro-image-preview
|
||||||
|
# 火山方舟 Seedream 图片模型。真实 key 只填本地/服务器 .env,不提交到 git。
|
||||||
|
ARK_SEEDREAM_ENABLED=true
|
||||||
|
ARK_IMAGE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||||
|
ARK_IMAGE_API_KEY=
|
||||||
|
ARK_SEEDREAM_IMAGE_MODEL=doubao-seedream-4-5-251128
|
||||||
# 可选:本地网络需要代理访问 ai.skg.com 时配置;launchd 不一定继承 shell 代理变量。
|
# 可选:本地网络需要代理访问 ai.skg.com 时配置;launchd 不一定继承 shell 代理变量。
|
||||||
AI_HTTP_PROXY=
|
AI_HTTP_PROXY=
|
||||||
|
YTDLP_COOKIES_FILE=
|
||||||
|
YTDLP_COOKIES_FROM_BROWSER=
|
||||||
VIDEO_MODEL=seedance
|
VIDEO_MODEL=seedance
|
||||||
VIDEO_MODEL_SEEDANCE=seedance-2-fast
|
VIDEO_MODEL_SEEDANCE=seedance-2-fast
|
||||||
|
VIDEO_MODEL_XAI=grok-imagine-video
|
||||||
|
# Kling / Veo aliases are only exposed when the active video gateway is Poe or another verified compatible gateway.
|
||||||
VIDEO_MODEL_KLING=kling-omni
|
VIDEO_MODEL_KLING=kling-omni
|
||||||
VIDEO_MODEL_VEO3=veo-3.1-fast
|
VIDEO_MODEL_VEO3=veo-3.1-fast
|
||||||
|
|
||||||
# 音频文案改写 + Azure OpenAI 配音
|
# 音频文案改写 + Azure OpenAI 配音
|
||||||
AUDIO_REWRITE_MODEL=gemini-2.5-pro
|
AUDIO_REWRITE_MODEL=gemini-2.5-pro
|
||||||
AUDIO_PRODUCT_BRIEF="SKG 智能按摩产品,主打日常肩颈、腰背、眼部、膝盖或足部放松;广告表达要高级、干净、可信,不做医疗疗效承诺。"
|
AUDIO_PRODUCT_BRIEF="SKG 智能按摩产品,主打日常肩颈、腰背、眼部、膝盖或足部放松;广告表达要高级、干净、可信,不做医疗疗效承诺。"
|
||||||
|
# 语音通道服务端固定为 Azure OpenAI。
|
||||||
VOICE_PROVIDER=azure_openai
|
VOICE_PROVIDER=azure_openai
|
||||||
AZURE_OPENAI_BASE_URL=https://ai.skg.com/azure
|
AZURE_OPENAI_BASE_URL=https://ai.skg.com/azure
|
||||||
AZURE_OPENAI_API_KEY=
|
AZURE_OPENAI_API_KEY=
|
||||||
@@ -42,13 +77,7 @@ AZURE_TTS_MODEL=gpt-4o-mini-tts
|
|||||||
AZURE_TTS_VOICE_ID=alloy
|
AZURE_TTS_VOICE_ID=alloy
|
||||||
AZURE_TTS_VOICE_POOL=alloy,verse,shimmer
|
AZURE_TTS_VOICE_POOL=alloy,verse,shimmer
|
||||||
AZURE_TTS_PATH=/audio/speech
|
AZURE_TTS_PATH=/audio/speech
|
||||||
|
AZURE_TTS_PATHS=/audio/speech,/v1/audio/speech
|
||||||
# MiniMax 旧配音通道,保留兼容;默认不走
|
|
||||||
MINIMAX_API_KEY=
|
|
||||||
MINIMAX_TTS_BASE_URL=https://api.minimax.io
|
|
||||||
MINIMAX_TTS_MODEL=speech-2.8-turbo
|
|
||||||
MINIMAX_TTS_VOICE_ID=English_expressive_narrator
|
|
||||||
MINIMAX_TTS_VOICE_POOL=English_magnetic_voiced_man,English_Upbeat_Woman,English_MaturePartner
|
|
||||||
|
|
||||||
# Poe 视频 API(优先用于 Seedance / Kling / Veo)
|
# Poe 视频 API(优先用于 Seedance / Kling / Veo)
|
||||||
POE_API_BASE_URL=https://api.poe.com/v1
|
POE_API_BASE_URL=https://api.poe.com/v1
|
||||||
@@ -69,6 +98,13 @@ POE_API_KEY=
|
|||||||
# VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id}
|
# VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id}
|
||||||
# VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content
|
# VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content
|
||||||
#
|
#
|
||||||
|
# SKG xAI/Grok Imagine 视频网关。真实 key 只填本地/服务器私有 .env。
|
||||||
|
XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai
|
||||||
|
XAI_VIDEO_API_KEY=
|
||||||
|
XAI_VIDEO_CREATE_PATH=/v1/videos/generations
|
||||||
|
XAI_VIDEO_STATUS_PATH=/v1/videos/{id}
|
||||||
|
XAI_VIDEO_CONTENT_PATH=
|
||||||
|
#
|
||||||
# 自定义视频网关覆盖;留空时如配置 POE_API_KEY,则走 Poe。
|
# 自定义视频网关覆盖;留空时如配置 POE_API_KEY,则走 Poe。
|
||||||
VIDEO_API_BASE_URL=
|
VIDEO_API_BASE_URL=
|
||||||
VIDEO_API_KEY=
|
VIDEO_API_KEY=
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# SKG TK 二创 API
|
# SKG TK 二创 API
|
||||||
|
|
||||||
FastAPI 后端,跑 yt-dlp + ffmpeg + ASR/翻译/英文 SKG 产品介绍文案 + MiniMax 英文配音管线。
|
FastAPI 后端,跑 yt-dlp + ffmpeg + ASR/翻译/音频画像、抽帧、GPT 图像生成/修图、Azure OpenAI TTS 预留和视频候选预留管线。
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ cd api
|
|||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
cp .env.example .env # 按需填 LLM_API_KEY / MINIMAX_API_KEY
|
cp .env.example .env # 按需填 LLM_API_KEY / AZURE_OPENAI_API_KEY
|
||||||
uvicorn main:app --host 127.0.0.1 --port 4291
|
uvicorn main:app --host 127.0.0.1 --port 4291
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -18,21 +18,23 @@ uvicorn main:app --host 127.0.0.1 --port 4291
|
|||||||
## 路由
|
## 路由
|
||||||
|
|
||||||
- `GET /health` — 健康检查 + 配置状态
|
- `GET /health` — 健康检查 + 配置状态
|
||||||
- `POST /jobs` `{url}` — 创建 job,后台下载源视频,视频就绪后可手动解析或提取音频
|
- `POST /jobs` `{url}` — 创建 job,后台下载源视频;前端“开始分析”会在视频就绪后自动启动音频解析和视觉抽帧
|
||||||
|
- `POST /jobs/{id}/download/retry` — TK 链接下载失败后重试下载;上传视频任务不能重下载
|
||||||
- `GET /jobs/{id}` — 当前状态 + 产物;若原始音轨已拆出,会返回 `source_audio_url`
|
- `GET /jobs/{id}` — 当前状态 + 产物;若原始音轨已拆出,会返回 `source_audio_url`
|
||||||
- `POST /jobs/{id}/transcribe` — 触发音频提取 + ASR + 翻译 + SKG 英文产品介绍文案;文案长度按原音频时长估算,配置 MiniMax 后从英文随机音色池生成配音。前端 Audio 节点提供“提取音频 / 重新提取音频”按钮,可与抽帧并行,不自动触发
|
- `POST /jobs/{id}/transcribe` — 触发音频提取 + ASR + 中文翻译 + 讲话人 / 节奏 / 背景音分析;当前第一步不默认生成 SKG 新口播或 TTS 配音
|
||||||
- `GET /jobs/{id}/video.mp4` — 原视频
|
- `GET /jobs/{id}/video.mp4` — 原视频
|
||||||
- `GET /jobs/{id}/audio.wav` — 拆轨后的原始音频,供前端底部音频条生成波形
|
- `GET /jobs/{id}/audio.wav` — 拆轨后的原始音频,供前端音频波形和多模态音频分析使用
|
||||||
- `GET /jobs/{id}/audio-script.mp3` — 英文改写文案的 MiniMax 配音
|
- `GET /jobs/{id}/audio-script.mp3` — 后续新配音阶段保留的 Azure OpenAI TTS 文件
|
||||||
- `GET /jobs/{id}/frames/{i}.jpg` — 第 i 张关键帧(0-9)
|
- `GET /jobs/{id}/frames/{i}.jpg` — 第 i 张参考帧;当前主流程自动抽 12 张动作 / 节奏参考帧,也支持手动按当前播放点补帧
|
||||||
|
|
||||||
## Mock 模式
|
## Mock 模式
|
||||||
|
|
||||||
未设 `LLM_API_KEY` 时,转录走本地 mock,便于 UI 联调;未设 `MINIMAX_API_KEY` 时只生成改写文案,不生成配音文件。
|
未设 `LLM_API_KEY` 时,转录走本地 mock,便于 UI 联调;未设 `AZURE_OPENAI_API_KEY` 时,后续 TTS 文件不会生成,但不影响当前第一步音频解析。
|
||||||
|
|
||||||
## 依赖
|
## 依赖
|
||||||
|
|
||||||
- `ffmpeg` 系统二进制(拆轨 / 抽帧)
|
- `ffmpeg` 系统二进制(拆轨 / 抽帧)
|
||||||
- `yt-dlp` 系统二进制(也可走 Python 包)
|
- `yt-dlp` 系统二进制(也可走 Python 包)
|
||||||
- OpenAI 兼容 LLM 网关(ASR / 翻译 / 文案改写);如果 `/audio/transcriptions` 不可用,会用 `ASR_FALLBACK_MODEL` 走 Gemini 多模态音频识别
|
- OpenAI 兼容 LLM 网关(ASR / 翻译 / 文案改写 / 视觉 brief);ASR 默认自动识别中文、英文和其他多语言,远端失败后先走容器内多语言 `faster-whisper` / 本机 `mlx_whisper`,再按开关用 `ASR_FALLBACK_MODEL` 走多模态音频识别,后端会拒绝疑似假字幕或覆盖率过低的时间轴
|
||||||
- MiniMax T2A HTTP(英文产品介绍文案配音,使用 `MINIMAX_API_KEY`;默认随机音色池 `English_magnetic_voiced_man,English_Upbeat_Woman,English_MaturePartner`)
|
- GPT 图片网关(当前所有生图 / 修图 / 产品视角识别 / 主体资产 / 首尾帧都强制使用 `gpt-image-2`,不做其他图片模型 fallback)
|
||||||
|
- Azure OpenAI TTS(后续新配音阶段使用 `AZURE_OPENAI_API_KEY`;默认模型 `gpt-4o-mini-tts`,按 `AZURE_TTS_PATHS` 依次尝试语音路径)
|
||||||
|
|||||||
3
api/asset_library/index.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"name": "运动阳光男",
|
"name": "运动阳光男",
|
||||||
"folder": "01_运动阳光男",
|
"folder": "01_运动阳光男",
|
||||||
"description": "运动阳光男透明骨架人角色,含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
|
"description": "运动阳光男透明骨架人角色,含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
|
||||||
|
"prompt_brief": "Athletic sunny male transparent wellness character, young adult energy, lean fit proportions, open and upbeat posture, clean translucent skin shell with visible white skeleton. The character should feel friendly, active, outdoor-sport inspired, bright, healthy, and suitable for premium SKG neck-and-shoulder wearable device ads. Keep neck, collarbone, shoulders, upper back, and cervical spine readable without bulky clothing or props.",
|
||||||
"primary_image": "character-01-front",
|
"primary_image": "character-01-front",
|
||||||
"images": [
|
"images": [
|
||||||
{
|
{
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
"name": "都市型男",
|
"name": "都市型男",
|
||||||
"folder": "02_都市型男",
|
"folder": "02_都市型男",
|
||||||
"description": "都市型男透明骨架人角色,含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
|
"description": "都市型男透明骨架人角色,含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
|
||||||
|
"prompt_brief": "Urban stylish male transparent wellness character, adult metropolitan feel, clean confident posture, refined proportions, translucent body shell with visible white skeleton. The commercial mood is premium city lifestyle, composed, sharp, and modern, suitable for office or commute-oriented SKG neck-and-shoulder massage ads. Keep shoulder line, side neck, collarbone, and upper back clear for wearable device placement.",
|
||||||
"primary_image": "character-02-front",
|
"primary_image": "character-02-front",
|
||||||
"images": [
|
"images": [
|
||||||
{
|
{
|
||||||
@@ -152,6 +154,7 @@
|
|||||||
"name": "优雅白领女",
|
"name": "优雅白领女",
|
||||||
"folder": "03_优雅白领女",
|
"folder": "03_优雅白领女",
|
||||||
"description": "优雅白领女透明骨架人角色,含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
|
"description": "优雅白领女透明骨架人角色,含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
|
||||||
|
"prompt_brief": "Elegant professional female transparent wellness character, young adult to adult office-worker mood, slim balanced proportions, calm poised posture, translucent outer body with a clean visible white skeleton. The style should feel premium, gentle, trustworthy, and workplace-friendly for SKG neck-and-shoulder wearable device ads. Keep hair, collars, and accessories from hiding the neck, shoulders, collarbone, upper back, and cervical spine.",
|
||||||
"primary_image": "character-03-front",
|
"primary_image": "character-03-front",
|
||||||
"images": [
|
"images": [
|
||||||
{
|
{
|
||||||
@@ -224,6 +227,7 @@
|
|||||||
"name": "运动辣妹",
|
"name": "运动辣妹",
|
||||||
"folder": "04_运动辣妹",
|
"folder": "04_运动辣妹",
|
||||||
"description": "运动辣妹透明骨架人角色,含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
|
"description": "运动辣妹透明骨架人角色,含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
|
||||||
|
"prompt_brief": "Sporty confident female transparent wellness character, energetic young adult fitness mood, toned proportions, expressive posture, translucent skin shell with visible white skeleton. The character should feel active, fashionable, bright, and creator-ad friendly while remaining premium and non-horror. Keep the neck, side neck, shoulders, collarbone, upper trapezius, and upper back open and readable for SKG wearable massage device scenes.",
|
||||||
"primary_image": "character-04-front",
|
"primary_image": "character-04-front",
|
||||||
"images": [
|
"images": [
|
||||||
{
|
{
|
||||||
@@ -296,6 +300,7 @@
|
|||||||
"name": "绅士大叔",
|
"name": "绅士大叔",
|
||||||
"folder": "05_绅士大叔",
|
"folder": "05_绅士大叔",
|
||||||
"description": "绅士大叔透明骨架人角色,含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
|
"description": "绅士大叔透明骨架人角色,含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
|
||||||
|
"prompt_brief": "Mature gentleman transparent wellness character, adult to middle-aged presence without exact age, steady confident posture, slightly stronger build, translucent body shell with a clean visible white skeleton. The commercial mood is calm, trustworthy, premium, and lifestyle-oriented for SKG neck-and-shoulder wearable device ads. Keep collars and styling minimal so the neck, shoulders, upper back, cervical spine, and shoulder blades remain visible.",
|
||||||
"primary_image": "character-05-front",
|
"primary_image": "character-05-front",
|
||||||
"images": [
|
"images": [
|
||||||
{
|
{
|
||||||
@@ -364,4 +369,4 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
950
api/db.py
Normal file
@@ -0,0 +1,950 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
except ModuleNotFoundError: # Local dev can still run without Postgres deps installed.
|
||||||
|
psycopg = None
|
||||||
|
dict_row = None
|
||||||
|
Jsonb = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from psycopg_pool import ConnectionPool
|
||||||
|
except ModuleNotFoundError: # Pool is optional; fall back to per-call connections.
|
||||||
|
ConnectionPool = None
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("skg.db")
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "").strip()
|
||||||
|
DB_ENABLED = bool(DATABASE_URL and psycopg is not None)
|
||||||
|
|
||||||
|
_POOL = None
|
||||||
|
_POOL_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
return DB_ENABLED
|
||||||
|
|
||||||
|
|
||||||
|
def _pool():
|
||||||
|
"""Lazily build a process-wide connection pool so concurrent workers/requests
|
||||||
|
don't exhaust Postgres by opening a fresh connection per query."""
|
||||||
|
global _POOL
|
||||||
|
if _POOL is not None:
|
||||||
|
return _POOL
|
||||||
|
with _POOL_LOCK:
|
||||||
|
if _POOL is None:
|
||||||
|
pool = ConnectionPool(
|
||||||
|
DATABASE_URL,
|
||||||
|
min_size=1,
|
||||||
|
max_size=int(os.getenv("DB_POOL_MAX_SIZE", "10")),
|
||||||
|
timeout=10,
|
||||||
|
kwargs={"row_factory": dict_row, "connect_timeout": 5},
|
||||||
|
open=False,
|
||||||
|
)
|
||||||
|
pool.open()
|
||||||
|
_POOL = pool
|
||||||
|
return _POOL
|
||||||
|
|
||||||
|
|
||||||
|
def _connect():
|
||||||
|
if not DB_ENABLED:
|
||||||
|
raise RuntimeError("database disabled")
|
||||||
|
if ConnectionPool is not None:
|
||||||
|
# pool.connection() is a context manager that returns the conn to the
|
||||||
|
# pool on exit, matching the existing `with _connect() as conn:` callers.
|
||||||
|
return _pool().connection()
|
||||||
|
return psycopg.connect(DATABASE_URL, row_factory=dict_row, connect_timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(ts: float | int | None = None) -> datetime:
|
||||||
|
try:
|
||||||
|
value = float(ts or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
value = 0
|
||||||
|
if value <= 0:
|
||||||
|
value = time.time()
|
||||||
|
return datetime.fromtimestamp(value, tz=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _json(value: Any):
|
||||||
|
return Jsonb(value if value is not None else {})
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_safely(label: str, fn):
|
||||||
|
# DB disabled is an expected, silent no-op; an actual failure while the DB is
|
||||||
|
# enabled is a real problem (stale job index / dropped audit) and must be loud.
|
||||||
|
if not DB_ENABLED:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return fn()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("[db] %s failed: %s", label, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def init_schema() -> bool:
|
||||||
|
if not DB_ENABLED:
|
||||||
|
print("[db] disabled: DATABASE_URL is empty or psycopg is missing", flush=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
ddl = [
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS app_users (
|
||||||
|
uid TEXT PRIMARY KEY,
|
||||||
|
provider TEXT NOT NULL DEFAULT '',
|
||||||
|
username TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
email TEXT NOT NULL DEFAULT '',
|
||||||
|
open_id TEXT NOT NULL DEFAULT '',
|
||||||
|
union_id TEXT NOT NULL DEFAULT '',
|
||||||
|
tenant_key TEXT NOT NULL DEFAULT '',
|
||||||
|
avatar_url TEXT NOT NULL DEFAULT '',
|
||||||
|
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
last_ip TEXT NOT NULL DEFAULT '',
|
||||||
|
last_user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS canvas_projects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL REFERENCES app_users(uid) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
thumbnail TEXT NOT NULL DEFAULT '',
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'private',
|
||||||
|
canvas_data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
source TEXT NOT NULL DEFAULT 'canvas',
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS canvas_workflows (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL REFERENCES app_users(uid) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
thumbnail TEXT NOT NULL DEFAULT '',
|
||||||
|
workflow_data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
source TEXT NOT NULL DEFAULT 'canvas',
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS job_index (
|
||||||
|
job_id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL DEFAULT '',
|
||||||
|
owner_name TEXT NOT NULL DEFAULT '',
|
||||||
|
owner_email TEXT NOT NULL DEFAULT '',
|
||||||
|
owner_provider TEXT NOT NULL DEFAULT '',
|
||||||
|
tenant_key TEXT NOT NULL DEFAULT '',
|
||||||
|
url TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT '',
|
||||||
|
progress INTEGER NOT NULL DEFAULT 0,
|
||||||
|
message TEXT NOT NULL DEFAULT '',
|
||||||
|
job_kind TEXT NOT NULL DEFAULT '',
|
||||||
|
width INTEGER NOT NULL DEFAULT 0,
|
||||||
|
height INTEGER NOT NULL DEFAULT 0,
|
||||||
|
duration DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
frame_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
video_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
thumbnail TEXT NOT NULL DEFAULT '',
|
||||||
|
state_path TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
last_synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS generated_assets (
|
||||||
|
asset_key TEXT PRIMARY KEY,
|
||||||
|
asset_id TEXT NOT NULL DEFAULT '',
|
||||||
|
job_id TEXT NOT NULL DEFAULT '',
|
||||||
|
owner_id TEXT NOT NULL DEFAULT '',
|
||||||
|
kind TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT '',
|
||||||
|
url TEXT NOT NULL DEFAULT '',
|
||||||
|
model TEXT NOT NULL DEFAULT '',
|
||||||
|
prompt TEXT NOT NULL DEFAULT '',
|
||||||
|
width INTEGER NOT NULL DEFAULT 0,
|
||||||
|
height INTEGER NOT NULL DEFAULT 0,
|
||||||
|
duration DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS prompt_library_index (
|
||||||
|
item_id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL DEFAULT '',
|
||||||
|
category TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'company',
|
||||||
|
source_job_id TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_library_index (
|
||||||
|
item_key TEXT PRIMARY KEY,
|
||||||
|
item_id TEXT NOT NULL DEFAULT '',
|
||||||
|
owner_id TEXT NOT NULL DEFAULT '',
|
||||||
|
kind TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'company',
|
||||||
|
source_job_id TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_run_index (
|
||||||
|
run_id TEXT PRIMARY KEY,
|
||||||
|
job_id TEXT NOT NULL DEFAULT '',
|
||||||
|
owner_id TEXT NOT NULL DEFAULT '',
|
||||||
|
owner_name TEXT NOT NULL DEFAULT '',
|
||||||
|
owner_email TEXT NOT NULL DEFAULT '',
|
||||||
|
owner_provider TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT '',
|
||||||
|
stage TEXT NOT NULL DEFAULT '',
|
||||||
|
progress INTEGER NOT NULL DEFAULT 0,
|
||||||
|
final_video_url TEXT NOT NULL DEFAULT '',
|
||||||
|
contact_sheet_url TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_events (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
user_id TEXT NOT NULL DEFAULT '',
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL DEFAULT '',
|
||||||
|
entity_id TEXT NOT NULL DEFAULT '',
|
||||||
|
visibility TEXT NOT NULL DEFAULT '',
|
||||||
|
ip TEXT NOT NULL DEFAULT '',
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_canvas_projects_owner_updated ON canvas_projects(owner_id, updated_at DESC) WHERE deleted_at IS NULL",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_canvas_projects_visibility_updated ON canvas_projects(visibility, updated_at DESC) WHERE deleted_at IS NULL",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_canvas_workflows_owner_updated ON canvas_workflows(owner_id, updated_at DESC) WHERE deleted_at IS NULL",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_job_index_owner_updated ON job_index(owner_id, updated_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_generated_assets_owner_created ON generated_assets(owner_id, created_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_prompt_library_visibility ON prompt_library_index(visibility, updated_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_asset_library_visibility ON asset_library_index(visibility, updated_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_audit_events_user_ts ON audit_events(user_id, ts DESC)",
|
||||||
|
]
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with _connect() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for stmt in ddl:
|
||||||
|
cur.execute(stmt)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
return bool(_execute_safely("init_schema", run))
|
||||||
|
|
||||||
|
|
||||||
|
def health() -> dict:
|
||||||
|
if not DB_ENABLED:
|
||||||
|
return {"enabled": False, "connected": False}
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with _connect() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT 1 AS ok")
|
||||||
|
cur.fetchone()
|
||||||
|
return {"enabled": True, "connected": True}
|
||||||
|
|
||||||
|
return _execute_safely("health", run) or {"enabled": True, "connected": False}
|
||||||
|
|
||||||
|
|
||||||
|
def request_ip(request: Any) -> str:
|
||||||
|
if request is None:
|
||||||
|
return ""
|
||||||
|
forwarded = str(request.headers.get("x-forwarded-for") or "").split(",", 1)[0].strip()
|
||||||
|
return forwarded or getattr(getattr(request, "client", None), "host", "") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def request_user_agent(request: Any) -> str:
|
||||||
|
if request is None:
|
||||||
|
return ""
|
||||||
|
return str(request.headers.get("user-agent") or "")[:600]
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_user(user: dict, request: Any = None) -> None:
|
||||||
|
uid = str(user.get("uid") or "").strip()
|
||||||
|
if not uid:
|
||||||
|
return
|
||||||
|
payload = {
|
||||||
|
"username": str(user.get("username") or user.get("u") or ""),
|
||||||
|
"name": str(user.get("name") or ""),
|
||||||
|
"email": str(user.get("email") or ""),
|
||||||
|
"open_id": str(user.get("open_id") or ""),
|
||||||
|
"union_id": str(user.get("union_id") or ""),
|
||||||
|
"tenant_key": str(user.get("tenant_key") or ""),
|
||||||
|
"avatar_url": str(user.get("avatar_url") or ""),
|
||||||
|
"provider": str(user.get("provider") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with _connect() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO app_users (
|
||||||
|
uid, provider, username, name, email, open_id, union_id,
|
||||||
|
tenant_key, avatar_url, last_ip, last_user_agent, metadata
|
||||||
|
)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
ON CONFLICT (uid) DO UPDATE SET
|
||||||
|
provider = EXCLUDED.provider,
|
||||||
|
username = EXCLUDED.username,
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
email = EXCLUDED.email,
|
||||||
|
open_id = EXCLUDED.open_id,
|
||||||
|
union_id = EXCLUDED.union_id,
|
||||||
|
tenant_key = EXCLUDED.tenant_key,
|
||||||
|
avatar_url = EXCLUDED.avatar_url,
|
||||||
|
last_seen_at = now(),
|
||||||
|
last_ip = EXCLUDED.last_ip,
|
||||||
|
last_user_agent = EXCLUDED.last_user_agent,
|
||||||
|
metadata = EXCLUDED.metadata
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
uid,
|
||||||
|
payload["provider"],
|
||||||
|
payload["username"],
|
||||||
|
payload["name"],
|
||||||
|
payload["email"],
|
||||||
|
payload["open_id"],
|
||||||
|
payload["union_id"],
|
||||||
|
payload["tenant_key"],
|
||||||
|
payload["avatar_url"],
|
||||||
|
request_ip(request),
|
||||||
|
request_user_agent(request),
|
||||||
|
_json(payload),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
_execute_safely("upsert_user", run)
|
||||||
|
|
||||||
|
|
||||||
|
def audit(user: dict | None, action: str, entity_type: str = "", entity_id: str = "", metadata: dict | None = None, request: Any = None, visibility: str = "") -> None:
|
||||||
|
def run():
|
||||||
|
with _connect() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_events (id, user_id, action, entity_type, entity_id, visibility, ip, user_agent, metadata)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
str(uuid.uuid4()),
|
||||||
|
str((user or {}).get("uid") or ""),
|
||||||
|
action,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
visibility,
|
||||||
|
request_ip(request),
|
||||||
|
request_user_agent(request),
|
||||||
|
_json(metadata or {}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
_execute_safely("audit", run)
|
||||||
|
|
||||||
|
|
||||||
|
def list_canvas_projects(user: dict, include_shared: bool = True) -> list[dict]:
|
||||||
|
uid = str(user.get("uid") or "")
|
||||||
|
tenant_key = str(user.get("tenant_key") or "")
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with _connect() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
p.id, p.name, p.thumbnail, p.visibility, p.canvas_data,
|
||||||
|
p.created_at, p.updated_at, p.version, p.owner_id,
|
||||||
|
u.name AS owner_name, u.email AS owner_email, u.provider AS owner_provider
|
||||||
|
FROM canvas_projects p
|
||||||
|
LEFT JOIN app_users u ON u.uid = p.owner_id
|
||||||
|
WHERE p.deleted_at IS NULL
|
||||||
|
AND (
|
||||||
|
p.owner_id = %s
|
||||||
|
OR (%s AND p.visibility = 'company')
|
||||||
|
OR (%s AND p.visibility = 'team' AND COALESCE(u.tenant_key, '') = %s)
|
||||||
|
)
|
||||||
|
ORDER BY p.updated_at DESC
|
||||||
|
LIMIT 500
|
||||||
|
""",
|
||||||
|
(uid, include_shared, bool(tenant_key), tenant_key),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
return _execute_safely("list_canvas_projects", run) or []
|
||||||
|
|
||||||
|
|
||||||
|
def get_canvas_project(project_id: str, user: dict) -> dict | None:
|
||||||
|
uid = str(user.get("uid") or "")
|
||||||
|
tenant_key = str(user.get("tenant_key") or "")
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with _connect() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT p.*, u.tenant_key AS owner_tenant_key
|
||||||
|
FROM canvas_projects p
|
||||||
|
LEFT JOIN app_users u ON u.uid = p.owner_id
|
||||||
|
WHERE p.id = %s AND p.deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
(project_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
if row["owner_id"] == uid or row["visibility"] == "company" or (row["visibility"] == "team" and tenant_key and row["owner_tenant_key"] == tenant_key):
|
||||||
|
return dict(row)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _execute_safely("get_canvas_project", run)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_canvas_project(user: dict, project: dict) -> dict | None:
|
||||||
|
uid = str(user.get("uid") or "")
|
||||||
|
if not uid:
|
||||||
|
return None
|
||||||
|
project_id = str(project.get("id") or "").strip()
|
||||||
|
if not project_id:
|
||||||
|
project_id = f"project_{int(time.time() * 1000)}_{uuid.uuid4().hex[:9]}"
|
||||||
|
name = str(project.get("name") or "未命名项目").strip() or "未命名项目"
|
||||||
|
thumbnail = str(project.get("thumbnail") or "")
|
||||||
|
visibility = str(project.get("visibility") or "private").strip()
|
||||||
|
if visibility not in {"private", "team", "company"}:
|
||||||
|
visibility = "private"
|
||||||
|
canvas_data = project.get("canvas_data") or project.get("canvasData") or {"nodes": [], "edges": [], "viewport": {"x": 100, "y": 50, "zoom": 0.8}}
|
||||||
|
created_at = _dt(project.get("created_at") or project.get("createdAt"))
|
||||||
|
updated_at = _dt(project.get("updated_at") or project.get("updatedAt"))
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with _connect() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO canvas_projects (
|
||||||
|
id, owner_id, name, thumbnail, visibility, canvas_data,
|
||||||
|
created_at, updated_at, version, source, metadata
|
||||||
|
)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,1,%s,%s)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
name = CASE
|
||||||
|
WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN EXCLUDED.name
|
||||||
|
ELSE canvas_projects.name
|
||||||
|
END,
|
||||||
|
thumbnail = CASE
|
||||||
|
WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN EXCLUDED.thumbnail
|
||||||
|
ELSE canvas_projects.thumbnail
|
||||||
|
END,
|
||||||
|
visibility = CASE
|
||||||
|
WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN EXCLUDED.visibility
|
||||||
|
ELSE canvas_projects.visibility
|
||||||
|
END,
|
||||||
|
canvas_data = CASE
|
||||||
|
WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN EXCLUDED.canvas_data
|
||||||
|
ELSE canvas_projects.canvas_data
|
||||||
|
END,
|
||||||
|
updated_at = CASE
|
||||||
|
WHEN canvas_projects.owner_id = EXCLUDED.owner_id THEN GREATEST(canvas_projects.updated_at, EXCLUDED.updated_at)
|
||||||
|
ELSE canvas_projects.updated_at
|
||||||
|
END,
|
||||||
|
version = CASE
|
||||||
|
WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN canvas_projects.version + 1
|
||||||
|
ELSE canvas_projects.version
|
||||||
|
END,
|
||||||
|
deleted_at = CASE
|
||||||
|
WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN NULL
|
||||||
|
ELSE canvas_projects.deleted_at
|
||||||
|
END
|
||||||
|
WHERE canvas_projects.owner_id = EXCLUDED.owner_id
|
||||||
|
RETURNING id, name, thumbnail, visibility, canvas_data, created_at, updated_at, version, owner_id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
project_id,
|
||||||
|
uid,
|
||||||
|
name,
|
||||||
|
thumbnail,
|
||||||
|
visibility,
|
||||||
|
_json(canvas_data),
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
str(project.get("source") or "canvas"),
|
||||||
|
_json({"migrated_from": project.get("source") or "canvas"}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
return _execute_safely("upsert_canvas_project", run)
|
||||||
|
|
||||||
|
|
||||||
|
def soft_delete_canvas_project(user: dict, project_id: str) -> bool:
|
||||||
|
uid = str(user.get("uid") or "")
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with _connect() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE canvas_projects
|
||||||
|
SET deleted_at = now(), updated_at = now(), version = version + 1
|
||||||
|
WHERE id = %s AND owner_id = %s AND deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
(project_id, uid),
|
||||||
|
)
|
||||||
|
changed = cur.rowcount > 0
|
||||||
|
conn.commit()
|
||||||
|
return changed
|
||||||
|
|
||||||
|
return bool(_execute_safely("soft_delete_canvas_project", run))
|
||||||
|
|
||||||
|
|
||||||
|
def list_canvas_workflows(user: dict) -> list[dict]:
|
||||||
|
uid = str(user.get("uid") or "")
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with _connect() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
w.id, w.name, w.description, w.thumbnail, w.workflow_data,
|
||||||
|
w.created_at, w.updated_at, w.version, w.owner_id,
|
||||||
|
u.name AS owner_name, u.email AS owner_email, u.provider AS owner_provider
|
||||||
|
FROM canvas_workflows w
|
||||||
|
LEFT JOIN app_users u ON u.uid = w.owner_id
|
||||||
|
WHERE w.deleted_at IS NULL
|
||||||
|
AND w.owner_id = %s
|
||||||
|
ORDER BY w.updated_at DESC
|
||||||
|
LIMIT 500
|
||||||
|
""",
|
||||||
|
(uid,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
return _execute_safely("list_canvas_workflows", run) or []
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_canvas_workflow(user: dict, workflow: dict) -> dict | None:
|
||||||
|
uid = str(user.get("uid") or "")
|
||||||
|
if not uid:
|
||||||
|
return None
|
||||||
|
workflow_id = str(workflow.get("id") or "").strip()
|
||||||
|
if not workflow_id:
|
||||||
|
workflow_id = f"workflow_{int(time.time() * 1000)}_{uuid.uuid4().hex[:9]}"
|
||||||
|
name = str(workflow.get("name") or "未命名工作流").strip() or "未命名工作流"
|
||||||
|
description = str(workflow.get("description") or "").strip()
|
||||||
|
thumbnail = str(workflow.get("thumbnail") or "")
|
||||||
|
workflow_data = workflow.get("workflow_data") or workflow.get("workflowData") or {"nodes": [], "edges": [], "viewport": {"x": 100, "y": 50, "zoom": 0.8}}
|
||||||
|
created_at = _dt(workflow.get("created_at") or workflow.get("createdAt"))
|
||||||
|
updated_at = _dt(workflow.get("updated_at") or workflow.get("updatedAt"))
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with _connect() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO canvas_workflows (
|
||||||
|
id, owner_id, name, description, thumbnail, workflow_data,
|
||||||
|
created_at, updated_at, version, source, metadata
|
||||||
|
)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,1,%s,%s)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
name = CASE
|
||||||
|
WHEN canvas_workflows.owner_id = EXCLUDED.owner_id THEN EXCLUDED.name
|
||||||
|
ELSE canvas_workflows.name
|
||||||
|
END,
|
||||||
|
description = CASE
|
||||||
|
WHEN canvas_workflows.owner_id = EXCLUDED.owner_id THEN EXCLUDED.description
|
||||||
|
ELSE canvas_workflows.description
|
||||||
|
END,
|
||||||
|
thumbnail = CASE
|
||||||
|
WHEN canvas_workflows.owner_id = EXCLUDED.owner_id THEN EXCLUDED.thumbnail
|
||||||
|
ELSE canvas_workflows.thumbnail
|
||||||
|
END,
|
||||||
|
workflow_data = CASE
|
||||||
|
WHEN canvas_workflows.owner_id = EXCLUDED.owner_id THEN EXCLUDED.workflow_data
|
||||||
|
ELSE canvas_workflows.workflow_data
|
||||||
|
END,
|
||||||
|
updated_at = CASE
|
||||||
|
WHEN canvas_workflows.owner_id = EXCLUDED.owner_id THEN EXCLUDED.updated_at
|
||||||
|
ELSE canvas_workflows.updated_at
|
||||||
|
END,
|
||||||
|
version = CASE
|
||||||
|
WHEN canvas_workflows.owner_id = EXCLUDED.owner_id THEN canvas_workflows.version + 1
|
||||||
|
ELSE canvas_workflows.version
|
||||||
|
END,
|
||||||
|
deleted_at = CASE
|
||||||
|
WHEN canvas_workflows.owner_id = EXCLUDED.owner_id THEN NULL
|
||||||
|
ELSE canvas_workflows.deleted_at
|
||||||
|
END
|
||||||
|
WHERE canvas_workflows.owner_id = EXCLUDED.owner_id
|
||||||
|
RETURNING id, name, description, thumbnail, workflow_data, created_at, updated_at, version, owner_id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
workflow_id,
|
||||||
|
uid,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
thumbnail,
|
||||||
|
_json(workflow_data),
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
str(workflow.get("source") or "canvas"),
|
||||||
|
_json({"source_project_id": workflow.get("source_project_id") or workflow.get("sourceProjectId") or ""}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
return _execute_safely("upsert_canvas_workflow", run)
|
||||||
|
|
||||||
|
|
||||||
|
def soft_delete_canvas_workflow(user: dict, workflow_id: str) -> bool:
|
||||||
|
uid = str(user.get("uid") or "")
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with _connect() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE canvas_workflows
|
||||||
|
SET deleted_at = now(), updated_at = now(), version = version + 1
|
||||||
|
WHERE id = %s AND owner_id = %s AND deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
(workflow_id, uid),
|
||||||
|
)
|
||||||
|
changed = cur.rowcount > 0
|
||||||
|
conn.commit()
|
||||||
|
return changed
|
||||||
|
|
||||||
|
return bool(_execute_safely("soft_delete_canvas_workflow", run))
|
||||||
|
|
||||||
|
|
||||||
|
def index_job(job: dict, state_path: str = "") -> None:
|
||||||
|
job_id = str(job.get("id") or "")
|
||||||
|
if not job_id:
|
||||||
|
return
|
||||||
|
frames = job.get("frames") or []
|
||||||
|
generated_videos = job.get("generated_videos") or []
|
||||||
|
thumbnail = ""
|
||||||
|
if frames:
|
||||||
|
first = frames[0] if isinstance(frames[0], dict) else {}
|
||||||
|
thumbnail = str(first.get("url") or "")
|
||||||
|
updated_at = _dt()
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with _connect() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO job_index (
|
||||||
|
job_id, owner_id, owner_name, owner_email, owner_provider, tenant_key,
|
||||||
|
url, status, progress, message, job_kind, width, height, duration,
|
||||||
|
frame_count, video_count, thumbnail, state_path, updated_at, payload
|
||||||
|
)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
ON CONFLICT (job_id) DO UPDATE SET
|
||||||
|
owner_id = EXCLUDED.owner_id,
|
||||||
|
owner_name = EXCLUDED.owner_name,
|
||||||
|
owner_email = EXCLUDED.owner_email,
|
||||||
|
owner_provider = EXCLUDED.owner_provider,
|
||||||
|
tenant_key = EXCLUDED.tenant_key,
|
||||||
|
url = EXCLUDED.url,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
progress = EXCLUDED.progress,
|
||||||
|
message = EXCLUDED.message,
|
||||||
|
job_kind = EXCLUDED.job_kind,
|
||||||
|
width = EXCLUDED.width,
|
||||||
|
height = EXCLUDED.height,
|
||||||
|
duration = EXCLUDED.duration,
|
||||||
|
frame_count = EXCLUDED.frame_count,
|
||||||
|
video_count = EXCLUDED.video_count,
|
||||||
|
thumbnail = EXCLUDED.thumbnail,
|
||||||
|
state_path = EXCLUDED.state_path,
|
||||||
|
updated_at = EXCLUDED.updated_at,
|
||||||
|
last_synced_at = now(),
|
||||||
|
payload = EXCLUDED.payload
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
job_id,
|
||||||
|
str(job.get("owner_id") or ""),
|
||||||
|
str(job.get("owner_name") or ""),
|
||||||
|
str(job.get("owner_email") or ""),
|
||||||
|
str(job.get("owner_provider") or ""),
|
||||||
|
str(job.get("tenant_key") or ""),
|
||||||
|
str(job.get("url") or ""),
|
||||||
|
str(job.get("status") or ""),
|
||||||
|
int(job.get("progress") or 0),
|
||||||
|
str(job.get("message") or "")[:1000],
|
||||||
|
str(job.get("url") or "").split("://", 1)[0] or "job",
|
||||||
|
int(job.get("width") or 0),
|
||||||
|
int(job.get("height") or 0),
|
||||||
|
float(job.get("duration") or 0),
|
||||||
|
len(frames),
|
||||||
|
len(generated_videos),
|
||||||
|
thumbnail,
|
||||||
|
state_path,
|
||||||
|
updated_at,
|
||||||
|
_json(job),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for frame in frames:
|
||||||
|
if not isinstance(frame, dict):
|
||||||
|
continue
|
||||||
|
frame_idx = frame.get("index", 0)
|
||||||
|
for image in frame.get("generated_images") or []:
|
||||||
|
if not isinstance(image, dict):
|
||||||
|
continue
|
||||||
|
asset_key = f"{job_id}:image:{image.get('id')}"
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO generated_assets (
|
||||||
|
asset_key, asset_id, job_id, owner_id, kind, status, url,
|
||||||
|
model, prompt, created_at, updated_at, metadata
|
||||||
|
)
|
||||||
|
VALUES (%s,%s,%s,%s,'image','completed',%s,%s,%s,%s,%s,%s)
|
||||||
|
ON CONFLICT (asset_key) DO UPDATE SET
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
url = EXCLUDED.url,
|
||||||
|
model = EXCLUDED.model,
|
||||||
|
prompt = EXCLUDED.prompt,
|
||||||
|
updated_at = EXCLUDED.updated_at,
|
||||||
|
metadata = EXCLUDED.metadata
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
asset_key,
|
||||||
|
str(image.get("id") or ""),
|
||||||
|
job_id,
|
||||||
|
str(job.get("owner_id") or ""),
|
||||||
|
str(image.get("url") or ""),
|
||||||
|
str(image.get("model") or ""),
|
||||||
|
str(image.get("prompt") or ""),
|
||||||
|
_dt(image.get("created_at")),
|
||||||
|
updated_at,
|
||||||
|
_json({"frame_idx": frame_idx, **image}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for video in generated_videos:
|
||||||
|
if not isinstance(video, dict):
|
||||||
|
continue
|
||||||
|
asset_key = f"{job_id}:video:{video.get('id')}"
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO generated_assets (
|
||||||
|
asset_key, asset_id, job_id, owner_id, kind, status, url,
|
||||||
|
model, prompt, duration, created_at, updated_at, metadata
|
||||||
|
)
|
||||||
|
VALUES (%s,%s,%s,%s,'video',%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
ON CONFLICT (asset_key) DO UPDATE SET
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
url = EXCLUDED.url,
|
||||||
|
model = EXCLUDED.model,
|
||||||
|
prompt = EXCLUDED.prompt,
|
||||||
|
duration = EXCLUDED.duration,
|
||||||
|
updated_at = EXCLUDED.updated_at,
|
||||||
|
metadata = EXCLUDED.metadata
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
asset_key,
|
||||||
|
str(video.get("id") or ""),
|
||||||
|
job_id,
|
||||||
|
str(job.get("owner_id") or ""),
|
||||||
|
str(video.get("status") or ""),
|
||||||
|
str(video.get("url") or ""),
|
||||||
|
str(video.get("model") or ""),
|
||||||
|
str(video.get("prompt") or ""),
|
||||||
|
float(video.get("duration") or 0),
|
||||||
|
_dt(video.get("created_at")),
|
||||||
|
updated_at,
|
||||||
|
_json(video),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
_execute_safely("index_job", run)
|
||||||
|
|
||||||
|
|
||||||
|
def index_agent_run(run_payload: dict) -> None:
|
||||||
|
run_id = str(run_payload.get("id") or "")
|
||||||
|
if not run_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with _connect() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_run_index (
|
||||||
|
run_id, job_id, owner_id, owner_name, owner_email, owner_provider,
|
||||||
|
status, stage, progress, final_video_url, contact_sheet_url,
|
||||||
|
created_at, updated_at, payload
|
||||||
|
)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
ON CONFLICT (run_id) DO UPDATE SET
|
||||||
|
job_id = EXCLUDED.job_id,
|
||||||
|
owner_id = EXCLUDED.owner_id,
|
||||||
|
owner_name = EXCLUDED.owner_name,
|
||||||
|
owner_email = EXCLUDED.owner_email,
|
||||||
|
owner_provider = EXCLUDED.owner_provider,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
stage = EXCLUDED.stage,
|
||||||
|
progress = EXCLUDED.progress,
|
||||||
|
final_video_url = EXCLUDED.final_video_url,
|
||||||
|
contact_sheet_url = EXCLUDED.contact_sheet_url,
|
||||||
|
updated_at = EXCLUDED.updated_at,
|
||||||
|
payload = EXCLUDED.payload
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
run_id,
|
||||||
|
str(run_payload.get("job_id") or ""),
|
||||||
|
str(run_payload.get("owner_id") or ""),
|
||||||
|
str(run_payload.get("owner_name") or ""),
|
||||||
|
str(run_payload.get("owner_email") or ""),
|
||||||
|
str(run_payload.get("owner_provider") or ""),
|
||||||
|
str(run_payload.get("status") or ""),
|
||||||
|
str(run_payload.get("stage") or ""),
|
||||||
|
int(run_payload.get("progress") or 0),
|
||||||
|
str(run_payload.get("final_video_url") or ""),
|
||||||
|
str(run_payload.get("contact_sheet_url") or ""),
|
||||||
|
_dt(run_payload.get("created_at")),
|
||||||
|
_dt(run_payload.get("updated_at")),
|
||||||
|
_json(run_payload),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
_execute_safely("index_agent_run", run)
|
||||||
|
|
||||||
|
|
||||||
|
def index_prompt_item(item: dict, owner_id: str = "") -> None:
|
||||||
|
item_id = str(item.get("id") or "")
|
||||||
|
if not item_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with _connect() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO prompt_library_index (
|
||||||
|
item_id, owner_id, category, name, tags, source_job_id,
|
||||||
|
created_at, updated_at, payload
|
||||||
|
)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
ON CONFLICT (item_id) DO UPDATE SET
|
||||||
|
owner_id = EXCLUDED.owner_id,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
tags = EXCLUDED.tags,
|
||||||
|
source_job_id = EXCLUDED.source_job_id,
|
||||||
|
updated_at = EXCLUDED.updated_at,
|
||||||
|
payload = EXCLUDED.payload
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
item_id,
|
||||||
|
owner_id,
|
||||||
|
str(item.get("category") or ""),
|
||||||
|
str(item.get("name") or ""),
|
||||||
|
_json(item.get("tags") or []),
|
||||||
|
str(item.get("source_job_id") or ""),
|
||||||
|
_dt(item.get("created_at")),
|
||||||
|
_dt(item.get("updated_at") or item.get("created_at")),
|
||||||
|
_json(item),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
_execute_safely("index_prompt_item", run)
|
||||||
|
|
||||||
|
|
||||||
|
def index_asset_item(item: dict, owner_id: str = "") -> None:
|
||||||
|
item_id = str(item.get("id") or "")
|
||||||
|
kind = str(item.get("kind") or "")
|
||||||
|
if not item_id or not kind:
|
||||||
|
return
|
||||||
|
item_key = f"{kind}:{item_id}"
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with _connect() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO asset_library_index (
|
||||||
|
item_key, item_id, owner_id, kind, name, tags, source_job_id,
|
||||||
|
created_at, updated_at, payload
|
||||||
|
)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
ON CONFLICT (item_key) DO UPDATE SET
|
||||||
|
owner_id = EXCLUDED.owner_id,
|
||||||
|
kind = EXCLUDED.kind,
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
tags = EXCLUDED.tags,
|
||||||
|
source_job_id = EXCLUDED.source_job_id,
|
||||||
|
updated_at = EXCLUDED.updated_at,
|
||||||
|
payload = EXCLUDED.payload
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
item_key,
|
||||||
|
item_id,
|
||||||
|
owner_id,
|
||||||
|
kind,
|
||||||
|
str(item.get("name") or ""),
|
||||||
|
_json(item.get("tags") or []),
|
||||||
|
str(item.get("source_job_id") or ""),
|
||||||
|
_dt(item.get("created_at")),
|
||||||
|
_dt(item.get("updated_at") or item.get("created_at")),
|
||||||
|
_json(item),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
_execute_safely("index_asset_item", run)
|
||||||
6590
api/main.py
3
api/prompt_library/index.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ python-dotenv==1.0.1
|
|||||||
yt-dlp==2026.3.17
|
yt-dlp==2026.3.17
|
||||||
openai==1.55.3
|
openai==1.55.3
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
|
requests==2.32.5
|
||||||
|
psycopg[binary]==3.2.3
|
||||||
|
psycopg-pool==3.2.4
|
||||||
imagehash==4.3.1
|
imagehash==4.3.1
|
||||||
Pillow>=11.0
|
Pillow>=11.0
|
||||||
numpy>=2.0
|
numpy>=2.0
|
||||||
|
faster-whisper==1.1.1
|
||||||
|
|||||||
0
asset_library/.gitkeep
Normal file
103
deploy/.env.local.example
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Local Docker only. Copy to deploy/.env.local or run scripts/start-local-docker.sh.
|
||||||
|
# Real production secrets stay in deploy/.env.production on the VPS.
|
||||||
|
|
||||||
|
# Local ports
|
||||||
|
LOCAL_WEB_PORT=4390
|
||||||
|
LOCAL_API_PORT=4391
|
||||||
|
|
||||||
|
# Local Postgres
|
||||||
|
POSTGRES_DB=skg_marketing_local
|
||||||
|
POSTGRES_USER=skg_marketing
|
||||||
|
POSTGRES_PASSWORD=skg_marketing_local_password
|
||||||
|
|
||||||
|
# Local password login for Docker smoke tests
|
||||||
|
PASSWORD_AUTH_ENABLED=true
|
||||||
|
WEB_AUTH_USERNAME=skg
|
||||||
|
WEB_AUTH_PASSWORD=local-skg
|
||||||
|
WEB_AUTH_SESSION_SECRET=local-docker-session-secret-change-me
|
||||||
|
WEB_AUTH_COOKIE_NAME=skg_marketing_local_session
|
||||||
|
WEB_AUTH_COOKIE_SECURE=false
|
||||||
|
AUTH_DATA_ISOLATION_ENABLED=true
|
||||||
|
|
||||||
|
# Feishu can be filled locally if OAuth needs to be tested from localhost.
|
||||||
|
FEISHU_APP_ID=
|
||||||
|
FEISHU_APP_SECRET=
|
||||||
|
FEISHU_REDIRECT_URI=http://localhost:4390/api/auth/feishu/callback
|
||||||
|
FEISHU_OAUTH_SCOPE=
|
||||||
|
FEISHU_ALLOWED_EMAIL_DOMAINS=
|
||||||
|
FEISHU_ALLOWED_EMAILS=
|
||||||
|
FEISHU_ALLOWED_TENANT_KEYS=
|
||||||
|
|
||||||
|
# SKG AI gateway. Leave blank when only testing UI/login/database locally.
|
||||||
|
LLM_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||||
|
LLM_API_KEY=
|
||||||
|
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||||
|
IMAGE_API_KEY=
|
||||||
|
IMAGE_MODEL=gpt-image-2
|
||||||
|
IMAGE_REQUEST_TIMEOUT_SECONDS=60
|
||||||
|
IMAGE_FALLBACK_ENABLED=true
|
||||||
|
IMAGE_FALLBACK_MODEL=gemini-3-pro-image-preview
|
||||||
|
IMAGE_FALLBACK_MODELS=gemini-3-pro-image-preview
|
||||||
|
IMAGE_EXTRA_MODELS=
|
||||||
|
# Optional JSON model overrides. Use api_key_env/base_url_env; do not put real keys in git.
|
||||||
|
# IMAGE_MODEL_CONFIGS_JSON={"custom-model":{"label":"Custom Image","base_url_env":"CUSTOM_IMAGE_BASE_URL","api_key_env":"CUSTOM_IMAGE_API_KEY","provider":"openai","sizes":["1024x1024"],"default_size":"1024x1024"}}
|
||||||
|
IMAGE_CIRCUIT_FAILURE_THRESHOLD=2
|
||||||
|
IMAGE_CIRCUIT_COOLDOWN_SECONDS=600
|
||||||
|
GPT_IMAGE_MODEL=gpt-image-2
|
||||||
|
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
|
||||||
|
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2,gemini-3-pro-image-preview
|
||||||
|
ARK_SEEDREAM_ENABLED=true
|
||||||
|
ARK_IMAGE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||||
|
ARK_IMAGE_API_KEY=
|
||||||
|
ARK_SEEDREAM_IMAGE_MODEL=doubao-seedream-4-5-251128
|
||||||
|
AI_HTTP_PROXY=
|
||||||
|
|
||||||
|
# Text/vision/audio model names
|
||||||
|
GPT_TEXT_MODEL=gpt-4o
|
||||||
|
REWRITE_MODEL=gpt-4o
|
||||||
|
VISION_MODEL=gpt-4o
|
||||||
|
TRANSLATE_MODEL=gemini-2.5-flash
|
||||||
|
ASR_BASE_URL=https://ai.skg.com/azure/v1
|
||||||
|
ASR_API_KEY=
|
||||||
|
ASR_MODEL=gpt-4o-transcribe
|
||||||
|
ASR_LANGUAGE=auto
|
||||||
|
ASR_REMOTE_ENABLED=false
|
||||||
|
ASR_LOCAL_FALLBACK_ENABLED=true
|
||||||
|
ASR_AUDIO_FALLBACK_ENABLED=false
|
||||||
|
ASR_FALLBACK_MODEL=gemini-2.5-flash
|
||||||
|
ASR_TIMEOUT_SECONDS=45
|
||||||
|
FASTER_WHISPER_MODEL=tiny
|
||||||
|
FASTER_WHISPER_DEVICE=cpu
|
||||||
|
FASTER_WHISPER_COMPUTE_TYPE=int8
|
||||||
|
|
||||||
|
# Video generation. Fill VIDEO_API_KEY only when testing real video generation locally.
|
||||||
|
VIDEO_API_BASE_URL=https://ai.skg.com/doubao
|
||||||
|
VIDEO_API_KEY=
|
||||||
|
VIDEO_MODEL=seedance
|
||||||
|
VIDEO_MODEL_SEEDANCE=doubao-seedance-2-0-fast-260128
|
||||||
|
VIDEO_MODEL_XAI=grok-imagine-video
|
||||||
|
VIDEO_CREATE_PATHS=/api/v3/contents/generations/tasks
|
||||||
|
VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id}
|
||||||
|
VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content
|
||||||
|
VIDEO_DURATION_FIELD=seconds
|
||||||
|
VIDEO_POLL_TIMEOUT_SECONDS=900
|
||||||
|
XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai
|
||||||
|
XAI_VIDEO_API_KEY=
|
||||||
|
XAI_VIDEO_CREATE_PATH=/v1/videos/generations
|
||||||
|
XAI_VIDEO_STATUS_PATH=/v1/videos/{id}
|
||||||
|
XAI_VIDEO_CONTENT_PATH=
|
||||||
|
|
||||||
|
# Azure OpenAI TTS. Leave blank unless testing voice locally.
|
||||||
|
AUDIO_REWRITE_MODEL=gemini-2.5-pro
|
||||||
|
VOICE_PROVIDER=azure_openai
|
||||||
|
AZURE_OPENAI_BASE_URL=https://ai.skg.com/azure
|
||||||
|
AZURE_OPENAI_API_KEY=
|
||||||
|
AZURE_TTS_MODEL=gpt-4o-mini-tts
|
||||||
|
AZURE_TTS_VOICE_ID=alloy
|
||||||
|
AZURE_TTS_VOICE_POOL=alloy,verse,shimmer
|
||||||
|
AZURE_TTS_PATH=/audio/speech
|
||||||
|
AZURE_TTS_PATHS=/audio/speech,/v1/audio/speech
|
||||||
|
|
||||||
|
# Optional TikTok cookies. Keep files local and out of git.
|
||||||
|
YTDLP_COOKIES_FILE=
|
||||||
|
YTDLP_COOKIES_FROM_BROWSER=
|
||||||
@@ -3,39 +3,94 @@
|
|||||||
|
|
||||||
# Runtime
|
# Runtime
|
||||||
JOBS_DIR=/data/jobs
|
JOBS_DIR=/data/jobs
|
||||||
|
ASSET_LIBRARY_DIR=/data/asset_library
|
||||||
|
PROMPT_LIBRARY_DIR=/data/prompt_library
|
||||||
KEYFRAME_COUNT=12
|
KEYFRAME_COUNT=12
|
||||||
CORS_ORIGINS=https://marketing.skg.com
|
CORS_ORIGINS=https://marketing.skg.com
|
||||||
API_PORT=4291
|
API_PORT=4291
|
||||||
|
|
||||||
|
# Company persistence database. Real password and DATABASE_URL live only on server.
|
||||||
|
POSTGRES_DB=skg_marketing
|
||||||
|
POSTGRES_USER=skg_marketing
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
DATABASE_URL=postgresql://skg_marketing:CHANGE_ME@postgres:5432/skg_marketing
|
||||||
|
|
||||||
# Web login. Keep real password and session secret only on the server.
|
# Web login. Keep real password and session secret only on the server.
|
||||||
|
PASSWORD_AUTH_ENABLED=false
|
||||||
WEB_AUTH_USERNAME=skg
|
WEB_AUTH_USERNAME=skg
|
||||||
WEB_AUTH_PASSWORD=
|
WEB_AUTH_PASSWORD=
|
||||||
WEB_AUTH_SESSION_SECRET=
|
WEB_AUTH_SESSION_SECRET=
|
||||||
WEB_AUTH_COOKIE_NAME=skg_marketing_session
|
WEB_AUTH_COOKIE_NAME=skg_marketing_session
|
||||||
WEB_AUTH_COOKIE_SECURE=true
|
WEB_AUTH_COOKIE_SECURE=true
|
||||||
|
AUTH_DATA_ISOLATION_ENABLED=true
|
||||||
|
VIDEO_QUEUE_MAX_CONCURRENT=2
|
||||||
|
VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1
|
||||||
|
|
||||||
|
# Feishu OAuth login. Register this callback in the Feishu developer console:
|
||||||
|
# https://marketing.skg.com/api/auth/feishu/callback
|
||||||
|
FEISHU_APP_ID=
|
||||||
|
FEISHU_APP_SECRET=
|
||||||
|
FEISHU_REDIRECT_URI=https://marketing.skg.com/api/auth/feishu/callback
|
||||||
|
FEISHU_OAUTH_SCOPE=
|
||||||
|
FEISHU_ALLOWED_EMAIL_DOMAINS=
|
||||||
|
FEISHU_ALLOWED_EMAILS=
|
||||||
|
FEISHU_ALLOWED_TENANT_KEYS=
|
||||||
|
|
||||||
# SKG AI gateway, OpenAI-compatible
|
# SKG AI gateway, OpenAI-compatible
|
||||||
LLM_BASE_URL=https://ai.skg.com/ezlink/v1
|
LLM_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||||
LLM_API_KEY=
|
LLM_API_KEY=
|
||||||
|
|
||||||
# Model routing
|
# Model routing
|
||||||
ASR_MODEL=whisper-1
|
# Azure ASR can be re-enabled once the real deployment name exists.
|
||||||
|
ASR_BASE_URL=https://ai.skg.com/azure/v1
|
||||||
|
ASR_API_KEY=
|
||||||
|
ASR_MODEL=gpt-4o-transcribe
|
||||||
|
ASR_LANGUAGE=en
|
||||||
|
ASR_REMOTE_ENABLED=false
|
||||||
|
ASR_LOCAL_FALLBACK_ENABLED=true
|
||||||
|
ASR_AUDIO_FALLBACK_ENABLED=false
|
||||||
ASR_FALLBACK_MODEL=gemini-2.5-flash
|
ASR_FALLBACK_MODEL=gemini-2.5-flash
|
||||||
|
ASR_TIMEOUT_SECONDS=45
|
||||||
|
FASTER_WHISPER_MODEL=tiny.en
|
||||||
|
FASTER_WHISPER_DEVICE=cpu
|
||||||
|
FASTER_WHISPER_COMPUTE_TYPE=int8
|
||||||
TRANSLATE_MODEL=gemini-2.5-flash
|
TRANSLATE_MODEL=gemini-2.5-flash
|
||||||
REWRITE_MODEL=gemini-2.5-pro
|
GPT_TEXT_MODEL=gpt-4o
|
||||||
|
REWRITE_MODEL=gpt-4o
|
||||||
|
VISION_MODEL=gpt-4o
|
||||||
PRODUCT_VIEW_MODEL=gpt-image-2
|
PRODUCT_VIEW_MODEL=gpt-image-2
|
||||||
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
|
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||||
IMAGE_API_KEY=
|
IMAGE_API_KEY=
|
||||||
IMAGE_MODEL=gpt-image-2
|
IMAGE_MODEL=gpt-image-2
|
||||||
|
IMAGE_REQUEST_TIMEOUT_SECONDS=60
|
||||||
|
IMAGE_FALLBACK_ENABLED=true
|
||||||
|
IMAGE_FALLBACK_MODEL=gemini-3-pro-image-preview
|
||||||
|
IMAGE_FALLBACK_MODELS=gemini-3-pro-image-preview
|
||||||
|
IMAGE_EXTRA_MODELS=
|
||||||
|
# Optional JSON model overrides. Use api_key_env/base_url_env; do not put real keys in git.
|
||||||
|
# IMAGE_MODEL_CONFIGS_JSON={"custom-model":{"label":"Custom Image","base_url_env":"CUSTOM_IMAGE_BASE_URL","api_key_env":"CUSTOM_IMAGE_API_KEY","provider":"openai","sizes":["1024x1024"],"default_size":"1024x1024"}}
|
||||||
|
IMAGE_CIRCUIT_FAILURE_THRESHOLD=2
|
||||||
|
IMAGE_CIRCUIT_COOLDOWN_SECONDS=600
|
||||||
GPT_IMAGE_MODEL=gpt-image-2
|
GPT_IMAGE_MODEL=gpt-image-2
|
||||||
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
|
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
|
||||||
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2
|
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2,gemini-3-pro-image-preview
|
||||||
|
# Optional Ark Seedream image channel. Keep the real key only in deploy/.env.production on the VPS.
|
||||||
|
ARK_SEEDREAM_ENABLED=true
|
||||||
|
ARK_IMAGE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||||
|
ARK_IMAGE_API_KEY=
|
||||||
|
ARK_SEEDREAM_IMAGE_MODEL=doubao-seedream-4-5-251128
|
||||||
# Optional outbound proxy for AI gateway calls. Leave blank on normal VPS networking.
|
# Optional outbound proxy for AI gateway calls. Leave blank on normal VPS networking.
|
||||||
AI_HTTP_PROXY=
|
AI_HTTP_PROXY=
|
||||||
|
|
||||||
|
# Optional TikTok download login state for yt-dlp. Keep cookies files private.
|
||||||
|
# Leave blank for public TikTok videos. Set to /run/secrets/tiktok_cookies.txt only when a link explicitly requires login cookies.
|
||||||
|
YTDLP_COOKIES_FILE=
|
||||||
|
YTDLP_COOKIES_FROM_BROWSER=
|
||||||
|
|
||||||
# Audio rewrite and Azure OpenAI TTS
|
# Audio rewrite and Azure OpenAI TTS
|
||||||
AUDIO_REWRITE_MODEL=gemini-2.5-pro
|
AUDIO_REWRITE_MODEL=gemini-2.5-pro
|
||||||
AUDIO_PRODUCT_BRIEF="SKG smart massage products for daily neck, shoulder, back, eye, knee, and foot relaxation. Keep claims premium, clean, credible, and non-medical."
|
AUDIO_PRODUCT_BRIEF="SKG smart massage products for daily neck, shoulder, back, eye, knee, and foot relaxation. Keep claims premium, clean, credible, and non-medical."
|
||||||
|
# Voice is fixed to Azure OpenAI in the backend.
|
||||||
VOICE_PROVIDER=azure_openai
|
VOICE_PROVIDER=azure_openai
|
||||||
AZURE_OPENAI_BASE_URL=https://ai.skg.com/azure
|
AZURE_OPENAI_BASE_URL=https://ai.skg.com/azure
|
||||||
AZURE_OPENAI_API_KEY=
|
AZURE_OPENAI_API_KEY=
|
||||||
@@ -43,13 +98,7 @@ AZURE_TTS_MODEL=gpt-4o-mini-tts
|
|||||||
AZURE_TTS_VOICE_ID=alloy
|
AZURE_TTS_VOICE_ID=alloy
|
||||||
AZURE_TTS_VOICE_POOL=alloy,verse,shimmer
|
AZURE_TTS_VOICE_POOL=alloy,verse,shimmer
|
||||||
AZURE_TTS_PATH=/audio/speech
|
AZURE_TTS_PATH=/audio/speech
|
||||||
|
AZURE_TTS_PATHS=/audio/speech,/v1/audio/speech
|
||||||
# Legacy MiniMax TTS fallback; not the default voice provider.
|
|
||||||
MINIMAX_API_KEY=
|
|
||||||
MINIMAX_TTS_BASE_URL=https://api.minimax.io
|
|
||||||
MINIMAX_TTS_MODEL=speech-2.8-turbo
|
|
||||||
MINIMAX_TTS_VOICE_ID=English_expressive_narrator
|
|
||||||
MINIMAX_TTS_VOICE_POOL=English_magnetic_voiced_man,English_Upbeat_Woman,English_MaturePartner
|
|
||||||
|
|
||||||
# Video generation. Use SKG Doubao / Seedance gateway in production.
|
# Video generation. Use SKG Doubao / Seedance gateway in production.
|
||||||
POE_API_BASE_URL=https://api.poe.com/v1
|
POE_API_BASE_URL=https://api.poe.com/v1
|
||||||
@@ -58,10 +107,14 @@ VIDEO_API_BASE_URL=https://ai.skg.com/doubao
|
|||||||
VIDEO_API_KEY=
|
VIDEO_API_KEY=
|
||||||
VIDEO_MODEL=seedance
|
VIDEO_MODEL=seedance
|
||||||
VIDEO_MODEL_SEEDANCE=doubao-seedance-2-0-fast-260128
|
VIDEO_MODEL_SEEDANCE=doubao-seedance-2-0-fast-260128
|
||||||
VIDEO_MODEL_KLING=kling-omni
|
VIDEO_MODEL_XAI=grok-imagine-video
|
||||||
VIDEO_MODEL_VEO3=veo-3.1-fast
|
|
||||||
VIDEO_CREATE_PATHS=/api/v3/contents/generations/tasks
|
VIDEO_CREATE_PATHS=/api/v3/contents/generations/tasks
|
||||||
VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id}
|
VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id}
|
||||||
VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content
|
VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content
|
||||||
VIDEO_DURATION_FIELD=seconds
|
VIDEO_DURATION_FIELD=seconds
|
||||||
VIDEO_POLL_TIMEOUT_SECONDS=900
|
VIDEO_POLL_TIMEOUT_SECONDS=900
|
||||||
|
XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai
|
||||||
|
XAI_VIDEO_API_KEY=
|
||||||
|
XAI_VIDEO_CREATE_PATH=/v1/videos/generations
|
||||||
|
XAI_VIDEO_STATUS_PATH=/v1/videos/{id}
|
||||||
|
XAI_VIDEO_CONTENT_PATH=
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /api/auth/ {
|
||||||
|
proxy_pass http://skg-marketing-api:4291/auth/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
location = /api/auth/login {
|
location = /api/auth/login {
|
||||||
proxy_pass http://skg-marketing-api:4291/auth/login;
|
proxy_pass http://skg-marketing-api:4291/auth/login;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -92,6 +106,14 @@ server {
|
|||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /canvas {
|
||||||
|
return 308 /;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/canvas/(.*)$ {
|
||||||
|
return 308 /$1$is_args$args;
|
||||||
|
}
|
||||||
|
|
||||||
location = /skg-logo-black.svg {
|
location = /skg-logo-black.svg {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
@@ -108,7 +130,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location @login_redirect {
|
location @login_redirect {
|
||||||
return 302 /login/;
|
return 302 /login/?next=$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
85
docker-compose.local.yml
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
name: skg-marketing-local
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: skg-marketing-local-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-skg_marketing_local}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-skg_marketing}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-skg_marketing_local_password}
|
||||||
|
volumes:
|
||||||
|
- ./data-local/postgres:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- skg-marketing-local
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skg_marketing} -d ${POSTGRES_DB:-skg_marketing_local}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: skg-marketing-local-api:latest
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.api
|
||||||
|
container_name: skg-marketing-local-api
|
||||||
|
env_file:
|
||||||
|
- ./deploy/.env.local
|
||||||
|
environment:
|
||||||
|
JOBS_DIR: /data/jobs
|
||||||
|
AGENT_RUNS_DIR: /data/agent_runs
|
||||||
|
ASSET_LIBRARY_DIR: /data/asset_library
|
||||||
|
PROMPT_LIBRARY_DIR: /data/prompt_library
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-skg_marketing}:${POSTGRES_PASSWORD:-skg_marketing_local_password}@postgres:5432/${POSTGRES_DB:-skg_marketing_local}
|
||||||
|
CORS_ORIGINS: http://localhost:${LOCAL_WEB_PORT:-4390},http://127.0.0.1:${LOCAL_WEB_PORT:-4390}
|
||||||
|
PASSWORD_AUTH_ENABLED: ${PASSWORD_AUTH_ENABLED:-true}
|
||||||
|
WEB_AUTH_USERNAME: ${WEB_AUTH_USERNAME:-skg}
|
||||||
|
WEB_AUTH_PASSWORD: ${WEB_AUTH_PASSWORD:-local-skg}
|
||||||
|
WEB_AUTH_SESSION_SECRET: ${WEB_AUTH_SESSION_SECRET:-local-docker-session-secret-change-me}
|
||||||
|
WEB_AUTH_COOKIE_SECURE: "false"
|
||||||
|
WEB_AUTH_COOKIE_NAME: ${WEB_AUTH_COOKIE_NAME:-skg_marketing_local_session}
|
||||||
|
AUTH_DATA_ISOLATION_ENABLED: "true"
|
||||||
|
FEISHU_REDIRECT_URI: http://localhost:${LOCAL_WEB_PORT:-4390}/api/auth/feishu/callback
|
||||||
|
KEYFRAME_COUNT: ${KEYFRAME_COUNT:-12}
|
||||||
|
VIDEO_QUEUE_MAX_CONCURRENT: ${VIDEO_QUEUE_MAX_CONCURRENT:-2}
|
||||||
|
VIDEO_QUEUE_MAX_CONCURRENT_PER_USER: ${VIDEO_QUEUE_MAX_CONCURRENT_PER_USER:-1}
|
||||||
|
YTDLP_COOKIES_FILE: ${YTDLP_COOKIES_FILE:-}
|
||||||
|
YTDLP_COOKIES_FROM_BROWSER: ${YTDLP_COOKIES_FROM_BROWSER:-}
|
||||||
|
volumes:
|
||||||
|
- ./data-local/jobs:/data/jobs
|
||||||
|
- ./data-local/agent_runs:/data/agent_runs
|
||||||
|
- ./data-local/asset_library:/data/asset_library
|
||||||
|
- ./data-local/prompt_library:/data/prompt_library
|
||||||
|
- ./data-local/_trash:/data/_trash
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${LOCAL_API_PORT:-4391}:4291"
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
skg-marketing-local:
|
||||||
|
aliases:
|
||||||
|
- skg-marketing-api
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: skg-marketing-local-web:latest
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.web
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_API_BASE: /api
|
||||||
|
container_name: skg-marketing-local-web
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${LOCAL_WEB_PORT:-4390}:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- skg-marketing-local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
skg-marketing-local:
|
||||||
|
name: skg-marketing-local
|
||||||
@@ -1,6 +1,24 @@
|
|||||||
name: skg-marketing-studio
|
name: skg-marketing-studio
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: skg-marketing-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-skg_marketing}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-skg_marketing}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
|
volumes:
|
||||||
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- skg-marketing-internal
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skg_marketing} -d ${POSTGRES_DB:-skg_marketing}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -10,10 +28,22 @@ services:
|
|||||||
- ./deploy/.env.production
|
- ./deploy/.env.production
|
||||||
environment:
|
environment:
|
||||||
JOBS_DIR: /data/jobs
|
JOBS_DIR: /data/jobs
|
||||||
|
AGENT_RUNS_DIR: /data/agent_runs
|
||||||
|
ASSET_LIBRARY_DIR: /data/asset_library
|
||||||
|
PROMPT_LIBRARY_DIR: /data/prompt_library
|
||||||
|
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
|
||||||
CORS_ORIGINS: https://marketing.skg.com
|
CORS_ORIGINS: https://marketing.skg.com
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/jobs:/data/jobs
|
- ./data/jobs:/data/jobs
|
||||||
|
- ./data/agent_runs:/data/agent_runs
|
||||||
|
- ./data/asset_library:/data/asset_library
|
||||||
|
- ./data/prompt_library:/data/prompt_library
|
||||||
|
- ./data/_trash:/data/_trash
|
||||||
|
- ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- skg-marketing-internal
|
- skg-marketing-internal
|
||||||
|
|
||||||
|
|||||||
69
docker-compose.standalone.yml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
name: skg-agent-cut
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: skg-agent-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-skg_marketing}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-skg_marketing}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
|
volumes:
|
||||||
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- skg-agent-internal
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skg_marketing} -d ${POSTGRES_DB:-skg_marketing}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.api
|
||||||
|
container_name: skg-agent-api
|
||||||
|
env_file:
|
||||||
|
- ./deploy/.env.production
|
||||||
|
environment:
|
||||||
|
JOBS_DIR: /data/jobs
|
||||||
|
AGENT_RUNS_DIR: /data/agent_runs
|
||||||
|
ASSET_LIBRARY_DIR: /data/asset_library
|
||||||
|
PROMPT_LIBRARY_DIR: /data/prompt_library
|
||||||
|
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
|
||||||
|
CORS_ORIGINS: http://2.24.28.41:4290,http://localhost:4290
|
||||||
|
volumes:
|
||||||
|
- ./data/jobs:/data/jobs
|
||||||
|
- ./data/agent_runs:/data/agent_runs
|
||||||
|
- ./data/asset_library:/data/asset_library
|
||||||
|
- ./data/prompt_library:/data/prompt_library
|
||||||
|
- ./data/_trash:/data/_trash
|
||||||
|
- ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
skg-agent-internal:
|
||||||
|
aliases:
|
||||||
|
- skg-marketing-api
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.web
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_API_BASE: /api
|
||||||
|
container_name: skg-agent-web
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:4290:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- skg-agent-internal
|
||||||
|
|
||||||
|
networks:
|
||||||
|
skg-agent-internal:
|
||||||
|
name: skg-agent-internal
|
||||||
146
docs/SKG营销内容生产平台使用说明-发布版.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# SKG 营销内容生产平台使用说明
|
||||||
|
|
||||||
|
适用入口:`https://marketing.skg.com`
|
||||||
|
|
||||||
|
本平台用于快速生成营销图片和短视频素材。当前主要支持三类工作:文生图、图生视频、以及把多个节点串成一个可复用的创作流程。
|
||||||
|
|
||||||
|
## 1. 登录与项目
|
||||||
|
|
||||||
|
1. 打开 `https://marketing.skg.com`。
|
||||||
|
2. 使用飞书授权登录。
|
||||||
|
3. 登录后进入个人画布。每个人只能看到自己的项目、生成记录和工作流模板。
|
||||||
|
4. 可以直接在当前画布开始,也可以打开已有项目继续编辑。
|
||||||
|
|
||||||
|
建议一个主题建立一个项目,例如“新品主图测试”“K5 视频首帧”“直播间背景图”。这样后续查找和复用更清楚。
|
||||||
|
|
||||||
|
## 2. 文生图
|
||||||
|
|
||||||
|
1. 点击左侧或底部工具里的“文生图”。
|
||||||
|
2. 在节点里输入画面描述。
|
||||||
|
3. 选择图片模型和尺寸。
|
||||||
|
4. 点击“立即生成”。
|
||||||
|
5. 生成完成后,可以预览、继续接到视频节点,或批量下载。
|
||||||
|
|
||||||
|
当前推荐:
|
||||||
|
|
||||||
|
- 默认生图模型:Seedream 4.5
|
||||||
|
- 常用尺寸:
|
||||||
|
- 方图:适合产品主图、素材图、头像类构图
|
||||||
|
- 竖图:适合后续生成竖屏短视频首帧
|
||||||
|
- 横图:适合封面、官网或详情页横版素材
|
||||||
|
|
||||||
|
如果准备继续做竖屏视频,建议优先生成竖图。方图也可以接视频,但生成竖屏视频时可能出现上下留黑边。
|
||||||
|
|
||||||
|
## 3. 图生视频
|
||||||
|
|
||||||
|
1. 先生成或上传一张首帧图。
|
||||||
|
2. 添加“视频生成”节点。
|
||||||
|
3. 把图片节点连接到视频节点,作为首帧。
|
||||||
|
4. 在视频节点输入运动描述,例如镜头推进、产品旋转、光线扫过、人物动作等。
|
||||||
|
5. 选择视频模型、画幅、时长和清晰度。
|
||||||
|
6. 点击生成,等待视频状态完成。
|
||||||
|
|
||||||
|
当前推荐:
|
||||||
|
|
||||||
|
- 快速测试:Seedance 2.0 Fast,720p,5 秒
|
||||||
|
- 需要更高清:Seedance 2.0 高清,1080p
|
||||||
|
- 竖屏短视频:选择 9:16
|
||||||
|
- 横版展示:选择 16:9
|
||||||
|
- 方形素材:选择 1:1
|
||||||
|
|
||||||
|
视频生成通常需要等待几分钟。生成中可以继续编辑其他节点,但不要反复重复点击同一个生成按钮。
|
||||||
|
|
||||||
|
## 4. 模型选择建议
|
||||||
|
|
||||||
|
生图:
|
||||||
|
|
||||||
|
- Seedream 4.5:默认推荐,适合高分辨率产品图、营销图和首帧图。
|
||||||
|
- GPT Image 2:适合需要更强语义理解、复杂画面描述的生图。
|
||||||
|
- Gemini 图片:适合部分风格化、补充尝试或备用生成。
|
||||||
|
|
||||||
|
生视频:
|
||||||
|
|
||||||
|
- Seedance 2.0 Fast:适合快速出片和批量测试,支持 480p / 720p。
|
||||||
|
- Seedance 2.0 高清:适合正式素材或更清晰视频,支持 1080p。
|
||||||
|
|
||||||
|
注意:2K / 4K 是当前生图能力,不等于视频也能生成 2K / 4K。视频清晰度以页面可选项为准。
|
||||||
|
|
||||||
|
## 5. 写提示词的方式
|
||||||
|
|
||||||
|
可以直接用中文输入。系统会尽量把生成请求整理成更适合模型理解的英文提示词。
|
||||||
|
|
||||||
|
建议写清楚这几项:
|
||||||
|
|
||||||
|
- 主体:是什么产品、人物、场景或物体
|
||||||
|
- 构图:特写、全景、居中、俯拍、侧面等
|
||||||
|
- 风格:电商白底、棚拍、写实广告、生活方式、科技感等
|
||||||
|
- 动作:旋转、推进、拉远、手拿起、光线扫过等
|
||||||
|
- 排除项:不要文字、不要水印、不要 logo、不要变形等
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
一张白色颈部按摩仪的高端产品主图,浅灰色棚拍背景,产品悬浮在桌面上方,柔和阴影,干净商业摄影风格,不要文字,不要水印。
|
||||||
|
```
|
||||||
|
|
||||||
|
图生视频示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
以首帧为起点,镜头缓慢推进,产品轻微旋转,柔和光线从左到右扫过,保持产品结构稳定,不要文字,不要水印。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 保存与复用
|
||||||
|
|
||||||
|
- 画布项目会保存到账号下,刷新页面后仍可继续编辑。
|
||||||
|
- 常用节点组合可以保存为“我的工作流”。
|
||||||
|
- 团队共用模板可以从“公共工作流”里插入。
|
||||||
|
- 生成结果可以在节点里预览,也可以批量下载。
|
||||||
|
|
||||||
|
建议把稳定流程保存成工作流,例如:
|
||||||
|
|
||||||
|
- 产品主图生成
|
||||||
|
- 首帧图生视频
|
||||||
|
- 多角度产品图
|
||||||
|
- 社媒竖屏短视频
|
||||||
|
|
||||||
|
## 7. 常见问题
|
||||||
|
|
||||||
|
### 生成按钮点了没有立刻出结果
|
||||||
|
|
||||||
|
图片和视频都需要调用外部模型。图片通常较快,视频通常需要几分钟。视频生成中会显示排队或生成状态。
|
||||||
|
|
||||||
|
### 视频生成失败
|
||||||
|
|
||||||
|
常见原因包括参考图不适合、人物脸部过清晰、提示词冲突、上游模型临时繁忙。可以尝试:
|
||||||
|
|
||||||
|
- 换一张更清晰、主体更稳定的首帧
|
||||||
|
- 简化提示词
|
||||||
|
- 避免真实人物或明显人脸
|
||||||
|
- 重新生成首帧后再生成视频
|
||||||
|
|
||||||
|
### 生成视频有黑边
|
||||||
|
|
||||||
|
通常是首帧比例和视频画幅不一致导致。比如方图接 9:16 视频,就可能出现上下黑边。建议:
|
||||||
|
|
||||||
|
- 做竖屏视频前先生成竖图首帧
|
||||||
|
- 或者把视频画幅改成 1:1
|
||||||
|
|
||||||
|
### 刷新后内容不见了
|
||||||
|
|
||||||
|
正常情况下项目会保存到账号下。如果刷新后看不到,先确认是否登录了同一个飞书账号,以及是否打开了同一个项目链接。
|
||||||
|
|
||||||
|
### 可以上传自己的图片吗
|
||||||
|
|
||||||
|
可以。上传图片后可以作为参考图、首帧或素材继续生成。但建议使用清晰、无水印、主体明确的图片。
|
||||||
|
|
||||||
|
## 8. 发布前建议
|
||||||
|
|
||||||
|
首次发布时,建议团队先按下面顺序试用:
|
||||||
|
|
||||||
|
1. 用文生图生成一张产品主图。
|
||||||
|
2. 把生成图接到视频节点生成 5 秒短视频。
|
||||||
|
3. 尝试保存当前画布。
|
||||||
|
4. 刷新页面,确认项目和结果仍在。
|
||||||
|
5. 下载图片和视频素材。
|
||||||
|
|
||||||
|
如果以上流程都正常,就可以开始用于日常营销素材探索。
|
||||||
BIN
docs/SKG营销内容生产平台操作指南.pdf
Normal file
76
docs/pre-redesign-backup-20260524.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# 2026-05-24 Pre-Redesign Backup
|
||||||
|
|
||||||
|
This record was created before the full platform redesign that replaces the old video-recreation-first product flow.
|
||||||
|
|
||||||
|
## Code Restore Point
|
||||||
|
|
||||||
|
- Branch at backup time: `main`
|
||||||
|
- Commit at backup time: `04a822ac7903aa249b491c68c80b3cd98d88ae85`
|
||||||
|
- Remote: `ssh://git@git.kang-kang.com:22222/kangwan/20260512-skg-tk.git`
|
||||||
|
- Pushed tag: `backup/pre-redesign-20260524-012047`
|
||||||
|
|
||||||
|
Restore tracked code to the exact pre-redesign point:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin --tags
|
||||||
|
git reset --hard backup/pre-redesign-20260524-012047
|
||||||
|
```
|
||||||
|
|
||||||
|
Only run the reset when intentionally replacing the current working tree.
|
||||||
|
|
||||||
|
## Local Data Snapshot
|
||||||
|
|
||||||
|
Backup directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.backups/pre-redesign-20260524-012047/
|
||||||
|
```
|
||||||
|
|
||||||
|
Snapshot contents:
|
||||||
|
|
||||||
|
- `api-jobs/` from `api/jobs/`
|
||||||
|
- `api-product_library/` from `api/product_library/`
|
||||||
|
- `api-character_library/` from `api/character_library/`
|
||||||
|
- `api-asset_library/` from `api/asset_library/`
|
||||||
|
- `api-prompt_library/` from `api/prompt_library/`
|
||||||
|
- `asset_library/` from `asset_library/`
|
||||||
|
- `prompt_library/` from `prompt_library/`
|
||||||
|
- `jobs/` from `jobs/`
|
||||||
|
- `_trash/` from `_trash/`
|
||||||
|
- `output/` from `output/`
|
||||||
|
- `source-04a822ac7903.tgz`
|
||||||
|
- `dirty-worktree.patch`
|
||||||
|
|
||||||
|
Verification at backup time:
|
||||||
|
|
||||||
|
- Full backup size: `328M`
|
||||||
|
- `api/jobs` source task directories: `12`
|
||||||
|
- `api-jobs` backup task directories: `12`
|
||||||
|
- `api-jobs` backup size: `132M`
|
||||||
|
- `api-product_library` backup size: `9.6M`
|
||||||
|
- `api-character_library` backup size: `91M`
|
||||||
|
|
||||||
|
## Data Restore By Overwrite
|
||||||
|
|
||||||
|
Use `ditto` to copy the backed up data over the current runtime data directories:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ditto .backups/pre-redesign-20260524-012047/api-jobs api/jobs
|
||||||
|
ditto .backups/pre-redesign-20260524-012047/api-product_library api/product_library
|
||||||
|
ditto .backups/pre-redesign-20260524-012047/api-character_library api/character_library
|
||||||
|
ditto .backups/pre-redesign-20260524-012047/api-asset_library api/asset_library
|
||||||
|
ditto .backups/pre-redesign-20260524-012047/api-prompt_library api/prompt_library
|
||||||
|
ditto .backups/pre-redesign-20260524-012047/asset_library asset_library
|
||||||
|
ditto .backups/pre-redesign-20260524-012047/prompt_library prompt_library
|
||||||
|
ditto .backups/pre-redesign-20260524-012047/jobs jobs
|
||||||
|
ditto .backups/pre-redesign-20260524-012047/_trash _trash
|
||||||
|
ditto .backups/pre-redesign-20260524-012047/output output
|
||||||
|
```
|
||||||
|
|
||||||
|
`ditto` overwrites matching files but does not delete extra files that were created after the backup. If an exact replacement is required later, remove or move the target directory first, then run the matching `ditto` command.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `.backups/` is ignored by Git and remains local.
|
||||||
|
- The pushed Git tag protects the source-code restore point even if the local `.backups/` folder is moved.
|
||||||
|
- The only dirty working-tree change at backup time was `.memory/worklog.json`; its diff was saved as `dirty-worktree.patch`.
|
||||||
BIN
docs/user-guide-assets/01-home.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/user-guide-assets/02-canvas-overview.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
docs/user-guide-assets/03-node-menu.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
docs/user-guide-assets/04-workflows-public.png
Normal file
|
After Width: | Height: | Size: 322 KiB |
BIN
docs/user-guide-assets/05-workflows-my.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/user-guide-assets/06-api-settings.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
docs/user-guide-assets/07-download-modal.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
639
docs/user-guide.html
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>SKG 营销内容生产平台操作指南</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f6f8fb;
|
||||||
|
--paper: #ffffff;
|
||||||
|
--ink: #111827;
|
||||||
|
--muted: #5f6b7a;
|
||||||
|
--line: #dde4ee;
|
||||||
|
--brand: #08a6a6;
|
||||||
|
--brand-dark: #087f82;
|
||||||
|
--soft: #eefafa;
|
||||||
|
--warn: #fff7ed;
|
||||||
|
--warn-line: #fed7aa;
|
||||||
|
--danger: #fff1f2;
|
||||||
|
--danger-line: #fecdd3;
|
||||||
|
--code: #f3f6fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--bg);
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
max-width: 1160px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 34px 36px;
|
||||||
|
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--brand-dark);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4 { line-height: 1.35; letter-spacing: 0; }
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 44px 0 16px;
|
||||||
|
font-size: 25px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 28px 0 10px;
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
margin: 20px 0 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p { margin: 8px 0 12px; }
|
||||||
|
a { color: var(--brand-dark); text-decoration: none; }
|
||||||
|
code {
|
||||||
|
background: var(--code);
|
||||||
|
border: 1px solid #e6ebf2;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 14px;
|
||||||
|
max-width: 860px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin: 18px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--paper);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px 18px;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: var(--paper);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 28px 32px;
|
||||||
|
margin-top: 24px;
|
||||||
|
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #f9fbfd;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
.notice.good {
|
||||||
|
background: var(--soft);
|
||||||
|
border-color: #b8eeee;
|
||||||
|
}
|
||||||
|
.notice.warn {
|
||||||
|
background: var(--warn);
|
||||||
|
border-color: var(--warn-line);
|
||||||
|
}
|
||||||
|
.notice.danger {
|
||||||
|
background: var(--danger);
|
||||||
|
border-color: var(--danger-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
counter-reset: step;
|
||||||
|
padding-left: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.steps li {
|
||||||
|
counter-increment: step;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 44px;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
.steps li::before {
|
||||||
|
content: counter(step);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 1px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--brand);
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol { padding-left: 22px; }
|
||||||
|
li { margin: 6px 0; }
|
||||||
|
|
||||||
|
figure {
|
||||||
|
margin: 22px 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
figure img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
figcaption {
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: #fbfdff;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 16px 0;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 11px 12px;
|
||||||
|
vertical-align: top;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
width: 22%;
|
||||||
|
background: #f7fafc;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
tr:last-child th,
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbd {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 24px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.shell { padding: 18px 12px 48px; }
|
||||||
|
.hero, .section { padding: 22px 18px; border-radius: 14px; }
|
||||||
|
h1 { font-size: 27px; }
|
||||||
|
h2 { font-size: 22px; }
|
||||||
|
.grid, .toc { grid-template-columns: 1fr; }
|
||||||
|
th, td { display: block; width: 100%; }
|
||||||
|
th { border-bottom: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 8mm;
|
||||||
|
}
|
||||||
|
body { background: #fff; }
|
||||||
|
.shell { max-width: none; padding: 0; }
|
||||||
|
.hero, .section {
|
||||||
|
box-shadow: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
h1 { font-size: 23px; }
|
||||||
|
h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 9px 0 4px;
|
||||||
|
}
|
||||||
|
p, li, td, th {
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1.38;
|
||||||
|
}
|
||||||
|
.meta { margin-top: 8px; }
|
||||||
|
.grid {
|
||||||
|
gap: 8px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1.38;
|
||||||
|
}
|
||||||
|
.card strong {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.toc {
|
||||||
|
gap: 2px 14px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin: 8px 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1.38;
|
||||||
|
}
|
||||||
|
.steps li {
|
||||||
|
padding-left: 30px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.steps li::before {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
table { margin: 9px 0; border-radius: 8px; }
|
||||||
|
th, td { padding: 6px 8px; }
|
||||||
|
figure {
|
||||||
|
box-shadow: none;
|
||||||
|
break-inside: avoid;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 5px auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
figure img {
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 62mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
figcaption {
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
a { color: #111827; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<header class="hero">
|
||||||
|
<div class="eyebrow">SKG 内部使用</div>
|
||||||
|
<h1>营销内容生产平台操作指南</h1>
|
||||||
|
<p class="meta">适用于使用 <a href="https://marketing.skg.com">https://marketing.skg.com</a> 进行文生图、图生图、文生视频、图生视频、工作流模板和素材沉淀的员工。本文截图为 2026-05-26 明亮模式界面,生产登录方式以飞书免登录为准。</p>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<strong>入口</strong>
|
||||||
|
线上访问 <code>https://marketing.skg.com</code>,从飞书授权进入。员工不需要配置个人 API Key。
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<strong>核心用途</strong>
|
||||||
|
在个人画布里组织提示词、参考图、图片生成、视频生成和可复用工作流。
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<strong>数据归属</strong>
|
||||||
|
项目、任务、素材和“我的工作流”按当前登录账号隔离,同一飞书账号可在多台设备看到自己的内容。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>目录</h2>
|
||||||
|
<ol class="toc">
|
||||||
|
<li><a href="#login">登录与账号</a></li>
|
||||||
|
<li><a href="#home">首页与项目</a></li>
|
||||||
|
<li><a href="#canvas">画布基础操作</a></li>
|
||||||
|
<li><a href="#nodes">节点功能说明</a></li>
|
||||||
|
<li><a href="#workflow">公共工作流与我的工作流</a></li>
|
||||||
|
<li><a href="#generate">常用生成流程</a></li>
|
||||||
|
<li><a href="#assets">素材下载与沉淀</a></li>
|
||||||
|
<li><a href="#prompt">提示词写法</a></li>
|
||||||
|
<li><a href="#errors">常见问题与报错处理</a></li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="login" class="section">
|
||||||
|
<h2>1. 登录与账号</h2>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>打开 <code>https://marketing.skg.com</code>。</li>
|
||||||
|
<li>点击登录页上的“飞书免登录”,或在飞书内打开时按页面提示授权。</li>
|
||||||
|
<li>授权成功后会进入个人画布首页。后续同一飞书账号在电脑、手机或其他浏览器登录,会看到自己的项目和工作流。</li>
|
||||||
|
</ol>
|
||||||
|
<div class="notice good">
|
||||||
|
<strong>账号数据隔离:</strong>每个人只能看到自己账号下创建的项目、生成任务、素材和“我的工作流”。这不是浏览器缓存隔离,而是服务端按登录用户归属过滤。
|
||||||
|
</div>
|
||||||
|
<div class="notice warn">
|
||||||
|
<strong>手机访问提示:</strong>如果手机在公司 Wi-Fi 下显示“无法加载网页”或类似错误,先切换到个人网络或用手机浏览器打开同一地址;如果电脑正常而手机飞书内异常,多半是当前网络或飞书内置浏览器限制。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="home" class="section">
|
||||||
|
<h2>2. 首页与项目</h2>
|
||||||
|
<p>登录后默认进入首页。这里可以快速创建项目,也可以打开已有项目继续编辑。</p>
|
||||||
|
<figure>
|
||||||
|
<img src="user-guide-assets/01-home.png" alt="首页与我的项目列表" />
|
||||||
|
<figcaption>首页:顶部是创意输入框和推荐词,下面是“我的项目”。</figcaption>
|
||||||
|
</figure>
|
||||||
|
<h3>首页主要区域</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>创意输入框</th><td>输入一句需求后点击发送,会创建一个新项目并进入画布。适合从一个想法快速开始。</td></tr>
|
||||||
|
<tr><th>推荐词</th><td>点击推荐词可快速填入输入框;右侧刷新按钮会换一组推荐。</td></tr>
|
||||||
|
<tr><th>新建项目</th><td>点击“新建项目”会创建空白项目,适合手动搭节点。</td></tr>
|
||||||
|
<tr><th>我的项目</th><td>点击项目卡片进入画布。项目会按更新时间排序。</td></tr>
|
||||||
|
<tr><th>项目菜单</th><td>项目卡片右上角菜单支持重命名、复制和删除。</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="notice">
|
||||||
|
建议每个主题单独建一个项目,例如“新品主图测试”“母亲节短视频”“达人口播改图”。这样后续查找和复用更清楚。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="canvas" class="section">
|
||||||
|
<h2>3. 画布基础操作</h2>
|
||||||
|
<p>画布是主要工作区。你可以把提示词、参考图、文生图、视频生成等能力连接起来,让图片和视频从上一步结果继续往下生成。</p>
|
||||||
|
<figure>
|
||||||
|
<img src="user-guide-assets/02-canvas-overview.png" alt="画布总览" />
|
||||||
|
<figcaption>画布总览:左侧工具栏添加节点,中间是节点和连线,底部是对话式输入区。</figcaption>
|
||||||
|
</figure>
|
||||||
|
<h3>顶部栏</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>返回</th><td>左上角箭头返回首页。</td></tr>
|
||||||
|
<tr><th>项目名称</th><td>点击项目名旁边的小箭头,可重命名、复制或删除当前项目。</td></tr>
|
||||||
|
<tr><th>明暗模式</th><td>右上角月亮/太阳图标切换明亮模式或暗色模式。</td></tr>
|
||||||
|
<tr><th>素材下载</th><td>右上角下载图标打开当前画布素材列表。</td></tr>
|
||||||
|
<tr><th>API 设置</th><td>右上角齿轮查看内部接口、模型配置和端点。普通员工一般不需要改。</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>左侧工具栏</h3>
|
||||||
|
<figure>
|
||||||
|
<img src="user-guide-assets/03-node-menu.png" alt="添加节点菜单" />
|
||||||
|
<figcaption>添加节点菜单:可以手动添加文本、LLM、文生图、视频生成、图片和视频节点。</figcaption>
|
||||||
|
</figure>
|
||||||
|
<table>
|
||||||
|
<tr><th>加号</th><td>打开节点菜单,手动添加任意节点。</td></tr>
|
||||||
|
<tr><th>九宫格</th><td>打开工作流模板面板。</td></tr>
|
||||||
|
<tr><th>文本</th><td>快速添加文本节点。</td></tr>
|
||||||
|
<tr><th>图片</th><td>快速添加图片节点,用于上传参考图或承接生成图。</td></tr>
|
||||||
|
<tr><th>文生图</th><td>快速添加文生图配置节点。</td></tr>
|
||||||
|
<tr><th>视频</th><td>快速添加视频生成配置节点。</td></tr>
|
||||||
|
<tr><th>撤销/重做</th><td>回退或恢复最近的画布编辑。</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>底部输入区</h3>
|
||||||
|
<ul>
|
||||||
|
<li>在输入框里写需求,点击发送按钮创建节点或执行工作流。</li>
|
||||||
|
<li>开启“自动执行”后,系统会先判断你的意图,再自动创建并启动合适的工作流。</li>
|
||||||
|
<li>关闭“自动执行”时,输入内容更偏向生成文本节点,方便你手动连接。</li>
|
||||||
|
<li>点击“AI 润色”可把提示词优化成更适合上游模型的专业英文提示词。</li>
|
||||||
|
<li>键盘操作:<span class="kbd">Enter</span> 发送,<span class="kbd">Ctrl</span> + <span class="kbd">Enter</span> 也可作为发送习惯使用。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="nodes" class="section">
|
||||||
|
<h2>4. 节点功能说明</h2>
|
||||||
|
<p>节点之间通过左右两侧的小连接点相连。通常从左侧节点拖到右侧节点,表示“把左边的内容作为右边生成的输入”。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>文本节点</th><td>存放提示词、脚本、镜头描述或要求。可点击“AI 润色”优化文本。</td></tr>
|
||||||
|
<tr><th>LLM 文本生成</th><td>用于先生成文案、分镜描述或提示词,再把输出连接给文生图或视频节点。</td></tr>
|
||||||
|
<tr><th>文生图配置</th><td>选择图片模型、尺寸和质量,接收文本/参考图后点击“立即生成”。当前常用尺寸为 <code>auto</code>、<code>1024x1536</code>、<code>1024x1024</code>、<code>1536x1024</code>,质量为标准。</td></tr>
|
||||||
|
<tr><th>图片节点</th><td>可上传图片、粘贴图片地址,或承接生成结果。图片节点可以作为图生图参考,也可以作为视频首帧、尾帧或参考图。</td></tr>
|
||||||
|
<tr><th>视频生成配置</th><td>选择视频模型、比例、时长,接收提示词和图片后生成视频。常用比例包括竖屏、横屏、方形和 3:4;时长以页面可选项为准。</td></tr>
|
||||||
|
<tr><th>视频节点</th><td>承接生成后的视频结果,后续可预览和下载。</td></tr>
|
||||||
|
</table>
|
||||||
|
<h3>连线规则</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>文本 → 文生图:</strong>文本内容作为生图提示词。</li>
|
||||||
|
<li><strong>图片 → 文生图:</strong>图片作为图生图或参考图。</li>
|
||||||
|
<li><strong>文本 → 视频生成:</strong>文本内容作为视频动作、镜头和场景说明。</li>
|
||||||
|
<li><strong>图片 → 视频生成:</strong>默认作为首帧,可在线上边的小标签中切换为尾帧或参考图。</li>
|
||||||
|
<li><strong>LLM → 文生图/视频生成:</strong>先让 LLM 产出提示词,再继续生成图片或视频。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="workflow" class="section">
|
||||||
|
<h2>5. 公共工作流与我的工作流</h2>
|
||||||
|
<p>工作流模板适合重复使用的创作链路。点击左侧九宫格图标打开工作流面板。</p>
|
||||||
|
<figure>
|
||||||
|
<img src="user-guide-assets/04-workflows-public.png" alt="公共工作流" />
|
||||||
|
<figcaption>公共工作流:点击任意模板,会把一组节点插入当前画布。</figcaption>
|
||||||
|
</figure>
|
||||||
|
<h3>公共工作流</h3>
|
||||||
|
<ul>
|
||||||
|
<li>适合多人共用的标准链路,例如多角度分镜、产品套图、角色设计、场景背景、绘本等。</li>
|
||||||
|
<li>点击模板后,系统会把节点组放到当前画布中,你再填写提示词、上传素材或调整配置。</li>
|
||||||
|
<li>公共工作流不会覆盖当前画布,只会新增一组节点。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<img src="user-guide-assets/05-workflows-my.png" alt="我的工作流" />
|
||||||
|
<figcaption>我的工作流:可以保存当前画布结构,在同一账号的其他设备上复用。</figcaption>
|
||||||
|
</figure>
|
||||||
|
<h3>我的工作流</h3>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>在画布中搭好一套常用节点结构。</li>
|
||||||
|
<li>打开工作流面板,切换到“我的工作流”。</li>
|
||||||
|
<li>点击“保存当前”,填写名称并保存。</li>
|
||||||
|
<li>以后打开同一账号时,可在“我的工作流”里点击模板插回画布。</li>
|
||||||
|
</ol>
|
||||||
|
<div class="notice good">
|
||||||
|
“我的工作流”保存的是节点结构、连线、配置和提示词,不保存一次性生成出来的图片、视频、进度和错误。这样模板不会被旧结果污染。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="generate" class="section">
|
||||||
|
<h2>6. 常用生成流程</h2>
|
||||||
|
|
||||||
|
<h3>流程 A:快速文生图</h3>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>在首页或画布底部输入需求,例如“白底产品主图,颈部按摩仪悬浮展示,柔和自然光”。</li>
|
||||||
|
<li>需要更专业时先点“AI 润色”。如果不希望系统改太多,就在原提示词里写清楚“保持主体和构图”。</li>
|
||||||
|
<li>开启“自动执行”,点击发送。</li>
|
||||||
|
<li>系统会创建文生图节点并开始生成。完成后图片会回填到画布。</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>流程 B:手动文生图</h3>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>点击左侧加号,添加“文本节点”和“文生图配置”。</li>
|
||||||
|
<li>在文本节点写提示词。</li>
|
||||||
|
<li>从文本节点右侧连接点拖到文生图节点左侧连接点。</li>
|
||||||
|
<li>在文生图节点里选择模型和尺寸,点击“立即生成”。</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>流程 C:图生视频</h3>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>添加“图片节点”,上传或粘贴一张参考图。</li>
|
||||||
|
<li>添加“视频生成配置”节点。</li>
|
||||||
|
<li>从图片节点连接到视频生成节点。默认角色为“首帧”,需要时可切换为“尾帧”或“参考图”。</li>
|
||||||
|
<li>在视频节点提示词里写动作和镜头,例如“产品缓慢旋转,镜头轻推近,背景干净”。</li>
|
||||||
|
<li>选择比例和时长后点击“生成视频”。</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>流程 D:一张图继续改图</h3>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>把参考图放进图片节点。</li>
|
||||||
|
<li>连接到文生图配置节点。</li>
|
||||||
|
<li>在文本节点或文生图节点里写明“保留哪些内容、调整哪些内容”。</li>
|
||||||
|
<li>点击“立即生成”。</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>流程 E:用工作流做复杂任务</h3>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>打开“公共工作流”,选择接近目标的模板。</li>
|
||||||
|
<li>按模板节点提示补充角色、产品、场景、分镜或参考图。</li>
|
||||||
|
<li>逐个点击节点里的生成按钮,或用底部自动执行辅助创建链路。</li>
|
||||||
|
<li>得到满意结构后,切到“我的工作流”保存,后续复用。</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="assets" class="section">
|
||||||
|
<h2>7. 素材下载与沉淀</h2>
|
||||||
|
<p>生成出的图片和视频会保存在当前账号自己的项目里。当前画布里有可下载素材时,右上角下载图标会高亮。</p>
|
||||||
|
<figure>
|
||||||
|
<img src="user-guide-assets/07-download-modal.png" alt="素材下载弹窗" />
|
||||||
|
<figcaption>素材下载:会汇总当前画布里可下载的图片和视频。</figcaption>
|
||||||
|
</figure>
|
||||||
|
<ul>
|
||||||
|
<li>点击右上角下载图标打开“素材下载”。</li>
|
||||||
|
<li>图片会以缩略图展示,点击可打开原图。</li>
|
||||||
|
<li>视频会以列表展示,点击可打开视频链接。</li>
|
||||||
|
<li>如果显示“暂无可下载的素材”,说明当前画布还没有完成的图片或视频结果。</li>
|
||||||
|
</ul>
|
||||||
|
<div class="notice">
|
||||||
|
数据库存的是项目、任务、索引和归属信息;生成的大图片、视频文件仍作为媒体文件保存。你换电脑后能看到项目和结果,是因为服务端按账号把任务和素材索引关联起来。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>8. 模型与接口设置</h2>
|
||||||
|
<p>平台默认使用 SKG 内部生成接口。普通员工不需要填写 API Key,也不需要改 Base URL。</p>
|
||||||
|
<figure>
|
||||||
|
<img src="user-guide-assets/06-api-settings.png" alt="API 设置" />
|
||||||
|
<figcaption>API 设置:默认渠道为 SKG 内部,生成调用走当前登录会话。</figcaption>
|
||||||
|
</figure>
|
||||||
|
<table>
|
||||||
|
<tr><th>API 配置</th><td>查看当前渠道、Base URL、端点路径。一般保持默认即可。</td></tr>
|
||||||
|
<tr><th>模型配置</th><td>查看问答、图片、视频模型。不要随意添加不存在的模型名。</td></tr>
|
||||||
|
<tr><th>API Key</th><td>内部接口无需个人填写,页面提示“生成调用走当前登录会话”。</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="notice warn">
|
||||||
|
如果生成按钮可点但连续失败,不要自行改 API 设置。先看节点里的中文错误提示,再联系管理员排查上游模型、额度或风控。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="prompt" class="section">
|
||||||
|
<h2>9. 提示词写法</h2>
|
||||||
|
<h3>基本原则</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>主体明确:</strong>说明你要生成什么,例如“SKG 颈部按摩仪”“雨夜街头摊位”“年轻女性虚拟角色”。</li>
|
||||||
|
<li><strong>动作明确:</strong>视频要写动作,例如“缓慢旋转”“镜头从左向右滑动”“人物抬头看向镜头”。</li>
|
||||||
|
<li><strong>场景明确:</strong>写背景、光线、镜头、风格和画幅。</li>
|
||||||
|
<li><strong>不要期待自动加品牌:</strong>系统不会主动把所有内容套成 SKG 或按摩产品。如果需要 SKG、TikTok、产品卖点,必须自己写进提示词。</li>
|
||||||
|
<li><strong>中文可以输入:</strong>系统会在需要时做专业化润色,但越具体越稳定。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>推荐结构</h3>
|
||||||
|
<div class="notice">
|
||||||
|
主体 + 场景 + 动作/镜头 + 风格 + 画幅/用途 + 必须保留/禁止事项
|
||||||
|
</div>
|
||||||
|
<p>示例:<code>SKG 颈部按摩仪,白底电商主图,产品悬浮展示,柔和自然光,高级产品摄影,竖版 1024x1536,保留产品真实外观,不要出现人物。</code></p>
|
||||||
|
<p>示例:<code>AI 生成的虚拟女性角色站在明亮浴室中,手持护理产品,镜头缓慢推进,清爽自然光,短视频竖屏,人物为虚构角色,不对应任何真实人物。</code></p>
|
||||||
|
|
||||||
|
<h3>使用 AI 人像素材时</h3>
|
||||||
|
<p>平台允许你使用 AI 生成的人像素材继续做图生视频,但上游视频模型仍可能把清晰人脸误判为真实肖像或隐私信息。遇到这类报错时优先尝试:</p>
|
||||||
|
<ul>
|
||||||
|
<li>在提示词中写明“AI 生成的虚拟角色、非真人、非公众人物”。</li>
|
||||||
|
<li>降低人脸识别度,例如侧脸、远景、背影、卡通化、轻微遮挡。</li>
|
||||||
|
<li>裁切或模糊过于清晰的脸部,再作为首帧/参考图。</li>
|
||||||
|
<li>避免使用真实员工、明星、网红或公众人物照片。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="errors" class="section">
|
||||||
|
<h2>10. 常见问题与报错处理</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>看不到别人项目</th><td>这是正常的。平台按当前登录账号隔离数据,每个人默认只能看到自己的项目和生成结果。</td></tr>
|
||||||
|
<tr><th>换电脑后看不到内容</th><td>确认是否用同一个飞书账号登录。如果以前用过旧密码账号,需联系管理员确认历史内容是否已归属到飞书账号。</td></tr>
|
||||||
|
<tr><th>登录过期</th><td>重新从飞书进入或点击登录页授权。不要反复刷新生成中的页面。</td></tr>
|
||||||
|
<tr><th>视频一直排队</th><td>视频生成通常较慢,且同一用户可能限制并发。保持页面打开,等待状态更新。</td></tr>
|
||||||
|
<tr><th>图片生成超时</th><td>上游图片模型响应慢或不可用。缩短提示词、稍后重试,或联系管理员确认模型状态。</td></tr>
|
||||||
|
<tr><th>视频提示人脸/隐私风控</th><td>参考图里有清晰人脸或疑似真实人物。按“AI 人像素材”建议处理,再重新生成。</td></tr>
|
||||||
|
<tr><th>素材下载为空</th><td>当前画布没有完成的图片或视频节点。生成完成后再打开下载面板。</td></tr>
|
||||||
|
<tr><th>公司 Wi-Fi 手机打不开</th><td>先切个人网络或浏览器访问。如果电脑同网络正常、手机飞书内不正常,通常是移动端网络或飞书内置浏览器限制。</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>11. 发布给团队时的使用建议</h2>
|
||||||
|
<ul>
|
||||||
|
<li>建议先从“首页输入一句需求”开始,不熟悉节点的人不要一开始就搭复杂工作流。</li>
|
||||||
|
<li>每次生成前先确认项目名,避免把不同主题混在一个项目里。</li>
|
||||||
|
<li>满意的链路及时保存到“我的工作流”,下次直接复用。</li>
|
||||||
|
<li>提示词里不要默认省略主体、品牌和使用场景;系统不会替你自动脑补业务背景。</li>
|
||||||
|
<li>报错优先看页面中文提示,不要只截图问“为什么失败”。错误框里通常已经写了原因和改法。</li>
|
||||||
|
<li>不要上传真实敏感人脸、客户资料、未授权素材或公司外部不可公开资料。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
0
prompt_library/.gitkeep
Normal file
124
scripts/check-huobao-upstream.sh
Executable file
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
REPO_URL="${HUOBAO_WATCH_REPO_URL:-https://github.com/chatfire-AI/huobao-canvas}"
|
||||||
|
REF_NAME="${HUOBAO_WATCH_REF:-refs/heads/main}"
|
||||||
|
STATE_DIR="${HUOBAO_WATCH_STATE_DIR:-$ROOT_DIR/.logs/upstream-watch}"
|
||||||
|
LAST_SHA_FILE="$STATE_DIR/huobao-canvas.last-sha"
|
||||||
|
LAST_CHECK_FILE="$STATE_DIR/huobao-canvas.last-check"
|
||||||
|
LATEST_REPORT_FILE="$STATE_DIR/huobao-canvas.latest-update.md"
|
||||||
|
LOG_FILE="$STATE_DIR/huobao-canvas.watch.log"
|
||||||
|
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
|
||||||
|
timestamp() {
|
||||||
|
date "+%Y-%m-%d %H:%M:%S %z"
|
||||||
|
}
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf "%s %s\n" "$(timestamp)" "$*" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
notify() {
|
||||||
|
local title="$1"
|
||||||
|
local message="$2"
|
||||||
|
|
||||||
|
if [[ "${HUOBAO_WATCH_NOTIFY:-1}" != "1" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if ! command -v osascript >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
/usr/bin/osascript \
|
||||||
|
-e 'on run argv' \
|
||||||
|
-e 'display notification (item 2 of argv) with title (item 1 of argv)' \
|
||||||
|
-e 'end run' \
|
||||||
|
"$title" "$message" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
git_no_proxy() {
|
||||||
|
git -c http.proxy= -c https.proxy= "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
remote_line="$(git_no_proxy ls-remote "$REPO_URL" "$REF_NAME" | head -n 1 || true)"
|
||||||
|
if [[ -z "$remote_line" ]]; then
|
||||||
|
log "ERROR failed to query $REPO_URL $REF_NAME"
|
||||||
|
notify "huobao-canvas 检查失败" "无法读取 GitHub 上游,请看 $LOG_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
current_sha="$(awk '{print $1}' <<<"$remote_line")"
|
||||||
|
if [[ -z "$current_sha" ]]; then
|
||||||
|
log "ERROR empty sha from $REPO_URL $REF_NAME"
|
||||||
|
notify "huobao-canvas 检查失败" "上游返回空提交号,请看 $LOG_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
previous_sha=""
|
||||||
|
if [[ -f "$LAST_SHA_FILE" ]]; then
|
||||||
|
previous_sha="$(tr -d '[:space:]' < "$LAST_SHA_FILE")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "%s %s %s\n" "$(timestamp)" "$REPO_URL" "$current_sha" > "$LAST_CHECK_FILE"
|
||||||
|
|
||||||
|
if [[ -z "$previous_sha" ]]; then
|
||||||
|
printf "%s\n" "$current_sha" > "$LAST_SHA_FILE"
|
||||||
|
log "initialized huobao-canvas upstream watch at $current_sha"
|
||||||
|
if [[ "${HUOBAO_WATCH_NOTIFY_ON_INIT:-0}" == "1" ]]; then
|
||||||
|
notify "huobao-canvas 已开始关注" "当前 main: ${current_sha:0:7}"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$current_sha" == "$previous_sha" ]]; then
|
||||||
|
log "unchanged huobao-canvas main at ${current_sha:0:7}"
|
||||||
|
if [[ "${HUOBAO_WATCH_NOTIFY_UNCHANGED:-0}" == "1" ]]; then
|
||||||
|
notify "huobao-canvas 无更新" "当前 main 仍是 ${current_sha:0:7}"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/huobao-canvas-watch.XXXXXX")"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
repo_dir="$tmp_dir/repo"
|
||||||
|
git_no_proxy clone --depth=50 "$REPO_URL" "$repo_dir" >/dev/null 2>&1 || {
|
||||||
|
log "ERROR update detected but failed to clone $REPO_URL"
|
||||||
|
notify "huobao-canvas 有更新但拉取失败" "${previous_sha:0:7} -> ${current_sha:0:7},请看 $LOG_FILE"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
latest_subject="$(git -C "$repo_dir" log -1 --format=%s "$current_sha" 2>/dev/null || echo "unknown commit")"
|
||||||
|
latest_author="$(git -C "$repo_dir" log -1 --format=%an "$current_sha" 2>/dev/null || echo "unknown author")"
|
||||||
|
latest_date="$(git -C "$repo_dir" log -1 --date=format-local:"%Y-%m-%d %H:%M:%S %z" --format=%ad "$current_sha" 2>/dev/null || echo "unknown date")"
|
||||||
|
commit_list="$(git -C "$repo_dir" log --oneline --max-count=20 "$previous_sha..$current_sha" 2>/dev/null || git -C "$repo_dir" log -1 --oneline "$current_sha")"
|
||||||
|
compare_url="https://github.com/chatfire-AI/huobao-canvas/compare/$previous_sha...$current_sha"
|
||||||
|
|
||||||
|
cat > "$LATEST_REPORT_FILE" <<REPORT
|
||||||
|
# huobao-canvas upstream update
|
||||||
|
|
||||||
|
- Checked at: $(timestamp)
|
||||||
|
- Repository: $REPO_URL
|
||||||
|
- Ref: $REF_NAME
|
||||||
|
- Previous: $previous_sha
|
||||||
|
- Current: $current_sha
|
||||||
|
- Latest commit: ${current_sha:0:7} $latest_subject
|
||||||
|
- Author: $latest_author
|
||||||
|
- Commit date: $latest_date
|
||||||
|
- Compare: $compare_url
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
$commit_list
|
||||||
|
\`\`\`
|
||||||
|
REPORT
|
||||||
|
|
||||||
|
printf "%s\n" "$current_sha" > "$LAST_SHA_FILE"
|
||||||
|
log "UPDATED huobao-canvas ${previous_sha:0:7} -> ${current_sha:0:7}: $latest_subject"
|
||||||
|
notify "huobao-canvas 有更新" "${previous_sha:0:7} -> ${current_sha:0:7}: $latest_subject"
|
||||||
87
scripts/deploy-prod-safe.sh
Executable file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST="${HOST:-root@76.13.31.179}"
|
||||||
|
APP_DIR="${APP_DIR:-/opt/skg-marketing-studio}"
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/opt/skg-marketing-studio-backups}"
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--no-build" ]]; then
|
||||||
|
BUILD_FLAG=""
|
||||||
|
else
|
||||||
|
BUILD_FLAG="--build"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Preflight: creating remote data/env backup"
|
||||||
|
ssh "$HOST" "set -euo pipefail
|
||||||
|
cd '$APP_DIR'
|
||||||
|
mkdir -p '$BACKUP_DIR'
|
||||||
|
stamp=\$(date +%Y%m%d%H%M%S)
|
||||||
|
tar -czf '$BACKUP_DIR/skg-marketing-preserve-'\$stamp'.tgz' \
|
||||||
|
deploy/.env.production \
|
||||||
|
data/jobs \
|
||||||
|
data/asset_library \
|
||||||
|
data/prompt_library \
|
||||||
|
data/_trash \
|
||||||
|
secrets 2>/tmp/skg-backup-warnings.log || {
|
||||||
|
cat /tmp/skg-backup-warnings.log >&2 || true
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if docker ps --format '{{.Names}}' | grep -qx skg-marketing-postgres; then
|
||||||
|
docker exec skg-marketing-postgres sh -lc 'pg_dump -U "\$POSTGRES_USER" "\$POSTGRES_DB"' \
|
||||||
|
| gzip > '$BACKUP_DIR/skg-marketing-postgres-'\$stamp'.sql.gz'
|
||||||
|
fi
|
||||||
|
find '$BACKUP_DIR' -name 'skg-marketing-preserve-*.tgz' -type f -printf '%T@ %p\n' | sort -nr | tail -n +8 | cut -d' ' -f2- | xargs -r rm -f
|
||||||
|
find '$BACKUP_DIR' -name 'skg-marketing-postgres-*.sql.gz' -type f -printf '%T@ %p\n' | sort -nr | tail -n +8 | cut -d' ' -f2- | xargs -r rm -f
|
||||||
|
echo backup:\$(ls -t '$BACKUP_DIR'/skg-marketing-preserve-*.tgz | head -1)
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "==> Syncing code with production data protected"
|
||||||
|
rsync -az --delete \
|
||||||
|
--filter='P /data/***' \
|
||||||
|
--filter='P /jobs/***' \
|
||||||
|
--filter='P /secrets/***' \
|
||||||
|
--filter='P /deploy/.env.production' \
|
||||||
|
--filter='P /api/jobs/***' \
|
||||||
|
--filter='P /api/.env' \
|
||||||
|
--filter='P /api/.env.local' \
|
||||||
|
--filter='P /api/.env.production' \
|
||||||
|
--exclude='/.git/' \
|
||||||
|
--exclude='/.memory/' \
|
||||||
|
--exclude='/.backups/' \
|
||||||
|
--exclude='/.logs/' \
|
||||||
|
--exclude='/.pids/' \
|
||||||
|
--exclude='/.playwright-mcp/' \
|
||||||
|
--exclude='/.DS_Store' \
|
||||||
|
--exclude='*.log' \
|
||||||
|
--exclude='__pycache__/' \
|
||||||
|
--exclude='*.pyc' \
|
||||||
|
--exclude='/data/' \
|
||||||
|
--exclude='/data-local/' \
|
||||||
|
--exclude='/jobs/' \
|
||||||
|
--exclude='/output/' \
|
||||||
|
--exclude='/secrets/' \
|
||||||
|
--exclude='/api/.venv/' \
|
||||||
|
--exclude='/api/jobs/' \
|
||||||
|
--exclude='/api/.env' \
|
||||||
|
--exclude='/api/.env.local' \
|
||||||
|
--exclude='/api/.env.production' \
|
||||||
|
--exclude='/deploy/.env.production' \
|
||||||
|
--exclude='/web/node_modules/' \
|
||||||
|
--exclude='/web/.next/' \
|
||||||
|
--exclude='/web/out/' \
|
||||||
|
--exclude='/web/canvas-app/node_modules/' \
|
||||||
|
--exclude='/web/canvas-app/dist/' \
|
||||||
|
--exclude='/node_modules/' \
|
||||||
|
--exclude='内部分享-口播脚本.md' \
|
||||||
|
./ "$HOST:$APP_DIR/"
|
||||||
|
|
||||||
|
echo "==> Rebuilding production containers"
|
||||||
|
ssh "$HOST" "cd '$APP_DIR' && docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d $BUILD_FLAG"
|
||||||
|
|
||||||
|
echo "==> Verifying production"
|
||||||
|
"$ROOT_DIR/scripts/verify-prod-docker.sh" "$HOST"
|
||||||
|
|
||||||
|
echo "==> Done"
|
||||||
29
scripts/install-huobao-upstream-watch.sh
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
LABEL="com.skg.huobao-canvas.upstream-watch"
|
||||||
|
SOURCE_PLIST="$ROOT_DIR/scripts/launchd/$LABEL.plist"
|
||||||
|
INSTALL_DIR="$HOME/Library/LaunchAgents"
|
||||||
|
INSTALL_PLIST="$INSTALL_DIR/$LABEL.plist"
|
||||||
|
LAUNCHD_DOMAIN="gui/$(id -u)"
|
||||||
|
LOG_DIR="$ROOT_DIR/.logs/upstream-watch"
|
||||||
|
|
||||||
|
if [[ ! -f "$SOURCE_PLIST" ]]; then
|
||||||
|
echo "missing launchd plist: $SOURCE_PLIST" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$INSTALL_DIR" "$LOG_DIR"
|
||||||
|
cp "$SOURCE_PLIST" "$INSTALL_PLIST"
|
||||||
|
plutil -lint "$INSTALL_PLIST" >/dev/null
|
||||||
|
|
||||||
|
launchctl bootout "$LAUNCHD_DOMAIN/$LABEL" >/dev/null 2>&1 || true
|
||||||
|
launchctl bootstrap "$LAUNCHD_DOMAIN" "$INSTALL_PLIST"
|
||||||
|
launchctl kickstart -k "$LAUNCHD_DOMAIN/$LABEL"
|
||||||
|
|
||||||
|
echo "huobao-canvas upstream watch installed"
|
||||||
|
echo "label: $LABEL"
|
||||||
|
echo "schedule: daily 09:30 local time"
|
||||||
|
echo "plist: $INSTALL_PLIST"
|
||||||
|
echo "state/logs: $LOG_DIR"
|
||||||
40
scripts/launchd/com.skg.huobao-canvas.upstream-watch.plist
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.skg.huobao-canvas.upstream-watch</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/bin/zsh</string>
|
||||||
|
<string>-lc</string>
|
||||||
|
<string>cd /Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证 && exec ./scripts/check-huobao-upstream.sh</string>
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证</string>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||||
|
<key>HUOBAO_WATCH_NOTIFY</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>HUOBAO_WATCH_NOTIFY_UNCHANGED</key>
|
||||||
|
<string>0</string>
|
||||||
|
</dict>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/.logs/upstream-watch/launchd.out.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/.logs/upstream-watch/launchd.err.log</string>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>StartCalendarInterval</key>
|
||||||
|
<dict>
|
||||||
|
<key>Hour</key>
|
||||||
|
<integer>9</integer>
|
||||||
|
<key>Minute</key>
|
||||||
|
<integer>30</integer>
|
||||||
|
</dict>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
20
scripts/start-local-docker.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if [ ! -f deploy/.env.local ]; then
|
||||||
|
cp deploy/.env.local.example deploy/.env.local
|
||||||
|
echo "created deploy/.env.local from deploy/.env.local.example"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker build -f Dockerfile.api -t skg-marketing-local-api:latest .
|
||||||
|
docker build -f Dockerfile.web -t skg-marketing-local-web:latest --build-arg NEXT_PUBLIC_API_BASE=/api .
|
||||||
|
docker compose -f docker-compose.local.yml --env-file deploy/.env.local up -d --no-build "$@"
|
||||||
|
|
||||||
|
WEB_PORT="$(grep -E '^LOCAL_WEB_PORT=' deploy/.env.local | tail -1 | cut -d= -f2-)"
|
||||||
|
WEB_PORT="${WEB_PORT:-4390}"
|
||||||
|
|
||||||
|
echo "local Docker is starting: http://localhost:${WEB_PORT}"
|
||||||
|
echo "login: skg / local-skg unless deploy/.env.local overrides it"
|
||||||
7
scripts/stop-local-docker.sh
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
docker compose -f docker-compose.local.yml --env-file deploy/.env.local down "$@"
|
||||||
64
scripts/verify-local-docker.sh
Executable file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if [ ! -f deploy/.env.local ]; then
|
||||||
|
echo "deploy/.env.local is missing. Run ./scripts/start-local-docker.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
WEB_PORT="$(grep -E '^LOCAL_WEB_PORT=' deploy/.env.local | tail -1 | cut -d= -f2-)"
|
||||||
|
WEB_PORT="${WEB_PORT:-4390}"
|
||||||
|
WEB_URL="http://127.0.0.1:${WEB_PORT}"
|
||||||
|
AUTH_USERNAME="$(grep -E '^WEB_AUTH_USERNAME=' deploy/.env.local | tail -1 | cut -d= -f2-)"
|
||||||
|
AUTH_USERNAME="${AUTH_USERNAME:-skg}"
|
||||||
|
AUTH_PASSWORD="$(grep -E '^WEB_AUTH_PASSWORD=' deploy/.env.local | tail -1 | cut -d= -f2-)"
|
||||||
|
AUTH_PASSWORD="${AUTH_PASSWORD:-local-skg}"
|
||||||
|
COMPOSE=(docker compose -f docker-compose.local.yml --env-file deploy/.env.local)
|
||||||
|
|
||||||
|
"${COMPOSE[@]}" ps
|
||||||
|
|
||||||
|
login_status="$(curl --noproxy '*' -sS -o /tmp/skg-local-login.html -w '%{http_code}' "${WEB_URL}/login/")"
|
||||||
|
if [ "$login_status" != "200" ]; then
|
||||||
|
echo "ERROR: unexpected /login/ status ${login_status}" >&2
|
||||||
|
head -40 /tmp/skg-local-login.html >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "web:/login/ 200"
|
||||||
|
|
||||||
|
root_status="$(curl --noproxy '*' -sS -o /tmp/skg-local-root.html -w '%{http_code}' "${WEB_URL}/")"
|
||||||
|
if [ "$root_status" != "302" ] && [ "$root_status" != "200" ]; then
|
||||||
|
echo "ERROR: unexpected / status ${root_status}" >&2
|
||||||
|
head -40 /tmp/skg-local-root.html >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "web:/ ${root_status}"
|
||||||
|
|
||||||
|
api_status="$(curl --noproxy '*' -sS -o /tmp/skg-local-api-health.json -w '%{http_code}' "${WEB_URL}/api/health")"
|
||||||
|
if [ "$api_status" != "401" ]; then
|
||||||
|
echo "ERROR: unexpected unauthenticated /api/health status ${api_status}" >&2
|
||||||
|
cat /tmp/skg-local-api-health.json >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "web:/api/health 401"
|
||||||
|
|
||||||
|
login_api_status="$(curl --noproxy '*' -sS -o /tmp/skg-local-login-api.json -w '%{http_code}' -c /tmp/skg-local-cookie.jar -X POST "${WEB_URL}/api/auth/login" -H 'content-type: application/json' --data "{\"username\":\"${AUTH_USERNAME}\",\"password\":\"${AUTH_PASSWORD}\"}")"
|
||||||
|
if [ "$login_api_status" != "200" ]; then
|
||||||
|
echo "ERROR: unexpected /api/auth/login status ${login_api_status}" >&2
|
||||||
|
cat /tmp/skg-local-login-api.json >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "web:/api/auth/login 200"
|
||||||
|
|
||||||
|
"${COMPOSE[@]}" exec -T api python - <<'PY'
|
||||||
|
import json
|
||||||
|
import main
|
||||||
|
|
||||||
|
data = main.health()
|
||||||
|
database = data.get("database") or {}
|
||||||
|
if not data.get("ok") or not database.get("connected"):
|
||||||
|
raise SystemExit(json.dumps(data, ensure_ascii=False)[:1000])
|
||||||
|
print("api:health ok db connected")
|
||||||
|
PY
|
||||||
44
scripts/verify-prod-docker.sh
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST="${1:-root@76.13.31.179}"
|
||||||
|
APP_DIR="${APP_DIR:-/opt/skg-marketing-studio}"
|
||||||
|
|
||||||
|
ssh "$HOST" "cd '$APP_DIR' && \
|
||||||
|
docker ps --filter name=skg-marketing --format '{{.Names}} {{.Status}}' && \
|
||||||
|
docker exec skg-marketing-web sh -lc '
|
||||||
|
set -e
|
||||||
|
echo web:no_local_api_refs
|
||||||
|
if grep -Rao \"http://localhost:4291\\|http://127.0.0.1:4291\\|localhost:4290\\|127.0.0.1:4290\" /usr/share/nginx/html/_next/static 2>/dev/null | head -1 | grep -q .; then
|
||||||
|
echo \"ERROR: local API/dev URL leaked into web static bundle\" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
check_route() {
|
||||||
|
p=\"\$1\"
|
||||||
|
expected=\"\$2\"
|
||||||
|
attempts=\"\${3:-30}\"
|
||||||
|
i=1
|
||||||
|
while [ \"\$i\" -le \"\$attempts\" ]; do
|
||||||
|
code=\$(curl -sS -o /tmp/skg-smoke.out -w \"%{http_code}\" \"http://127.0.0.1\$p\" || echo 000)
|
||||||
|
if [ \"\$code\" = \"\$expected\" ]; then
|
||||||
|
echo \"web:\$p \$code\"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
i=\$((i + 1))
|
||||||
|
done
|
||||||
|
echo \"ERROR: unexpected web route status \$p \$code\" >&2
|
||||||
|
head -c 200 /tmp/skg-smoke.out >&2 || true
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
for route in \"/ 302\" \"/login/ 200\" \"/_next/does-not-exist.js 404\" \"/api/health 401\"; do
|
||||||
|
set -- \$route
|
||||||
|
check_route \"\$1\" \"\$2\"
|
||||||
|
done
|
||||||
|
' && \
|
||||||
|
docker exec skg-marketing-api sh -lc '
|
||||||
|
set -e
|
||||||
|
test ! -f /app/.env || { echo \"ERROR: /app/.env leaked into API image\" >&2; exit 1; }
|
||||||
|
python -c \"import main; assert main.YTDLP_COOKIES_FROM_BROWSER == \\\"\\\", main.YTDLP_COOKIES_FROM_BROWSER; print(\\\"api:ytdlp_cookie_args\\\", main.ytdlp_cookie_args())\"
|
||||||
|
curl -sS http://127.0.0.1:4291/health | python -c \"import json,sys; d=json.load(sys.stdin); assert d[\\\"ok\\\"] is True; assert d[\\\"auth_configured\\\"] is True; assert d.get(\\\"database\\\",{}).get(\\\"connected\\\") is True; print(\\\"api:health ok db connected\\\")\"
|
||||||
|
'"
|
||||||
338
web/app/agent/page.tsx
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import {
|
||||||
|
ArrowDownToLine,
|
||||||
|
CheckCircle2,
|
||||||
|
CircleAlert,
|
||||||
|
Film,
|
||||||
|
ImagePlus,
|
||||||
|
Link2,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
RotateCcw,
|
||||||
|
TerminalSquare,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:4291"
|
||||||
|
|
||||||
|
type AgentRunLog = {
|
||||||
|
ts: number
|
||||||
|
level: "info" | "warn" | "error"
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentRun = {
|
||||||
|
id: string
|
||||||
|
job_id: string
|
||||||
|
status: "draft" | "queued" | "executing" | "reviewing" | "completed" | "failed"
|
||||||
|
stage: string
|
||||||
|
progress: number
|
||||||
|
logs: AgentRunLog[]
|
||||||
|
video_ids: string[]
|
||||||
|
final_video_url: string
|
||||||
|
contact_sheet_url: string
|
||||||
|
error: string
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGES = [
|
||||||
|
{ key: "download", label: "下载" },
|
||||||
|
{ key: "assets", label: "素材" },
|
||||||
|
{ key: "analyze", label: "拆解" },
|
||||||
|
{ key: "plan", label: "规划" },
|
||||||
|
{ key: "execute", label: "生成" },
|
||||||
|
{ key: "review", label: "审片" },
|
||||||
|
{ key: "compose", label: "合成" },
|
||||||
|
{ key: "final", label: "成片" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatClock(ts: number) {
|
||||||
|
if (!ts) return "--:--:--"
|
||||||
|
return new Date(ts * 1000).toLocaleTimeString("zh-CN", { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function runVideoUrl(run: AgentRun | null) {
|
||||||
|
if (!run?.final_video_url) return ""
|
||||||
|
return `${API_BASE}${run.final_video_url}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function runContactUrl(run: AgentRun | null) {
|
||||||
|
if (!run?.contact_sheet_url) return ""
|
||||||
|
return `${API_BASE}${run.contact_sheet_url}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentPage() {
|
||||||
|
const [url, setUrl] = useState("")
|
||||||
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
const [run, setRun] = useState<AgentRun | null>(null)
|
||||||
|
const [recent, setRecent] = useState<AgentRun[]>([])
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const terminalRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// create object URLs inside the effect (not during render) so every URL has a
|
||||||
|
// matching revoke even under React strict-mode double-invocation
|
||||||
|
const [previews, setPreviews] = useState<{ file: File; url: string }[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
const next = files.map((file) => ({ file, url: URL.createObjectURL(file) }))
|
||||||
|
setPreviews(next)
|
||||||
|
return () => next.forEach((item) => URL.revokeObjectURL(item.url))
|
||||||
|
}, [files])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/agent-runs?limit=8`, { cache: "no-store" })
|
||||||
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
|
.then((items: AgentRun[]) => {
|
||||||
|
setRecent(items)
|
||||||
|
const latest = items.find((item) => item.status === "executing" || item.status === "reviewing" || item.status === "completed")
|
||||||
|
if (latest) setRun(latest)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!run || run.status === "completed" || run.status === "failed") return
|
||||||
|
const timer = window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/agent-runs/${run.id}`, { cache: "no-store" })
|
||||||
|
if (!res.ok) return
|
||||||
|
const next = await res.json()
|
||||||
|
setRun(next)
|
||||||
|
} catch {
|
||||||
|
/* keep current state */
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [run?.id, run?.status])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = terminalRef.current
|
||||||
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
|
}, [run?.logs.length])
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setError("")
|
||||||
|
if (!url.trim()) {
|
||||||
|
setError("需要 TikTok 链接")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append("tk_url", url.trim())
|
||||||
|
files.slice(0, 6).forEach((file) => form.append("product_files", file))
|
||||||
|
const res = await fetch(`${API_BASE}/agent-runs`, { method: "POST", body: form })
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "")
|
||||||
|
throw new Error(text.slice(0, 260) || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
const created = await res.json()
|
||||||
|
setRun(created)
|
||||||
|
setRecent((prev) => [created, ...prev.filter((item) => item.id !== created.id)].slice(0, 8))
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeStageIndex = run ? Math.max(0, STAGES.findIndex((item) => item.key === run.stage)) : -1
|
||||||
|
const canStart = !!url.trim() && !submitting
|
||||||
|
const videoSrc = runVideoUrl(run)
|
||||||
|
const contactSrc = runContactUrl(run)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[#f3f4f7] text-[#111318]">
|
||||||
|
<div className="mx-auto flex min-h-screen w-full max-w-[1720px] flex-col gap-5 px-5 py-5">
|
||||||
|
<header className="flex items-center justify-between rounded-[28px] border border-black/5 bg-white/80 px-5 py-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
|
||||||
|
<div>
|
||||||
|
<div className="text-[12px] font-semibold uppercase tracking-[0.18em] text-[#7b8190]">SKG Agent Cut</div>
|
||||||
|
<h1 className="mt-1 text-[26px] font-semibold tracking-normal text-[#111318]">一分钟二创出片终端</h1>
|
||||||
|
</div>
|
||||||
|
<div className="hidden items-center gap-2 rounded-full bg-[#111318] px-3 py-2 text-[12px] font-medium text-white md:flex">
|
||||||
|
<TerminalSquare className="h-4 w-4 text-[#81d4ff]" />
|
||||||
|
{run ? `${run.status} · ${run.progress}%` : "standby"}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid min-h-[calc(100vh-128px)] grid-cols-1 gap-5 xl:grid-cols-[390px_minmax(520px,1fr)_420px]">
|
||||||
|
<aside className="flex flex-col gap-4 rounded-[30px] border border-black/5 bg-white/85 p-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
|
||||||
|
<div className="rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-4">
|
||||||
|
<label className="mb-2 flex items-center gap-2 text-[13px] font-semibold text-[#2b3038]">
|
||||||
|
<Link2 className="h-4 w-4 text-[#0a84ff]" />
|
||||||
|
TikTok 链接
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://www.tiktok.com/@..."
|
||||||
|
className="h-28 w-full resize-none rounded-[18px] border border-[#d9dee8] bg-white px-4 py-3 text-[14px] leading-relaxed text-[#111318] outline-none transition focus:border-[#0a84ff] focus:ring-4 focus:ring-[#0a84ff]/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-4">
|
||||||
|
<label className="mb-3 flex items-center gap-2 text-[13px] font-semibold text-[#2b3038]">
|
||||||
|
<ImagePlus className="h-4 w-4 text-[#34c759]" />
|
||||||
|
产品图
|
||||||
|
</label>
|
||||||
|
<label className="flex h-32 cursor-pointer flex-col items-center justify-center rounded-[20px] border border-dashed border-[#c7ceda] bg-white text-center transition hover:border-[#0a84ff] hover:bg-[#f7fbff]">
|
||||||
|
<Upload className="mb-2 h-6 w-6 text-[#7b8190]" />
|
||||||
|
<span className="text-[13px] font-medium text-[#2b3038]">上传产品图</span>
|
||||||
|
<span className="mt-1 text-[12px] text-[#7b8190]">最多 6 张</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = Array.from(e.target.files ?? []).slice(0, 6)
|
||||||
|
setFiles(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{previews.length > 0 && (
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
|
{previews.map((item) => (
|
||||||
|
<div key={`${item.file.name}-${item.file.size}`} className="aspect-square overflow-hidden rounded-[14px] border border-black/5 bg-white">
|
||||||
|
<img src={item.url} alt={item.file.name} className="h-full w-full object-contain" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-[18px] border border-[#ff453a]/20 bg-[#ff453a]/10 px-4 py-3 text-[13px] text-[#9f1d17]">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canStart}
|
||||||
|
onClick={submit}
|
||||||
|
className="flex h-14 items-center justify-center gap-2 rounded-[20px] bg-[#111318] text-[15px] font-semibold text-white shadow-[0_16px_40px_rgba(17,19,24,0.18)] transition hover:bg-black disabled:cursor-not-allowed disabled:bg-[#b8bec8]"
|
||||||
|
>
|
||||||
|
{submitting ? <Loader2 className="h-5 w-5 animate-spin" /> : <Play className="h-5 w-5" />}
|
||||||
|
开始出片
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-auto rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-3">
|
||||||
|
<div className="mb-2 text-[12px] font-semibold text-[#7b8190]">最近任务</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recent.slice(0, 4).map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRun(item)}
|
||||||
|
className="flex w-full items-center justify-between rounded-[16px] bg-white px-3 py-2 text-left text-[12px] text-[#2b3038] transition hover:bg-[#f1f5fb]"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{item.id}</span>
|
||||||
|
<span className="text-[#7b8190]">{item.status}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="flex min-h-[680px] flex-col rounded-[30px] border border-black/5 bg-[#111318] p-4 shadow-[0_24px_80px_rgba(20,25,38,0.16)]">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-[16px] bg-white/8">
|
||||||
|
<TerminalSquare className="h-5 w-5 text-[#81d4ff]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[16px] font-semibold text-white">Agent Terminal</h2>
|
||||||
|
<p className="text-[12px] text-white/45">{run ? `run ${run.id} · job ${run.job_id}` : "waiting for input"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{run?.status === "failed" ? (
|
||||||
|
<CircleAlert className="h-5 w-5 text-[#ff453a]" />
|
||||||
|
) : run?.status === "completed" ? (
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-[#34c759]" />
|
||||||
|
) : (
|
||||||
|
<Loader2 className={`h-5 w-5 text-[#81d4ff] ${run ? "animate-spin" : ""}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 grid grid-cols-4 gap-2 lg:grid-cols-8">
|
||||||
|
{STAGES.map((stage, index) => {
|
||||||
|
const active = index <= activeStageIndex || run?.status === "completed"
|
||||||
|
return (
|
||||||
|
<div key={stage.key} className={`rounded-[14px] px-3 py-2 text-[12px] ${active ? "bg-white text-[#111318]" : "bg-white/6 text-white/40"}`}>
|
||||||
|
{stage.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 h-2 overflow-hidden rounded-full bg-white/8">
|
||||||
|
<div className="h-full rounded-full bg-[#34c759] transition-all duration-700" style={{ width: `${run?.progress ?? 0}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={terminalRef} className="min-h-0 flex-1 overflow-auto rounded-[22px] border border-white/8 bg-black px-4 py-4 font-mono text-[12px] leading-relaxed text-[#d8f3dc]">
|
||||||
|
{!run && <div className="text-white/35">$ idle</div>}
|
||||||
|
{run?.logs.map((log, index) => (
|
||||||
|
<div key={`${log.ts}-${index}`} className={log.level === "error" ? "text-[#ff8a80]" : log.level === "warn" ? "text-[#ffd166]" : "text-[#d8f3dc]"}>
|
||||||
|
<span className="text-white/30">[{formatClock(log.ts)}]</span> {log.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="flex flex-col gap-4 rounded-[30px] border border-black/5 bg-white/85 p-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[12px] font-semibold uppercase tracking-[0.16em] text-[#7b8190]">Final</div>
|
||||||
|
<h2 className="mt-1 text-[18px] font-semibold text-[#111318]">成片播放器</h2>
|
||||||
|
</div>
|
||||||
|
<Film className="h-5 w-5 text-[#ff9f0a]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="aspect-[9/16] overflow-hidden rounded-[26px] border border-black/8 bg-[#111318]">
|
||||||
|
{videoSrc ? (
|
||||||
|
<video key={videoSrc} src={videoSrc} controls playsInline className="h-full w-full bg-black object-contain" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#7b8190]">
|
||||||
|
<Film className="h-8 w-8" />
|
||||||
|
<span className="text-[13px]">等待成片</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{contactSrc && (
|
||||||
|
<div className="overflow-hidden rounded-[18px] border border-black/8 bg-white">
|
||||||
|
<img src={contactSrc} alt="final contact sheet" className="w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<a
|
||||||
|
href={videoSrc || undefined}
|
||||||
|
download
|
||||||
|
className={`flex h-11 items-center justify-center gap-2 rounded-[16px] text-[13px] font-semibold ${videoSrc ? "bg-[#0a84ff] text-white" : "pointer-events-none bg-[#dfe3ea] text-[#8d94a1]"}`}
|
||||||
|
>
|
||||||
|
<ArrowDownToLine className="h-4 w-4" />
|
||||||
|
下载
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setRun(null)
|
||||||
|
setError("")
|
||||||
|
}}
|
||||||
|
className="flex h-11 items-center justify-center gap-2 rounded-[16px] bg-[#eef1f6] text-[13px] font-semibold text-[#2b3038] transition hover:bg-[#e3e7ef]"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
重来
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
548
web/app/detail/page.tsx
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Clapperboard,
|
||||||
|
Copy,
|
||||||
|
FileText,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
RefreshCw,
|
||||||
|
Sparkles,
|
||||||
|
Wand2,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Toaster, toast } from "sonner"
|
||||||
|
import { MediaAssetTile } from "@/components/media-asset-tile"
|
||||||
|
import {
|
||||||
|
apiAssetUrl,
|
||||||
|
deleteGeneratedImage,
|
||||||
|
deleteGeneratedVideo,
|
||||||
|
generateCreativeCopy,
|
||||||
|
generateImage,
|
||||||
|
generateStoryboardVideo,
|
||||||
|
getJob,
|
||||||
|
listJobs,
|
||||||
|
type CreativeCopyVariant,
|
||||||
|
type GeneratedImage,
|
||||||
|
type GeneratedVideo,
|
||||||
|
type Job,
|
||||||
|
type JobSummary,
|
||||||
|
} from "@/lib/api"
|
||||||
|
|
||||||
|
type ImageItem = GeneratedImage & { frameIdx: number }
|
||||||
|
type BusyTask = "image" | "video" | "copy" | "load" | null
|
||||||
|
|
||||||
|
function cx(...items: Array<string | false | null | undefined>) {
|
||||||
|
return items.filter(Boolean).join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobTitle(item: Job | JobSummary | null) {
|
||||||
|
if (!item) return "未选择任务"
|
||||||
|
const raw = item.url.replace(/^creative:\/\//, "").replace(/^upload:\/\//, "")
|
||||||
|
return raw || item.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceFrameSrc(job: Job | null) {
|
||||||
|
return job?.frames?.[0]?.url ? apiAssetUrl(job.frames[0].url) : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoSrc(job: Job, video: GeneratedVideo) {
|
||||||
|
return apiAssetUrl(video.url || `/jobs/${job.id}/storyboard-videos/${video.id}.mp4`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageItems(job: Job | null): ImageItem[] {
|
||||||
|
if (!job) return []
|
||||||
|
return job.frames
|
||||||
|
.flatMap((frame) => (frame.generated_images ?? []).map((image) => ({ ...image, frameIdx: frame.index })))
|
||||||
|
.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createdLabel(ts?: number) {
|
||||||
|
if (!ts) return ""
|
||||||
|
return new Date(ts * 1000).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailPage() {
|
||||||
|
const [jobId, setJobId] = useState("")
|
||||||
|
const [job, setJob] = useState<Job | null>(null)
|
||||||
|
const [recentJobs, setRecentJobs] = useState<JobSummary[]>([])
|
||||||
|
const [prompt, setPrompt] = useState("")
|
||||||
|
const [product, setProduct] = useState("SKG 颈部按摩仪")
|
||||||
|
const [audience, setAudience] = useState("久坐办公、低头刷手机的人群")
|
||||||
|
const [tone, setTone] = useState("真实、直接、有购买理由")
|
||||||
|
const [seconds, setSeconds] = useState(12)
|
||||||
|
const [copyVariants, setCopyVariants] = useState<CreativeCopyVariant[]>([])
|
||||||
|
const [busy, setBusy] = useState<BusyTask>(null)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
const images = useMemo(() => imageItems(job), [job])
|
||||||
|
const videos = useMemo(() => job?.generated_videos ?? [], [job])
|
||||||
|
const runningVideo = videos.some((item) => item.status === "queued" || item.status === "in_progress")
|
||||||
|
|
||||||
|
const loadJob = useCallback(async (id: string) => {
|
||||||
|
if (!id) return
|
||||||
|
setBusy("load")
|
||||||
|
setError("")
|
||||||
|
try {
|
||||||
|
const loaded = await getJob(id)
|
||||||
|
setJob(loaded)
|
||||||
|
setJobId(id)
|
||||||
|
window.history.replaceState(null, "", `/detail/?job=${id}`)
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "读取任务失败"
|
||||||
|
setError(message)
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshJobs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setRecentJobs(await listJobs(20))
|
||||||
|
} catch {
|
||||||
|
setRecentJobs([])
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = new URLSearchParams(window.location.search).get("job") || ""
|
||||||
|
setJobId(id)
|
||||||
|
refreshJobs()
|
||||||
|
if (id) loadJob(id)
|
||||||
|
}, [loadJob, refreshJobs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!job || !runningVideo) return
|
||||||
|
let failures = 0
|
||||||
|
const timer = window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
setJob(await getJob(job.id))
|
||||||
|
failures = 0
|
||||||
|
} catch {
|
||||||
|
// one transient 5xx / network blip must not freeze progress forever;
|
||||||
|
// only give up after sustained failures
|
||||||
|
failures += 1
|
||||||
|
if (failures >= 10) window.clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, 2600)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [job, runningVideo])
|
||||||
|
|
||||||
|
const requireJobAndPrompt = () => {
|
||||||
|
if (!job) {
|
||||||
|
toast.error("先选择任务")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!prompt.trim()) {
|
||||||
|
toast.error("先写生成要求")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const runImage = async () => {
|
||||||
|
if (!requireJobAndPrompt() || !job) return
|
||||||
|
setBusy("image")
|
||||||
|
setError("")
|
||||||
|
try {
|
||||||
|
setJob(await generateImage(job.id, 0, {
|
||||||
|
prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Tone: ${tone}.`,
|
||||||
|
mode: sourceFrameSrc(job) ? "edit" : "text",
|
||||||
|
}))
|
||||||
|
toast.success("图片已生成")
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "生图失败"
|
||||||
|
setError(message)
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runVideo = async () => {
|
||||||
|
if (!requireJobAndPrompt() || !job) return
|
||||||
|
setBusy("video")
|
||||||
|
setError("")
|
||||||
|
try {
|
||||||
|
setJob(await generateStoryboardVideo(job.id, 0, {
|
||||||
|
prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Tone: ${tone}. Keep the SKG product shape stable and visible.`,
|
||||||
|
duration: seconds,
|
||||||
|
count: 1,
|
||||||
|
first_image: { kind: "keyframe", frame_idx: 0 },
|
||||||
|
size: "720x1280",
|
||||||
|
}))
|
||||||
|
toast.success("视频已提交")
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "生视频失败"
|
||||||
|
setError(message)
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runCopy = async () => {
|
||||||
|
const goal = prompt.trim() || `${product} ${audience}`
|
||||||
|
setBusy("copy")
|
||||||
|
setError("")
|
||||||
|
try {
|
||||||
|
const result = await generateCreativeCopy({ goal, product, audience, tone, seconds })
|
||||||
|
setCopyVariants(result.variants)
|
||||||
|
toast.success("图文方案已生成")
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "写文案失败"
|
||||||
|
setError(message)
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteImage = async (image: ImageItem) => {
|
||||||
|
if (!job) return
|
||||||
|
try {
|
||||||
|
setJob(await deleteGeneratedImage(job.id, image.frameIdx, image.id))
|
||||||
|
toast.success("图片已删除")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "删除失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteVideo = async (video: GeneratedVideo) => {
|
||||||
|
if (!job) return
|
||||||
|
try {
|
||||||
|
setJob(await deleteGeneratedVideo(job.id, video.id))
|
||||||
|
toast.success("视频已删除")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "删除失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyText = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
toast.success("已复制")
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[#eef2ec] text-[#17201d]">
|
||||||
|
<Toaster richColors position="top-center" />
|
||||||
|
<div className="mx-auto grid min-h-screen w-full max-w-[1760px] grid-rows-[auto_minmax(0,1fr)] px-4 py-4 sm:px-6">
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[#d8dfd4] pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-[#cbd6d0] bg-white text-[#35443f] transition hover:border-[#0f766e]/60"
|
||||||
|
aria-label="返回工作台"
|
||||||
|
title="返回工作台"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold tracking-normal">任务详情</h1>
|
||||||
|
<p className="mt-1 max-w-[620px] truncate text-sm text-[#66746e]">{jobTitle(job)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => jobId && loadJob(jobId)}
|
||||||
|
disabled={!jobId || !!busy}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-md border border-[#cbd6d0] bg-white px-3 text-sm font-semibold text-[#35443f] transition hover:border-[#0f766e]/60 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy === "load" ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid min-h-0 gap-4 py-4 xl:grid-cols-[300px_minmax(0,1fr)_420px]">
|
||||||
|
<aside className="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-3">
|
||||||
|
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
|
||||||
|
<h2 className="text-sm font-semibold">任务信息</h2>
|
||||||
|
<div className="mt-3 grid gap-2 text-sm">
|
||||||
|
<div className="flex justify-between gap-3 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2">
|
||||||
|
<span className="text-[#66746e]">ID</span>
|
||||||
|
<span className="font-mono text-xs">{job?.id || jobId || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2">
|
||||||
|
<span className="text-[#66746e]">图片</span>
|
||||||
|
<span>{images.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2">
|
||||||
|
<span className="text-[#66746e]">视频</span>
|
||||||
|
<span>{videos.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sourceFrameSrc(job) ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<MediaAssetTile
|
||||||
|
src={sourceFrameSrc(job)}
|
||||||
|
alt="reference"
|
||||||
|
objectFit="contain"
|
||||||
|
previewObjectFit="contain"
|
||||||
|
className="aspect-[4/5] w-full rounded-md"
|
||||||
|
label="参考图"
|
||||||
|
meta={job?.frames?.[0]?.index ?? 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="min-h-0 rounded-lg border border-[#d8dfd4] bg-white p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold">我的任务</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={refreshJobs}
|
||||||
|
className="rounded p-1 text-[#66746e] hover:bg-[#f0f3ee] hover:text-[#17201d]"
|
||||||
|
aria-label="刷新任务列表"
|
||||||
|
title="刷新任务列表"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid max-h-[540px] gap-2 overflow-y-auto pr-1">
|
||||||
|
{recentJobs.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => loadJob(item.id)}
|
||||||
|
className={cx(
|
||||||
|
"grid grid-cols-[48px_minmax(0,1fr)] gap-2 rounded-md border bg-[#f7f9f5] p-1.5 text-left transition hover:border-[#0f766e]/60",
|
||||||
|
job?.id === item.id ? "border-[#0f766e] ring-2 ring-[#0f766e]/10" : "border-[#d8dfd4]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MediaAssetTile
|
||||||
|
src={apiAssetUrl(item.thumbnail)}
|
||||||
|
alt=""
|
||||||
|
objectFit="cover"
|
||||||
|
className="aspect-square rounded"
|
||||||
|
disablePreview={!item.thumbnail}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-xs font-semibold">{jobTitle(item)}</span>
|
||||||
|
<span className="block text-[11px] text-[#66746e]">{item.frame_count} 图源 · {item.video_count} 视频</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!recentJobs.length ? (
|
||||||
|
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-5 text-center text-xs text-[#66746e]">暂无任务</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="min-h-0 overflow-y-auto rounded-lg border border-[#d8dfd4] bg-white p-4">
|
||||||
|
{job ? (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="h-4 w-4 text-[#0f766e]" />
|
||||||
|
<h2 className="text-base font-semibold">生成图片</h2>
|
||||||
|
</div>
|
||||||
|
{images.length ? (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{images.map((image) => (
|
||||||
|
<div key={image.id} className="grid gap-1.5">
|
||||||
|
<MediaAssetTile
|
||||||
|
src={apiAssetUrl(image.url)}
|
||||||
|
alt="generated image"
|
||||||
|
objectFit="contain"
|
||||||
|
previewObjectFit="contain"
|
||||||
|
className="aspect-[4/5] w-full rounded-md"
|
||||||
|
label={image.model}
|
||||||
|
meta={image.mode}
|
||||||
|
onDelete={() => deleteImage(image)}
|
||||||
|
/>
|
||||||
|
<div className="truncate text-[11px] text-[#66746e]">{createdLabel(image.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-4 py-8 text-center text-sm text-[#66746e]">暂无图片结果</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clapperboard className="h-4 w-4 text-[#ea5b2d]" />
|
||||||
|
<h2 className="text-base font-semibold">生成视频</h2>
|
||||||
|
</div>
|
||||||
|
{videos.length ? (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{videos.map((video) => (
|
||||||
|
<div key={video.id} className="grid gap-1.5 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-2">
|
||||||
|
<MediaAssetTile
|
||||||
|
kind="video"
|
||||||
|
src={video.status === "completed" ? videoSrc(job, video) : undefined}
|
||||||
|
poster={apiAssetUrl(video.poster_url)}
|
||||||
|
objectFit="cover"
|
||||||
|
previewObjectFit="contain"
|
||||||
|
className="aspect-video w-full rounded-md"
|
||||||
|
label={video.model}
|
||||||
|
meta={`${video.status} · ${Math.round(video.progress)}%`}
|
||||||
|
busy={video.status === "queued" || video.status === "in_progress"}
|
||||||
|
onDelete={() => deleteVideo(video)}
|
||||||
|
/>
|
||||||
|
<div className="h-1 overflow-hidden rounded-full bg-[#e4ebe6]">
|
||||||
|
<div className="h-full rounded-full bg-[#ea5b2d]" style={{ width: `${Math.max(4, video.progress)}%` }} />
|
||||||
|
</div>
|
||||||
|
{video.error ? <div className="text-xs text-rose-700">{video.error}</div> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-4 py-8 text-center text-sm text-[#66746e]">暂无视频结果</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4 text-[#2563eb]" />
|
||||||
|
<h2 className="text-base font-semibold">营销图文</h2>
|
||||||
|
</div>
|
||||||
|
{copyVariants.length ? (
|
||||||
|
<div className="grid gap-3 lg:grid-cols-3">
|
||||||
|
{copyVariants.map((variant, index) => (
|
||||||
|
<article key={`${variant.title}-${index}`} className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-3">
|
||||||
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-semibold">{variant.title || `方案 ${index + 1}`}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyText([variant.hook_zh, variant.script_zh, variant.caption_zh].filter(Boolean).join("\n\n"))}
|
||||||
|
className="rounded p-1 text-[#66746e] hover:bg-white hover:text-[#17201d]"
|
||||||
|
aria-label="复制文案"
|
||||||
|
title="复制文案"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6 text-[#243530]">{variant.hook_zh}</p>
|
||||||
|
<pre className="mt-2 max-h-44 overflow-y-auto whitespace-pre-wrap rounded bg-white p-2 text-xs leading-5 text-[#42524c]">{variant.script_zh}</pre>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-4 py-8 text-center text-sm text-[#66746e]">暂无图文方案</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-[520px] items-center justify-center rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] text-sm text-[#66746e]">
|
||||||
|
请选择任务
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="grid min-h-0 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
|
||||||
|
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
|
||||||
|
<h2 className="text-sm font-semibold">继续生成</h2>
|
||||||
|
<div className="mt-3 grid gap-3">
|
||||||
|
<label className="grid gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-[#52635d]">产品</span>
|
||||||
|
<input
|
||||||
|
value={product}
|
||||||
|
onChange={(event) => setProduct(event.target.value)}
|
||||||
|
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-[#52635d]">人群</span>
|
||||||
|
<input
|
||||||
|
value={audience}
|
||||||
|
onChange={(event) => setAudience(event.target.value)}
|
||||||
|
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-[#52635d]">要求</span>
|
||||||
|
<textarea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(event) => setPrompt(event.target.value)}
|
||||||
|
placeholder="继续生成一组更高端的营销图,或者提交一条快速视频要求。"
|
||||||
|
className="min-h-40 resize-none rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-3 text-sm leading-6 outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-[#52635d]">语气</span>
|
||||||
|
<input
|
||||||
|
value={tone}
|
||||||
|
onChange={(event) => setTone(event.target.value)}
|
||||||
|
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-[#52635d]">视频时长</span>
|
||||||
|
<select
|
||||||
|
value={seconds}
|
||||||
|
onChange={(event) => setSeconds(Number(event.target.value))}
|
||||||
|
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
|
||||||
|
>
|
||||||
|
{[8, 12, 15, 20, 30, 45].map((value) => <option key={value} value={value}>{value} 秒</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{error ? <div className="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-2 rounded-lg border border-[#d8dfd4] bg-white p-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runImage}
|
||||||
|
disabled={!job || !!busy}
|
||||||
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-[#0f766e] text-sm font-semibold text-white transition hover:bg-[#115e59] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy === "image" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wand2 className="h-4 w-4" />}
|
||||||
|
生成图片
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runVideo}
|
||||||
|
disabled={!job || !!busy}
|
||||||
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-[#ea5b2d] text-sm font-semibold text-white transition hover:bg-[#d94f25] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy === "video" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||||
|
生成视频
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runCopy}
|
||||||
|
disabled={!!busy}
|
||||||
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-[#cbd6d0] bg-[#f7f9f5] text-sm font-semibold text-[#35443f] transition hover:border-[#9ba9a2] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy === "copy" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||||
|
生成图文方案
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="min-h-0 rounded-lg border border-[#d8dfd4] bg-white p-3">
|
||||||
|
<h2 className="mb-2 text-sm font-semibold">提示词</h2>
|
||||||
|
<div className="grid max-h-[360px] gap-2 overflow-y-auto pr-1">
|
||||||
|
{[...images.slice(0, 4).map((item) => item.prompt), ...videos.slice(0, 4).map((item) => item.prompt)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={`${index}-${item.slice(0, 20)}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPrompt(item)}
|
||||||
|
className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-2 text-left text-xs leading-5 text-[#42524c] transition hover:border-[#0f766e]/60"
|
||||||
|
>
|
||||||
|
{item.slice(0, 180)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!images.length && !videos.length ? (
|
||||||
|
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-5 text-center text-xs text-[#66746e]">暂无提示词</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -464,6 +464,688 @@ nextjs-portal {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
transform: translateY(44px);
|
transform: translateY(44px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
信息流工作台 · 登录页同源质感
|
||||||
|
============================================================ */
|
||||||
|
.skg-board-theme {
|
||||||
|
--skg-gold-1: #c8cd19;
|
||||||
|
--skg-gold-2: #a2c638;
|
||||||
|
--skg-cream: #f6f6ee;
|
||||||
|
--skg-bg-1: #1b1b1b;
|
||||||
|
--skg-bg-2: #242424;
|
||||||
|
--skg-bg-3: rgba(255, 255, 255, 0.1);
|
||||||
|
--skg-border: rgba(255, 255, 255, 0.14);
|
||||||
|
--skg-text-1: #ffffff;
|
||||||
|
--skg-text-2: rgba(255, 255, 255, 0.56);
|
||||||
|
--skg-text-3: rgba(255, 255, 255, 0.36);
|
||||||
|
--skg-success: #a2c638;
|
||||||
|
--skg-warn: #c8cd19;
|
||||||
|
--skg-danger: #fb7185;
|
||||||
|
--skg-info: #a6d533;
|
||||||
|
--skg-radius-sm: 6px;
|
||||||
|
--skg-radius-md: 8px;
|
||||||
|
--skg-radius-lg: 20px;
|
||||||
|
--skg-shadow-button: 10px 10px 10px rgba(0, 0, 0, 0.3);
|
||||||
|
--skg-shadow-card: 10px 10px 10px rgba(0, 0, 0, 0.3);
|
||||||
|
--skg-glass-bg: rgba(255, 255, 255, 0.1);
|
||||||
|
--skg-glass-bg-soft: rgba(255, 255, 255, 0.055);
|
||||||
|
--skg-rail: #383838;
|
||||||
|
--skg-wave-bg: rgba(0, 0, 0, 0.35);
|
||||||
|
--skg-wave-fill: rgba(209, 213, 219, 0.74);
|
||||||
|
--skg-wave-stroke-1: rgba(229, 231, 235, 0.7);
|
||||||
|
--skg-wave-stroke-2: rgba(229, 231, 235, 0.52);
|
||||||
|
--skg-wave-grid: rgba(255, 255, 255, 0.14);
|
||||||
|
--skg-wave-marker: rgba(255, 255, 255, 0.12);
|
||||||
|
--skg-wave-hover: rgba(207, 250, 254, 0.7);
|
||||||
|
--skg-wave-playhead: #a7f3d0;
|
||||||
|
--skg-wave-playhead-shadow: rgba(110, 231, 183, 0.85);
|
||||||
|
color: var(--skg-text-1);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 18%, rgba(162, 198, 56, 0.09), transparent 28%),
|
||||||
|
radial-gradient(circle at 86% 78%, rgba(200, 205, 25, 0.1), transparent 28%),
|
||||||
|
linear-gradient(120deg, #171717 0%, #202020 48%, #101010 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.018) 1px, transparent 1px),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.016) 1px, transparent 1px);
|
||||||
|
background-size: 56px 56px;
|
||||||
|
opacity: 0.34;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(0, 0, 0, 0.2), transparent 45%, rgba(0, 0, 0, 0.42)),
|
||||||
|
linear-gradient(90deg, rgba(0, 0, 0, 0.38), transparent 36%, rgba(0, 0, 0, 0.22));
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-ambient {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 72% 12%, rgba(162, 198, 56, 0.13), transparent 28%),
|
||||||
|
radial-gradient(circle at 18% 92%, rgba(200, 205, 25, 0.12), transparent 32%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-topbar,
|
||||||
|
.skg-board-panel {
|
||||||
|
border-color: var(--skg-border) !important;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 88% 22%, rgba(162, 198, 56, 0.06), transparent 38%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.032)),
|
||||||
|
rgba(30, 30, 30, 0.78) !important;
|
||||||
|
box-shadow: var(--skg-shadow-card);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-topbar {
|
||||||
|
background:
|
||||||
|
linear-gradient(100deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.038) 58%, rgba(162, 198, 56, 0.07)),
|
||||||
|
rgba(28, 28, 28, 0.84) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme input:focus,
|
||||||
|
.skg-board-theme textarea:focus,
|
||||||
|
.skg-board-theme select:focus {
|
||||||
|
border-color: rgba(214, 179, 106, 0.58) !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(162, 198, 56, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme input[type="checkbox"] {
|
||||||
|
accent-color: #d6b36a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme ::selection {
|
||||||
|
background: rgba(214, 179, 106, 0.28);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-shell {
|
||||||
|
min-height: calc(100vh - 32px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 24px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.026), rgba(255, 255, 255, 0.01)),
|
||||||
|
rgba(18, 18, 18, 0.72);
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.38);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-rail {
|
||||||
|
width: 65px;
|
||||||
|
height: calc(100vh - 64px);
|
||||||
|
min-height: 600px;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
align-self: flex-start;
|
||||||
|
top: 16px;
|
||||||
|
z-index: 30;
|
||||||
|
overflow: visible;
|
||||||
|
transition: width 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-rail.is-open {
|
||||||
|
width: 397px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-rail__strip {
|
||||||
|
width: 65px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 600px;
|
||||||
|
border: 1px solid #383838;
|
||||||
|
border-radius: 0 70px 70px 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 86% 18%, rgba(162, 198, 56, 0.1), transparent 28%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
|
||||||
|
#383838;
|
||||||
|
box-shadow: 10px 10px 10px rgba(0, 0, 0, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width 220ms ease, border-radius 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-rail.is-open .skg-board-rail__strip {
|
||||||
|
width: 397px;
|
||||||
|
border-radius: 0 34px 34px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-rail__iconbar {
|
||||||
|
width: 65px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-rail__logo {
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 68% 38%, #a2c638 0 34%, transparent 36%),
|
||||||
|
radial-gradient(circle at 50% 50%, #c8cd19 0 47%, transparent 49%),
|
||||||
|
#ffffff;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-rail__button {
|
||||||
|
color: rgba(255, 255, 255, 0.52);
|
||||||
|
transition: color 180ms ease, background 180ms ease, transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-rail__button:hover,
|
||||||
|
.skg-board-rail__button:focus-visible,
|
||||||
|
.skg-board-rail__button.is-active {
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-rail__drawer {
|
||||||
|
height: 100%;
|
||||||
|
animation: skgRailDrawerIn 220ms ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-rail__drawer .skg-board-panel {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0 !important;
|
||||||
|
border-radius: 0 30px 30px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skgRailDrawerIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-glass-card {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 20px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 80% 86%, rgba(162, 198, 56, 0.13), transparent 36%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.034)),
|
||||||
|
rgba(38, 38, 38, 0.76);
|
||||||
|
box-shadow: var(--skg-shadow-card);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-glass-card--flat {
|
||||||
|
border-radius: 16px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.022)),
|
||||||
|
rgba(18, 18, 18, 0.62);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-audio-waveform {
|
||||||
|
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
background: var(--skg-wave-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-audio-waveform__center {
|
||||||
|
background: var(--skg-wave-grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-audio-waveform__segment {
|
||||||
|
background: var(--skg-wave-marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-audio-waveform__hover {
|
||||||
|
background: var(--skg-wave-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-audio-waveform__playhead {
|
||||||
|
background: var(--skg-wave-playhead);
|
||||||
|
box-shadow: 0 0 16px var(--skg-wave-playhead-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-status-orb {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 5px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 78% 32%, #a2c638 0 12%, transparent 13%),
|
||||||
|
conic-gradient(from 40deg, #a2c638 0 74%, rgba(255, 255, 255, 0.22) 75% 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light {
|
||||||
|
--skg-bg-1: #f4f1e8;
|
||||||
|
--skg-bg-2: #fbfaf5;
|
||||||
|
--skg-bg-3: rgba(0, 0, 0, 0.03);
|
||||||
|
--skg-border: rgba(72, 78, 56, 0.14);
|
||||||
|
--skg-text-1: #20241b;
|
||||||
|
--skg-text-2: rgba(32, 36, 27, 0.68);
|
||||||
|
--skg-text-3: rgba(32, 36, 27, 0.42);
|
||||||
|
--skg-success: #059669;
|
||||||
|
--skg-warn: #b7791f;
|
||||||
|
--skg-danger: #e11d48;
|
||||||
|
--skg-info: #0891b2;
|
||||||
|
--skg-wave-bg: rgba(255, 255, 255, 0.74);
|
||||||
|
--skg-wave-fill: rgba(80, 90, 70, 0.42);
|
||||||
|
--skg-wave-stroke-1: rgba(47, 57, 44, 0.46);
|
||||||
|
--skg-wave-stroke-2: rgba(47, 57, 44, 0.3);
|
||||||
|
--skg-wave-grid: rgba(72, 78, 56, 0.16);
|
||||||
|
--skg-wave-marker: rgba(72, 78, 56, 0.14);
|
||||||
|
--skg-wave-hover: rgba(23, 96, 111, 0.52);
|
||||||
|
--skg-wave-playhead: #10b981;
|
||||||
|
--skg-wave-playhead-shadow: rgba(16, 185, 129, 0.36);
|
||||||
|
color: var(--skg-text-1);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 0%, rgba(232, 212, 168, 0.16), transparent 31%),
|
||||||
|
radial-gradient(circle at 4% 100%, rgba(143, 176, 113, 0.1), transparent 28%),
|
||||||
|
linear-gradient(126deg, #f5f2e9 0%, #ece7dc 48%, #fbfaf5 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light::before {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(42, 50, 36, 0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(180deg, rgba(42, 50, 36, 0.045) 1px, transparent 1px);
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light::after {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.36), transparent 46%, rgba(214, 179, 106, 0.08)),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.3), transparent 42%, rgba(255, 255, 255, 0.24));
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-board-ambient {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 18%, rgba(214, 179, 106, 0.2), transparent 28%),
|
||||||
|
radial-gradient(circle at 70% 6%, rgba(143, 176, 113, 0.16), transparent 30%),
|
||||||
|
radial-gradient(circle at 52% 100%, rgba(214, 179, 106, 0.12), transparent 38%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-board-topbar,
|
||||||
|
.skg-board-theme--light .skg-board-panel {
|
||||||
|
border-color: rgba(82, 93, 62, 0.16) !important;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 88% 18%, rgba(143, 176, 113, 0.12), transparent 36%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(249, 247, 239, 0.64)),
|
||||||
|
rgba(246, 243, 234, 0.84) !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.78),
|
||||||
|
0 18px 48px rgba(65, 55, 30, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-board-topbar {
|
||||||
|
background:
|
||||||
|
linear-gradient(100deg, rgba(214, 179, 106, 0.1), rgba(143, 176, 113, 0.07) 42%, rgba(255, 255, 255, 0.72)),
|
||||||
|
rgba(250, 248, 241, 0.9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-board-shell {
|
||||||
|
border-color: rgba(72, 78, 56, 0.14);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(246, 243, 235, 0.62)),
|
||||||
|
rgba(246, 243, 235, 0.74);
|
||||||
|
box-shadow: 0 24px 70px rgba(74, 68, 44, 0.13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-board-rail__strip {
|
||||||
|
border-color: #3f4239;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 86% 18%, rgba(162, 198, 56, 0.12), transparent 28%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
|
||||||
|
#3f4239;
|
||||||
|
box-shadow: 10px 10px 26px rgba(74, 68, 44, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-glass-card {
|
||||||
|
border-color: rgba(72, 78, 56, 0.16);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 80% 86%, rgba(143, 176, 113, 0.12), transparent 36%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(246, 243, 235, 0.64)),
|
||||||
|
rgba(250, 248, 241, 0.82);
|
||||||
|
box-shadow: 0 16px 42px rgba(74, 68, 44, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-glass-card--flat {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(246, 243, 235, 0.5)),
|
||||||
|
rgba(255, 255, 255, 0.54);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-board-theme-toggle {
|
||||||
|
border-color: rgba(82, 93, 62, 0.16) !important;
|
||||||
|
background: rgba(255, 255, 255, 0.54) !important;
|
||||||
|
color: rgba(36, 40, 30, 0.72) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .text-white,
|
||||||
|
.skg-board-theme--light [class*="text-white/"] {
|
||||||
|
color: rgba(32, 36, 28, 0.78) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light [class*="bg-black/"],
|
||||||
|
.skg-board-theme--light [class*="bg-white/"] {
|
||||||
|
background-color: rgba(255, 255, 250, 0.52) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light [class*="border-white/"] {
|
||||||
|
border-color: rgba(70, 78, 54, 0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light [class*="text-[#d7efbc]"] {
|
||||||
|
color: #43662d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light [class*="text-[#e8c77a]"],
|
||||||
|
.skg-board-theme--light [class*="text-[#f2d58a]"],
|
||||||
|
.skg-board-theme--light [class*="text-[#f5d98e]"] {
|
||||||
|
color: #856015 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light [class*="text-emerald-"] {
|
||||||
|
color: #2f6d3d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light [class*="text-cyan-"],
|
||||||
|
.skg-board-theme--light [class*="text-sky-"],
|
||||||
|
.skg-board-theme--light [class*="text-teal-"] {
|
||||||
|
color: #17606f !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light [class*="text-amber-"],
|
||||||
|
.skg-board-theme--light [class*="text-yellow-"] {
|
||||||
|
color: #8a5c00 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light [class*="text-rose-"],
|
||||||
|
.skg-board-theme--light [class*="text-red-"] {
|
||||||
|
color: #9f1239 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light [class*="text-violet-"],
|
||||||
|
.skg-board-theme--light [class*="text-purple-"] {
|
||||||
|
color: #62438a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light [class*="border-[#8fb071]"] {
|
||||||
|
border-color: rgba(67, 102, 45, 0.28) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light [class*="border-[#d6b36a]"] {
|
||||||
|
border-color: rgba(133, 96, 21, 0.26) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light [class*="bg-[#8fb071]"],
|
||||||
|
.skg-board-theme--light [class*="bg-[#d6b36a]"] {
|
||||||
|
background-color: rgba(214, 179, 106, 0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light input,
|
||||||
|
.skg-board-theme--light textarea,
|
||||||
|
.skg-board-theme--light select {
|
||||||
|
color: #22261f !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light input::placeholder,
|
||||||
|
.skg-board-theme--light textarea::placeholder {
|
||||||
|
color: rgba(34, 38, 31, 0.36) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light ::selection {
|
||||||
|
background: rgba(214, 179, 106, 0.32);
|
||||||
|
color: #171a14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-brand {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-brand__logo-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
height: 42px;
|
||||||
|
width: 132px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid rgba(214, 179, 106, 0.24);
|
||||||
|
border-radius: var(--skg-radius-md);
|
||||||
|
background: #f5efe3;
|
||||||
|
box-shadow: var(--skg-shadow-button);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-brand__logo {
|
||||||
|
width: 96px;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-brand__system {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: var(--skg-gold-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-brand__title {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--skg-text-1);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-brand__subtitle {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--skg-text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-stat-card {
|
||||||
|
--skg-stat-glow:
|
||||||
|
radial-gradient(circle at 92% 86%, rgba(162, 198, 56, 0.22), transparent 48%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
var(--skg-stat-glow),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.045)),
|
||||||
|
rgba(0, 0, 0, 0.16);
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 8px 8px 10px rgba(0, 0, 0, 0.22);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-stat-card--violet {
|
||||||
|
--skg-stat-glow:
|
||||||
|
radial-gradient(circle at 94% 82%, rgba(126, 87, 194, 0.74), transparent 48%),
|
||||||
|
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-stat-card--lime {
|
||||||
|
--skg-stat-glow:
|
||||||
|
radial-gradient(circle at 94% 84%, rgba(200, 205, 25, 0.72), transparent 48%),
|
||||||
|
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-stat-card--gold {
|
||||||
|
--skg-stat-glow:
|
||||||
|
radial-gradient(circle at 94% 84%, rgba(214, 179, 106, 0.7), transparent 48%),
|
||||||
|
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-stat-card--teal {
|
||||||
|
--skg-stat-glow:
|
||||||
|
radial-gradient(circle at 94% 84%, rgba(68, 162, 150, 0.7), transparent 48%),
|
||||||
|
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-stat-card--green {
|
||||||
|
--skg-stat-glow:
|
||||||
|
radial-gradient(circle at 94% 84%, rgba(84, 190, 104, 0.72), transparent 48%),
|
||||||
|
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-stat-card__label {
|
||||||
|
color: rgba(255, 255, 255, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-stat-card__value {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-primary-action {
|
||||||
|
border-radius: var(--skg-radius-md);
|
||||||
|
background: linear-gradient(135deg, #c8cd19, #a2c638);
|
||||||
|
color: #101010;
|
||||||
|
box-shadow: var(--skg-shadow-button);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-primary-action:hover {
|
||||||
|
background: linear-gradient(135deg, #d6db25, #b0d83d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-secondary-action {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||||
|
border-radius: var(--skg-radius-md);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: rgba(255, 255, 255, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-secondary-action:hover {
|
||||||
|
border-color: rgba(162, 198, 56, 0.44);
|
||||||
|
background: rgba(162, 198, 56, 0.11);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-empty-state {
|
||||||
|
border: 1px dashed rgba(214, 179, 106, 0.22);
|
||||||
|
border-radius: var(--skg-radius-lg);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 0%, rgba(214, 179, 106, 0.1), transparent 38%),
|
||||||
|
rgba(255, 255, 255, 0.028);
|
||||||
|
color: var(--skg-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-empty-character {
|
||||||
|
width: min(230px, 82%);
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-empty-character .login-character-stage {
|
||||||
|
min-height: 112px;
|
||||||
|
border-color: rgba(214, 179, 106, 0.16);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 78% 18%, rgba(214, 179, 106, 0.16), transparent 28%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.026));
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-empty-character .login-character-stage::after,
|
||||||
|
.skg-empty-character .login-stage-grid {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-empty-character .login-characters-container {
|
||||||
|
bottom: -6px;
|
||||||
|
transform: translateX(-50%) scale(0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-board-brand__logo-chip {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-stat-card {
|
||||||
|
--skg-stat-glow:
|
||||||
|
radial-gradient(circle at 94% 84%, rgba(143, 176, 113, 0.2), transparent 50%);
|
||||||
|
border-color: rgba(72, 78, 56, 0.14);
|
||||||
|
background:
|
||||||
|
var(--skg-stat-glow),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(246, 243, 235, 0.58)),
|
||||||
|
rgba(255, 255, 255, 0.56);
|
||||||
|
color: #20241b;
|
||||||
|
box-shadow: 0 12px 28px rgba(74, 68, 44, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-stat-card--violet {
|
||||||
|
--skg-stat-glow:
|
||||||
|
radial-gradient(circle at 94% 84%, rgba(126, 87, 194, 0.22), transparent 50%),
|
||||||
|
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-stat-card--lime {
|
||||||
|
--skg-stat-glow:
|
||||||
|
radial-gradient(circle at 94% 84%, rgba(200, 205, 25, 0.28), transparent 50%),
|
||||||
|
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-stat-card--gold {
|
||||||
|
--skg-stat-glow:
|
||||||
|
radial-gradient(circle at 94% 84%, rgba(214, 179, 106, 0.26), transparent 50%),
|
||||||
|
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-stat-card--teal {
|
||||||
|
--skg-stat-glow:
|
||||||
|
radial-gradient(circle at 94% 84%, rgba(68, 162, 150, 0.24), transparent 50%),
|
||||||
|
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-stat-card--green {
|
||||||
|
--skg-stat-glow:
|
||||||
|
radial-gradient(circle at 94% 84%, rgba(84, 190, 104, 0.24), transparent 50%),
|
||||||
|
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-stat-card__label {
|
||||||
|
color: rgba(32, 36, 27, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-stat-card__value {
|
||||||
|
color: #20241b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-primary-action {
|
||||||
|
background: linear-gradient(135deg, #c8cd19, #a2c638);
|
||||||
|
color: #10140d;
|
||||||
|
box-shadow: 0 12px 28px rgba(128, 144, 37, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-primary-action:hover {
|
||||||
|
background: linear-gradient(135deg, #d6db25, #b0d83d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-secondary-action {
|
||||||
|
border-color: rgba(72, 78, 56, 0.16);
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
color: rgba(32, 36, 27, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-secondary-action:hover {
|
||||||
|
border-color: rgba(143, 176, 113, 0.38);
|
||||||
|
background: rgba(143, 176, 113, 0.12);
|
||||||
|
color: #20241b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skg-board-theme--light .skg-empty-state {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 0%, rgba(232, 212, 168, 0.28), transparent 38%),
|
||||||
|
rgba(255, 255, 255, 0.66);
|
||||||
|
}
|
||||||
|
|
||||||
.login-hero {
|
.login-hero {
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
color: #282828;
|
color: #282828;
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ const _playfairDisplay = Playfair_Display({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "SKG TK 二创工作台",
|
title: "SKG",
|
||||||
description: "SKG AI 素材生产管线 · 节点工作流",
|
description: "SKG AI 图片、视频和图文内容生产入口",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type { FormEvent } from "react"
|
import type { FormEvent } from "react"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
Building2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -14,8 +15,26 @@ import { AnimatedLoginCharacters, type LoginCharacterMood } from "@/components/l
|
|||||||
import { OasisCanvas } from "@/components/login/oasis-canvas"
|
import { OasisCanvas } from "@/components/login/oasis-canvas"
|
||||||
|
|
||||||
type LoginStatus = "idle" | "loading" | "success"
|
type LoginStatus = "idle" | "loading" | "success"
|
||||||
|
type AuthConfig = {
|
||||||
|
auth_configured?: boolean
|
||||||
|
password_enabled?: boolean
|
||||||
|
feishu_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNextPath(value: string | null | undefined) {
|
||||||
|
const next = (value || "/").trim() || "/"
|
||||||
|
if (!next.startsWith("/") || next.startsWith("//")) return "/"
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginNextPath() {
|
||||||
|
if (typeof window === "undefined") return "/"
|
||||||
|
return normalizeNextPath(new URLSearchParams(window.location.search).get("next"))
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null)
|
||||||
|
const [nextPath] = useState(loginNextPath)
|
||||||
const [username, setUsername] = useState("")
|
const [username, setUsername] = useState("")
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [remember, setRemember] = useState(true)
|
const [remember, setRemember] = useState(true)
|
||||||
@@ -24,20 +43,59 @@ export default function LoginPage() {
|
|||||||
const [hasError, setHasError] = useState(false)
|
const [hasError, setHasError] = useState(false)
|
||||||
const [status, setStatus] = useState<LoginStatus>("idle")
|
const [status, setStatus] = useState<LoginStatus>("idle")
|
||||||
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 })
|
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 })
|
||||||
|
const autoFeishuAttemptedRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch("/api/auth/config", { cache: "no-store", credentials: "include" })
|
||||||
|
.then((res) => res.ok ? res.json() : null)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled && data) setAuthConfig(data)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setAuthConfig(null)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// skip touch / coarse pointers — the eye-follow effect is pointless there and
|
||||||
|
// would thrash state (and battery) on scroll-driven pointer events
|
||||||
|
if (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches) return
|
||||||
|
let frame = 0
|
||||||
const onPointerMove = (event: PointerEvent) => {
|
const onPointerMove = (event: PointerEvent) => {
|
||||||
const centerX = window.innerWidth / 2
|
if (frame) return // coalesce to at most one state update per animation frame
|
||||||
const centerY = window.innerHeight / 2
|
frame = window.requestAnimationFrame(() => {
|
||||||
const nextX = Math.max(-1, Math.min(1, (event.clientX - centerX) / centerX))
|
frame = 0
|
||||||
const nextY = Math.max(-1, Math.min(1, (event.clientY - centerY) / centerY))
|
const centerX = window.innerWidth / 2
|
||||||
setEyeOffset({ x: nextX * 8, y: nextY * 5.5 })
|
const centerY = window.innerHeight / 2
|
||||||
|
const nextX = Math.max(-1, Math.min(1, (event.clientX - centerX) / centerX))
|
||||||
|
const nextY = Math.max(-1, Math.min(1, (event.clientY - centerY) / centerY))
|
||||||
|
setEyeOffset({ x: nextX * 8, y: nextY * 5.5 })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
window.addEventListener("pointermove", onPointerMove)
|
window.addEventListener("pointermove", onPointerMove)
|
||||||
return () => window.removeEventListener("pointermove", onPointerMove)
|
return () => {
|
||||||
|
window.removeEventListener("pointermove", onPointerMove)
|
||||||
|
if (frame) window.cancelAnimationFrame(frame)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const disabled = status === "loading" || status === "success"
|
const disabled = status === "loading" || status === "success"
|
||||||
|
const feishuEnabled = Boolean(authConfig?.feishu_enabled)
|
||||||
|
const passwordEnabled = authConfig?.password_enabled ?? true
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!feishuEnabled || status !== "idle" || autoFeishuAttemptedRef.current) return
|
||||||
|
const attemptKey = `skg-feishu-auto-login:${nextPath}`
|
||||||
|
if (window.sessionStorage.getItem(attemptKey) === "1") return
|
||||||
|
window.sessionStorage.setItem(attemptKey, "1")
|
||||||
|
autoFeishuAttemptedRef.current = true
|
||||||
|
setStatus("loading")
|
||||||
|
window.location.href = `/api/auth/feishu/start?next=${encodeURIComponent(nextPath)}`
|
||||||
|
}, [feishuEnabled, nextPath, status])
|
||||||
|
|
||||||
const mood: LoginCharacterMood = useMemo(() => {
|
const mood: LoginCharacterMood = useMemo(() => {
|
||||||
if (status === "success") return "success"
|
if (status === "success") return "success"
|
||||||
@@ -50,6 +108,7 @@ export default function LoginPage() {
|
|||||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setHasError(false)
|
setHasError(false)
|
||||||
|
if (!passwordEnabled) return
|
||||||
if (!username.trim() || !password) {
|
if (!username.trim() || !password) {
|
||||||
setHasError(true)
|
setHasError(true)
|
||||||
return
|
return
|
||||||
@@ -67,7 +126,7 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
setStatus("success")
|
setStatus("success")
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
window.location.href = "/"
|
window.location.href = nextPath
|
||||||
}, 420)
|
}, 420)
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("idle")
|
setStatus("idle")
|
||||||
@@ -75,6 +134,11 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onFeishuLogin() {
|
||||||
|
setStatus("loading")
|
||||||
|
window.location.href = `/api/auth/feishu/start?next=${encodeURIComponent(nextPath)}`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="login-page login-page--oasis login-page--source relative min-h-screen overflow-hidden text-white">
|
<main className="login-page login-page--oasis login-page--source relative min-h-screen overflow-hidden text-white">
|
||||||
<OasisCanvas />
|
<OasisCanvas />
|
||||||
@@ -83,13 +147,34 @@ export default function LoginPage() {
|
|||||||
<section className="login-auth-panel login-source-auth-panel login-source-combo-panel rounded-[8px]">
|
<section className="login-auth-panel login-source-auth-panel login-source-combo-panel rounded-[8px]">
|
||||||
<div className="login-top-brand" aria-hidden="true">
|
<div className="login-top-brand" aria-hidden="true">
|
||||||
<img className="login-top-brand__logo" src="/skg-logo-black.svg" alt="" />
|
<img className="login-top-brand__logo" src="/skg-logo-black.svg" alt="" />
|
||||||
<span className="login-top-brand__system">营销内容工作台</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="login-source-character-strip" aria-hidden="true">
|
<div className="login-source-character-strip" aria-hidden="true">
|
||||||
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
|
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
|
||||||
</div>
|
</div>
|
||||||
<form className="login-source-form-pane w-full" onSubmit={onSubmit}>
|
<form className="login-source-form-pane w-full" onSubmit={onSubmit}>
|
||||||
<div className="space-y-3">
|
{feishuEnabled ? (
|
||||||
|
<button
|
||||||
|
className="mb-3 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onFeishuLogin}
|
||||||
|
>
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
飞书登录
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{feishuEnabled && passwordEnabled ? (
|
||||||
|
<div className="mb-3 flex items-center gap-3 text-xs text-white/35">
|
||||||
|
<span className="h-px flex-1 bg-white/10" />
|
||||||
|
<span>备用账号</span>
|
||||||
|
<span className="h-px flex-1 bg-white/10" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{passwordEnabled ? (
|
||||||
|
<div className="space-y-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="flex h-11 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#d6b36a] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#d6b36a]/25">
|
<span className="flex h-11 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#d6b36a] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#d6b36a]/25">
|
||||||
<UserRound className="h-4 w-4 text-white/45" />
|
<UserRound className="h-4 w-4 text-white/45" />
|
||||||
@@ -135,9 +220,11 @@ export default function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
|
{passwordEnabled ? (
|
||||||
|
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
|
||||||
<input
|
<input
|
||||||
className="h-4 w-4 rounded border-white/20 bg-black/30 accent-[#c89b3c]"
|
className="h-4 w-4 rounded border-white/20 bg-black/30 accent-[#c89b3c]"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -146,7 +233,8 @@ export default function LoginPage() {
|
|||||||
onChange={(event) => setRemember(event.target.checked)}
|
onChange={(event) => setRemember(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>保持登录</span>
|
<span>保持登录</span>
|
||||||
</label>
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{status === "success" ? (
|
{status === "success" ? (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
@@ -156,13 +244,15 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<button
|
{passwordEnabled ? (
|
||||||
|
<button
|
||||||
className="mt-4 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
|
className="mt-4 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1723
web/app/page.tsx
5
web/canvas-app/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
24
web/canvas-app/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
20
web/canvas-app/README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# SKG 画布
|
||||||
|
|
||||||
|
这是 SKG 营销内容生产平台的内部画布模块,部署在主站 `/canvas/` 路径下。
|
||||||
|
|
||||||
|
## 内部使用方式
|
||||||
|
|
||||||
|
- `/canvas/`:项目入口和本机项目列表。
|
||||||
|
- `/canvas/p/new`:直接进入一个新画布。
|
||||||
|
- 画布里的生图、生视频请求统一走主后端 `/api`,员工不需要填写模型密钥。
|
||||||
|
- 生成的图片和视频仍由主后端保存到当前登录用户可访问的任务数据里,画布状态保存在当前浏览器本地。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web/canvas-app
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
主站构建会自动执行 `web/package.json` 里的 `build:canvas`,把 Vite 输出同步到 `web/public/canvas/` 后再执行 Next 静态导出。
|
||||||
13
web/canvas-app/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/skg-logo-black.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SKG</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
web/canvas-app/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "skg-internal-canvas",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
|
"@vue-flow/background": "^1.3.2",
|
||||||
|
"@vue-flow/controls": "^1.1.3",
|
||||||
|
"@vue-flow/core": "^1.48.1",
|
||||||
|
"@vue-flow/minimap": "^1.5.4",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"naive-ui": "^2.43.2",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.24",
|
||||||
|
"vue-router": "^4.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"vite": "^5.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
2039
web/canvas-app/pnpm-lock.yaml
generated
Normal file
6
web/canvas-app/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
17
web/canvas-app/public/skg-logo-black.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<svg id="组_464" data-name="组 464" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="126.523" height="20.579" viewBox="0 0 126.523 20.579">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip-path">
|
||||||
|
<rect id="矩形_97" data-name="矩形 97" width="126.523" height="20.579" fill="#252525"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g id="组_37" data-name="组 37" clip-path="url(#clip-path)">
|
||||||
|
<path id="路径_171" data-name="路径 171" d="M382.888,44.125a.471.471,0,0,1,.526.539.465.465,0,0,1-.526.526h-4.477v1.942h5.164a.526.526,0,1,1,0,1.052H378.52a14.282,14.282,0,0,0,2.279,2.171,23.357,23.357,0,0,0,3.141,2.05.6.6,0,0,1,.364.634.513.513,0,0,1-.58.58,2.425,2.425,0,0,1-.526-.162Q381.89,52.62,380.7,51.7a20.51,20.51,0,0,1-2.292-2.171v4.706q0,.539-.58.539a.471.471,0,0,1-.526-.539v-4.6a14.979,14.979,0,0,1-1.564,1.618,18.642,18.642,0,0,1-3.2,2.292,1.39,1.39,0,0,1-.35.121q-.58,0-.58-.688a.569.569,0,0,1,.418-.58,18.559,18.559,0,0,0,3.344-2.252,10.037,10.037,0,0,0,1.794-1.969H372.4a.526.526,0,1,1,0-1.052h4.908V45.191h-4.477a.465.465,0,0,1-.526-.526.471.471,0,0,1,.526-.539h4.477V42.979a.471.471,0,0,1,.526-.539q.579,0,.58.539v1.146Z" transform="translate(-299.773 -34.235)" fill="#252525"/>
|
||||||
|
<path id="路径_172" data-name="路径 172" d="M455.771,43.632q.448.014.475.529a.453.453,0,0,1-.475.475H451.16v3.445h5.181a.485.485,0,0,1,.489.529.46.46,0,0,1-.489.475h-4.68a17.982,17.982,0,0,0,1.967,2.008,17.664,17.664,0,0,0,2.713,1.763.788.788,0,0,1,.638.692q-.013.461-.529.475a.421.421,0,0,1-.258-.108,12.929,12.929,0,0,1-2.876-1.75A18.024,18.024,0,0,1,451.16,50.1v4.015a.528.528,0,0,1-.529.542.506.506,0,0,1-.529-.542V50.211a11.854,11.854,0,0,1-1.614,1.533,17.92,17.92,0,0,1-3.432,2.17.4.4,0,0,1-.2.108.623.623,0,0,1-.529-.38q0-.556.42-.678a20.3,20.3,0,0,0,3.188-2.129,12.461,12.461,0,0,0,1.777-1.75H444.96a.459.459,0,0,1-.488-.475.484.484,0,0,1,.488-.529H450.1V44.636h-4.463a.453.453,0,0,1-.475-.475q.027-.516.475-.529H450.1v-.841a.5.5,0,0,1,.529-.543.517.517,0,0,1,.529.543v.841Zm-8.587,1.221a.584.584,0,0,1,.421.217,10.848,10.848,0,0,1,1,1.953v.366q-.054.38-.529.434a.41.41,0,0,1-.366-.271,8.655,8.655,0,0,0-.909-1.858,1.136,1.136,0,0,1-.095-.366.463.463,0,0,1,.474-.475m7.311-.109q.353.028.366.529a1.033,1.033,0,0,1-.027.149,9.743,9.743,0,0,1-1.085,2.238.814.814,0,0,1-.474.163.524.524,0,0,1-.475-.529,1.127,1.127,0,0,1,.149-.326,9.623,9.623,0,0,0,.909-1.858.628.628,0,0,1,.637-.366" transform="translate(-358.424 -34.081)" fill="#252525"/>
|
||||||
|
<path id="路径_173" data-name="路径 173" d="M519.865,43.241q.524,0,.524.406a.854.854,0,0,1-.052.249,18.483,18.483,0,0,1-.616,2.5v8.4q0,.472-.511.472a.411.411,0,0,1-.459-.472V48.783q-.157.315-.314.59a.682.682,0,0,1-.406.157q-.511,0-.511-.458a.9.9,0,0,1,.1-.315,14.351,14.351,0,0,0,1-2.241,18.69,18.69,0,0,0,.786-3.027q.066-.249.459-.249m1.035.459h1.48q.564,0,.563.459a2.922,2.922,0,0,1-.328.982l-1.14,2.332a.708.708,0,0,0-.066.262h1.166q.524,0,.524.721a10.8,10.8,0,0,1-.17,1.572,11.214,11.214,0,0,1-.629,2.555c-.026.07-.052.136-.079.2a3.086,3.086,0,0,0,.943.721,4.25,4.25,0,0,0,1.756.432h4.625q.407,0,.406.511a.452.452,0,0,1-.511.511h-4.271a6.355,6.355,0,0,1-1.952-.327,4.037,4.037,0,0,1-1.467-.878,5.2,5.2,0,0,1-1.061,1.258.437.437,0,0,1-.773-.3.6.6,0,0,1,.209-.419,5.262,5.262,0,0,0,1.035-1.258,5.843,5.843,0,0,1-.629-1.48,11.654,11.654,0,0,1-.354-1.808q0-.511.459-.511a.415.415,0,0,1,.406.3,10.438,10.438,0,0,0,.616,2.332,9.233,9.233,0,0,0,.34-1.349,9.41,9.41,0,0,0,.171-1.6.22.22,0,0,0-.249-.249h-1.074q-.616,0-.616-.472a2,2,0,0,1,.223-.773l1.219-2.45a.717.717,0,0,0,.092-.3c0-.035-.07-.052-.21-.052H520.9a.459.459,0,1,1,0-.917m2.857.459h1.69V43.7a.405.405,0,0,1,.459-.459q.51,0,.511.459v.459h1.8q.917,0,.917.812v.93h.367a.458.458,0,1,1,0,.917h-.367v.917q0,.878-.917.878h-1.8v.97h2.109a.406.406,0,0,1,.459.459q0,.511-.459.511h-2.109v.878h2.516a.405.405,0,0,1,.458.459q0,.511-.458.511h-2.516v.97q0,.459-.511.459a.405.405,0,0,1-.459-.459V52.4H523.4q-.459,0-.459-.511a.406.406,0,0,1,.459-.459h2.044v-.878h-1.69q-.459,0-.459-.511a.406.406,0,0,1,.459-.459h1.69v-.97h-1.638q-.511,0-.511-.459,0-.419.511-.419h1.638v-.917h-2.306a.458.458,0,1,1,0-.917h2.306v-.878h-1.69q-.511,0-.511-.459,0-.406.511-.406m2.659.865V45.9h1.743V45.39q0-.3-.406-.367Zm0,1.795v.917h1.441q.3,0,.3-.354v-.564Z" transform="translate(-417.47 -34.881)" fill="#252525"/>
|
||||||
|
<path id="路径_174" data-name="路径 174" d="M601.82,43.86q.509,0,.509.47a.45.45,0,0,1-.509.509h-4.488a.547.547,0,0,1,.156.4V45.6h2.792q1.03,0,1.031.965v.718h.757a.457.457,0,0,1,0,.913h-.757v.77q0,.966-1.031.965h-2.61a4.034,4.034,0,0,0,.5.992,4.9,4.9,0,0,0,1.057,1.161,8.22,8.22,0,0,0,1.279.887,6.927,6.927,0,0,0,1.331.587,1.1,1.1,0,0,1,.639.352.586.586,0,0,1,.1.353.5.5,0,0,1-.561.561,3.513,3.513,0,0,1-.992-.339,9.027,9.027,0,0,1-1.552-.874,11.617,11.617,0,0,1-1.149-.939,6.1,6.1,0,0,1-.835-1.1v2.44q0,1.174-1.435,1.174a4.988,4.988,0,0,1-1.226-.209.507.507,0,0,1-.4-.509q.026-.483.457-.509a.892.892,0,0,1,.183.026,4.822,4.822,0,0,0,.887.17.449.449,0,0,0,.508-.509V49.927h-3.405q-.417,0-.417-.456t.417-.457h3.405v-.822h-3.77a.457.457,0,1,1,0-.913h3.77v-.77H593q-.4,0-.4-.457t.4-.457h3.457v-.352a.474.474,0,0,1,.157-.4h-4.279a.265.265,0,0,0-.3.3v3.875a20.713,20.713,0,0,1-.274,3.614,11.665,11.665,0,0,1-.548,1.944.543.543,0,0,1-.509.352q-.535-.026-.561-.509a25.775,25.775,0,0,0,.626-2.518A16.351,16.351,0,0,0,591,49.014V44.578q0-.717.77-.718h4.11a.483.483,0,0,1-.078-.248.45.45,0,0,1,.509-.509.9.9,0,0,1,.353.1q.143.117.274.235a3.914,3.914,0,0,1,.339.365.22.22,0,0,1,.013.052ZM595.7,51.454q.509,0,.509.561,0,.2-.457.574a13.053,13.053,0,0,1-1.344,1.018,10.525,10.525,0,0,1-1.279.77,1.305,1.305,0,0,1-.444.091.45.45,0,0,1-.509-.509q0-.274.352-.431a8.458,8.458,0,0,0,1.448-.835,11.143,11.143,0,0,0,1.409-1.083.452.452,0,0,1,.313-.156m-2.6-1.214a1,1,0,0,1,.352.091q.26.209.5.391.248.222.627.639a.813.813,0,0,1,.052.3q0,.4-.509.4a.639.639,0,0,1-.365-.1q-.34-.339-.574-.535-.2-.157-.444-.326a.577.577,0,0,1-.143-.352.45.45,0,0,1,.509-.509m4.384-3.731v.77h2.792v-.4q0-.3-.352-.365Zm0,1.683v.822h2.544c.165,0,.248-.1.248-.313v-.509Zm4.071,1.892a.4.4,0,0,1,.456.457,1.041,1.041,0,0,1-.2.561,8.481,8.481,0,0,1-1.435,1.018.663.663,0,0,1-.352.1q-.561,0-.561-.509a.662.662,0,0,1,.1-.352,8.327,8.327,0,0,0,1.579-1.07.463.463,0,0,1,.4-.209" transform="translate(-476.054 -34.771)" fill="#252525"/>
|
||||||
|
<path id="路径_175" data-name="路径 175" d="M3.625,6.084a2.037,2.037,0,0,1,.06-2.413c.986-1.363,2.875-1.626,4.4-1.613a9.6,9.6,0,0,1,2.885.4,4.809,4.809,0,0,1,1.848,1.147,7.853,7.853,0,0,0,3.371,2.012,9.119,9.119,0,0,0,4.164,2.059c.582.044.564-.29.381-.476-.729-.575-1.884-1.305-2.122-2.367.366-.547.593-2.216-1.376-3.292A10.944,10.944,0,0,0,12.912.374,24.56,24.56,0,0,0,9.1,0,13.054,13.054,0,0,0,3.212,1.243C.958,2.415.032,4.451.547,6.275c.828,2.931,4.67,3.774,7.412,4.359,2.162.461,5.015.942,6.828,2.19,1.126.775,1.782,1.71,1.475,2.706a2.527,2.527,0,0,1-1.627,1.48,15.021,15.021,0,0,1-7.277.352,15.757,15.757,0,0,1-5.4-1.831,1.215,1.215,0,0,0-1.626.222,1.78,1.78,0,0,0-.32,1.257,2.518,2.518,0,0,0,1.751,2.077,23.2,23.2,0,0,0,8.788,1.483,16.031,16.031,0,0,0,7.266-1.656c3.485-2.024,3.417-5.992-.093-8.02C15.108,9.383,11.18,8.558,8.326,8c-1.648-.319-3.746-.476-4.7-1.92" transform="translate(0 0)" fill="#252525" fill-rule="evenodd"/>
|
||||||
|
<path id="路径_176" data-name="路径 176" d="M143.656,11.318c6.6-3.007,10-4.054,11.119-4.5a1.976,1.976,0,0,0,1.146-2.545,1.014,1.014,0,0,0-1.253-.571,88.026,88.026,0,0,0-13.116,5.438c-1.588.782-2.06,2.329-2.06,4.216,0,1.964.489,3.383,2.046,4.2A83.552,83.552,0,0,0,154.687,23a1.006,1.006,0,0,0,1.222-.578,1.992,1.992,0,0,0-1.128-2.59c-1.2-.4-4.44-1.552-11.126-4.45-1.335-.579-2.068-.96-2.068-2.025s.722-1.424,2.069-2.037" transform="translate(-112.525 -2.945)" fill="#252525" fill-rule="evenodd"/>
|
||||||
|
<path id="路径_177" data-name="路径 177" d="M123.463,19.464c0,1.649,2.673,1.329,2.673,0V1.088c0-1.649-2.673-1.219-2.673,0Z" transform="translate(-99.595 -0.005)" fill="#252525" fill-rule="evenodd"/>
|
||||||
|
<path id="路径_178" data-name="路径 178" d="M249.654,8.73h-8.53a2.09,2.09,0,0,0-2.276,2.259.922.922,0,0,0,.9.886c.62,0,8.683.005,8.683,0-.363,3.1-3.24,5.7-8.28,5.7-5.692,0-9.631-3.56-9.631-7.3,0-3.634,3.793-7.253,9.627-7.253a10.64,10.64,0,0,1,8.034,3.255c1.031,1.159,1.751-1.744.823-2.686A12.433,12.433,0,0,0,240.147,0c-6.834,0-11.667,4.992-11.667,10.285,0,5.431,4.946,10.293,11.667,10.293a11.854,11.854,0,0,0,2.5-.28,9.954,9.954,0,0,0,8.048-10c0-.811-.254-1.572-1.04-1.572" transform="translate(-184.309 -0.002)" fill="#252525" fill-rule="evenodd"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.3 KiB |
57
web/canvas-app/src/App.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Root App component | 根组件
|
||||||
|
* Provides naive-ui config and router view
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { NConfigProvider, NMessageProvider, NDialogProvider, darkTheme } from 'naive-ui'
|
||||||
|
import { isDark } from './stores/theme'
|
||||||
|
|
||||||
|
// Naive UI theme based on dark mode | 基于深色模式的 Naive UI 主题
|
||||||
|
const theme = computed(() => isDark.value ? darkTheme : null)
|
||||||
|
|
||||||
|
// Global theme overrides | 全局主题覆盖
|
||||||
|
const themeOverrides = {
|
||||||
|
common: {
|
||||||
|
borderRadius: '12px',
|
||||||
|
borderRadiusSmall: '8px'
|
||||||
|
},
|
||||||
|
Dialog: {
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '24px'
|
||||||
|
},
|
||||||
|
Modal: {
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '24px'
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '24px'
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
borderRadiusMedium: '10px',
|
||||||
|
borderRadiusSmall: '8px',
|
||||||
|
borderRadiusLarge: '12px',
|
||||||
|
heightMedium: '36px',
|
||||||
|
paddingMedium: '0 16px'
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
borderRadius: '10px',
|
||||||
|
heightMedium: '36px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-config-provider :theme="theme" :theme-overrides="themeOverrides">
|
||||||
|
<n-message-provider>
|
||||||
|
<n-dialog-provider>
|
||||||
|
<router-view />
|
||||||
|
</n-dialog-provider>
|
||||||
|
</n-message-provider>
|
||||||
|
</n-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Global app styles handled in style.css */
|
||||||
|
</style>
|
||||||
40
web/canvas-app/src/api/chat.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Chat API | 对话 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { request } from '@/utils'
|
||||||
|
|
||||||
|
// 对话补全
|
||||||
|
export const chatCompletions = (data) =>
|
||||||
|
request({
|
||||||
|
url: `/chat/completions`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
// 流式对话补全
|
||||||
|
export const streamChatCompletions = async function* (data, signal, options = {}) {
|
||||||
|
const text = data?.messages?.at?.(-1)?.content || data?.goal || ''
|
||||||
|
const systemPrompt = data?.messages?.find?.((message) => message?.role === 'system')?.content || ''
|
||||||
|
const response = await fetch('/api/prompt/polish', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: typeof text === 'string' ? text : JSON.stringify(text),
|
||||||
|
system_prompt: systemPrompt,
|
||||||
|
mode: 'chat',
|
||||||
|
target_language: 'keep'
|
||||||
|
}),
|
||||||
|
signal
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(error?.detail || error?.message || '提示词助手请求失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
yield json.text || ''
|
||||||
|
}
|
||||||
17
web/canvas-app/src/api/image.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Image API | 图片生成 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { request } from '@/utils'
|
||||||
|
|
||||||
|
// 生成图片
|
||||||
|
export const generateImage = (data, options = {}) => {
|
||||||
|
const { requestType = 'json', endpoint = '/images/generations' } = options
|
||||||
|
|
||||||
|
return request({
|
||||||
|
url: endpoint,
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
headers: requestType === 'formdata' ? { 'Content-Type': 'multipart/form-data' } : {}
|
||||||
|
})
|
||||||
|
}
|
||||||
8
web/canvas-app/src/api/index.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* API Index | API 索引
|
||||||
|
* Simplified for open source version | 开源版简化版
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './image'
|
||||||
|
export * from './video'
|
||||||
|
export * from './chat'
|
||||||
34
web/canvas-app/src/api/model.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Model API | 模型 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { request } from '@/utils'
|
||||||
|
|
||||||
|
// 分页查询模型列表
|
||||||
|
export const getModelPage = (params) =>
|
||||||
|
request({
|
||||||
|
url: `/model/page`,
|
||||||
|
method: 'get',
|
||||||
|
params: { enable: true, size: 1000, current: 1, ...params }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 根据类型获取模型列表
|
||||||
|
export const getModelsByType = async (type) => {
|
||||||
|
const rsp = await getModelPage({ type, enable: true, size: 1000, current: 1 })
|
||||||
|
return rsp?.data?.records || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据全称获取模型详情
|
||||||
|
export const getModelByFullName = (fullName) =>
|
||||||
|
request({
|
||||||
|
url: `/model/fullName`,
|
||||||
|
method: 'get',
|
||||||
|
params: { fullName }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取所有模型类型
|
||||||
|
export const getModelTypes = () =>
|
||||||
|
request({
|
||||||
|
url: `/model/types`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
22
web/canvas-app/src/api/video.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Video API | 视频生成 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { request } from '@/utils'
|
||||||
|
|
||||||
|
// 创建视频任务
|
||||||
|
export const createVideoTask = (data, options = {}) => {
|
||||||
|
const { endpoint = '/videos', requestType = 'json' } = options
|
||||||
|
return request({
|
||||||
|
url: endpoint,
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
headers: requestType === 'formdata'
|
||||||
|
? { 'Content-Type': 'multipart/form-data' }
|
||||||
|
: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: getVideoTaskStatus / pollVideoTask were removed — they ignored taskId and
|
||||||
|
// polled the list endpoint, and were superseded by readVideoTask() in hooks/useApi.js
|
||||||
|
// plus the Canvas-level syncPendingVideoNodes() loop. Nothing imported them.
|
||||||
BIN
web/canvas-app/src/assets/loading.webp
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
web/canvas-app/src/assets/product01.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
web/canvas-app/src/assets/scene01.jpeg
Normal file
|
After Width: | Height: | Size: 898 KiB |
BIN
web/canvas-app/src/assets/shot01.jpeg
Normal file
|
After Width: | Height: | Size: 936 KiB |
BIN
web/canvas-app/src/assets/workflow01.jpeg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
web/canvas-app/src/assets/workflow02.jpeg
Normal file
|
After Width: | Height: | Size: 902 KiB |
358
web/canvas-app/src/components/ApiSettings.vue
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
<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-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-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('')
|
||||||
|
|
||||||
|
// 初始化或切换渠道时,更新 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 = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
44
web/canvas-app/src/components/AppHeader.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<!-- App Header | 应用头部 -->
|
||||||
|
<header class="flex items-center justify-between px-4 md:px-8 py-4 border-b border-[var(--border-color)]">
|
||||||
|
<!-- Left slot | 左侧插槽 -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<slot name="left">
|
||||||
|
<!-- Default: empty or logo -->
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right section | 右侧区域 -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- Center slot | 中间插槽 -->
|
||||||
|
<slot name="center"></slot>
|
||||||
|
|
||||||
|
<!-- Theme toggle | 主题切换 -->
|
||||||
|
<button
|
||||||
|
@click="toggleTheme"
|
||||||
|
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<n-icon :size="20">
|
||||||
|
<SunnyOutline v-if="isDark" />
|
||||||
|
<MoonOutline v-else />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Right slot | 右侧插槽 -->
|
||||||
|
<slot name="right"></slot>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* App Header component | 应用头部组件
|
||||||
|
* Reusable header with slots for customization
|
||||||
|
*/
|
||||||
|
import { NIcon } from 'naive-ui'
|
||||||
|
import {
|
||||||
|
SunnyOutline,
|
||||||
|
MoonOutline
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
|
import { isDark, toggleTheme } from '../stores/theme'
|
||||||
|
</script>
|
||||||
120
web/canvas-app/src/components/DownloadModal.vue
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Download Modal | 下载弹窗 -->
|
||||||
|
<n-modal v-model:show="visible" preset="card" title="素材下载" style="width: 600px; max-width: 90vw;">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Stats | 统计 -->
|
||||||
|
<div class="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
|
||||||
|
<span>图片: {{ imageAssets.length }} 张</span>
|
||||||
|
<span>视频: {{ videoAssets.length }} 个</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image assets | 图片素材 -->
|
||||||
|
<div v-if="imageAssets.length > 0">
|
||||||
|
<h4 class="text-sm font-medium mb-2">图片素材</h4>
|
||||||
|
<div class="grid grid-cols-4 gap-2 max-h-[200px] overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="(asset, idx) in imageAssets"
|
||||||
|
:key="idx"
|
||||||
|
class="relative aspect-square rounded-lg overflow-hidden bg-[var(--bg-tertiary)] cursor-pointer group"
|
||||||
|
@click="downloadAsset(asset)"
|
||||||
|
>
|
||||||
|
<img :src="asset.url" class="w-full h-full object-cover" />
|
||||||
|
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<n-icon :size="24" color="white"><DownloadOutline /></n-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Video assets | 视频素材 -->
|
||||||
|
<div v-if="videoAssets.length > 0">
|
||||||
|
<h4 class="text-sm font-medium mb-2">视频素材</h4>
|
||||||
|
<div class="space-y-2 max-h-[200px] overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="(asset, idx) in videoAssets"
|
||||||
|
:key="idx"
|
||||||
|
class="flex items-center gap-3 p-2 rounded-lg bg-[var(--bg-tertiary)] hover:bg-[var(--bg-secondary)] cursor-pointer transition-colors"
|
||||||
|
@click="downloadAsset(asset)"
|
||||||
|
>
|
||||||
|
<div class="w-16 h-10 rounded bg-[var(--bg-primary)] flex items-center justify-center">
|
||||||
|
<n-icon :size="20"><VideocamOutline /></n-icon>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm truncate">{{ asset.label || '视频' }}</div>
|
||||||
|
<div class="text-xs text-[var(--text-secondary)]">{{ asset.duration ? asset.duration + 's' : '' }}</div>
|
||||||
|
</div>
|
||||||
|
<n-icon :size="20" class="text-[var(--text-secondary)]"><DownloadOutline /></n-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state | 空状态 -->
|
||||||
|
<div v-if="imageAssets.length === 0 && videoAssets.length === 0" class="text-center py-8 text-[var(--text-secondary)]">
|
||||||
|
暂无可下载的素材
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<n-button @click="visible = false">关闭</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Download Modal Component | 下载弹窗组件
|
||||||
|
* Display and download image/video assets from canvas nodes
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { NModal, NButton, NIcon } from 'naive-ui'
|
||||||
|
import { DownloadOutline, VideocamOutline } from '@vicons/ionicons5'
|
||||||
|
import { nodes } from '../stores/canvas'
|
||||||
|
|
||||||
|
// Props | 属性
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emits | 事件
|
||||||
|
const emit = defineEmits(['update:show'])
|
||||||
|
|
||||||
|
// Visible state with v-model support | 支持 v-model 的显示状态
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (val) => emit('update:show', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get downloadable image assets | 获取可下载的图片素材
|
||||||
|
const imageAssets = computed(() => {
|
||||||
|
return nodes.value
|
||||||
|
.filter(n => n.type === 'image' && n.data?.url)
|
||||||
|
.map(n => ({
|
||||||
|
url: n.data.url,
|
||||||
|
label: n.data.label || '图片',
|
||||||
|
nodeId: n.id
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get downloadable video assets | 获取可下载的视频素材
|
||||||
|
const videoAssets = computed(() => {
|
||||||
|
return nodes.value
|
||||||
|
.filter(n => n.type === 'video' && n.data?.url)
|
||||||
|
.map(n => ({
|
||||||
|
url: n.data.url,
|
||||||
|
label: n.data.label || '视频',
|
||||||
|
duration: n.data.duration,
|
||||||
|
nodeId: n.id
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Download single asset | 下载单个素材
|
||||||
|
const downloadAsset = (asset) => {
|
||||||
|
window.open(asset.url, '_blank')
|
||||||
|
window.$message?.success('已在新标签页打开')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
350
web/canvas-app/src/components/MentionsPicker.vue
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
<template>
|
||||||
|
<n-popover
|
||||||
|
:show="isShow"
|
||||||
|
trigger="manual"
|
||||||
|
placement="bottom-start"
|
||||||
|
:x="position.x"
|
||||||
|
:y="position.y"
|
||||||
|
:style="{ padding: 0 }"
|
||||||
|
raw
|
||||||
|
:show-arrow="false"
|
||||||
|
@update:show="handleShowChange"
|
||||||
|
>
|
||||||
|
<div class="mentions-picker">
|
||||||
|
<div class="mentions-search" v-if="showSearch">
|
||||||
|
<n-input
|
||||||
|
v-model:value="searchQuery"
|
||||||
|
placeholder="搜索节点..."
|
||||||
|
size="small"
|
||||||
|
:autofocus="true"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mentions-list" v-if="filteredNodes.length > 0">
|
||||||
|
<div
|
||||||
|
v-for="(node, index) in filteredNodes"
|
||||||
|
:key="node.id"
|
||||||
|
class="mentions-item"
|
||||||
|
:class="{ active: index === selectedIndex }"
|
||||||
|
@click="selectNode(node)"
|
||||||
|
@mouseenter="selectedIndex = index"
|
||||||
|
>
|
||||||
|
<!-- ImageNode 显示图片预览 -->
|
||||||
|
<div v-if="node.type === 'image'" class="mentions-item-image">
|
||||||
|
<img v-if="node.data?.url" :src="node.data.url" :alt="node.data.publicProps?.name" />
|
||||||
|
<div v-else class="mentions-item-image-placeholder">
|
||||||
|
<n-icon :size="20"><ImageOutline /></n-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 非 ImageNode 显示图标 -->
|
||||||
|
<div v-else class="mentions-item-icon">
|
||||||
|
<n-icon :component="getNodeIcon(node.type)" />
|
||||||
|
</div>
|
||||||
|
<div class="mentions-item-content">
|
||||||
|
<div class="mentions-item-label">
|
||||||
|
<!-- ImageNode 优先显示 publicProps.name -->
|
||||||
|
{{ node.type === 'image' ? (node.data?.publicProps?.name || node.data?.label || '未命名') : (node.data?.label || node.id) }}
|
||||||
|
</div>
|
||||||
|
<div class="mentions-item-id">{{ node.id }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mentions-empty" v-else>
|
||||||
|
<span>没有可引用的节点</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { NPopover, NInput, NIcon } from 'naive-ui'
|
||||||
|
import { ImageOutline } from '@vicons/ionicons5'
|
||||||
|
import { nodes } from '@/stores/canvas'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 可见性
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 位置
|
||||||
|
position: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ x: 0, y: 0 })
|
||||||
|
},
|
||||||
|
// 上下文类型:'text' | 'llmConfig'
|
||||||
|
context: {
|
||||||
|
type: String,
|
||||||
|
default: 'text'
|
||||||
|
},
|
||||||
|
// 是否显示搜索框
|
||||||
|
showSearch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
// 限制只显示已连接的节点 ID 列表(可选)
|
||||||
|
connectedNodeIds: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:visible', 'select'])
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const selectedIndex = ref(0)
|
||||||
|
const isShow = ref(false)
|
||||||
|
|
||||||
|
// Sync with prop | 与 prop 同步
|
||||||
|
watch(() => props.visible, (newVal) => {
|
||||||
|
isShow.value = newVal
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Handle show change | 处理显示变化
|
||||||
|
const handleShowChange = (val) => {
|
||||||
|
isShow.value = val
|
||||||
|
if (!val) {
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据上下文获取可引用的节点类型
|
||||||
|
const targetTypes = computed(() => {
|
||||||
|
if (props.context === 'llmConfig') {
|
||||||
|
return ['text']
|
||||||
|
}
|
||||||
|
return ['image']
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查节点是否公开(仅 ImageNode 需要检查 publicProps.name)
|
||||||
|
const isNodePublic = (node) => {
|
||||||
|
if (node.type === 'image') {
|
||||||
|
// ImageNode 需要有 publicProps.name 才算公开
|
||||||
|
return node.data?.publicProps?.name && node.data.publicProps.name !== ''
|
||||||
|
}
|
||||||
|
// 其他节点类型默认公开
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可引用的节点列表
|
||||||
|
const availableNodes = computed(() => {
|
||||||
|
return nodes.value.filter(node => {
|
||||||
|
// 先检查类型
|
||||||
|
if (!targetTypes.value.includes(node.type)) return false
|
||||||
|
// 再检查是否公开
|
||||||
|
if (!isNodePublic(node)) return false
|
||||||
|
// 如果指定了 connectedNodeIds,则只显示已连接的节点
|
||||||
|
if (props.connectedNodeIds.length > 0) {
|
||||||
|
return props.connectedNodeIds.includes(node.id)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 过滤后的节点列表
|
||||||
|
const filteredNodes = computed(() => {
|
||||||
|
if (!searchQuery.value) {
|
||||||
|
return availableNodes.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
return availableNodes.value.filter(node => {
|
||||||
|
const label = node.data?.label?.toLowerCase() || ''
|
||||||
|
const name = node.data?.publicProps?.name?.toLowerCase() || ''
|
||||||
|
const id = node.id.toLowerCase()
|
||||||
|
return label.includes(query) || name.includes(query) || id.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听搜索变化,重置选中索引
|
||||||
|
watch(searchQuery, () => {
|
||||||
|
selectedIndex.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听可见性变化,重置搜索
|
||||||
|
watch(() => props.visible, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
searchQuery.value = ''
|
||||||
|
selectedIndex.value = 0
|
||||||
|
// 添加全局键盘事件监听
|
||||||
|
document.addEventListener('keydown', handleGlobalKeydown)
|
||||||
|
} else {
|
||||||
|
// 移除全局键盘事件监听
|
||||||
|
document.removeEventListener('keydown', handleGlobalKeydown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 全局键盘事件处理(用于在选择器显示时处理 Enter/Escape)
|
||||||
|
function handleGlobalKeydown(event) {
|
||||||
|
if (!isShow.value) return
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
if (filteredNodes.value[selectedIndex.value]) {
|
||||||
|
selectNode(filteredNodes.value[selectedIndex.value])
|
||||||
|
}
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
isShow.value = false
|
||||||
|
emit('update:visible', false)
|
||||||
|
} else if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
selectedIndex.value = Math.min(selectedIndex.value + 1, filteredNodes.value.length - 1)
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取节点图标
|
||||||
|
function getNodeIcon(type) {
|
||||||
|
const icons = {
|
||||||
|
image: '📷',
|
||||||
|
text: '📝',
|
||||||
|
llmConfig: '🤖',
|
||||||
|
imageConfig: '🎨',
|
||||||
|
video: '🎬',
|
||||||
|
videoConfig: '🎥'
|
||||||
|
}
|
||||||
|
return icons[type] || '📄'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择节点
|
||||||
|
function selectNode(node) {
|
||||||
|
// ImageNode 优先使用 publicProps.name,其他节点使用 label
|
||||||
|
const displayName = node.type === 'image'
|
||||||
|
? (node.data?.publicProps?.name || node.data?.label || node.id)
|
||||||
|
: (node.data?.label || node.id)
|
||||||
|
|
||||||
|
emit('select', {
|
||||||
|
nodeId: node.id,
|
||||||
|
label: displayName,
|
||||||
|
type: node.type
|
||||||
|
})
|
||||||
|
isShow.value = false
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 键盘导航
|
||||||
|
function handleKeydown(event) {
|
||||||
|
const { key } = event
|
||||||
|
|
||||||
|
if (key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
selectedIndex.value = Math.min(selectedIndex.value + 1, filteredNodes.value.length - 1)
|
||||||
|
} else if (key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
|
||||||
|
} else if (key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
if (filteredNodes.value[selectedIndex.value]) {
|
||||||
|
selectNode(filteredNodes.value[selectedIndex.value])
|
||||||
|
}
|
||||||
|
} else if (key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
isShow.value = false
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mentions-picker {
|
||||||
|
width: 240px;
|
||||||
|
max-height: 300px;
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-search {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-list {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item:hover,
|
||||||
|
.mentions-item.active {
|
||||||
|
background: var(--hover-bg, #f5f5f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
background: var(--bg-color, #f0f0f0);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item-image {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-right: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item-image-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-color, #f0f0f0);
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color, #333);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-item-id {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-empty {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
445
web/canvas-app/src/components/WorkflowPanel.vue
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Workflow panel | 工作流浮动面板 -->
|
||||||
|
<Transition name="panel-slide">
|
||||||
|
<div v-if="visible" class="workflow-panel" v-click-outside="handleClickOutside">
|
||||||
|
<!-- Header | 头部 -->
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-tabs">
|
||||||
|
<span
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: activeTab === 'public' }"
|
||||||
|
@click="activeTab = 'public'"
|
||||||
|
>公共工作流</span>
|
||||||
|
<span
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: activeTab === 'my' }"
|
||||||
|
@click="activeTab = 'my'"
|
||||||
|
>我的工作流</span>
|
||||||
|
</div>
|
||||||
|
<button class="expand-btn" @click="visible = false">
|
||||||
|
<n-icon :size="16"><CloseOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content | 内容 -->
|
||||||
|
<div class="panel-content">
|
||||||
|
<!-- Public workflows | 公共工作流 -->
|
||||||
|
<div v-if="activeTab === 'public'" class="workflow-grid">
|
||||||
|
<div
|
||||||
|
v-for="workflow in publicWorkflows"
|
||||||
|
:key="workflow.id"
|
||||||
|
class="workflow-card"
|
||||||
|
@click="handleAddWorkflow(workflow)"
|
||||||
|
>
|
||||||
|
<div class="card-cover">
|
||||||
|
<img v-if="workflow.cover" :src="workflow.cover" :alt="workflow.name" class="cover-img" />
|
||||||
|
<n-icon v-else :size="36" class="cover-icon">
|
||||||
|
<component :is="getIcon(workflow.icon)" />
|
||||||
|
</n-icon>
|
||||||
|
</div>
|
||||||
|
<div class="card-title">{{ workflow.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- My workflows | 我的工作流 -->
|
||||||
|
<div v-else class="my-workflows">
|
||||||
|
<div class="my-toolbar">
|
||||||
|
<button class="save-current-btn" @click="$emit('save-current')" title="保存当前工作流">
|
||||||
|
<n-icon :size="15"><BookmarkOutline /></n-icon>
|
||||||
|
<span>保存当前</span>
|
||||||
|
</button>
|
||||||
|
<button class="refresh-btn" @click="$emit('refresh-workflows')" title="刷新我的工作流">
|
||||||
|
<n-icon :size="16"><RefreshOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingMyWorkflows" class="empty-state">
|
||||||
|
<n-icon :size="30" class="text-gray-500">
|
||||||
|
<RefreshOutline />
|
||||||
|
</n-icon>
|
||||||
|
<p class="text-gray-500 text-sm mt-2">正在加载...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="myWorkflows.length" class="workflow-grid">
|
||||||
|
<div
|
||||||
|
v-for="workflow in myWorkflows"
|
||||||
|
:key="workflow.id"
|
||||||
|
class="workflow-card my-workflow-card"
|
||||||
|
@click="handleAddWorkflow(workflow)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="delete-workflow-btn"
|
||||||
|
title="删除工作流"
|
||||||
|
@click.stop="$emit('delete-workflow', workflow)"
|
||||||
|
>
|
||||||
|
<n-icon :size="13"><TrashOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
<div class="card-cover">
|
||||||
|
<img v-if="workflow.thumbnail" :src="workflow.thumbnail" :alt="workflow.name" class="cover-img" />
|
||||||
|
<n-icon v-else :size="34" class="cover-icon">
|
||||||
|
<BookmarkOutline />
|
||||||
|
</n-icon>
|
||||||
|
</div>
|
||||||
|
<div class="card-title">{{ workflow.name }}</div>
|
||||||
|
<div class="card-meta">{{ formatWorkflowMeta(workflow) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<n-icon :size="36" class="text-gray-500">
|
||||||
|
<FolderOpenOutline />
|
||||||
|
</n-icon>
|
||||||
|
<p class="text-gray-500 text-sm mt-2">暂无自定义工作流</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Workflow Panel Component | 工作流面板组件
|
||||||
|
* 显示工作流模板列表,支持一键添加到画布
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { NIcon } from 'naive-ui'
|
||||||
|
import {
|
||||||
|
CloseOutline,
|
||||||
|
GridOutline,
|
||||||
|
ImageOutline,
|
||||||
|
VideocamOutline,
|
||||||
|
FolderOpenOutline,
|
||||||
|
BookOutline,
|
||||||
|
PersonOutline,
|
||||||
|
CartOutline,
|
||||||
|
ChatbubbleOutline,
|
||||||
|
BookmarkOutline,
|
||||||
|
RefreshOutline,
|
||||||
|
TrashOutline
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
|
import { WORKFLOW_TEMPLATES } from '../config/workflows'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
myWorkflows: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
loadingMyWorkflows: Boolean
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:show', 'add-workflow', 'save-current', 'delete-workflow', 'refresh-workflows'])
|
||||||
|
|
||||||
|
// Active tab | 当前标签
|
||||||
|
const activeTab = ref('public')
|
||||||
|
|
||||||
|
// Visible state | 显示状态
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (val) => emit('update:show', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Public workflows | 公共工作流
|
||||||
|
const publicWorkflows = computed(() => WORKFLOW_TEMPLATES)
|
||||||
|
|
||||||
|
// Icon mapping | 图标映射
|
||||||
|
const iconMap = {
|
||||||
|
GridOutline,
|
||||||
|
ImageOutline,
|
||||||
|
VideocamOutline,
|
||||||
|
BookOutline,
|
||||||
|
PersonOutline,
|
||||||
|
ShoppingOutline: CartOutline,
|
||||||
|
ChatbubbleOutline
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIcon = (iconName) => {
|
||||||
|
return iconMap[iconName] || GridOutline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle add workflow | 处理添加工作流
|
||||||
|
const handleAddWorkflow = (workflow) => {
|
||||||
|
// 直接添加工作流,节点内容由用户自己填写
|
||||||
|
emit('add-workflow', { workflow, options: {} })
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatWorkflowMeta = (workflow) => {
|
||||||
|
const count = workflow.workflowData?.nodes?.length || 0
|
||||||
|
return `${count} 个节点`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle click outside | 点击外部关闭
|
||||||
|
const handleClickOutside = () => {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom directive | 自定义指令
|
||||||
|
const vClickOutside = {
|
||||||
|
mounted(el, binding) {
|
||||||
|
el._clickOutside = (e) => {
|
||||||
|
if (!el.contains(e.target)) {
|
||||||
|
binding.value()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', el._clickOutside)
|
||||||
|
}, 0)
|
||||||
|
},
|
||||||
|
unmounted(el) {
|
||||||
|
document.removeEventListener('click', el._clickOutside)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Panel container | 面板容器 */
|
||||||
|
.workflow-panel {
|
||||||
|
position: fixed;
|
||||||
|
left: 72px;
|
||||||
|
top: 100px;
|
||||||
|
width: 520px;
|
||||||
|
max-height: 70vh;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .workflow-panel {
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header | 头部 */
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn:hover {
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content | 内容区 */
|
||||||
|
.panel-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-workflows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-current-btn,
|
||||||
|
.refresh-btn,
|
||||||
|
.delete-workflow-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-current-btn {
|
||||||
|
gap: 6px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-current-btn:hover,
|
||||||
|
.refresh-btn:hover,
|
||||||
|
.delete-workflow-btn:hover {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workflow grid | 工作流网格 */
|
||||||
|
.workflow-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workflow card | 工作流卡片 */
|
||||||
|
.workflow-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-workflow-card {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-card:hover .card-cover {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-cover {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-icon {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-workflow-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
top: 6px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 7px;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-workflow-card:hover .delete-workflow-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state | 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition | 过渡动画 */
|
||||||
|
.panel-slide-enter-active,
|
||||||
|
.panel-slide-leave-active {
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-slide-enter-from,
|
||||||
|
.panel-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar | 滚动条 */
|
||||||
|
.panel-content::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
150
web/canvas-app/src/components/edges/ImageOrderEdge.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Custom edge with image order selector | 带图片顺序选择器的自定义边 -->
|
||||||
|
<BaseEdge :path="path" :style="edgeStyle" />
|
||||||
|
|
||||||
|
<!-- Edge label with order selector | 带顺序选择器的边标签 -->
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
|
pointerEvents: 'all'
|
||||||
|
}"
|
||||||
|
class="nodrag nopan"
|
||||||
|
>
|
||||||
|
<n-dropdown
|
||||||
|
:options="orderOptions"
|
||||||
|
@select="handleOrderSelect"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center w-6 h-6 text-xs font-bold rounded-full bg-blue-500 text-white border-2 border-white shadow-md hover:scale-110 transition-transform"
|
||||||
|
>
|
||||||
|
{{ currentOrder }}
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { BaseEdge, EdgeLabelRenderer, getBezierPath, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { NDropdown } from 'naive-ui'
|
||||||
|
import { edges, nodes } from '../../stores/canvas'
|
||||||
|
|
||||||
|
// Get VueFlow instance | 获取 VueFlow 实例
|
||||||
|
const { updateEdgeData } = useVueFlow()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: String,
|
||||||
|
source: String,
|
||||||
|
target: String,
|
||||||
|
sourceX: Number,
|
||||||
|
sourceY: Number,
|
||||||
|
targetX: Number,
|
||||||
|
targetY: Number,
|
||||||
|
sourcePosition: String,
|
||||||
|
targetPosition: String,
|
||||||
|
data: Object,
|
||||||
|
markerEnd: String,
|
||||||
|
style: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
// Order labels | 顺序标签
|
||||||
|
const orderLabels = [
|
||||||
|
{ label: '① 第一张', key: 1 },
|
||||||
|
{ label: '② 第二张', key: 2 },
|
||||||
|
{ label: '③ 第三张', key: 3 },
|
||||||
|
{ label: '④ 第四张', key: 4 },
|
||||||
|
{ label: '⑤ 第五张', key: 5 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Dynamic order options based on connected edges count + @ mentioned images | 基于连接边数量和@提及图片的动态顺序选项
|
||||||
|
const orderOptions = computed(() => {
|
||||||
|
// Get all imageOrder edges connected to the same target | 获取连接到同一目标的图片边
|
||||||
|
const sameTargetImageEdges = edges.value.filter(edge =>
|
||||||
|
edge.target === props.target &&
|
||||||
|
edge.type === 'imageOrder'
|
||||||
|
)
|
||||||
|
const edgeCount = sameTargetImageEdges.length || 1
|
||||||
|
|
||||||
|
// Get @ mentioned image count from connected TextNodes | 获取已连接 TextNode 中 @ 提及的图片数量
|
||||||
|
let mentionedImageCount = 0
|
||||||
|
const connectedTextEdges = edges.value.filter(e => e.target === props.target)
|
||||||
|
for (const edge of connectedTextEdges) {
|
||||||
|
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||||||
|
if (sourceNode?.type === 'text') {
|
||||||
|
const content = sourceNode.data?.content || ''
|
||||||
|
// Count @ mentions of image nodes | 统计图片节点的 @ 提及
|
||||||
|
const mentionRegex = /@\[([^\]|]+)(?:\|([^\]]+))?\]/g
|
||||||
|
let match
|
||||||
|
while ((match = mentionRegex.exec(content)) !== null) {
|
||||||
|
const mentionedNode = nodes.value.find(n => n.id === match[1])
|
||||||
|
if (mentionedNode?.type === 'image') {
|
||||||
|
mentionedImageCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimum order is mentionedImageCount + 1 | 最小顺序是 @ 提及图片数量 + 1
|
||||||
|
const minOrder = mentionedImageCount + 1
|
||||||
|
// Total count = edge count + mentioned image count | 总数量 = 边数量 + @ 提及图片数量
|
||||||
|
const totalCount = edgeCount + mentionedImageCount
|
||||||
|
const maxOrder = Math.min(totalCount, 5)
|
||||||
|
|
||||||
|
// Return options from minOrder to maxOrder | 返回从 minOrder 到 maxOrder 的选项
|
||||||
|
return orderLabels.filter(label => label.key >= minOrder && label.key <= maxOrder)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Current order from edge data | 从边数据获取当前顺序
|
||||||
|
const currentOrder = computed(() => props.data?.imageOrder || 1)
|
||||||
|
|
||||||
|
// Calculate bezier path | 计算贝塞尔路径
|
||||||
|
const path = computed(() => {
|
||||||
|
const [edgePath] = getBezierPath({
|
||||||
|
sourceX: props.sourceX,
|
||||||
|
sourceY: props.sourceY,
|
||||||
|
targetX: props.targetX,
|
||||||
|
targetY: props.targetY,
|
||||||
|
sourcePosition: props.sourcePosition,
|
||||||
|
targetPosition: props.targetPosition
|
||||||
|
})
|
||||||
|
return edgePath
|
||||||
|
})
|
||||||
|
|
||||||
|
// Label position (center of edge) | 标签位置(边的中心)
|
||||||
|
const labelX = computed(() => (props.sourceX + props.targetX) / 2)
|
||||||
|
const labelY = computed(() => (props.sourceY + props.targetY) / 2)
|
||||||
|
|
||||||
|
// Edge style | 边样式
|
||||||
|
const edgeStyle = computed(() => ({
|
||||||
|
stroke: '#3b82f6',
|
||||||
|
strokeWidth: 2,
|
||||||
|
...props.style
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Handle order selection | 处理顺序选择
|
||||||
|
const handleOrderSelect = (newOrder) => {
|
||||||
|
// Get all image edges connected to the same target | 获取连接到同一目标的所有图片边
|
||||||
|
const sameTargetImageEdges = edges.value.filter(edge =>
|
||||||
|
edge.target === props.target &&
|
||||||
|
edge.type === 'imageOrder'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Find edge currently using this order | 查找当前使用此顺序的边
|
||||||
|
const edgeWithSameOrder = sameTargetImageEdges.find(edge =>
|
||||||
|
edge.id !== props.id &&
|
||||||
|
edge.data?.imageOrder === newOrder
|
||||||
|
)
|
||||||
|
|
||||||
|
// If another edge has this order, swap with current | 如果另一条边有此顺序,则交换
|
||||||
|
if (edgeWithSameOrder) {
|
||||||
|
updateEdgeData(edgeWithSameOrder.id, { imageOrder: currentOrder.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current edge order | 更新当前边顺序
|
||||||
|
updateEdgeData(props.id, { imageOrder: newOrder })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
117
web/canvas-app/src/components/edges/ImageRoleEdge.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Custom edge with image role selector | 带图片角色选择器的自定义边 -->
|
||||||
|
<BaseEdge :path="path" :style="edgeStyle" />
|
||||||
|
|
||||||
|
<!-- Edge label with role dropdown | 带角色下拉的边标签 -->
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
|
pointerEvents: 'all'
|
||||||
|
}"
|
||||||
|
class="nodrag nopan"
|
||||||
|
>
|
||||||
|
<n-dropdown
|
||||||
|
:options="imageRoleOptions"
|
||||||
|
@select="handleRoleSelect"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 shadow-sm hover:shadow transition-shadow"
|
||||||
|
>
|
||||||
|
{{ currentRoleLabel }}
|
||||||
|
<n-icon :size="10"><ChevronDownOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { BaseEdge, EdgeLabelRenderer, getBezierPath, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { NDropdown, NIcon } from 'naive-ui'
|
||||||
|
import { ChevronDownOutline } from '@vicons/ionicons5'
|
||||||
|
import { edges } from '../../stores/canvas'
|
||||||
|
|
||||||
|
// Get VueFlow instance | 获取 VueFlow 实例
|
||||||
|
const { updateEdgeData } = useVueFlow()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: String,
|
||||||
|
source: String,
|
||||||
|
target: String,
|
||||||
|
sourceX: Number,
|
||||||
|
sourceY: Number,
|
||||||
|
targetX: Number,
|
||||||
|
targetY: Number,
|
||||||
|
sourcePosition: String,
|
||||||
|
targetPosition: String,
|
||||||
|
data: Object,
|
||||||
|
markerEnd: String,
|
||||||
|
style: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
// Image role options | 图片角色选项
|
||||||
|
const imageRoleOptions = [
|
||||||
|
{ label: '首帧', key: 'first_frame_image' },
|
||||||
|
{ label: '尾帧', key: 'last_frame_image' },
|
||||||
|
{ label: '参考图', key: 'input_reference' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Current role from edge data | 从边数据获取当前角色
|
||||||
|
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 || '首帧'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate bezier path | 计算贝塞尔路径
|
||||||
|
const path = computed(() => {
|
||||||
|
const [edgePath] = getBezierPath({
|
||||||
|
sourceX: props.sourceX,
|
||||||
|
sourceY: props.sourceY,
|
||||||
|
targetX: props.targetX,
|
||||||
|
targetY: props.targetY,
|
||||||
|
sourcePosition: props.sourcePosition,
|
||||||
|
targetPosition: props.targetPosition
|
||||||
|
})
|
||||||
|
return edgePath
|
||||||
|
})
|
||||||
|
|
||||||
|
// Label position (center of edge) | 标签位置(边的中心)
|
||||||
|
const labelX = computed(() => (props.sourceX + props.targetX) / 2)
|
||||||
|
const labelY = computed(() => (props.sourceY + props.targetY) / 2)
|
||||||
|
|
||||||
|
// Edge style | 边样式
|
||||||
|
const edgeStyle = computed(() => ({
|
||||||
|
stroke: '#6366f1',
|
||||||
|
strokeWidth: 2,
|
||||||
|
...props.style
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Handle role selection | 处理角色选择
|
||||||
|
const handleRoleSelect = (role) => {
|
||||||
|
// 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 =>
|
||||||
|
edge.target === props.target &&
|
||||||
|
edge.id !== props.id &&
|
||||||
|
edge.data?.imageRole === role
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auto-switch the other edge to the opposite role | 自动切换其他边到相反角色
|
||||||
|
sameTargetEdges.forEach(edge => {
|
||||||
|
const oppositeRole = role === 'first_frame_image' ? 'last_frame_image' : 'first_frame_image'
|
||||||
|
updateEdgeData(edge.id, { imageRole: oppositeRole })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current edge role | 更新当前边角色
|
||||||
|
updateEdgeData(props.id, { imageRole: role })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
123
web/canvas-app/src/components/edges/PromptOrderEdge.vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Custom edge with prompt order selector | 带提示词顺序选择器的自定义边 -->
|
||||||
|
<BaseEdge :path="path" :style="edgeStyle" />
|
||||||
|
|
||||||
|
<!-- Edge label with order selector | 带顺序选择器的边标签 -->
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
|
pointerEvents: 'all'
|
||||||
|
}"
|
||||||
|
class="nodrag nopan"
|
||||||
|
>
|
||||||
|
<n-dropdown
|
||||||
|
:options="orderOptions"
|
||||||
|
@select="handleOrderSelect"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center w-6 h-6 text-xs font-bold rounded-full bg-[var(--accent-color)] text-white border-2 border-white shadow-md hover:scale-110 transition-transform"
|
||||||
|
>
|
||||||
|
{{ currentOrder }}
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { BaseEdge, EdgeLabelRenderer, getBezierPath, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { NDropdown } from 'naive-ui'
|
||||||
|
import { edges } from '../../stores/canvas'
|
||||||
|
|
||||||
|
// Get VueFlow instance | 获取 VueFlow 实例
|
||||||
|
const { updateEdgeData } = useVueFlow()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: String,
|
||||||
|
source: String,
|
||||||
|
target: String,
|
||||||
|
sourceX: Number,
|
||||||
|
sourceY: Number,
|
||||||
|
targetX: Number,
|
||||||
|
targetY: Number,
|
||||||
|
sourcePosition: String,
|
||||||
|
targetPosition: String,
|
||||||
|
data: Object,
|
||||||
|
markerEnd: String,
|
||||||
|
style: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
// Order labels | 顺序标签
|
||||||
|
const orderLabels = [
|
||||||
|
{ label: '① 第一个', key: 1 },
|
||||||
|
{ label: '② 第二个', key: 2 },
|
||||||
|
{ label: '③ 第三个', key: 3 },
|
||||||
|
{ label: '④ 第四个', key: 4 },
|
||||||
|
{ label: '⑤ 第五个', key: 5 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Dynamic order options based on connected edges count | 基于连接边数量的动态顺序选项
|
||||||
|
const orderOptions = computed(() => {
|
||||||
|
// Get all promptOrder edges connected to the same target | 获取连接到同一目标的所有文本边
|
||||||
|
const sameTargetTextEdges = edges.value.filter(edge =>
|
||||||
|
edge.target === props.target &&
|
||||||
|
edge.type === 'promptOrder'
|
||||||
|
)
|
||||||
|
const count = sameTargetTextEdges.length || 1
|
||||||
|
return orderLabels.slice(0, count)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Current order from edge data | 从边数据获取当前顺序
|
||||||
|
const currentOrder = computed(() => props.data?.promptOrder || 1)
|
||||||
|
|
||||||
|
// Calculate bezier path | 计算贝塞尔路径
|
||||||
|
const path = computed(() => {
|
||||||
|
const [edgePath] = getBezierPath({
|
||||||
|
sourceX: props.sourceX,
|
||||||
|
sourceY: props.sourceY,
|
||||||
|
targetX: props.targetX,
|
||||||
|
targetY: props.targetY,
|
||||||
|
sourcePosition: props.sourcePosition,
|
||||||
|
targetPosition: props.targetPosition
|
||||||
|
})
|
||||||
|
return edgePath
|
||||||
|
})
|
||||||
|
|
||||||
|
// Label position (center of edge) | 标签位置(边的中心)
|
||||||
|
const labelX = computed(() => (props.sourceX + props.targetX) / 2)
|
||||||
|
const labelY = computed(() => (props.sourceY + props.targetY) / 2)
|
||||||
|
|
||||||
|
// Edge style | 边样式
|
||||||
|
const edgeStyle = computed(() => ({
|
||||||
|
stroke: '#10b981',
|
||||||
|
strokeWidth: 2,
|
||||||
|
...props.style
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Handle order selection | 处理顺序选择
|
||||||
|
const handleOrderSelect = (newOrder) => {
|
||||||
|
// Get all text edges connected to the same target | 获取连接到同一目标的所有文本边
|
||||||
|
const sameTargetTextEdges = edges.value.filter(edge =>
|
||||||
|
edge.target === props.target &&
|
||||||
|
edge.type === 'promptOrder'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Find edge currently using this order | 查找当前使用此顺序的边
|
||||||
|
const edgeWithSameOrder = sameTargetTextEdges.find(edge =>
|
||||||
|
edge.id !== props.id &&
|
||||||
|
edge.data?.promptOrder === newOrder
|
||||||
|
)
|
||||||
|
|
||||||
|
// If another edge has this order, swap with current | 如果另一条边有此顺序,则交换
|
||||||
|
if (edgeWithSameOrder) {
|
||||||
|
updateEdgeData(edgeWithSameOrder.id, { promptOrder: currentOrder.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current edge order | 更新当前边顺序
|
||||||
|
updateEdgeData(props.id, { promptOrder: newOrder })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
795
web/canvas-app/src/components/nodes/ImageConfigNode.vue
Normal file
@@ -0,0 +1,795 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Image config node wrapper | 文生图配置节点包裹层 -->
|
||||||
|
<div class="image-config-node-wrapper" @mouseenter="showHandleMenu = true" @mouseleave="showHandleMenu = false">
|
||||||
|
<!-- Image config node | 文生图配置节点 -->
|
||||||
|
<div
|
||||||
|
class="image-config-node bg-[var(--bg-secondary)] rounded-xl border min-w-[300px] transition-all duration-200"
|
||||||
|
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'">
|
||||||
|
<!-- Header | 头部 -->
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<span
|
||||||
|
v-if="!isEditingLabel"
|
||||||
|
@dblclick="startEditLabel"
|
||||||
|
class="text-sm font-medium text-[var(--text-secondary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
|
||||||
|
title="双击编辑名称"
|
||||||
|
>{{ data.label }}</span>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
ref="labelInputRef"
|
||||||
|
v-model="editingLabelValue"
|
||||||
|
@blur="finishEditLabel"
|
||||||
|
@keydown.enter="finishEditLabel"
|
||||||
|
@keydown.escape="cancelEditLabel"
|
||||||
|
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] px-1 rounded outline-none border border-blue-500"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="复制节点">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<CopyOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="删除节点">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<TrashOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config options | 配置选项 -->
|
||||||
|
<div class="p-3 space-y-3">
|
||||||
|
<!-- Model selector | 模型选择 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">模型</span>
|
||||||
|
<n-dropdown trigger="click" :options="modelOptions" @select="handleModelSelect">
|
||||||
|
<button class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||||||
|
{{ displayModelName }}
|
||||||
|
<n-icon :size="12"><ChevronDownOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quality selector | 画质选择 -->
|
||||||
|
<div v-if="hasQualityOptions" class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">画质</span>
|
||||||
|
<n-dropdown trigger="click" :options="qualityOptions" @select="handleQualitySelect">
|
||||||
|
<button class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||||||
|
{{ displayQuality }}
|
||||||
|
<n-icon :size="12"><ChevronForwardOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Size selector | 尺寸选择 -->
|
||||||
|
<div v-if="hasSizeOptions" class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">尺寸</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<n-dropdown trigger="click" :options="sizeOptions" @select="handleSizeSelect">
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||||||
|
{{ displaySize }}
|
||||||
|
<n-icon :size="12">
|
||||||
|
<ChevronForwardOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model tips | 模型提示 -->
|
||||||
|
<div v-if="currentModelConfig?.tips" class="text-xs text-[var(--text-tertiary)] bg-[var(--bg-tertiary)] rounded px-2 py-1">
|
||||||
|
💡 {{ currentModelConfig.tips }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connected inputs indicator | 连接输入指示 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 text-xs text-[var(--text-secondary)] py-1 border-t border-[var(--border-color)]">
|
||||||
|
<span class="px-2 py-0.5 rounded-full"
|
||||||
|
:class="connectedPrompts.length > 0 ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
|
||||||
|
提示词 {{ connectedPrompts.length > 0 ? `${connectedPrompts.length}个` : '○' }}
|
||||||
|
</span>
|
||||||
|
<span class="px-2 py-0.5 rounded-full"
|
||||||
|
:class="connectedRefImages.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'">
|
||||||
|
参考图 {{ connectedRefImages.length > 0 ? `${connectedRefImages.length}张` : '○' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generate button | 生成按钮 -->
|
||||||
|
<div v-if="hasConnectedImageWithContent" class="flex gap-2">
|
||||||
|
<!-- Create new (primary) | 新建节点(主按钮) -->
|
||||||
|
<button @click="handleGenerate('new')" :disabled="loading || !canGenerate"
|
||||||
|
class="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<n-spin v-if="loading" :size="14" />
|
||||||
|
<template v-else>
|
||||||
|
<n-icon :size="14"><AddOutline /></n-icon>
|
||||||
|
新建生成
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
<!-- Replace existing (secondary) | 替换现有(次按钮) -->
|
||||||
|
<button @click="handleGenerate('replace')" :disabled="loading || !canGenerate"
|
||||||
|
class="flex-shrink-0 flex items-center justify-center gap-1 py-2 px-2.5 rounded-lg border border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-color)] hover:text-[var(--accent-color)] text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<n-spin v-if="loading" :size="14" />
|
||||||
|
<template v-else>
|
||||||
|
<n-icon :size="14"><RefreshOutline /></n-icon>
|
||||||
|
替换
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button v-else @click="handleGenerate('auto')" :disabled="loading || !canGenerate"
|
||||||
|
class="w-full flex items-center justify-center gap-2 py-2 px-4 rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<n-spin v-if="loading" :size="14" />
|
||||||
|
<template v-else>
|
||||||
|
<span
|
||||||
|
class="text-[var(--accent-color)] bg-white rounded-full w-4 h-4 flex items-center justify-center text-xs">◆</span>
|
||||||
|
立即生成
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
<div v-if="!canGenerate" class="text-xs text-amber-500 mt-2">
|
||||||
|
当前环境未配置该图片模型 API,只能预览和选择模型参数。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error message | 错误信息 -->
|
||||||
|
<div v-if="error" class="text-xs text-red-500 mt-2">
|
||||||
|
{{ error.message || '生成失败' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generated images preview | 生成图片预览 -->
|
||||||
|
<!-- <div v-if="generatedImages.length > 0" class="mt-3 space-y-2">
|
||||||
|
<div class="text-xs text-[var(--text-secondary)]">生成结果:</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2 max-w-[240px]">
|
||||||
|
<div
|
||||||
|
v-for="(img, idx) in generatedImages"
|
||||||
|
:key="idx"
|
||||||
|
class="aspect-square rounded-lg overflow-hidden bg-[var(--bg-tertiary)] max-w-[110px]"
|
||||||
|
>
|
||||||
|
<img :src="img.url" class="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Handles | 连接点 -->
|
||||||
|
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
|
||||||
|
<NodeHandleMenu :nodeId="id" nodeType="imageConfig" :visible="showHandleMenu" :operations="operations" @select="handleSelect" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Image config node component | 文生图配置节点组件
|
||||||
|
* Configuration panel for text-to-image generation with API integration
|
||||||
|
*/
|
||||||
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||||
|
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { NIcon, NDropdown, NSpin } from 'naive-ui'
|
||||||
|
import { ChevronDownOutline, ChevronForwardOutline, CopyOutline, TrashOutline, RefreshOutline, AddOutline, ImageOutline, CreateOutline } from '@vicons/ionicons5'
|
||||||
|
import { useImageGeneration } from '../../hooks'
|
||||||
|
import { updateNode, addNode, addEdge, nodes, edges, duplicateNode, removeNode } from '../../stores/canvas'
|
||||||
|
import NodeHandleMenu from './NodeHandleMenu.vue'
|
||||||
|
import { useModelStore } from '../../stores/pinia'
|
||||||
|
import { getModelSizeOptions, getModelQualityOptions, getModelConfig, DEFAULT_IMAGE_MODEL, DEFAULT_IMAGE_SIZE } from '../../stores/models'
|
||||||
|
import { parseMentions } from '../../hooks/useNodeRef'
|
||||||
|
|
||||||
|
// 使用 Pinia store 获取模型选项(根据渠道过滤)
|
||||||
|
const modelStore = useModelStore()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: String,
|
||||||
|
data: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
// Vue Flow instance | Vue Flow 实例
|
||||||
|
const { updateNodeInternals } = useVueFlow()
|
||||||
|
|
||||||
|
// API config state | API 配置状态
|
||||||
|
const isConfigured = computed(() => !!modelStore.currentApiKey)
|
||||||
|
const hasAvailableImageRuntime = computed(() => {
|
||||||
|
const runtimeModels = modelStore.runtimeImageModels || []
|
||||||
|
return runtimeModels.length === 0 || runtimeModels.some(model => model.available !== false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Image generation hook | 图片生成 hook
|
||||||
|
const { loading, error, images: generatedImages, generate } = useImageGeneration()
|
||||||
|
|
||||||
|
// Local state | 本地状态
|
||||||
|
const showHandleMenu = ref(false)
|
||||||
|
const localModel = ref(props.data?.model || DEFAULT_IMAGE_MODEL)
|
||||||
|
const localSize = ref(props.data?.size || DEFAULT_IMAGE_SIZE)
|
||||||
|
const localQuality = ref(props.data?.quality || 'standard')
|
||||||
|
|
||||||
|
// Label editing state | Label 编辑状态
|
||||||
|
const isEditingLabel = ref(false)
|
||||||
|
const editingLabelValue = ref('')
|
||||||
|
const labelInputRef = ref(null)
|
||||||
|
|
||||||
|
// ImageConfig node menu operations | 图片配置节点菜单操作
|
||||||
|
const operations = [
|
||||||
|
// { type: 'imageConfig', label: '图生图', icon: ImageOutline, action: 'imageConfig_imageConfig' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Handle menu select | 处理菜单选择
|
||||||
|
const handleSelect = (item) => {
|
||||||
|
const action = item.action
|
||||||
|
|
||||||
|
if (action === 'imageConfig_imageConfig') {
|
||||||
|
// Image-to-image (create new image node for editing) | 图生图(创建新图片节点用于编辑)
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Create new image node for editing
|
||||||
|
const imageNodeId = addNode('image', { x: nodeX + 400, y: nodeY }, {
|
||||||
|
label: '图片编辑'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect current config to new image node
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: imageNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => updateNodeInternals(imageNodeId), 50)
|
||||||
|
window.$message?.success('已创建图片编辑节点')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current model config | 获取当前模型配置
|
||||||
|
const currentModelConfig = computed(() => getModelConfig(localModel.value))
|
||||||
|
const canGenerate = computed(() => (
|
||||||
|
isConfigured.value &&
|
||||||
|
hasAvailableImageRuntime.value &&
|
||||||
|
currentModelConfig.value?.available !== false
|
||||||
|
))
|
||||||
|
|
||||||
|
// Model options from Pinia store (filtered by provider) | 从 Pinia store 获取模型选项(根据渠道过滤)
|
||||||
|
const modelOptions = computed(() => modelStore.allImageModelOptions)
|
||||||
|
|
||||||
|
// Display model name | 显示模型名称
|
||||||
|
const displayModelName = computed(() => {
|
||||||
|
const model = modelOptions.value.find(m => m.key === localModel.value)
|
||||||
|
// 如果当前模型不在选项中,尝试从 allImageModels 找到
|
||||||
|
if (!model) {
|
||||||
|
const allModel = modelStore.allImageModels.find(m => m.key === localModel.value)
|
||||||
|
return allModel?.label || localModel.value || '选择模型'
|
||||||
|
}
|
||||||
|
return model?.label || localModel.value || '选择模型'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Quality options based on model | 基于模型的画质选项
|
||||||
|
const qualityOptions = computed(() => {
|
||||||
|
return getModelQualityOptions(localModel.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if model has quality options | 检查模型是否有画质选项
|
||||||
|
const hasQualityOptions = computed(() => {
|
||||||
|
return qualityOptions.value && qualityOptions.value.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Display quality | 显示画质
|
||||||
|
const displayQuality = computed(() => {
|
||||||
|
const option = qualityOptions.value.find(o => o.key === localQuality.value)
|
||||||
|
return option?.label || '标准画质'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Size options based on model and quality | 基于模型和画质的尺寸选项
|
||||||
|
const sizeOptions = computed(() => {
|
||||||
|
return getModelSizeOptions(localModel.value, localQuality.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if model has size options | 检查模型是否有尺寸选项
|
||||||
|
const hasSizeOptions = computed(() => {
|
||||||
|
const config = getModelConfig(localModel.value)
|
||||||
|
return config?.sizes && config.sizes.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Display size with label | 显示尺寸(带标签)
|
||||||
|
const displaySize = computed(() => {
|
||||||
|
const option = sizeOptions.value.find(o => o.key === localSize.value)
|
||||||
|
return option?.label || localSize.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize on mount | 挂载时初始化
|
||||||
|
onMounted(() => {
|
||||||
|
// 检查当前模型是否在可用模型列表中
|
||||||
|
const availableModels = modelStore.availableImageModels
|
||||||
|
const isModelAvailable = availableModels.some(m => m.key === localModel.value)
|
||||||
|
|
||||||
|
if (!localModel.value || !isModelAvailable) {
|
||||||
|
// 使用 store 中的默认模型或第一个可用模型
|
||||||
|
const selected = availableModels.find(m => m.key === modelStore.selectedImageModel)?.key
|
||||||
|
localModel.value = selected || availableModels[0]?.key || DEFAULT_IMAGE_MODEL
|
||||||
|
updateNode(props.id, { model: localModel.value })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 解析 textNode 内容中的 @ 引用,转换为简短引用(如 图 1)并收集图片
|
||||||
|
const resolveTextMentionsForImage = (textNode) => {
|
||||||
|
const content = textNode.data?.content || ''
|
||||||
|
const mentions = parseMentions(content)
|
||||||
|
|
||||||
|
if (mentions.length === 0) {
|
||||||
|
return { resolvedContent: content, refImages: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集引用的图片节点
|
||||||
|
const imageMentions = []
|
||||||
|
for (const mention of mentions) {
|
||||||
|
const referencedNode = nodes.value.find(n => n.id === mention.nodeId)
|
||||||
|
if (referencedNode?.type === 'image') {
|
||||||
|
const imageData = referencedNode.data?.base64 || referencedNode.data?.url
|
||||||
|
if (imageData) {
|
||||||
|
imageMentions.push({
|
||||||
|
order: mention.order,
|
||||||
|
nodeId: mention.nodeId,
|
||||||
|
imageData
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageMentions.length === 0) {
|
||||||
|
return { resolvedContent: content, refImages: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按出现顺序排序
|
||||||
|
imageMentions.sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
|
// 替换 @[nodeId] 为按顺序的 "图1"、"图2" 等
|
||||||
|
let resolvedContent = content
|
||||||
|
for (let i = 0; i < imageMentions.length; i++) {
|
||||||
|
const mention = imageMentions[i]
|
||||||
|
const placeholder = `@[${mention.nodeId}]`
|
||||||
|
// 按排序后的索引替换为 "图1"、"图2" 等
|
||||||
|
resolvedContent = resolvedContent.replace(placeholder, `图${i + 1}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回解析后的内容和图片数组(按引用顺序)
|
||||||
|
const refImages = imageMentions.map(m => m.imageData)
|
||||||
|
|
||||||
|
return { resolvedContent, refImages }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed connected prompts (sorted by order) | 计算连接的提示词(按顺序排列)
|
||||||
|
const connectedPrompts = computed(() => {
|
||||||
|
return getConnectedInputs().prompts
|
||||||
|
})
|
||||||
|
|
||||||
|
// Computed connected reference images | 计算连接的参考图
|
||||||
|
const connectedRefImages = computed(() => {
|
||||||
|
return getConnectedInputs().refImages
|
||||||
|
})
|
||||||
|
|
||||||
|
// 已连接的文本节点 ID 列表(用于 @ 提及时过滤)
|
||||||
|
const connectedTextNodeIds = computed(() => {
|
||||||
|
const incomingEdges = edges.value.filter(e => e.target === props.id)
|
||||||
|
const connectedIds = []
|
||||||
|
for (const edge of incomingEdges) {
|
||||||
|
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||||||
|
if (sourceNode?.type === 'text') {
|
||||||
|
connectedIds.push(sourceNode.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return connectedIds
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get connected nodes | 获取连接的节点
|
||||||
|
const getConnectedInputs = () => {
|
||||||
|
// 1. First check @ mentions | 首先检查 @ 引用
|
||||||
|
// Only check connected TextNodes | 只检查已连接的 TextNode
|
||||||
|
const textNodes = nodes.value.filter(n => n.type === 'text' && connectedTextNodeIds.value.includes(n.id))
|
||||||
|
const mentionsPrompts = []
|
||||||
|
const mentionsRefImages = []
|
||||||
|
|
||||||
|
for (const textNode of textNodes) {
|
||||||
|
const { resolvedContent, refImages: nodeRefImages } = resolveTextMentionsForImage(textNode)
|
||||||
|
|
||||||
|
// 如果有解析出图片引用
|
||||||
|
if (nodeRefImages.length > 0) {
|
||||||
|
// 添加解析后的提示词内容
|
||||||
|
mentionsPrompts.push({
|
||||||
|
order: mentionsPrompts.length,
|
||||||
|
content: resolvedContent,
|
||||||
|
nodeId: textNode.id
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加参考图
|
||||||
|
for (const imageData of nodeRefImages) {
|
||||||
|
mentionsRefImages.push({
|
||||||
|
order: mentionsRefImages.length,
|
||||||
|
imageData,
|
||||||
|
nodeId: textNode.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get edge-connected ImageNodes | 获取边连接的 ImageNode
|
||||||
|
const connectedEdges = edges.value.filter(e => e.target === props.id)
|
||||||
|
const edgeRefImages = [] // Array of { order, imageData, nodeId } | 参考图数组
|
||||||
|
|
||||||
|
for (const edge of connectedEdges) {
|
||||||
|
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||||||
|
if (!sourceNode) continue
|
||||||
|
|
||||||
|
if (sourceNode.type === 'image') {
|
||||||
|
// Prefer base64, fallback to url | 优先使用 base64,回退到 url
|
||||||
|
const imageData = sourceNode.data?.base64 || sourceNode.data?.url
|
||||||
|
if (imageData) {
|
||||||
|
// Get order from edge data, default to 1 | 从边数据获取顺序,默认为1
|
||||||
|
// Add offset of @ mentions count | 加上 @ 提及图片数量的偏移
|
||||||
|
const baseOrder = edge.data?.imageOrder || 1
|
||||||
|
const order = mentionsRefImages.length + baseOrder
|
||||||
|
edgeRefImages.push({ order, imageData, nodeId: sourceNode.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Merge and sort refImages | 合并并排序参考图
|
||||||
|
// Combine @ mentions refImages and edge-connected refImages | 合并 @ 提及和边连接的图片
|
||||||
|
const allRefImages = [...mentionsRefImages, ...edgeRefImages]
|
||||||
|
// Sort by order | 按顺序排序
|
||||||
|
allRefImages.sort((a, b) => a.order - b.order)
|
||||||
|
const sortedRefImages = allRefImages.map(r => r.imageData)
|
||||||
|
|
||||||
|
// 4. If there are @ mentions, use them | 如果有 @ 提及,使用它们
|
||||||
|
if (mentionsPrompts.length > 0) {
|
||||||
|
// Sort prompts by order | 按顺序排序提示词
|
||||||
|
mentionsPrompts.sort((a, b) => a.order - b.order)
|
||||||
|
const combinedPrompt = mentionsPrompts.map(p => p.content).join('\n\n')
|
||||||
|
|
||||||
|
return {
|
||||||
|
prompt: combinedPrompt,
|
||||||
|
prompts: mentionsPrompts,
|
||||||
|
refImages: sortedRefImages,
|
||||||
|
refImagesWithOrder: allRefImages,
|
||||||
|
fromMentions: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Fallback to edge connections | 降级到边的连接
|
||||||
|
// (only prompts, no @ mentions) (只有提示词,没有 @ 提及)
|
||||||
|
const prompts = [] // Array of { order, content } | 提示词数组
|
||||||
|
|
||||||
|
for (const edge of connectedEdges) {
|
||||||
|
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||||||
|
if (!sourceNode) continue
|
||||||
|
|
||||||
|
if (sourceNode.type === 'text') {
|
||||||
|
const content = sourceNode.data?.content || ''
|
||||||
|
if (content) {
|
||||||
|
// Get order from edge data, default to 1 | 从边数据获取顺序,默认为1
|
||||||
|
const order = edge.data?.promptOrder || 1
|
||||||
|
prompts.push({ order, content, nodeId: sourceNode.id })
|
||||||
|
}
|
||||||
|
} else if (sourceNode.type === 'llmConfig') {
|
||||||
|
// LLM node output as prompt | LLM 节点输出作为提示词
|
||||||
|
const content = sourceNode.data?.outputContent || ''
|
||||||
|
if (content) {
|
||||||
|
const order = edge.data?.promptOrder || 1
|
||||||
|
prompts.push({ order, content, nodeId: sourceNode.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Note: ImageNode handling moved to step 2 above | 注意:ImageNode 处理已移至步骤 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort prompts by order and concatenate | 按顺序排序并拼接
|
||||||
|
prompts.sort((a, b) => a.order - b.order)
|
||||||
|
const combinedPrompt = prompts.map(p => p.content).join('\n\n')
|
||||||
|
|
||||||
|
// Use edge-connected refImages (already sorted above) | 使用边连接的参考图(已在上面排序)
|
||||||
|
return { prompt: combinedPrompt, prompts, refImages: sortedRefImages, refImagesWithOrder: allRefImages, fromMentions: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle model selection | 处理模型选择
|
||||||
|
const handleModelSelect = (key) => {
|
||||||
|
localModel.value = key
|
||||||
|
const config = getModelConfig(key)
|
||||||
|
|
||||||
|
// 同步 Quality 到模型默认值
|
||||||
|
if (config?.defaultParams?.quality) {
|
||||||
|
localQuality.value = config.defaultParams.quality
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步 Size 到模型默认值
|
||||||
|
const newSizeOptions = getModelSizeOptions(key, localQuality.value)
|
||||||
|
let defaultSize = config?.defaultParams?.size
|
||||||
|
|
||||||
|
if (!defaultSize && newSizeOptions.length > 0) {
|
||||||
|
defaultSize = newSizeOptions.find(o => o.key === DEFAULT_IMAGE_SIZE)?.key
|
||||||
|
|| newSizeOptions.find(o => o.key.includes('1024'))?.key
|
||||||
|
|| newSizeOptions[0].key
|
||||||
|
}
|
||||||
|
|
||||||
|
localSize.value = defaultSize
|
||||||
|
|
||||||
|
// 更新节点数据
|
||||||
|
updateNode(props.id, {
|
||||||
|
model: key,
|
||||||
|
quality: localQuality.value,
|
||||||
|
size: defaultSize
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle quality selection | 处理画质选择
|
||||||
|
const handleQualitySelect = (quality) => {
|
||||||
|
localQuality.value = quality
|
||||||
|
// Update size to first option of new quality | 更新尺寸为新画质的第一个选项
|
||||||
|
const newSizeOptions = getModelSizeOptions(localModel.value, quality)
|
||||||
|
if (newSizeOptions.length > 0) {
|
||||||
|
const defaultSize = newSizeOptions.find(o => o.key === DEFAULT_IMAGE_SIZE)?.key
|
||||||
|
localSize.value = defaultSize || newSizeOptions[0].key
|
||||||
|
updateNode(props.id, { quality, size: localSize.value })
|
||||||
|
} else {
|
||||||
|
updateNode(props.id, { quality })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle size selection | 处理尺寸选择
|
||||||
|
const handleSizeSelect = (size) => {
|
||||||
|
localSize.value = size
|
||||||
|
updateNode(props.id, { size })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update size from manual input | 更新手动输入的尺寸
|
||||||
|
const updateSize = () => {
|
||||||
|
updateNode(props.id, { size: localSize.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created image node ID | 创建的图片节点 ID
|
||||||
|
const createdImageNodeId = ref(null)
|
||||||
|
|
||||||
|
// Find connected output image node | 查找已连接的输出图片节点
|
||||||
|
const findConnectedOutputImageNode = (onlyEmpty = true) => {
|
||||||
|
// Find edges where this node is the source | 查找以当前节点为源的边
|
||||||
|
const outputEdges = edges.value.filter(e => e.source === props.id)
|
||||||
|
|
||||||
|
for (const edge of outputEdges) {
|
||||||
|
const targetNode = nodes.value.find(n => n.id === edge.target)
|
||||||
|
if (targetNode?.type === 'image') {
|
||||||
|
if (onlyEmpty) {
|
||||||
|
// Check if target is an image node with empty or no url | 检查目标是否为空白图片节点
|
||||||
|
if (!targetNode.data?.url || targetNode.data?.url === '') {
|
||||||
|
return targetNode.id
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Return any connected image node | 返回任意连接的图片节点
|
||||||
|
return targetNode.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's a connected image node with content | 检查是否有已连接且有内容的图片节点
|
||||||
|
const hasConnectedImageWithContent = computed(() => {
|
||||||
|
const outputEdges = edges.value.filter(e => e.source === props.id)
|
||||||
|
|
||||||
|
for (const edge of outputEdges) {
|
||||||
|
const targetNode = nodes.value.find(n => n.id === edge.target)
|
||||||
|
if (targetNode?.type === 'image' && targetNode.data?.url && targetNode.data.url !== '') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle generate action | 处理生成操作
|
||||||
|
// mode: 'auto' = 自动判断, 'replace' = 替换现有, 'new' = 新建节点
|
||||||
|
const handleGenerate = async (mode = 'auto') => {
|
||||||
|
const { prompt, prompts, refImages, refImagesWithOrder } = getConnectedInputs()
|
||||||
|
|
||||||
|
if (!prompt && refImages.length === 0) {
|
||||||
|
window.$message?.warning('请连接文本节点(提示词)或图片节点(参考图)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log prompt order for debugging | 记录提示词顺序用于调试
|
||||||
|
if (prompts.length > 1) {
|
||||||
|
console.log('[ImageConfigNode] 拼接提示词顺序:', prompts.map(p => `${p.order}: ${p.content.substring(0, 20)}...`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log image order for debugging | 记录图片顺序用于调试
|
||||||
|
if (refImagesWithOrder && refImagesWithOrder.length > 1) {
|
||||||
|
console.log('[ImageConfigNode] 参考图顺序:', refImagesWithOrder.map(r => `${r.order}: ${r.nodeId}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConfigured.value) {
|
||||||
|
window.$message?.warning('生成接口未就绪,请稍后重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageNodeId = null
|
||||||
|
|
||||||
|
if (mode === 'replace') {
|
||||||
|
// Replace mode: find any connected image node | 替换模式:查找任意连接的图片节点
|
||||||
|
imageNodeId = findConnectedOutputImageNode(false)
|
||||||
|
if (imageNodeId) {
|
||||||
|
updateNode(imageNodeId, { loading: true, url: '' })
|
||||||
|
}
|
||||||
|
} else if (mode === 'new') {
|
||||||
|
// New mode: always create new node | 新建模式:始终创建新节点
|
||||||
|
imageNodeId = null
|
||||||
|
} else {
|
||||||
|
// Auto mode: check for empty connected node first | 自动模式:先检查空白连接节点
|
||||||
|
imageNodeId = findConnectedOutputImageNode(true)
|
||||||
|
if (imageNodeId) {
|
||||||
|
updateNode(imageNodeId, { loading: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageNodeId) {
|
||||||
|
// Get current node position | 获取当前节点位置
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Calculate Y offset if creating new node alongside existing | 如果是新建节点,计算Y偏移
|
||||||
|
let yOffset = 0
|
||||||
|
if (mode === 'new') {
|
||||||
|
const outputEdges = edges.value.filter(e => e.source === props.id)
|
||||||
|
yOffset = outputEdges.length * 280 // Stack below existing outputs | 在现有输出下方堆叠
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create image node with loading state | 创建带加载状态的图片节点
|
||||||
|
imageNodeId = addNode('image', { x: nodeX + 400, y: nodeY + yOffset }, {
|
||||||
|
url: '',
|
||||||
|
loading: true,
|
||||||
|
label: '图像生成结果'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-connect imageConfig → image | 自动连接 生图配置 → 图片
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: imageNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createdImageNodeId.value = imageNodeId
|
||||||
|
|
||||||
|
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(imageNodeId)
|
||||||
|
}, 50)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build request params | 构建请求参数
|
||||||
|
const params = {
|
||||||
|
model: localModel.value,
|
||||||
|
prompt: prompt,
|
||||||
|
size: localSize.value,
|
||||||
|
quality: localQuality.value,
|
||||||
|
n: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add reference image if provided | 如果有参考图则添加
|
||||||
|
if (refImages.length > 0) {
|
||||||
|
params.image = refImages
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await generate(params)
|
||||||
|
|
||||||
|
// Update image node with generated URL | 更新图片节点 URL
|
||||||
|
if (result && result.length > 0) {
|
||||||
|
updateNode(imageNodeId, {
|
||||||
|
url: result[0].url,
|
||||||
|
loading: false,
|
||||||
|
label: '文生图',
|
||||||
|
model: localModel.value,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark this config node as executed | 标记配置节点已执行
|
||||||
|
updateNode(props.id, { executed: true, outputNodeId: imageNodeId })
|
||||||
|
}
|
||||||
|
window.$message?.success('图片生成成功')
|
||||||
|
} catch (err) {
|
||||||
|
// Update node to show error | 更新节点显示错误
|
||||||
|
updateNode(imageNodeId, {
|
||||||
|
loading: false,
|
||||||
|
error: err.message || '生成失败',
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
window.$message?.error(err.message || '图片生成失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duplicate | 处理复制
|
||||||
|
const handleDuplicate = () => {
|
||||||
|
const newNodeId = duplicateNode(props.id)
|
||||||
|
window.$message?.success('节点已复制')
|
||||||
|
if (newNodeId) {
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(newNodeId)
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start editing label | 开始编辑 label
|
||||||
|
const startEditLabel = () => {
|
||||||
|
editingLabelValue.value = props.data?.label || ''
|
||||||
|
isEditingLabel.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
labelInputRef.value?.focus()
|
||||||
|
labelInputRef.value?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish editing label | 完成编辑 label
|
||||||
|
const finishEditLabel = () => {
|
||||||
|
const newLabel = editingLabelValue.value.trim()
|
||||||
|
if (newLabel && newLabel !== props.data?.label) {
|
||||||
|
updateNode(props.id, { label: newLabel })
|
||||||
|
}
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel editing label | 取消编辑 label
|
||||||
|
const cancelEditLabel = () => {
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delete | 处理删除
|
||||||
|
const handleDelete = () => {
|
||||||
|
removeNode(props.id)
|
||||||
|
window.$message?.success('节点已删除')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听模型变化,同步 Quality 和 Size
|
||||||
|
watch(() => props.data?.model, (newModel) => {
|
||||||
|
if (newModel && newModel !== localModel.value) {
|
||||||
|
localModel.value = newModel
|
||||||
|
const config = getModelConfig(newModel)
|
||||||
|
|
||||||
|
// 同步 Quality
|
||||||
|
if (config?.defaultParams?.quality) {
|
||||||
|
localQuality.value = config.defaultParams.quality
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步 Size
|
||||||
|
if (config?.defaultParams?.size) {
|
||||||
|
localSize.value = config.defaultParams.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 修复 Vue Flow visibility: hidden 问题
|
||||||
|
watch(() => props.data, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
updateNodeInternals(props.id)
|
||||||
|
})
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Watch for auto-execute flag | 监听自动执行标志
|
||||||
|
watch(
|
||||||
|
() => props.data?.autoExecute,
|
||||||
|
(shouldExecute) => {
|
||||||
|
if (shouldExecute && !loading.value) {
|
||||||
|
// Clear the flag first to prevent re-triggering | 先清除标志防止重复触发
|
||||||
|
updateNode(props.id, { autoExecute: false })
|
||||||
|
// Delay to ensure node connections are established | 延迟确保节点连接已建立
|
||||||
|
setTimeout(() => {
|
||||||
|
handleGenerate()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.image-config-node-wrapper {
|
||||||
|
position: relative;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-config-node {
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
994
web/canvas-app/src/components/nodes/ImageNode.vue
Normal file
@@ -0,0 +1,994 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Image node wrapper for hover area | 图片节点包裹层,扩展悬浮区域 -->
|
||||||
|
<div class="image-node-wrapper" @mouseenter="showActions = true; showHandleMenu = true" @mouseleave="showActions = false; showHandleMenu = false">
|
||||||
|
<!-- Image node | 图片节点 -->
|
||||||
|
<div
|
||||||
|
class="image-node bg-[var(--bg-secondary)] rounded-xl border min-w-[200px] max-w-[280px] relative transition-all duration-200"
|
||||||
|
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'">
|
||||||
|
<!-- Header | 头部 -->
|
||||||
|
<div class="px-3 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
v-if="!isEditingLabel"
|
||||||
|
@dblclick="startEditLabel"
|
||||||
|
class="text-sm font-medium text-[var(--text-primary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
|
||||||
|
title="双击编辑名称"
|
||||||
|
>{{ data.label || '图像生成结果' }}</span>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
ref="labelInputRef"
|
||||||
|
v-model="editingLabelValue"
|
||||||
|
@blur="finishEditLabel"
|
||||||
|
@keydown.enter="finishEditLabel"
|
||||||
|
@keydown.escape="cancelEditLabel"
|
||||||
|
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-primary)] px-1 rounded outline-none border border-blue-500"
|
||||||
|
/>
|
||||||
|
<!-- Public switch | 公开开关 -->
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<button
|
||||||
|
class="flex items-center"
|
||||||
|
title="设置公开(可被 @ 引用)"
|
||||||
|
>
|
||||||
|
<n-switch
|
||||||
|
:value="isPublic"
|
||||||
|
@update:value="handleTogglePublic"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
{{ isPublic ? '已公开: ' + (data.label || '图片') : '点击公开(可被 @ 引用)' }}
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<!-- Replace button | 替换按钮 -->
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<button @click="showReplaceModal = true" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<SwapHorizontalOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
替换图片
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip v-if="data.url" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<button @click="handlePreview" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<EyeOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
预览
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip v-if="data.url" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<button @click="handleDownload" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<DownloadOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
下载
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<CopyOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
复制节点
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<TrashOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
删除节点
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Model name | 模型名称 -->
|
||||||
|
<div v-if="data.model" class="mt-1 text-xs text-[var(--text-secondary)] truncate">
|
||||||
|
{{ data.model }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image preview area | 图片预览区域 -->
|
||||||
|
<div class="p-3">
|
||||||
|
<!-- Loading state | 加载状态 -->
|
||||||
|
<div v-if="data.loading"
|
||||||
|
class="aspect-square rounded-xl bg-gradient-to-br from-cyan-400 via-blue-300 to-amber-200 flex flex-col items-center justify-center gap-3 relative overflow-hidden">
|
||||||
|
<!-- Animated gradient overlay | 动画渐变遮罩 -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-br from-cyan-500/20 via-blue-400/20 to-amber-300/20 animate-pulse">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading image | 加载图片 -->
|
||||||
|
<div class="relative z-10">
|
||||||
|
<img src="../../assets/loading.webp" alt="Loading" class="w-14 h-12" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-sm text-white font-medium relative z-10">创作中</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state | 错误状态 -->
|
||||||
|
<div v-else-if="data.error"
|
||||||
|
class="aspect-square rounded-xl bg-red-50 dark:bg-red-900/20 flex flex-col items-center justify-center gap-2 border border-red-200 dark:border-red-800">
|
||||||
|
<n-icon :size="32" class="text-red-500">
|
||||||
|
<CloseCircleOutline />
|
||||||
|
</n-icon>
|
||||||
|
<span class="text-sm text-red-600 dark:text-red-400 text-center px-2">{{ data.error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image display | 图片显示 -->
|
||||||
|
<div
|
||||||
|
v-else-if="data.url"
|
||||||
|
class="rounded-xl overflow-hidden relative"
|
||||||
|
ref="imageContainerRef"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="displayImageUrl"
|
||||||
|
:alt="data.label"
|
||||||
|
class="w-full h-auto object-cover"
|
||||||
|
:class="{ 'pointer-events-none': isInpaintMode }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Inpaint canvas with events | 涂抹画布(带事件) -->
|
||||||
|
<canvas
|
||||||
|
v-if="isInpaintMode"
|
||||||
|
ref="canvasRef"
|
||||||
|
class="absolute inset-0 w-full h-full cursor-none z-10"
|
||||||
|
@mousedown.stop.prevent="onCanvasPaint"
|
||||||
|
@mousemove.stop="onCanvasMove"
|
||||||
|
@mouseup.stop="onPaintEnd"
|
||||||
|
@mouseleave="onPaintEnd"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Brush cursor | 画笔光标 -->
|
||||||
|
<div
|
||||||
|
v-show="brushCursor.visible && isInpaintMode"
|
||||||
|
class="absolute pointer-events-none border-2 border-purple-500 rounded-full bg-purple-400/30 transition-none"
|
||||||
|
:style="{
|
||||||
|
width: brushSize * 2 + 'px',
|
||||||
|
height: brushSize * 2 + 'px',
|
||||||
|
left: brushCursor.x - brushSize + 'px',
|
||||||
|
top: brushCursor.y - brushSize + 'px'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Inpaint toolbar | 涂抹工具栏 -->
|
||||||
|
<div
|
||||||
|
v-show="isInpaintMode"
|
||||||
|
class="absolute top-1.5 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-2 py-1 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm rounded-full shadow-md border border-gray-200/80 dark:border-gray-700 z-[9999]"
|
||||||
|
@mousedown.stop
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<!-- Mode indicator | 模式指示 -->
|
||||||
|
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 pr-1.5 border-r border-gray-200 dark:border-gray-600">
|
||||||
|
<n-icon :size="12"><BrushOutline /></n-icon>
|
||||||
|
<span>擦除</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Size slider | 大小滑块 -->
|
||||||
|
<div class="flex items-center gap-1 w-16">
|
||||||
|
<div class="w-1.5 h-1.5 rounded-full bg-purple-400"></div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
v-model="brushSize"
|
||||||
|
min="10"
|
||||||
|
max="80"
|
||||||
|
class="w-full h-0.5 bg-gray-200 rounded-lg appearance-none cursor-pointer slider-purple"
|
||||||
|
/>
|
||||||
|
<div class="w-2.5 h-2.5 rounded-full bg-purple-400"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reset button | 重置按钮 -->
|
||||||
|
<button
|
||||||
|
@click="clearMask"
|
||||||
|
class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||||
|
title="清除"
|
||||||
|
>
|
||||||
|
<n-icon :size="12" class="text-gray-400"><RefreshOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Apply button | 应用按钮 -->
|
||||||
|
<button
|
||||||
|
@click="applyInpaint"
|
||||||
|
class="px-2 py-0.5 bg-purple-500 hover:bg-purple-600 text-white text-xs rounded transition-colors"
|
||||||
|
>
|
||||||
|
应用
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL Loading state | URL 加载状态 -->
|
||||||
|
<div v-else-if="urlLoading"
|
||||||
|
class="aspect-square rounded-xl bg-gradient-to-br from-cyan-400 via-blue-300 to-amber-200 flex flex-col items-center justify-center gap-3 relative overflow-hidden">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-br from-cyan-500/20 via-blue-400/20 to-amber-300/20 animate-pulse"></div>
|
||||||
|
<div class="relative z-10">
|
||||||
|
<img src="../../assets/loading.webp" alt="Loading" class="w-14 h-12" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-white font-medium relative z-10">加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload placeholder | 上传占位 -->
|
||||||
|
<div v-else class="rounded-xl bg-[var(--bg-tertiary)] border-2 border-dashed border-[var(--border-color)] p-3">
|
||||||
|
<!-- Upload area | 上传区域 -->
|
||||||
|
<div class="aspect-video flex flex-col items-center justify-center gap-2 relative cursor-pointer hover:bg-[var(--bg-secondary)] rounded-lg transition-colors">
|
||||||
|
<n-icon :size="32" class="text-[var(--text-secondary)]">
|
||||||
|
<ImageOutline />
|
||||||
|
</n-icon>
|
||||||
|
<span class="text-sm text-[var(--text-secondary)] text-center">拖放图片或点击上传</span>
|
||||||
|
<input type="file" accept="image/*" class="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
@change="handleFileUpload" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider | 分割线 -->
|
||||||
|
<div class="flex items-center gap-2 my-3">
|
||||||
|
<div class="flex-1 h-px bg-[var(--border-color)]"></div>
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">或</span>
|
||||||
|
<div class="flex-1 h-px bg-[var(--border-color)]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL input | URL 输入 -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="urlInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="输入图片地址..."
|
||||||
|
class="flex-1 px-2 py-1 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg outline-none focus:border-[var(--accent-color)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]"
|
||||||
|
@keydown.enter="handleUrlSubmit"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="handleUrlSubmit"
|
||||||
|
:disabled="!urlInput.trim()"
|
||||||
|
class="px-3 py-2 text-xs bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||||
|
>
|
||||||
|
预览
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Handles | 连接点 -->
|
||||||
|
<NodeHandleMenu :nodeId="id" nodeType="image" :visible="showHandleMenu" :operations="operations" @select="handleSelect" />
|
||||||
|
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image preview dialog | 图片预览弹窗 -->
|
||||||
|
<n-image-preview
|
||||||
|
v-model:show="showRef"
|
||||||
|
:src="displayImageUrl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Replace image modal | 替换图片弹窗 -->
|
||||||
|
<n-modal v-model:show="showReplaceModal" preset="card" title="替换图片" class="w-[400px]" :mask-closable="true">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Upload area | 上传区域 -->
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed border-[var(--border-color)] rounded-xl p-4 cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
@click="replaceFileInputRef?.click()"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<n-icon :size="32" class="text-[var(--text-secondary)]">
|
||||||
|
<ImageOutline />
|
||||||
|
</n-icon>
|
||||||
|
<span class="text-sm text-[var(--text-secondary)]">点击上传图片</span>
|
||||||
|
<input
|
||||||
|
ref="replaceFileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleReplaceFileUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider | 分割线 -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex-1 h-px bg-[var(--border-color)]"></div>
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">或</span>
|
||||||
|
<div class="flex-1 h-px bg-[var(--border-color)]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL input | URL 输入 -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="replaceUrlInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="输入图片地址..."
|
||||||
|
class="flex-1 px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg outline-none focus:border-[var(--accent-color)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]"
|
||||||
|
@keydown.enter="handleReplaceUrlSubmit"
|
||||||
|
/>
|
||||||
|
<n-button type="primary" size="small" :disabled="!replaceUrlInput.trim()" @click="handleReplaceUrlSubmit">
|
||||||
|
确认
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Image node component | 图片节点组件
|
||||||
|
* Displays and manages image content with loading state
|
||||||
|
*/
|
||||||
|
import { ref, nextTick, computed } from 'vue'
|
||||||
|
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { NIcon, NTooltip, NSwitch, NImagePreview, NModal, NButton } from 'naive-ui'
|
||||||
|
import { TrashOutline, ExpandOutline, ImageOutline, CloseCircleOutline, CopyOutline, VideocamOutline, DownloadOutline, EyeOutline, BrushOutline, RefreshOutline, ColorWandOutline, SwapHorizontalOutline } from '@vicons/ionicons5'
|
||||||
|
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
|
||||||
|
import { uploadCanvasImage } from '../../hooks/useApi'
|
||||||
|
import { useCachedMediaUrl } from '../../hooks/useCachedMediaUrl'
|
||||||
|
import NodeHandleMenu from './NodeHandleMenu.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: String,
|
||||||
|
data: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
// Vue Flow instance | Vue Flow 实例
|
||||||
|
const { updateNodeInternals } = useVueFlow()
|
||||||
|
const { cachedUrl: displayImageUrl, warmCache: warmImageCache } = useCachedMediaUrl(() => props.data?.url)
|
||||||
|
|
||||||
|
// Hover state | 悬浮状态
|
||||||
|
const showActions = ref(true)
|
||||||
|
const showHandleMenu = ref(false)
|
||||||
|
|
||||||
|
// Label editing state | Label 编辑状态
|
||||||
|
const isEditingLabel = ref(false)
|
||||||
|
const editingLabelValue = ref('')
|
||||||
|
const labelInputRef = ref(null)
|
||||||
|
|
||||||
|
// URL input state | URL 输入状态
|
||||||
|
const urlInput = ref('')
|
||||||
|
const urlLoading = ref(false)
|
||||||
|
|
||||||
|
// Replace modal state | 替换弹窗状态
|
||||||
|
const showReplaceModal = ref(false)
|
||||||
|
const replaceUrlInput = ref('')
|
||||||
|
const replaceFileInputRef = ref(null)
|
||||||
|
|
||||||
|
// Inpainting state | 涂抹重绘状态
|
||||||
|
const isInpaintMode = ref(false)
|
||||||
|
const brushSize = ref(40)
|
||||||
|
const isDrawing = ref(false)
|
||||||
|
const canvasRef = ref(null)
|
||||||
|
const imageContainerRef = ref(null)
|
||||||
|
const interactionLayerRef = ref(null)
|
||||||
|
const brushCursor = ref({ x: 0, y: 0, visible: false })
|
||||||
|
const maskData = ref(null)
|
||||||
|
|
||||||
|
|
||||||
|
// Computed public props status | 计算是否公开
|
||||||
|
const isPublic = computed(() => {
|
||||||
|
return props.data?.publicProps?.name != null && props.data?.publicProps?.name !== ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle toggle public | 处理切换公开状态
|
||||||
|
const handleTogglePublic = (value) => {
|
||||||
|
if (value) {
|
||||||
|
// 公开:使用节点名称
|
||||||
|
const name = props.data?.label || '图片'
|
||||||
|
updateNode(props.id, {
|
||||||
|
publicProps: { name }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 取消公开
|
||||||
|
updateNode(props.id, {
|
||||||
|
publicProps: {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image node menu operations | 图片节点菜单操作
|
||||||
|
const operations = [
|
||||||
|
{ type: 'imageConfig', label: '图生图', icon: ImageOutline, action: 'image_imageConfig' },
|
||||||
|
{ type: 'videoConfig', label: '生视频', icon: VideocamOutline, action: 'image_videoConfig' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Handle menu select | 处理菜单选择
|
||||||
|
const handleSelect = (item) => {
|
||||||
|
const action = item.action
|
||||||
|
|
||||||
|
if (action === 'image_imageConfig') {
|
||||||
|
// Image-to-image workflow | 图生图工作流
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
const sourceUrl = currentNode?.data?.url
|
||||||
|
|
||||||
|
if (!sourceUrl) {
|
||||||
|
window.$message?.warning('当前图片节点没有图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create text node for prompt
|
||||||
|
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
|
||||||
|
content: '',
|
||||||
|
label: '提示词'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create imageConfig node
|
||||||
|
const configNodeId = addNode('imageConfig', { x: nodeX + 900, y: nodeY }, {
|
||||||
|
model: 'auto',
|
||||||
|
size: '1024x1536',
|
||||||
|
label: '生图配置'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect edges
|
||||||
|
addEdge({ source: props.id, target: configNodeId, sourceHandle: 'right', targetHandle: 'left' })
|
||||||
|
addEdge({ source: textNodeId, target: configNodeId, sourceHandle: 'right', targetHandle: 'left' })
|
||||||
|
|
||||||
|
setTimeout(() => updateNodeInternals([textNodeId, configNodeId]), 50)
|
||||||
|
window.$message?.success('已创建图生图工作流')
|
||||||
|
} else if (action === 'image_videoConfig') {
|
||||||
|
// Video generation workflow | 视频生成工作流
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Create text node for prompt
|
||||||
|
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
|
||||||
|
content: '',
|
||||||
|
label: '提示词'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create videoConfig node
|
||||||
|
const configNodeId = addNode('videoConfig', { x: nodeX + 600, y: nodeY }, {
|
||||||
|
label: '视频生成'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect image to videoConfig
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left',
|
||||||
|
type: 'imageRole',
|
||||||
|
data: { imageRole: 'first_frame_image' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect text to videoConfig
|
||||||
|
addEdge({
|
||||||
|
source: textNodeId,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => updateNodeInternals([textNodeId, configNodeId]), 50)
|
||||||
|
window.$message?.success('已创建视频生成工作流')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle inpaint mode | 切换涂抹模式
|
||||||
|
const toggleInpaintMode = () => {
|
||||||
|
isInpaintMode.value = !isInpaintMode.value
|
||||||
|
if (isInpaintMode.value) {
|
||||||
|
nextTick(() => initCanvas())
|
||||||
|
} else {
|
||||||
|
clearMask()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize canvas | 初始化画布
|
||||||
|
const initCanvas = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
// Set canvas internal size to match its CSS rendered size | 设置画布内部尺寸匹配 CSS 渲染尺寸
|
||||||
|
// clientWidth/clientHeight give the CSS box size
|
||||||
|
canvas.width = canvas.clientWidth
|
||||||
|
canvas.height = canvas.clientHeight
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure canvas size matches display | 确保画布尺寸匹配显示
|
||||||
|
const syncCanvasSize = () => {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return
|
||||||
|
if (canvas.width !== canvas.clientWidth || canvas.height !== canvas.clientHeight) {
|
||||||
|
canvas.width = canvas.clientWidth
|
||||||
|
canvas.height = canvas.clientHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas paint handlers | 画布绘制处理器
|
||||||
|
const onCanvasPaint = (e) => {
|
||||||
|
syncCanvasSize()
|
||||||
|
isDrawing.value = true
|
||||||
|
paintAt(e.offsetX, e.offsetY)
|
||||||
|
brushCursor.value = { x: e.offsetX, y: e.offsetY, visible: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCanvasMove = (e) => {
|
||||||
|
brushCursor.value = { x: e.offsetX, y: e.offsetY, visible: true }
|
||||||
|
if (isDrawing.value) {
|
||||||
|
paintAt(e.offsetX, e.offsetY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPaintEnd = () => {
|
||||||
|
isDrawing.value = false
|
||||||
|
brushCursor.value.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paint at coordinates | 在坐标绘制
|
||||||
|
const paintAt = (x, y) => {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(x, y, brushSize.value, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = 'rgba(139, 92, 246, 0.5)'
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide brush cursor | 隐藏画笔光标
|
||||||
|
const hideBrushCursor = () => {
|
||||||
|
brushCursor.value.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear mask | 清除蒙版
|
||||||
|
const clearMask = () => {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
maskData.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply inpaint and create workflow | 应用重绘并创建工作流
|
||||||
|
const applyInpaint = () => {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas || canvas.width === 0 || canvas.height === 0) {
|
||||||
|
window.$message?.error('画布未初始化')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the original image and resize mask to match | 获取原图并调整蒙版大小匹配
|
||||||
|
const container = imageContainerRef.value
|
||||||
|
const img = container?.querySelector('img')
|
||||||
|
if (!img) {
|
||||||
|
window.$message?.error('未找到图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mask at original image resolution | 创建原图分辨率的蒙版
|
||||||
|
const maskCanvas = document.createElement('canvas')
|
||||||
|
const imgWidth = img.naturalWidth || img.width
|
||||||
|
const imgHeight = img.naturalHeight || img.height
|
||||||
|
maskCanvas.width = imgWidth
|
||||||
|
maskCanvas.height = imgHeight
|
||||||
|
const maskCtx = maskCanvas.getContext('2d')
|
||||||
|
|
||||||
|
// Fill black background | 填充黑色背景
|
||||||
|
maskCtx.fillStyle = '#000000'
|
||||||
|
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height)
|
||||||
|
|
||||||
|
// Scale factor from display to original | 从显示尺寸到原图的缩放因子
|
||||||
|
const scaleX = imgWidth / canvas.width
|
||||||
|
const scaleY = imgHeight / canvas.height
|
||||||
|
|
||||||
|
// Get painted areas and scale to original resolution | 获取绑制区域并缩放到原图分辨率
|
||||||
|
const originalData = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Draw scaled white areas on mask | 在蒙版上绘制缩放后的白色区域
|
||||||
|
maskCtx.fillStyle = '#FFFFFF'
|
||||||
|
for (let y = 0; y < canvas.height; y++) {
|
||||||
|
for (let x = 0; x < canvas.width; x++) {
|
||||||
|
const i = (y * canvas.width + x) * 4
|
||||||
|
if (originalData.data[i + 3] > 0) {
|
||||||
|
// Scale and draw | 缩放并绘制
|
||||||
|
maskCtx.fillRect(
|
||||||
|
Math.floor(x * scaleX),
|
||||||
|
Math.floor(y * scaleY),
|
||||||
|
Math.ceil(scaleX),
|
||||||
|
Math.ceil(scaleY)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to base64 (remove data URL prefix for API) | 转换为 base64(移除前缀用于 API)
|
||||||
|
const dataUrl = maskCanvas.toDataURL('image/png')
|
||||||
|
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '')
|
||||||
|
maskData.value = base64Data
|
||||||
|
|
||||||
|
// Create inpaint workflow | 创建重绘工作流
|
||||||
|
createInpaintWorkflow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inpaint workflow | 创建重绘工作流
|
||||||
|
const createInpaintWorkflow = () => {
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Create text node for prompt | 创建文本节点用于提示词
|
||||||
|
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
|
||||||
|
content: '请输入重绘提示词...',
|
||||||
|
label: '重绘提示词'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create imageConfig node for inpainting | 创建图生图配置节点
|
||||||
|
const configNodeId = addNode('imageConfig', { x: nodeX + 600, y: nodeY }, {
|
||||||
|
model: 'auto',
|
||||||
|
size: '1024x1536',
|
||||||
|
label: '局部重绘',
|
||||||
|
inpaintMode: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update current node with mask data | 更新当前节点的蒙版数据
|
||||||
|
updateNode(props.id, {
|
||||||
|
maskData: maskData.value,
|
||||||
|
hasInpaintMask: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect image node to config node | 连接图片节点到配置节点
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect text node to config node | 连接文本节点到配置节点
|
||||||
|
addEdge({
|
||||||
|
source: textNodeId,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Exit inpaint mode | 退出涂抹模式
|
||||||
|
isInpaintMode.value = false
|
||||||
|
|
||||||
|
// Force Vue Flow to recalculate | 强制重新计算
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals([textNodeId, configNodeId])
|
||||||
|
}, 50)
|
||||||
|
|
||||||
|
window.$message?.success('已创建局部重绘工作流')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file upload | 处理文件上传
|
||||||
|
const handleFileUpload = async (event) => {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (file) {
|
||||||
|
try {
|
||||||
|
urlLoading.value = true
|
||||||
|
const uploaded = await uploadCanvasImage(file)
|
||||||
|
updateNode(props.id, {
|
||||||
|
url: uploaded.url,
|
||||||
|
sourceJobId: uploaded.jobId,
|
||||||
|
sourceFrameIdx: uploaded.frameIdx,
|
||||||
|
fileName: file.name,
|
||||||
|
fileType: file.type,
|
||||||
|
label: '参考图',
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('File upload error:', err)
|
||||||
|
window.$message?.error('图片上传失败')
|
||||||
|
} finally {
|
||||||
|
urlLoading.value = false
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle URL submit | 处理 URL 提交
|
||||||
|
const handleUrlSubmit = () => {
|
||||||
|
const url = urlInput.value.trim()
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
// Validate URL format | 验证 URL 格式
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
window.$message?.warning('请输入有效的图片地址 (http:// 或 https://)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state | 显示加载状态
|
||||||
|
urlLoading.value = true
|
||||||
|
|
||||||
|
// Preload image to check validity | 预加载图片检查有效性
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
// Update node with URL | 更新节点 URL
|
||||||
|
updateNode(props.id, {
|
||||||
|
url: url,
|
||||||
|
label: '网络图片',
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
urlInput.value = ''
|
||||||
|
urlLoading.value = false
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
window.$message?.error('图片加载失败,请检查地址是否正确')
|
||||||
|
urlLoading.value = false
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Handle replace file upload | 处理替换文件上传
|
||||||
|
const handleReplaceFileUpload = async (event) => {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (file) {
|
||||||
|
try {
|
||||||
|
urlLoading.value = true
|
||||||
|
const uploaded = await uploadCanvasImage(file)
|
||||||
|
updateNode(props.id, {
|
||||||
|
url: uploaded.url,
|
||||||
|
sourceJobId: uploaded.jobId,
|
||||||
|
sourceFrameIdx: uploaded.frameIdx,
|
||||||
|
fileName: file.name,
|
||||||
|
fileType: file.type,
|
||||||
|
label: '参考图',
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
showReplaceModal.value = false
|
||||||
|
replaceUrlInput.value = ''
|
||||||
|
window.$message?.success('图片已替换')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('File upload error:', err)
|
||||||
|
window.$message?.error('图片上传失败')
|
||||||
|
} finally {
|
||||||
|
urlLoading.value = false
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle replace URL submit | 处理替换 URL 提交
|
||||||
|
const handleReplaceUrlSubmit = () => {
|
||||||
|
const url = replaceUrlInput.value.trim()
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
window.$message?.warning('请输入有效的图片地址 (http:// 或 https://)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
updateNode(props.id, {
|
||||||
|
url: url,
|
||||||
|
label: '网络图片',
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
showReplaceModal.value = false
|
||||||
|
replaceUrlInput.value = ''
|
||||||
|
window.$message?.success('图片已替换')
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
window.$message?.error('图片加载失败,请检查地址是否正确')
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start editing label | 开始编辑 label
|
||||||
|
const startEditLabel = () => {
|
||||||
|
editingLabelValue.value = props.data?.label || '图像生成结果'
|
||||||
|
isEditingLabel.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
labelInputRef.value?.focus()
|
||||||
|
labelInputRef.value?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish editing label | 完成编辑 label
|
||||||
|
const finishEditLabel = () => {
|
||||||
|
const newLabel = editingLabelValue.value.trim()
|
||||||
|
if (newLabel && newLabel !== props.data?.label) {
|
||||||
|
updateNode(props.id, { label: newLabel })
|
||||||
|
}
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel editing label | 取消编辑 label
|
||||||
|
const cancelEditLabel = () => {
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delete | 处理删除
|
||||||
|
const handleDelete = () => {
|
||||||
|
removeNode(props.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duplicate | 处理复制
|
||||||
|
const handleDuplicate = () => {
|
||||||
|
const newId = duplicateNode(props.id)
|
||||||
|
if (newId) {
|
||||||
|
// Clear selection and select the new node | 清除选中并选中新节点
|
||||||
|
updateNode(props.id, { selected: false })
|
||||||
|
updateNode(newId, { selected: true })
|
||||||
|
window.$message?.success('节点已复制')
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(newId)
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle image generation | 处理图片生图(图生图)
|
||||||
|
const handleImageGen = () => {
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Create text node for prompt | 创建文本节点用于提示词
|
||||||
|
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
|
||||||
|
content: '',
|
||||||
|
label: '提示词'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create ImageNode for editing | 创建图片编辑节点
|
||||||
|
const imageNodeId = addNode('image', { x: nodeX + 600, y: nodeY }, {
|
||||||
|
url: props.data.url, // Pass the current image as input
|
||||||
|
label: '图生图',
|
||||||
|
refImage: props.data.url // Mark as reference image
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create imageConfig node for generation | 创建生图配置节点
|
||||||
|
const configNodeId = addNode('imageConfig', { x: nodeX + 900, y: nodeY }, {
|
||||||
|
model: 'auto',
|
||||||
|
size: '1024x1536',
|
||||||
|
label: '生图配置'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect image node to new image node | 连接当前图片节点到新图片节点
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: imageNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect new image node to config node | 连接新图片节点到配置节点
|
||||||
|
addEdge({
|
||||||
|
source: imageNodeId,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect text node to config node | 连接文本节点到配置节点
|
||||||
|
addEdge({
|
||||||
|
source: textNodeId,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals([textNodeId, imageNodeId, configNodeId])
|
||||||
|
}, 50)
|
||||||
|
|
||||||
|
window.$message?.success('已创建图生图工作流')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview state | 预览状态
|
||||||
|
const showRef = ref(false)
|
||||||
|
|
||||||
|
// Handle preview | 处理预览
|
||||||
|
const handlePreview = () => {
|
||||||
|
if (props.data.url) {
|
||||||
|
warmImageCache()
|
||||||
|
showRef.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle download | 处理下载
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (props.data.url) {
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = displayImageUrl.value || props.data.url
|
||||||
|
link.download = props.data.fileName || `image_${Date.now()}.png`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.$message?.success('图片下载中...')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle video generation | 处理视频生成
|
||||||
|
const handleVideoGen = () => {
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Create text node for prompt | 创建文本节点用于提示词
|
||||||
|
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
|
||||||
|
content: '',
|
||||||
|
label: '提示词'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create videoConfig node | 创建视频配置节点
|
||||||
|
const configNodeId = addNode('videoConfig', { x: nodeX + 600, y: nodeY }, {
|
||||||
|
label: '视频生成'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect image node to config node with role | 连接图片节点到配置节点并设置角色
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left',
|
||||||
|
type: 'imageRole',
|
||||||
|
data: { imageRole: 'first_frame_image' } // Default to first frame | 默认首帧
|
||||||
|
})
|
||||||
|
|
||||||
|
// Connect text node to config node | 连接文本节点到配置节点
|
||||||
|
addEdge({
|
||||||
|
source: textNodeId,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals([textNodeId, configNodeId])
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.image-node-wrapper {
|
||||||
|
position: relative;
|
||||||
|
padding-right: 50px;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-node {
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slider styling | 滑块样式 */
|
||||||
|
.slider-purple::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #8b5cf6;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-purple::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #8b5cf6;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inpaint mode cursor | 涂抹模式光标 */
|
||||||
|
.cursor-none {
|
||||||
|
cursor: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1220
web/canvas-app/src/components/nodes/LLMConfigNode.vue
Normal file
232
web/canvas-app/src/components/nodes/NodeHandleMenu.vue
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Right handle with expandable menu | 右侧连接点带展开菜单 -->
|
||||||
|
<div class="handle-menu-anchor">
|
||||||
|
<!-- Vue Flow handle for edge connections - visible and draggable | 可见且可拖拽的 Vue Flow 连接点 -->
|
||||||
|
<Handle type="source" :position="Position.Right" id="right" style="width: 12px; height: 12px;" />
|
||||||
|
|
||||||
|
<!-- Hover zone with + icon | 带 + 图标的悬浮区域 -->
|
||||||
|
<div v-if="true && showHandleHoverZone" class="handle-hover-zone"
|
||||||
|
@mouseenter="handleMouseEnter"
|
||||||
|
@mouseleave="handleMouseLeave">
|
||||||
|
<n-icon :size="14" class="add-icon">
|
||||||
|
<AddOutline />
|
||||||
|
</n-icon>
|
||||||
|
<transition name="menu-fade">
|
||||||
|
<div v-if="showMenu" class="handle-menu"
|
||||||
|
@mouseenter="handleMenuMouseEnter"
|
||||||
|
@mouseleave="handleMenuMouseLeave"
|
||||||
|
@mousedown.stop>
|
||||||
|
<button v-for="item in menuItems" :key="item.type" @click.stop="handleCreate(item)" class="menu-item group">
|
||||||
|
<n-icon :size="14" class="text-gray-500 group-hover:text-white">
|
||||||
|
<component :is="item.icon" />
|
||||||
|
</n-icon>
|
||||||
|
<span class="menu-label">{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { Handle, Position } from '@vue-flow/core'
|
||||||
|
import { NIcon } from 'naive-ui'
|
||||||
|
import { AddOutline } from '@vicons/ionicons5'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
nodeId: { type: String, required: true },
|
||||||
|
nodeType: { type: String, required: true },
|
||||||
|
visible: { type: Boolean },
|
||||||
|
dotColor: { type: String, default: 'var(--accent-color)' },
|
||||||
|
operations: { type: Array, default: null } // 传空数组则不显示 handle-hover-zone
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emit select event to parent component | 向父组件发送选择事件
|
||||||
|
const emit = defineEmits(['select'])
|
||||||
|
|
||||||
|
const showMenu = ref(false)
|
||||||
|
let hideTimeout = null
|
||||||
|
|
||||||
|
// Handle mouse enter with delay cancellation
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (hideTimeout) {
|
||||||
|
clearTimeout(hideTimeout)
|
||||||
|
hideTimeout = null
|
||||||
|
}
|
||||||
|
showMenu.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle mouse leave with delay
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
hideTimeout = setTimeout(() => {
|
||||||
|
showMenu.value = false
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle menu mouse enter - cancel hide timeout
|
||||||
|
const handleMenuMouseEnter = () => {
|
||||||
|
if (hideTimeout) {
|
||||||
|
clearTimeout(hideTimeout)
|
||||||
|
hideTimeout = null
|
||||||
|
}
|
||||||
|
showMenu.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle menu mouse leave with delay
|
||||||
|
const handleMenuMouseLeave = () => {
|
||||||
|
hideTimeout = setTimeout(() => {
|
||||||
|
showMenu.value = false
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu items from operations prop | 从 operations prop 获取菜单项
|
||||||
|
const menuItems = computed(() => {
|
||||||
|
return props.operations || []
|
||||||
|
})
|
||||||
|
|
||||||
|
// Whether to show handle-hover-zone | 是否显示 handle-hover-zone
|
||||||
|
const showHandleHoverZone = computed(() => {
|
||||||
|
return props.operations && props.operations.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emit select event to parent component | 向父组件发送选择事件
|
||||||
|
const handleCreate = (item) => {
|
||||||
|
emit('select', item)
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Anchor sits at the right edge center of the parent node | 锚点在父节点右边缘中心 */
|
||||||
|
.handle-menu-anchor {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(50%, -50%);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover zone - hidden by default, show on anchor hover | 默认隐藏,锚点 hover 时显示 */
|
||||||
|
.handle-hover-zone {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: -30px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary, #2a2a3e);
|
||||||
|
border: 1px solid var(--border-color, #444);
|
||||||
|
opacity: 1;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show hover zone when anchor is hovered | 锚点 hover 时显示悬浮区域 */
|
||||||
|
.handle-menu-anchor:hover .handle-hover-zone {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-hover-zone:hover {
|
||||||
|
background: var(--accent-color, #8b5cf6);
|
||||||
|
border-color: var(--accent-color, #8b5cf6);
|
||||||
|
transform: translate(-50%, -50%) scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add icon | 添加图标 */
|
||||||
|
.add-icon {
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-hover-zone:hover .add-icon {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visible dot | 可见圆点 */
|
||||||
|
.handle-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.6);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-dot.is-active {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
box-shadow: 0 0 8px rgba(139, 92, 246, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu floats to the right of the dot | 菜单浮在圆点右侧 */
|
||||||
|
.handle-menu {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + 8px);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--bg-secondary, #1e1e2e);
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
background: var(--accent-color, #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu divider | 菜单分隔线 */
|
||||||
|
.menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color, #333);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation | 动画 */
|
||||||
|
.menu-fade-enter-active,
|
||||||
|
.menu-fade-leave-active {
|
||||||
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-fade-enter-from,
|
||||||
|
.menu-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-4px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
858
web/canvas-app/src/components/nodes/TextNode.vue
Normal file
@@ -0,0 +1,858 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Text node wrapper | 文本节点包裹层 -->
|
||||||
|
<div class="text-node-wrapper" @mouseenter="showHandleMenu = true" @mouseleave="showHandleMenu = false">
|
||||||
|
<!-- Text node | 文本节点 -->
|
||||||
|
<div
|
||||||
|
class="text-node bg-[var(--bg-secondary)] rounded-xl border min-w-[280px] max-w-[350px] relative transition-all duration-200"
|
||||||
|
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'">
|
||||||
|
<!-- Header | 头部 -->
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<span
|
||||||
|
v-if="!isEditingLabel"
|
||||||
|
@dblclick="startEditLabel"
|
||||||
|
class="text-sm font-medium text-[var(--text-secondary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
|
||||||
|
title="双击编辑名称"
|
||||||
|
>{{ data.label }}</span>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
ref="labelInputRef"
|
||||||
|
v-model="editingLabelValue"
|
||||||
|
@blur="finishEditLabel"
|
||||||
|
@keydown.enter="finishEditLabel"
|
||||||
|
@keydown.escape="cancelEditLabel"
|
||||||
|
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] px-1 rounded outline-none border border-blue-500"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="复制节点">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<CopyOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="删除节点">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<TrashOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
<!-- <button class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="展开">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<ExpandOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content | 内容 -->
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="textarea-wrapper" ref="textareaWrapper">
|
||||||
|
<!-- 可编辑的文本区域(支持 @ 引用图片显示)参考 MaterialInput -->
|
||||||
|
<div
|
||||||
|
ref="editorRef"
|
||||||
|
class="editor-content"
|
||||||
|
contenteditable="true"
|
||||||
|
@input="handleInput"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
@paste="handlePaste"
|
||||||
|
@blur="updateContent"
|
||||||
|
@wheel.stop
|
||||||
|
@mousedown.stop
|
||||||
|
:data-placeholder="placeholder"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<!-- Polish button | 润色按钮 -->
|
||||||
|
<button
|
||||||
|
@click="handlePolish"
|
||||||
|
:disabled="isPolishing || !plainText.trim()"
|
||||||
|
class="mt-2 px-3 py-1.5 text-xs rounded-lg bg-[var(--bg-tertiary)] hover:bg-[var(--accent-color)] hover:text-white border border-[var(--border-color)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<n-spin v-if="isPolishing" :size="12" />
|
||||||
|
<span v-else>✨</span>
|
||||||
|
AI 润色
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Handles | 连接点 -->
|
||||||
|
<NodeHandleMenu :nodeId="id" nodeType="text" :visible="showHandleMenu" :operations="operations" @select="handleSelect" />
|
||||||
|
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mentions picker | @ 选择器 -->
|
||||||
|
<MentionsPicker
|
||||||
|
v-model:visible="showMentionsPicker"
|
||||||
|
:position="mentionsPosition"
|
||||||
|
context="text"
|
||||||
|
@select="handleMentionSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Text node component | 文本节点组件
|
||||||
|
* Allows user to input and edit text content
|
||||||
|
*/
|
||||||
|
import { ref, watch, nextTick, computed, onMounted } from 'vue'
|
||||||
|
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { NIcon, NSpin } from 'naive-ui'
|
||||||
|
import { TrashOutline, ExpandOutline, CopyOutline, ImageOutline, VideocamOutline, ChatbubbleOutline, CreateOutline } from '@vicons/ionicons5'
|
||||||
|
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
|
||||||
|
import NodeHandleMenu from './NodeHandleMenu.vue'
|
||||||
|
import MentionsPicker from '../MentionsPicker.vue'
|
||||||
|
import { useChat } from '../../hooks'
|
||||||
|
import { useModelStore } from '../../stores/pinia'
|
||||||
|
import { parseMentions } from '../../hooks/useNodeRef'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: String,
|
||||||
|
data: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
// Vue Flow instance | Vue Flow 实例
|
||||||
|
const { updateNodeInternals } = useVueFlow()
|
||||||
|
|
||||||
|
// API config state | API 配置状态
|
||||||
|
const modelStore = useModelStore()
|
||||||
|
const isApiConfigured = computed(() => !!modelStore.currentApiKey)
|
||||||
|
|
||||||
|
// Chat hook for polish | 润色用的 Chat hook
|
||||||
|
const { send: sendChat } = useChat({
|
||||||
|
systemPrompt: '你是一个专业的 AI 绘画提示词编辑。只优化用户已经给出的主体、风格、光线、构图和细节,不添加用户没有提到的品牌、产品或营销话术。直接返回提示词,不要其他解释。',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
mode: 'image',
|
||||||
|
targetLanguage: 'en'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Local content state | 本地内容状态
|
||||||
|
const showHandleMenu = ref(false)
|
||||||
|
const content = ref(props.data?.content || '')
|
||||||
|
const placeholder = '请输入文本内容,输入 @ 可引用图片节点...'
|
||||||
|
|
||||||
|
// Label editing state | Label 编辑状态
|
||||||
|
const isEditingLabel = ref(false)
|
||||||
|
const editingLabelValue = ref('')
|
||||||
|
const labelInputRef = ref(null)
|
||||||
|
|
||||||
|
// Polish loading state | 润色加载状态
|
||||||
|
const isPolishing = ref(false)
|
||||||
|
|
||||||
|
// Mentions picker state | @ 选择器状态
|
||||||
|
const showMentionsPicker = ref(false)
|
||||||
|
const mentionsPosition = ref({ x: 0, y: 0 })
|
||||||
|
const editorRef = ref(null)
|
||||||
|
const textareaWrapper = ref(null)
|
||||||
|
const mentionSearchStart = ref(-1) // @ 触发搜索的起始位置
|
||||||
|
const lastContent = ref('') // 上一次的内容,用于检测变化
|
||||||
|
|
||||||
|
// ============ 参考 MaterialInput 的逻辑 ============
|
||||||
|
|
||||||
|
// 从 contenteditable 中提取纯文本(将 chip 转为 @label)
|
||||||
|
const getEditableText = () => {
|
||||||
|
const el = editorRef.value
|
||||||
|
if (!el) return ''
|
||||||
|
let text = ''
|
||||||
|
const walk = (node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
text += node.textContent
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
if (node.classList?.contains('mention-chip')) {
|
||||||
|
text += `@[${node.dataset.nodeId}]`
|
||||||
|
} else if (node.tagName === 'BR') {
|
||||||
|
text += '\n'
|
||||||
|
} else {
|
||||||
|
node.childNodes.forEach(walk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.childNodes.forEach(walk)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 DOM 光标位置计算纯文本中的位置(考虑 mention-chip 的转换)
|
||||||
|
const getTextPositionBeforeCursor = (editor, range) => {
|
||||||
|
const container = editor
|
||||||
|
let textLength = 0
|
||||||
|
let found = false
|
||||||
|
|
||||||
|
const walk = (node) => {
|
||||||
|
if (found) return
|
||||||
|
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const nodeLength = node.textContent.length
|
||||||
|
if (range.startContainer === node) {
|
||||||
|
textLength += range.startOffset
|
||||||
|
found = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
textLength += nodeLength
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
if (node.classList?.contains('mention-chip')) {
|
||||||
|
// mention-chip 在纯文本中算作 @[nodeId]
|
||||||
|
const replacement = `@[${node.dataset.nodeId || ''}]`
|
||||||
|
if (range.startContainer === node || isNodeInside(node, range.startContainer)) {
|
||||||
|
// 光标在 mention-chip 内部
|
||||||
|
found = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
textLength += replacement.length
|
||||||
|
} else if (node.tagName === 'BR') {
|
||||||
|
textLength += 1
|
||||||
|
} else {
|
||||||
|
for (const child of node.childNodes) {
|
||||||
|
walk(child)
|
||||||
|
if (found) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(container)
|
||||||
|
return textLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查节点是否在父节点内部
|
||||||
|
const isNodeInside = (parent, child) => {
|
||||||
|
let node = child
|
||||||
|
while (node) {
|
||||||
|
if (node === parent) return true
|
||||||
|
node = node.parentNode
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 mention chip 元素
|
||||||
|
const createMentionChip = (node) => {
|
||||||
|
const chip = document.createElement('span')
|
||||||
|
chip.className = 'mention-chip'
|
||||||
|
chip.contentEditable = 'false'
|
||||||
|
chip.dataset.nodeId = node.id
|
||||||
|
chip.dataset.label = node.data?.publicProps?.name || node.data?.label || '图片'
|
||||||
|
|
||||||
|
if (node.data?.url) {
|
||||||
|
const img = document.createElement('img')
|
||||||
|
img.src = node.data.url
|
||||||
|
img.className = 'mention-chip-thumb'
|
||||||
|
chip.appendChild(img)
|
||||||
|
} else {
|
||||||
|
const iconWrap = document.createElement('span')
|
||||||
|
iconWrap.className = 'mention-chip-icon'
|
||||||
|
iconWrap.textContent = '📷'
|
||||||
|
chip.appendChild(iconWrap)
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = document.createElement('span')
|
||||||
|
label.className = 'mention-chip-label'
|
||||||
|
label.textContent = chip.dataset.label
|
||||||
|
chip.appendChild(label)
|
||||||
|
|
||||||
|
return chip
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在 contenteditable 中插入 mention chip(替换 @searchText)
|
||||||
|
const insertMentionChipDOM = (node) => {
|
||||||
|
const el = editorRef.value
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
// 遍历文本节点,找到最后一个 @
|
||||||
|
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||||||
|
let lastAtNode = null
|
||||||
|
let lastAtOffset = -1
|
||||||
|
|
||||||
|
while (walker.nextNode()) {
|
||||||
|
const idx = walker.currentNode.textContent.lastIndexOf('@')
|
||||||
|
if (idx !== -1) {
|
||||||
|
lastAtNode = walker.currentNode
|
||||||
|
lastAtOffset = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastAtNode || lastAtOffset === -1) return
|
||||||
|
|
||||||
|
const chip = createMentionChip(node)
|
||||||
|
const spaceNode = document.createTextNode('\u00A0')
|
||||||
|
const beforeText = lastAtNode.textContent.substring(0, lastAtOffset)
|
||||||
|
|
||||||
|
if (beforeText) {
|
||||||
|
lastAtNode.textContent = beforeText
|
||||||
|
lastAtNode.parentNode.insertBefore(chip, lastAtNode.nextSibling)
|
||||||
|
lastAtNode.parentNode.insertBefore(spaceNode, chip.nextSibling)
|
||||||
|
} else {
|
||||||
|
const parent = lastAtNode.parentNode
|
||||||
|
parent.insertBefore(chip, lastAtNode)
|
||||||
|
parent.insertBefore(spaceNode, chip.nextSibling)
|
||||||
|
parent.removeChild(lastAtNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 光标移到空格之后
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStartAfter(spaceNode)
|
||||||
|
range.collapse(true)
|
||||||
|
const sel = window.getSelection()
|
||||||
|
sel.removeAllRanges()
|
||||||
|
sel.addRange(range)
|
||||||
|
|
||||||
|
// 同步文本
|
||||||
|
isInternalUpdate = true
|
||||||
|
content.value = getEditableText()
|
||||||
|
lastContent.value = content.value
|
||||||
|
nextTick(() => { isInternalUpdate = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 contenteditable 内容(纯文本)
|
||||||
|
const setEditableContent = (text) => {
|
||||||
|
if (!editorRef.value) return
|
||||||
|
editorRef.value.innerHTML = ''
|
||||||
|
if (text) {
|
||||||
|
editorRef.value.textContent = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫描 contenteditable 文本节点,将 @label 或 @[nodeId] 自动转为 chip
|
||||||
|
const convertTextMentionsToChips = () => {
|
||||||
|
const el = editorRef.value
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
// 获取所有可引用的图片节点(需要公开的)
|
||||||
|
const imageNodes = nodes.value.filter(n => n.type === 'image' && n.data?.publicProps?.name)
|
||||||
|
if (imageNodes.length === 0) return
|
||||||
|
|
||||||
|
// 快速检查:无 @ 直接跳过
|
||||||
|
if (!el.textContent.includes('@')) return
|
||||||
|
|
||||||
|
// 优先匹配 @[nodeId] 格式
|
||||||
|
const nodeIdPattern = /@\[([^\]|]+)(?:\|([^\]]+))?\]/g
|
||||||
|
|
||||||
|
// 收集需要替换的文本节点(跳过 chip 内部)
|
||||||
|
const targets = []
|
||||||
|
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||||||
|
while (walker.nextNode()) {
|
||||||
|
const node = walker.currentNode
|
||||||
|
if (node.parentElement?.closest('.mention-chip')) continue
|
||||||
|
nodeIdPattern.lastIndex = 0
|
||||||
|
if (nodeIdPattern.test(node.textContent)) {
|
||||||
|
targets.push(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targets.length === 0) return
|
||||||
|
|
||||||
|
// 替换文本节点为 chip + 文本片段
|
||||||
|
targets.forEach(textNode => {
|
||||||
|
const text = textNode.textContent
|
||||||
|
nodeIdPattern.lastIndex = 0
|
||||||
|
const fragment = document.createDocumentFragment()
|
||||||
|
let lastIdx = 0
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = nodeIdPattern.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIdx) {
|
||||||
|
fragment.appendChild(document.createTextNode(text.slice(lastIdx, match.index)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过 nodeId 查找节点
|
||||||
|
const nodeId = match[1]
|
||||||
|
const node = imageNodes.find(n => n.id === nodeId)
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
fragment.appendChild(createMentionChip(node))
|
||||||
|
fragment.appendChild(document.createTextNode('\u00A0'))
|
||||||
|
} else {
|
||||||
|
fragment.appendChild(document.createTextNode(match[0]))
|
||||||
|
}
|
||||||
|
lastIdx = nodeIdPattern.lastIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIdx < text.length) {
|
||||||
|
fragment.appendChild(document.createTextNode(text.slice(lastIdx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
textNode.parentNode.replaceChild(fragment, textNode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防抖版本(用于输入事件,避免频繁 DOM 操作)
|
||||||
|
let _convertTimer = null
|
||||||
|
const debouncedConvertMentions = () => {
|
||||||
|
if (_convertTimer) clearTimeout(_convertTimer)
|
||||||
|
_convertTimer = setTimeout(convertTextMentionsToChips, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聚焦 contenteditable 并将光标移到末尾
|
||||||
|
const focusEditableEnd = () => {
|
||||||
|
const el = editorRef.value
|
||||||
|
if (!el) return
|
||||||
|
el.focus()
|
||||||
|
const range = document.createRange()
|
||||||
|
range.selectNodeContents(el)
|
||||||
|
range.collapse(false)
|
||||||
|
const sel = window.getSelection()
|
||||||
|
sel.removeAllRanges()
|
||||||
|
sel.addRange(range)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle paste - 参考 MaterialInput,纯文本粘贴
|
||||||
|
const handlePaste = (e) => {
|
||||||
|
// 纯文本粘贴(防止粘入富文本)
|
||||||
|
e.preventDefault()
|
||||||
|
const text = e.clipboardData?.getData('text/plain') || ''
|
||||||
|
document.execCommand('insertText', false, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内部更新标志
|
||||||
|
let isInternalUpdate = false
|
||||||
|
|
||||||
|
// @ 提及预览列表(已移除,改为在 editor 中直接显示)
|
||||||
|
|
||||||
|
// 获取纯文本(用于 AI 润色)
|
||||||
|
const plainText = computed(() => {
|
||||||
|
return content.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 将 @[nodeId] 转换为带图片的 HTML
|
||||||
|
const editorHtml = computed(() => {
|
||||||
|
let html = content.value
|
||||||
|
// 转义 HTML 特殊字符
|
||||||
|
html = html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
|
||||||
|
// 替换 @[nodeId] 为图片
|
||||||
|
html = html.replace(/@\[([^\]|]+)(?:\|([^\]]+))?\]/g, (match, nodeId) => {
|
||||||
|
const node = nodes.value.find(n => n.id === nodeId)
|
||||||
|
if (node?.type === 'image' && node.data?.url) {
|
||||||
|
const displayName = node.data?.publicProps?.name || node.data?.label || '图片'
|
||||||
|
return `<span class="mention-inline" data-node-id="${nodeId}"><img src="${node.data.url}" alt="${displayName}" />${displayName}</span>`
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
|
||||||
|
// 换行符转换为 <br>
|
||||||
|
html = html.replace(/\n/g, '<br>')
|
||||||
|
|
||||||
|
return html
|
||||||
|
})
|
||||||
|
|
||||||
|
// Text node menu operations | 文本节点菜单操作
|
||||||
|
const operations = [
|
||||||
|
{ type: 'imageConfig', label: '生图', icon: ImageOutline },
|
||||||
|
{ type: 'videoConfig', label: '生视频', icon: VideocamOutline },
|
||||||
|
{ type: 'llmConfig', label: 'LLM', icon: ChatbubbleOutline }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Handle menu select | 处理菜单选择
|
||||||
|
const handleSelect = (item) => {
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
const defaultData = {
|
||||||
|
imageConfig: { model: 'auto', size: '1024x1536', label: '文生图' },
|
||||||
|
videoConfig: { label: '视频生成' },
|
||||||
|
llmConfig: { label: 'LLM文本生成' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const newId = addNode(item.type, { x: nodeX + 400, y: nodeY }, defaultData[item.type] || {})
|
||||||
|
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: newId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => updateNodeInternals(newId), 50)
|
||||||
|
window.$message?.success(`已创建${item.label}节点`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle input for @ trigger | 处理 @ 触发输入(参考 MaterialInput)
|
||||||
|
const handleInput = (e) => {
|
||||||
|
const editor = e.target
|
||||||
|
isInternalUpdate = true
|
||||||
|
content.value = getEditableText()
|
||||||
|
lastContent.value = content.value
|
||||||
|
nextTick(() => { isInternalUpdate = false })
|
||||||
|
|
||||||
|
// 触发文本到 chip 的转换
|
||||||
|
debouncedConvertMentions()
|
||||||
|
|
||||||
|
// 获取光标位置
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection.rangeCount) return
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
// 使用辅助函数计算纯文本中的光标位置
|
||||||
|
const cursorPos = getTextPositionBeforeCursor(editor, range)
|
||||||
|
const fullText = getEditableText()
|
||||||
|
const textBeforeCursor = fullText.slice(0, cursorPos)
|
||||||
|
|
||||||
|
// Check if cursor is after @ character | 检查光标是否在 @ 字符后
|
||||||
|
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||||||
|
|
||||||
|
if (lastAtIndex !== -1) {
|
||||||
|
// Check if there's a space after @ (meaning user finished typing mention) | 检查 @ 后面是否有空格(用户已完成输入)
|
||||||
|
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1)
|
||||||
|
|
||||||
|
// Check if there's a complete @[...] mention | 检查是否有完整的 @[...] 配对
|
||||||
|
const bracketMatch = textAfterAt.match(/\[([^\]]*)\]/)
|
||||||
|
const hasCompleteMention = bracketMatch !== null
|
||||||
|
|
||||||
|
// Show picker only if: @ exists, no space after @, and not part of a complete @[...] mention
|
||||||
|
if (!textAfterAt.includes(' ') && !hasCompleteMention) {
|
||||||
|
// Calculate position | 计算位置
|
||||||
|
showMentionsPicker.value = true
|
||||||
|
mentionSearchStart.value = lastAtIndex
|
||||||
|
|
||||||
|
// Get editor position relative to viewport | 获取 editor 相对于视口的位置
|
||||||
|
const rect = editor.getBoundingClientRect()
|
||||||
|
mentionsPosition.value = {
|
||||||
|
x: rect.left + 10,
|
||||||
|
y: rect.bottom + 5
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide picker if conditions not met | 如果条件不满足,隐藏选择器
|
||||||
|
showMentionsPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle keydown for mentions and Shift+Enter | 处理 @ 选择器和 Shift+Enter 换行
|
||||||
|
const handleKeydown = (e) => {
|
||||||
|
// 处理 @ 选择器
|
||||||
|
if (showMentionsPicker.value) {
|
||||||
|
// 回车键选中当前高亮的项
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
// 触发 MentionsPicker 的选择事件,需要通过自定义事件来处理
|
||||||
|
// 由于无法直接访问 MentionsPicker 的内部状态,这里暂时不做处理
|
||||||
|
// 让事件继续传播到 MentionsPicker
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
showMentionsPicker.value = false
|
||||||
|
// Remove the incomplete @ | 移除不完整的 @
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection.rangeCount) return
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
const editor = editorRef.value
|
||||||
|
const cursorPos = range.startOffset
|
||||||
|
const textBeforeCursor = content.value.slice(0, cursorPos)
|
||||||
|
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||||||
|
|
||||||
|
if (lastAtIndex !== -1) {
|
||||||
|
content.value = textBeforeCursor.slice(0, lastAtIndex) + content.value.slice(cursorPos)
|
||||||
|
lastContent.value = content.value
|
||||||
|
// Update editor content | 更新 editor 内容
|
||||||
|
nextTick(() => {
|
||||||
|
editor.innerHTML = editorHtml.value
|
||||||
|
// Set cursor position | 设置光标位置
|
||||||
|
const newRange = document.createRange()
|
||||||
|
newRange.setStart(editor.firstChild || editor, lastAtIndex)
|
||||||
|
newRange.collapse(true)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(newRange)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规范化 Shift+Enter 插入换行
|
||||||
|
if (e.key === 'Enter' && e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
document.execCommand('insertLineBreak')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle mention selection | 处理 @ 引用选择(参考 MaterialInput)
|
||||||
|
const handleMentionSelect = ({ nodeId }) => {
|
||||||
|
// 找到对应的图片节点
|
||||||
|
const node = nodes.value.find(n => n.id === nodeId)
|
||||||
|
if (!node) {
|
||||||
|
showMentionsPicker.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入 mention chip 到 DOM
|
||||||
|
insertMentionChipDOM(node)
|
||||||
|
|
||||||
|
// 更新 store
|
||||||
|
updateContent()
|
||||||
|
showMentionsPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for external data changes | 监听外部数据变化
|
||||||
|
watch(() => props.data?.content, (newVal) => {
|
||||||
|
if (newVal !== content.value) {
|
||||||
|
content.value = newVal || ''
|
||||||
|
lastContent.value = content.value
|
||||||
|
// Sync to editor | 同步到 editor
|
||||||
|
setEditableContent(content.value)
|
||||||
|
// 立即将文本中的 @label 转为 chip
|
||||||
|
nextTick(() => convertTextMentionsToChips())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch content changes and sync to editor | 监听内容变化并同步到编辑器
|
||||||
|
watch(content, (newVal) => {
|
||||||
|
if (isInternalUpdate) return
|
||||||
|
setEditableContent(newVal)
|
||||||
|
// 立即将文本中的 @label 转为 chip
|
||||||
|
nextTick(() => convertTextMentionsToChips())
|
||||||
|
lastContent.value = newVal
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize editor content | 初始化 editor 内容
|
||||||
|
onMounted(() => {
|
||||||
|
if (editorRef.value) {
|
||||||
|
if (props.data?.content) {
|
||||||
|
content.value = props.data.content
|
||||||
|
}
|
||||||
|
lastContent.value = content.value
|
||||||
|
// 使用 setEditableContent + convertTextMentionsToChips 确保正确创建 mention-chip
|
||||||
|
setEditableContent(content.value)
|
||||||
|
nextTick(() => convertTextMentionsToChips())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update content in store | 更新存储中的内容
|
||||||
|
const updateContent = () => {
|
||||||
|
updateNode(props.id, { content: content.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle AI polish | 处理 AI 润色
|
||||||
|
const handlePolish = async () => {
|
||||||
|
const input = content.value.trim()
|
||||||
|
if (!input) return
|
||||||
|
|
||||||
|
// Check API configuration | 检查 API 配置
|
||||||
|
if (!isApiConfigured.value) {
|
||||||
|
window.$message?.warning('生成接口未就绪,请稍后重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isPolishing.value = true
|
||||||
|
const originalContent = content.value
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call chat API to polish the prompt | 调用 AI 润色提示词
|
||||||
|
const result = await sendChat(input, true)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
content.value = result
|
||||||
|
updateNode(props.id, { content: result })
|
||||||
|
window.$message?.success('提示词已润色')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
content.value = originalContent
|
||||||
|
window.$message?.error(err.message || '润色失败')
|
||||||
|
} finally {
|
||||||
|
isPolishing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start editing label | 开始编辑 label
|
||||||
|
const startEditLabel = () => {
|
||||||
|
editingLabelValue.value = props.data?.label || ''
|
||||||
|
isEditingLabel.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
labelInputRef.value?.focus()
|
||||||
|
labelInputRef.value?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish editing label | 完成编辑 label
|
||||||
|
const finishEditLabel = () => {
|
||||||
|
const newLabel = editingLabelValue.value.trim()
|
||||||
|
if (newLabel && newLabel !== props.data?.label) {
|
||||||
|
updateNode(props.id, { label: newLabel })
|
||||||
|
}
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel editing label | 取消编辑 label
|
||||||
|
const cancelEditLabel = () => {
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delete | 处理删除
|
||||||
|
const handleDelete = () => {
|
||||||
|
removeNode(props.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duplicate | 处理复制
|
||||||
|
const handleDuplicate = () => {
|
||||||
|
const newNodeId = duplicateNode(props.id)
|
||||||
|
window.$message?.success('节点已复制')
|
||||||
|
if (newNodeId) {
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(newNodeId)
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle image generation | 处理图片生成
|
||||||
|
const handleImageGen = () => {
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Create imageConfig node | 创建text生图配置节点
|
||||||
|
const configNodeId = addNode('imageConfig', { x: nodeX + 400, y: nodeY }, {
|
||||||
|
model: 'auto',
|
||||||
|
size: '1024x1536',
|
||||||
|
label: '文生图'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto connect | 自动连接
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(configNodeId)
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle video generation | 处理视频生成
|
||||||
|
const handleVideoGen = () => {
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Create videoConfig node | 创建视频配置节点
|
||||||
|
const configNodeId = addNode('videoConfig', { x: nodeX + 400, y: nodeY }, {
|
||||||
|
label: '视频生成'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto connect | 自动连接
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: configNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(configNodeId)
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-node-wrapper {
|
||||||
|
padding-right: 50px;
|
||||||
|
padding-top: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-node {
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textarea wrapper - 参考 MaterialInput input-with-mention */
|
||||||
|
.textarea-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor styles | 编辑器样式 - 参考 MaterialInput */
|
||||||
|
.editor-content {
|
||||||
|
min-height: 60px;
|
||||||
|
max-height: 120px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
outline: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content:focus {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content:empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
|
||||||
|
/* Inline mention in editor | editor 中内联提及 */
|
||||||
|
.editor-content :deep(.mention-inline) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content :deep(.mention-inline img) {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 2px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mentions preview | @ 提及预览 */
|
||||||
|
.mentions-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mention chip - 参考 MaterialInput 样式 */
|
||||||
|
.mention-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px 2px 2px;
|
||||||
|
margin: 0 2px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-chip img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-placeholder {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-name {
|
||||||
|
max-width: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
617
web/canvas-app/src/components/nodes/VideoConfigNode.vue
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Video config node wrapper | 视频配置节点包裹层 -->
|
||||||
|
<div class="video-config-node-wrapper relative" @mouseenter="showHandleMenu = true" @mouseleave="showHandleMenu = false">
|
||||||
|
<!-- Video config node | 视频配置节点 -->
|
||||||
|
<div class="video-config-node bg-[var(--bg-secondary)] rounded-xl border min-w-[300px] transition-all duration-200"
|
||||||
|
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'">
|
||||||
|
<!-- Header | 头部 -->
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<span
|
||||||
|
v-if="!isEditingLabel"
|
||||||
|
@dblclick="startEditLabel"
|
||||||
|
class="text-sm font-medium text-[var(--text-secondary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
|
||||||
|
title="双击编辑名称"
|
||||||
|
>{{ data.label || '视频生成' }}</span>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
ref="labelInputRef"
|
||||||
|
v-model="editingLabelValue"
|
||||||
|
@blur="finishEditLabel"
|
||||||
|
@keydown.enter="finishEditLabel"
|
||||||
|
@keydown.escape="cancelEditLabel"
|
||||||
|
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] px-1 rounded outline-none border border-blue-500"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button type="button" @pointerdown.stop @mousedown.stop @click.stop.prevent="handleDuplicate" class="nodrag nopan p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="复制节点">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<CopyOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
<button type="button" @pointerdown.stop @mousedown.stop @click.stop.prevent="handleDelete" class="nodrag nopan p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="删除节点">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<TrashOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config options | 配置选项 -->
|
||||||
|
<div class="p-3 space-y-3">
|
||||||
|
<!-- Model selector | 模型选择 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">模型</span>
|
||||||
|
<n-dropdown trigger="click" :options="modelOptions" @select="handleModelSelect">
|
||||||
|
<button type="button" @pointerdown.stop @mousedown.stop @click.stop class="nodrag nopan flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||||||
|
{{ displayModelName }}
|
||||||
|
<n-icon :size="12"><ChevronDownOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aspect ratio selector | 宽高比选择 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">比例</span>
|
||||||
|
<n-dropdown trigger="click" :options="ratioOptions" @select="handleRatioSelect">
|
||||||
|
<button type="button" @pointerdown.stop @mousedown.stop @click.stop class="nodrag nopan flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||||||
|
{{ localRatio }}
|
||||||
|
<n-icon :size="12">
|
||||||
|
<ChevronForwardOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duration selector | 时长选择 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">时长</span>
|
||||||
|
<n-dropdown trigger="click" :options="durationOptions" @select="handleDurationSelect">
|
||||||
|
<button type="button" @pointerdown.stop @mousedown.stop @click.stop class="nodrag nopan flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||||||
|
{{ localDuration }}s
|
||||||
|
<n-icon :size="12">
|
||||||
|
<ChevronForwardOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resolution selector | 清晰度选择 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">清晰度</span>
|
||||||
|
<n-dropdown trigger="click" :options="resolutionOptions" @select="handleResolutionSelect">
|
||||||
|
<button type="button" @pointerdown.stop @mousedown.stop @click.stop class="nodrag nopan flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
|
||||||
|
{{ localResolution }}
|
||||||
|
<n-icon :size="12">
|
||||||
|
<ChevronForwardOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</n-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connected inputs indicator | 连接输入指示 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 text-xs text-[var(--text-secondary)] py-1 border-t border-[var(--border-color)]">
|
||||||
|
<span class="px-2 py-0.5 rounded-full"
|
||||||
|
:class="connectedPrompt ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
|
||||||
|
提示词 {{ connectedPrompt ? '✓' : '○' }}
|
||||||
|
</span>
|
||||||
|
<span class="px-2 py-0.5 rounded-full"
|
||||||
|
: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>
|
||||||
|
|
||||||
|
<!-- Progress bar | 进度条 -->
|
||||||
|
<!-- <div v-if="status === 'polling'" class="space-y-1">
|
||||||
|
<div class="flex justify-between text-xs text-[var(--text-secondary)]">
|
||||||
|
<span>生成中...</span>
|
||||||
|
<span>{{ progress.percentage }}%</span>
|
||||||
|
</div>
|
||||||
|
<n-progress type="line" :percentage="progress.percentage" :show-indicator="false" :height="4" />
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<!-- Generate button | 生成按钮 -->
|
||||||
|
<button type="button" @pointerdown.stop @mousedown.stop @touchstart.stop @click.stop.prevent="handleGenerate" :disabled="isGenerating || !canGenerate"
|
||||||
|
class="nodrag nopan w-full flex items-center justify-center gap-2 py-2 px-4 rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<n-spin v-if="isGenerating" :size="14" />
|
||||||
|
<template v-else>
|
||||||
|
<n-icon :size="16">
|
||||||
|
<VideocamOutline />
|
||||||
|
</n-icon>
|
||||||
|
生成视频
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
<div v-if="!canGenerate" class="text-xs text-amber-500 mt-2">
|
||||||
|
当前环境未配置视频 API,只能预览模型、比例和时长。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error message | 错误信息 -->
|
||||||
|
<div v-if="error" class="text-xs text-red-500 mt-2">
|
||||||
|
{{ error.message || '生成失败' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generated video preview | 生成视频预览 -->
|
||||||
|
<!-- <div v-if="generatedVideo?.url" class="mt-3 space-y-2">
|
||||||
|
<div class="text-xs text-[var(--text-secondary)]">生成结果:</div>
|
||||||
|
<div class="aspect-video rounded-lg overflow-hidden bg-black">
|
||||||
|
<video :src="generatedVideo.url" controls class="w-full h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Handles | 连接点 -->
|
||||||
|
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
|
||||||
|
<NodeHandleMenu :nodeId="id" nodeType="videoConfig" :visible="showHandleMenu" :operations="[]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Video config node component | 视频配置节点组件
|
||||||
|
* Configuration panel for video generation with API integration
|
||||||
|
*/
|
||||||
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||||
|
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { NIcon, NDropdown, NSpin } from 'naive-ui'
|
||||||
|
import { ChevronForwardOutline, ChevronDownOutline, TrashOutline, VideocamOutline, CopyOutline, CreateOutline } from '@vicons/ionicons5'
|
||||||
|
import { useVideoGeneration } from '../../hooks'
|
||||||
|
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes, edges } from '../../stores/canvas'
|
||||||
|
import NodeHandleMenu from './NodeHandleMenu.vue'
|
||||||
|
import { useModelStore } from '../../stores/pinia'
|
||||||
|
import { getModelRatioOptions, getModelDurationOptions, getModelResolutionOptions, getModelConfig, DEFAULT_VIDEO_MODEL } from '../../stores/models'
|
||||||
|
|
||||||
|
// 使用 Pinia store 获取模型选项(根据渠道过滤)
|
||||||
|
const modelStore = useModelStore()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: String,
|
||||||
|
data: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
// Vue Flow instance | Vue Flow 实例
|
||||||
|
const { updateNodeInternals } = useVueFlow()
|
||||||
|
|
||||||
|
// Video generation hook | 视频生成 hook
|
||||||
|
const { loading, error, status, video: generatedVideo, progress, createVideoTaskOnly } = useVideoGeneration()
|
||||||
|
|
||||||
|
const currentModelDefaultResolution = (modelKey) => {
|
||||||
|
const config = getModelConfig(modelKey)
|
||||||
|
return config?.defaultParams?.resolution || config?.defaultResolution || config?.resolutions?.[0] || '720p'
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeResolutionForModel = (modelKey, resolution) => {
|
||||||
|
const options = getModelResolutionOptions(modelKey)
|
||||||
|
const allowed = options.map(option => option.key)
|
||||||
|
if (resolution && allowed.includes(resolution)) return resolution
|
||||||
|
return currentModelDefaultResolution(modelKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local state | 本地状态
|
||||||
|
const showHandleMenu = ref(false)
|
||||||
|
const isGenerating = ref(false) // 任务创建中状态
|
||||||
|
const localModel = ref(props.data?.model || DEFAULT_VIDEO_MODEL)
|
||||||
|
const localRatio = ref(props.data?.ratio || '16:9')
|
||||||
|
const localDuration = ref(props.data?.dur || 5)
|
||||||
|
const localResolution = ref(props.data?.resolution || currentModelDefaultResolution(props.data?.model || DEFAULT_VIDEO_MODEL))
|
||||||
|
const availableVideoModels = computed(() => Array.isArray(modelStore.availableVideoModels) ? modelStore.availableVideoModels : [])
|
||||||
|
|
||||||
|
// Label editing state | Label 编辑状态
|
||||||
|
const isEditingLabel = ref(false)
|
||||||
|
const editingLabelValue = ref('')
|
||||||
|
const labelInputRef = ref(null)
|
||||||
|
|
||||||
|
// Get connected images with roles | 获取连接的图片及其角色
|
||||||
|
const connectedImages = computed(() => {
|
||||||
|
const connectedEdges = edges.value.filter(e => e.target === props.id)
|
||||||
|
const images = []
|
||||||
|
|
||||||
|
for (const edge of connectedEdges) {
|
||||||
|
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||||||
|
if (sourceNode?.type === 'image' && sourceNode.data?.url) {
|
||||||
|
images.push({
|
||||||
|
nodeId: sourceNode.id,
|
||||||
|
edgeId: edge.id,
|
||||||
|
url: sourceNode.data.url,
|
||||||
|
base64: sourceNode.data.base64,
|
||||||
|
role: edge.data?.imageRole || 'first_frame_image' // Default to first frame | 默认首帧
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return images
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get images by role | 按角色获取图片
|
||||||
|
const imagesByRole = computed(() => {
|
||||||
|
const firstFrame = connectedImages.value.find(img => img.role === 'first_frame_image')
|
||||||
|
const lastFrame = connectedImages.value.find(img => img.role === 'last_frame_image')
|
||||||
|
const referenceImages = connectedImages.value.filter(img => img.role === 'input_reference')
|
||||||
|
|
||||||
|
return {
|
||||||
|
firstFrame,
|
||||||
|
lastFrame,
|
||||||
|
referenceImages
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get current model config | 获取当前模型配置
|
||||||
|
const currentModelConfig = computed(() => getModelConfig(localModel.value))
|
||||||
|
const isConfigured = computed(() => !!modelStore.currentApiKey)
|
||||||
|
const currentModelAvailable = computed(() => availableVideoModels.value.some(model => model.key === localModel.value))
|
||||||
|
const canGenerate = computed(() => isConfigured.value && currentModelAvailable.value && currentModelConfig.value?.available !== false)
|
||||||
|
|
||||||
|
// Model options from Pinia store (filtered by provider) | 从 Pinia store 获取模型选项(根据渠道过滤)
|
||||||
|
const modelOptions = computed(() => modelStore.videoModelOptions)
|
||||||
|
|
||||||
|
// Display model name | 显示模型名称
|
||||||
|
const displayModelName = computed(() => {
|
||||||
|
const model = modelOptions.value.find(m => m.key === localModel.value)
|
||||||
|
// 如果当前模型不在选项中,尝试从 allVideoModels 找到
|
||||||
|
if (!model) {
|
||||||
|
const allModel = modelStore.allVideoModels.find(m => m.key === localModel.value)
|
||||||
|
return allModel?.label || localModel.value || '选择模型'
|
||||||
|
}
|
||||||
|
return model?.label || localModel.value || '选择模型'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ratio options based on model | 基于模型的比例选项
|
||||||
|
const ratioOptions = computed(() => {
|
||||||
|
return getModelRatioOptions(localModel.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Duration options based on model | 基于模型的时长选项
|
||||||
|
const durationOptions = computed(() => {
|
||||||
|
return getModelDurationOptions(localModel.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Resolution options based on model | 基于模型的清晰度选项
|
||||||
|
const resolutionOptions = computed(() => {
|
||||||
|
return getModelResolutionOptions(localModel.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle model selection | 处理模型选择
|
||||||
|
const applyModelSelection = (key) => {
|
||||||
|
if (!key) return
|
||||||
|
localModel.value = key
|
||||||
|
// Update ratio and duration to model's default | 更新为模型默认比例和时长
|
||||||
|
const config = getModelConfig(key)
|
||||||
|
const updates = { model: key }
|
||||||
|
if (config?.defaultParams?.ratio) {
|
||||||
|
localRatio.value = config.defaultParams.ratio
|
||||||
|
updates.ratio = config.defaultParams.ratio
|
||||||
|
}
|
||||||
|
if (config?.defaultParams?.duration) {
|
||||||
|
localDuration.value = config.defaultParams.duration
|
||||||
|
updates.dur = config.defaultParams.duration
|
||||||
|
}
|
||||||
|
const nextResolution = currentModelDefaultResolution(key)
|
||||||
|
localResolution.value = nextResolution
|
||||||
|
updates.resolution = nextResolution
|
||||||
|
updateNode(props.id, updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncModelWithAvailableOptions = () => {
|
||||||
|
const availableModels = availableVideoModels.value
|
||||||
|
if (!availableModels.length) return
|
||||||
|
|
||||||
|
const isModelAvailable = availableModels.some(model => model.key === localModel.value)
|
||||||
|
if (!localModel.value || !isModelAvailable) {
|
||||||
|
const selected = availableModels.find(model => model.key === modelStore.selectedVideoModel)?.key
|
||||||
|
applyModelSelection(selected || availableModels[0]?.key || DEFAULT_VIDEO_MODEL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextResolution = normalizeResolutionForModel(localModel.value, localResolution.value)
|
||||||
|
if (nextResolution !== localResolution.value || !props.data?.resolution) {
|
||||||
|
localResolution.value = nextResolution
|
||||||
|
updateNode(props.id, { resolution: nextResolution })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleModelSelect = (key) => {
|
||||||
|
applyModelSelection(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duplicate | 处理复制
|
||||||
|
const handleDuplicate = () => {
|
||||||
|
const newNodeId = duplicateNode(props.id)
|
||||||
|
window.$message?.success('节点已复制')
|
||||||
|
if (newNodeId) {
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(newNodeId)
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ratio selection | 处理比例选择
|
||||||
|
const handleRatioSelect = (key) => {
|
||||||
|
localRatio.value = key
|
||||||
|
updateNode(props.id, { ratio: key })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duration selection | 处理时长选择
|
||||||
|
const handleDurationSelect = (key) => {
|
||||||
|
localDuration.value = key
|
||||||
|
updateNode(props.id, { dur: key })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle resolution selection | 处理清晰度选择
|
||||||
|
const handleResolutionSelect = (key) => {
|
||||||
|
localResolution.value = key
|
||||||
|
updateNode(props.id, { resolution: key })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get connected inputs by role | 根据角色获取连接的输入
|
||||||
|
const getConnectedInputs = () => {
|
||||||
|
const connectedEdges = edges.value.filter(e => e.target === props.id)
|
||||||
|
|
||||||
|
let prompt = ''
|
||||||
|
let first_frame_image = ''
|
||||||
|
let last_frame_image = ''
|
||||||
|
const images = [] // input_reference images | 参考图
|
||||||
|
|
||||||
|
for (const edge of connectedEdges) {
|
||||||
|
const sourceNode = nodes.value.find(n => n.id === edge.source)
|
||||||
|
if (!sourceNode) continue
|
||||||
|
|
||||||
|
if (sourceNode.type === 'text') {
|
||||||
|
prompt = sourceNode.data?.content || ''
|
||||||
|
} else if (sourceNode.type === 'llmConfig') {
|
||||||
|
// LLM node output as prompt | LLM 节点输出作为提示词
|
||||||
|
const content = sourceNode.data?.outputContent || ''
|
||||||
|
if (content) prompt = content
|
||||||
|
} else if (sourceNode.type === 'image' && sourceNode.data?.url) {
|
||||||
|
const imageData = sourceNode.data.base64 || sourceNode.data.url
|
||||||
|
const role = edge.data?.imageRole || 'first_frame_image'
|
||||||
|
|
||||||
|
if (role === 'first_frame_image') {
|
||||||
|
first_frame_image = imageData
|
||||||
|
} else if (role === 'last_frame_image') {
|
||||||
|
last_frame_image = imageData
|
||||||
|
} else if (role === 'input_reference') {
|
||||||
|
images.push(imageData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { prompt, first_frame_image, last_frame_image, images }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed connected prompt | 计算连接的提示词
|
||||||
|
const connectedPrompt = computed(() => {
|
||||||
|
return getConnectedInputs().prompt
|
||||||
|
})
|
||||||
|
|
||||||
|
// Created video node ID | 创建的视频节点 ID
|
||||||
|
const createdVideoNodeId = ref(null)
|
||||||
|
|
||||||
|
// Handle generate action | 处理生成操作
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
// 设置生成中状态
|
||||||
|
isGenerating.value = true
|
||||||
|
|
||||||
|
const { prompt, first_frame_image, last_frame_image, images } = getConnectedInputs()
|
||||||
|
|
||||||
|
const hasInput = prompt || first_frame_image || last_frame_image || images.length > 0
|
||||||
|
if (!hasInput) {
|
||||||
|
window.$message?.warning('请先连接文本节点或图片节点')
|
||||||
|
isGenerating.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConfigured.value) {
|
||||||
|
window.$message?.warning('生成接口未就绪,请稍后重试')
|
||||||
|
isGenerating.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current node position | 获取当前节点位置
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
// Create video node with loading state | 创建带加载状态的视频节点
|
||||||
|
const videoNodeId = addNode('video', { x: nodeX + 350, y: nodeY }, {
|
||||||
|
url: '',
|
||||||
|
loading: true,
|
||||||
|
label: '视频生成中...'
|
||||||
|
})
|
||||||
|
createdVideoNodeId.value = videoNodeId
|
||||||
|
|
||||||
|
// Auto-connect videoConfig → video | 自动连接 视频配置 → 视频
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: videoNodeId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(videoNodeId)
|
||||||
|
}, 50)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build request params (raw form data) | 构建请求参数(原始表单数据)
|
||||||
|
// These will be transformed by inputTransform | 这些会被 inputTransform 转换
|
||||||
|
const params = {
|
||||||
|
model: localModel.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add prompt if provided | 如果有提示词则添加
|
||||||
|
if (prompt) {
|
||||||
|
params.prompt = prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add first frame image | 添加首帧图片
|
||||||
|
if (first_frame_image) {
|
||||||
|
params.first_frame_image = first_frame_image
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add last frame image | 添加尾帧图片
|
||||||
|
if (last_frame_image) {
|
||||||
|
params.last_frame_image = last_frame_image
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add reference images (input_reference) | 添加参考图
|
||||||
|
if (images.length > 0) {
|
||||||
|
params.images = images
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ratio/size | 添加比例参数
|
||||||
|
if (localRatio.value) {
|
||||||
|
params.ratio = localRatio.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add duration | 添加时长
|
||||||
|
if (localDuration.value) {
|
||||||
|
params.dur = localDuration.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add resolution | 添加清晰度
|
||||||
|
if (localResolution.value) {
|
||||||
|
params.resolution = localResolution.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只创建任务,获取 taskId,不在这里轮询
|
||||||
|
const { taskId: newTaskId, url } = await createVideoTaskOnly(params)
|
||||||
|
|
||||||
|
// 如果有直接 URL,更新视频节点
|
||||||
|
if (url) {
|
||||||
|
updateNode(videoNodeId, {
|
||||||
|
url: url,
|
||||||
|
loading: false,
|
||||||
|
label: '视频生成',
|
||||||
|
model: localModel.value,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
window.$message?.success('视频生成成功')
|
||||||
|
// Mark this config node as executed | 标记配置节点已执行
|
||||||
|
updateNode(props.id, { executed: true, outputNodeId: videoNodeId })
|
||||||
|
} else if (newTaskId) {
|
||||||
|
// 需要轮询,传递 taskId 给 VideoNode
|
||||||
|
updateNode(videoNodeId, {
|
||||||
|
taskId: newTaskId,
|
||||||
|
loading: true,
|
||||||
|
label: '视频生成中...',
|
||||||
|
model: localModel.value,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
window.$message?.success('视频任务已创建')
|
||||||
|
// Mark this config node as executed | 标记配置节点已执行
|
||||||
|
updateNode(props.id, { executed: true, outputNodeId: videoNodeId })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Update node to show error | 更新节点显示错误
|
||||||
|
updateNode(videoNodeId, {
|
||||||
|
loading: false,
|
||||||
|
error: err.message || '生成失败',
|
||||||
|
label: '生成失败',
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
window.$message?.error(err.message || '视频生成失败')
|
||||||
|
} finally {
|
||||||
|
isGenerating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start editing label | 开始编辑 label
|
||||||
|
const startEditLabel = () => {
|
||||||
|
editingLabelValue.value = props.data?.label || '视频生成'
|
||||||
|
isEditingLabel.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
labelInputRef.value?.focus()
|
||||||
|
labelInputRef.value?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish editing label | 完成编辑 label
|
||||||
|
const finishEditLabel = () => {
|
||||||
|
const newLabel = editingLabelValue.value.trim()
|
||||||
|
if (newLabel && newLabel !== props.data?.label) {
|
||||||
|
updateNode(props.id, { label: newLabel })
|
||||||
|
}
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel editing label | 取消编辑 label
|
||||||
|
const cancelEditLabel = () => {
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delete | 处理删除
|
||||||
|
const handleDelete = () => {
|
||||||
|
removeNode(props.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on mount | 挂载时初始化
|
||||||
|
onMounted(() => {
|
||||||
|
syncModelWithAvailableOptions()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for model changes from props | 监听 props 中模型变化
|
||||||
|
watch(() => props.data?.model, (newModel) => {
|
||||||
|
if (newModel && newModel !== localModel.value) {
|
||||||
|
localModel.value = newModel
|
||||||
|
localResolution.value = normalizeResolutionForModel(newModel, props.data?.resolution || localResolution.value)
|
||||||
|
syncModelWithAvailableOptions()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => availableVideoModels.value.map(model => model.key).join('|'),
|
||||||
|
() => syncModelWithAvailableOptions(),
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(() => props.data?.resolution, (newResolution) => {
|
||||||
|
if (newResolution && newResolution !== localResolution.value) {
|
||||||
|
localResolution.value = normalizeResolutionForModel(localModel.value, newResolution)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 修复 Vue Flow visibility: hidden 问题
|
||||||
|
// 当节点数据变化时,强制更新内部状态
|
||||||
|
watch(() => props.data, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
updateNodeInternals(props.id)
|
||||||
|
})
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Watch for auto-execute flag | 监听自动执行标志
|
||||||
|
watch(
|
||||||
|
() => props.data?.autoExecute,
|
||||||
|
(shouldExecute) => {
|
||||||
|
if (shouldExecute && !loading.value) {
|
||||||
|
// Clear the flag first to prevent re-triggering | 先清除标志防止重复触发
|
||||||
|
updateNode(props.id, { autoExecute: false })
|
||||||
|
// Delay to ensure node connections are established | 延迟确保节点连接已建立
|
||||||
|
setTimeout(() => {
|
||||||
|
handleGenerate()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.video-config-node-wrapper {
|
||||||
|
position: relative;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-config-node {
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
295
web/canvas-app/src/components/nodes/VideoNode.vue
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Video node wrapper | 视频节点包裹层 -->
|
||||||
|
<div class="video-node-wrapper relative" @mouseenter="showActions = true; showHandleMenu = true" @mouseleave="showActions = false; showHandleMenu = false">
|
||||||
|
<!-- Video node | 视频节点 -->
|
||||||
|
<div
|
||||||
|
class="video-node bg-[var(--bg-secondary)] rounded-xl border w-[400px] relative transition-all duration-200"
|
||||||
|
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'"
|
||||||
|
|
||||||
|
>
|
||||||
|
<!-- Header | 头部 -->
|
||||||
|
<div class="px-3 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
v-if="!isEditingLabel"
|
||||||
|
@dblclick="startEditLabel"
|
||||||
|
class="text-sm font-medium text-[var(--text-secondary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
|
||||||
|
title="双击编辑名称"
|
||||||
|
>{{ data.label }}</span>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
ref="labelInputRef"
|
||||||
|
v-model="editingLabelValue"
|
||||||
|
@blur="finishEditLabel"
|
||||||
|
@keydown.enter="finishEditLabel"
|
||||||
|
@keydown.escape="cancelEditLabel"
|
||||||
|
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] px-1 rounded outline-none border border-blue-500"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="复制节点">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<CopyOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="删除节点">
|
||||||
|
<n-icon :size="14">
|
||||||
|
<TrashOutline />
|
||||||
|
</n-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Model name | 模型名称 -->
|
||||||
|
<div v-if="data.model" class="mt-1 text-xs text-[var(--text-secondary)] truncate">
|
||||||
|
{{ data.model }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Video preview area | 视频预览区域 -->
|
||||||
|
<div class="p-3">
|
||||||
|
<!-- Loading state | 加载状态 -->
|
||||||
|
<div
|
||||||
|
v-if="(data.taskId && !data.url) || (data.loading && !data.taskId)"
|
||||||
|
class="aspect-video rounded-lg bg-gradient-to-br from-cyan-400 via-blue-300 to-amber-200 flex flex-col items-center justify-center gap-3 relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Animated gradient overlay | 动画渐变遮罩 -->
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-br from-cyan-500/20 via-blue-400/20 to-amber-300/20 animate-pulse"></div>
|
||||||
|
|
||||||
|
<!-- Loading image | 加载图片 -->
|
||||||
|
<div class="relative z-10">
|
||||||
|
<img
|
||||||
|
src="../../assets/loading.webp"
|
||||||
|
alt="Loading"
|
||||||
|
class="w-14 h-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-sm text-white font-medium relative z-10">{{ data.taskId ? '创作中,预计等待 1 分钟' : '任务创建中...' }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Error state | 错误状态 -->
|
||||||
|
<div
|
||||||
|
v-else-if="data.error"
|
||||||
|
class="aspect-video rounded-lg bg-red-50 dark:bg-red-900/20 flex flex-col items-center justify-center gap-2 border border-red-200 dark:border-red-800"
|
||||||
|
>
|
||||||
|
<n-icon :size="32" class="text-red-500"><CloseCircleOutline /></n-icon>
|
||||||
|
<span class="text-sm text-red-500">{{ data.error }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Video preview | 视频预览 -->
|
||||||
|
<div
|
||||||
|
v-else-if="data.url"
|
||||||
|
class="aspect-video rounded-lg overflow-hidden bg-black"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
:src="displayVideoUrl"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
class="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Empty state | 空状态 -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="aspect-video rounded-lg bg-[var(--bg-tertiary)] flex flex-col items-center justify-center gap-2 border-2 border-dashed border-[var(--border-color)] relative"
|
||||||
|
>
|
||||||
|
<n-icon :size="32" class="text-[var(--text-secondary)]"><VideocamOutline /></n-icon>
|
||||||
|
<span class="text-sm text-[var(--text-secondary)]">拖放视频或点击上传</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="video/*"
|
||||||
|
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
@change="handleFileUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duration info | 时长信息 -->
|
||||||
|
<div v-if="data.duration" class="mt-2 text-xs text-[var(--text-secondary)]">
|
||||||
|
时长: {{ formatDuration(data.duration) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Handles | 连接点 -->
|
||||||
|
<NodeHandleMenu :nodeId="id" nodeType="video" :visible="showHandleMenu" :operations="operations" @select="handleSelect" />
|
||||||
|
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right side - Action buttons | 右侧 - 操作按钮 -->
|
||||||
|
<div
|
||||||
|
v-show="showActions && data.url"
|
||||||
|
class="absolute right-10 top-20 -translate-y-1/2 translate-x-full flex flex-col gap-2 z-[1000]"
|
||||||
|
>
|
||||||
|
<!-- Preview button | 预览按钮 -->
|
||||||
|
<button
|
||||||
|
@click="handlePreview"
|
||||||
|
class="action-btn group p-2 bg-white rounded-lg transition-all border border-gray-200 flex items-center gap-0 hover:gap-1.5 w-max"
|
||||||
|
>
|
||||||
|
<n-icon :size="16" class="text-gray-600"><EyeOutline /></n-icon>
|
||||||
|
<span class="text-xs text-gray-600 max-w-0 overflow-hidden group-hover:max-w-[80px] transition-all duration-200 whitespace-nowrap">预览</span>
|
||||||
|
</button>
|
||||||
|
<!-- Download button | 下载按钮 -->
|
||||||
|
<button
|
||||||
|
@click="handleDownload"
|
||||||
|
class="action-btn group p-2 bg-white rounded-lg transition-all border border-gray-200 flex items-center gap-0 hover:gap-1.5 w-max"
|
||||||
|
>
|
||||||
|
<n-icon :size="16" class="text-gray-600"><DownloadOutline /></n-icon>
|
||||||
|
<span class="text-xs text-gray-600 max-w-0 overflow-hidden group-hover:max-w-[80px] transition-all duration-200 whitespace-nowrap">下载</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* Video node component | 视频节点组件
|
||||||
|
* Displays and manages video content
|
||||||
|
*/
|
||||||
|
import { computed, ref, nextTick } from 'vue'
|
||||||
|
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { NIcon, NSpin } from 'naive-ui'
|
||||||
|
import { TrashOutline, ExpandOutline, VideocamOutline, CopyOutline, CloseCircleOutline, DownloadOutline, EyeOutline, CreateOutline } from '@vicons/ionicons5'
|
||||||
|
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
|
||||||
|
import { useCachedMediaUrl } from '../../hooks/useCachedMediaUrl'
|
||||||
|
import { uploadCanvasVideo } from '../../hooks/useApi'
|
||||||
|
import NodeHandleMenu from './NodeHandleMenu.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: String,
|
||||||
|
data: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
// Vue Flow instance
|
||||||
|
const { updateNodeInternals } = useVueFlow()
|
||||||
|
const { cachedUrl: displayVideoUrl, warmCache: warmVideoCache } = useCachedMediaUrl(() => props.data?.url)
|
||||||
|
const activeVideoUrl = computed(() => displayVideoUrl.value || props.data?.url || '')
|
||||||
|
|
||||||
|
// Hover state | 悬浮状态
|
||||||
|
const showActions = ref(false)
|
||||||
|
const showHandleMenu = ref(false)
|
||||||
|
|
||||||
|
// Label editing state | Label 编辑状态
|
||||||
|
const isEditingLabel = ref(false)
|
||||||
|
const editingLabelValue = ref('')
|
||||||
|
const labelInputRef = ref(null)
|
||||||
|
|
||||||
|
// Video node menu operations | 视频节点菜单操作
|
||||||
|
const operations = [
|
||||||
|
{ type: 'videoConfig', label: '生视频', icon: VideocamOutline }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Handle menu select | 处理菜单选择
|
||||||
|
const handleSelect = (item) => {
|
||||||
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
const nodeX = currentNode?.position?.x || 0
|
||||||
|
const nodeY = currentNode?.position?.y || 0
|
||||||
|
|
||||||
|
const newId = addNode('videoConfig', { x: nodeX + 400, y: nodeY }, { label: '视频生成' })
|
||||||
|
|
||||||
|
addEdge({
|
||||||
|
source: props.id,
|
||||||
|
target: newId,
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left'
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
updateNodeInternals(newId)
|
||||||
|
}, 50)
|
||||||
|
window.$message?.success(`已创建视频生成节点`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file upload | 处理文件上传
|
||||||
|
const handleFileUpload = async (event) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
// reset so picking the same file again still fires @change
|
||||||
|
event.target.value = ''
|
||||||
|
// Upload to the backend and store the returned stable URL. A blob: object URL
|
||||||
|
// would leak (never revoked) and, once persisted, breaks on project reload.
|
||||||
|
updateNode(props.id, { loading: true })
|
||||||
|
try {
|
||||||
|
const { url } = await uploadCanvasVideo(file)
|
||||||
|
updateNode(props.id, { url, loading: false, updatedAt: Date.now() })
|
||||||
|
window.$message?.success('视频已上传')
|
||||||
|
} catch (e) {
|
||||||
|
updateNode(props.id, { loading: false })
|
||||||
|
window.$message?.error(`视频上传失败:${e?.message || e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format duration | 格式化时长
|
||||||
|
const formatDuration = (seconds) => {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start editing label | 开始编辑 label
|
||||||
|
const startEditLabel = () => {
|
||||||
|
editingLabelValue.value = props.data?.label || ''
|
||||||
|
isEditingLabel.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
labelInputRef.value?.focus()
|
||||||
|
labelInputRef.value?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish editing label | 完成编辑 label
|
||||||
|
const finishEditLabel = () => {
|
||||||
|
const newLabel = editingLabelValue.value.trim()
|
||||||
|
if (newLabel && newLabel !== props.data?.label) {
|
||||||
|
updateNode(props.id, { label: newLabel })
|
||||||
|
}
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel editing label | 取消编辑 label
|
||||||
|
const cancelEditLabel = () => {
|
||||||
|
isEditingLabel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delete | 处理删除
|
||||||
|
const handleDelete = () => {
|
||||||
|
removeNode(props.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle preview | 处理预览
|
||||||
|
const handlePreview = () => {
|
||||||
|
if (props.data.url) {
|
||||||
|
warmVideoCache()
|
||||||
|
window.open(activeVideoUrl.value, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle download | 处理下载
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (props.data.url) {
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = activeVideoUrl.value
|
||||||
|
link.download = props.data.fileName || `video_${Date.now()}.mp4`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.$message?.success('视频下载中...')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duplicate | 处理复制
|
||||||
|
const handleDuplicate = () => {
|
||||||
|
const newId = duplicateNode(props.id)
|
||||||
|
if (newId) {
|
||||||
|
// Clear selection and select the new node | 清除选中并选中新节点
|
||||||
|
updateNode(props.id, { selected: false })
|
||||||
|
updateNode(newId, { selected: true })
|
||||||
|
window.$message?.success('节点已复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.video-node-wrapper {
|
||||||
|
padding-right: 50px;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-node {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
222
web/canvas-app/src/config/models.js
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* Models Configuration | 模型配置
|
||||||
|
* Centralized model configuration | 集中模型配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
// SKG backend image size options | SKG 后端图片尺寸选项
|
||||||
|
export const SEEDREAM_SIZE_OPTIONS = [
|
||||||
|
{ label: '自动', key: 'auto' },
|
||||||
|
{ label: '竖图 2:3', key: '1024x1536' },
|
||||||
|
{ label: '方图 1:1', key: '1024x1024' },
|
||||||
|
{ label: '横图 3:2', key: '1536x1024' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ARK_SEEDREAM_SIZE_OPTIONS = [
|
||||||
|
{ label: '自动 2K', key: '2K' },
|
||||||
|
{ label: '方图 2048', key: '2048x2048' },
|
||||||
|
{ label: '竖图 9:16', key: '1440x2560' },
|
||||||
|
{ label: '横图 16:9', key: '2560x1440' },
|
||||||
|
{ label: '方图 2160', key: '2160x2160' },
|
||||||
|
{ label: '竖图 4K', key: '2160x3840' },
|
||||||
|
{ label: '横图 4K', key: '3840x2160' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Kept for compatibility with upstream model helpers.
|
||||||
|
export const SEEDREAM_4K_SIZE_OPTIONS = SEEDREAM_SIZE_OPTIONS
|
||||||
|
|
||||||
|
// SKG backend currently exposes model choice and size; quality is retained as a no-op UI field.
|
||||||
|
export const SEEDREAM_QUALITY_OPTIONS = [
|
||||||
|
{ label: '标准', key: 'standard' }
|
||||||
|
]
|
||||||
|
|
||||||
|
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: ['chatfire'],
|
||||||
|
sizes: SEEDREAM_SIZE_OPTIONS.map(s => s.key),
|
||||||
|
qualities: SEEDREAM_QUALITY_OPTIONS,
|
||||||
|
defaultParams: {
|
||||||
|
size: '1024x1536',
|
||||||
|
quality: 'standard',
|
||||||
|
style: 'vivid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'GPT Image 2',
|
||||||
|
key: 'gpt-image-2',
|
||||||
|
provider: ['chatfire'],
|
||||||
|
sizes: SEEDREAM_SIZE_OPTIONS.map(s => s.key),
|
||||||
|
qualities: SEEDREAM_QUALITY_OPTIONS,
|
||||||
|
defaultParams: {
|
||||||
|
size: '1024x1536',
|
||||||
|
quality: 'standard',
|
||||||
|
style: 'vivid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Gemini 图片',
|
||||||
|
key: 'gemini-3-pro-image-preview',
|
||||||
|
provider: ['chatfire'],
|
||||||
|
sizes: SEEDREAM_SIZE_OPTIONS.map(s => s.key),
|
||||||
|
qualities: SEEDREAM_QUALITY_OPTIONS,
|
||||||
|
defaultParams: {
|
||||||
|
size: '1024x1536',
|
||||||
|
quality: 'standard',
|
||||||
|
style: 'vivid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Seedream 4.5',
|
||||||
|
key: 'doubao-seedream-4-5-251128',
|
||||||
|
provider: ['chatfire'],
|
||||||
|
sizes: ARK_SEEDREAM_SIZE_OPTIONS.map(s => s.key),
|
||||||
|
sizeOptions: ARK_SEEDREAM_SIZE_OPTIONS,
|
||||||
|
qualities: SEEDREAM_QUALITY_OPTIONS,
|
||||||
|
defaultParams: {
|
||||||
|
size: '2048x2048',
|
||||||
|
quality: 'standard',
|
||||||
|
style: 'commercial'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
// Video ratio options | 视频比例选项
|
||||||
|
export const VIDEO_RATIO_LIST = [
|
||||||
|
{ label: '竖屏 9:16', key: '720x1280' },
|
||||||
|
{ label: '横屏 16:9', key: '1280x720' },
|
||||||
|
{ label: '方形 1:1', key: '1024x1024' },
|
||||||
|
{ label: '竖屏 3:4', key: '960x1280' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 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 2.0 Fast',
|
||||||
|
key: 'seedance',
|
||||||
|
provider: ['chatfire'],
|
||||||
|
type: 't2v+i2v',
|
||||||
|
ratios: ['720x1280', '1280x720', '1024x1024', '960x1280'],
|
||||||
|
durs: [
|
||||||
|
{ label: '5 秒', key: 5 },
|
||||||
|
{ label: '8 秒', key: 8 },
|
||||||
|
{ label: '10 秒', key: 10 },
|
||||||
|
{ label: '12 秒', key: 12 },
|
||||||
|
{ label: '15 秒', key: 15 }
|
||||||
|
],
|
||||||
|
resolutions: ['480p', '720p'],
|
||||||
|
defaultResolution: '720p',
|
||||||
|
defaultParams: { ratio: '720x1280', duration: 10, resolution: '720p' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Grok Imagine Video',
|
||||||
|
key: 'xai',
|
||||||
|
provider: ['chatfire'],
|
||||||
|
type: 't2v+i2v',
|
||||||
|
ratios: ['720x1280', '1280x720', '1024x1024'],
|
||||||
|
durs: [
|
||||||
|
{ label: '5 秒', key: 5 },
|
||||||
|
{ label: '8 秒', key: 8 },
|
||||||
|
{ label: '10 秒', key: 10 },
|
||||||
|
{ label: '12 秒', key: 12 },
|
||||||
|
{ label: '15 秒', key: 15 }
|
||||||
|
],
|
||||||
|
resolutions: ['480p', '720p'],
|
||||||
|
defaultResolution: '720p',
|
||||||
|
defaultParams: { ratio: '720x1280', duration: 8, resolution: '720p' },
|
||||||
|
available: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Seedance 2.0 高清',
|
||||||
|
key: 'seedance_hd',
|
||||||
|
provider: ['chatfire'],
|
||||||
|
type: 't2v+i2v',
|
||||||
|
ratios: ['720x1280', '1280x720', '1024x1024', '960x1280'],
|
||||||
|
durs: [
|
||||||
|
{ label: '5 秒', key: 5 },
|
||||||
|
{ label: '8 秒', key: 8 },
|
||||||
|
{ label: '10 秒', key: 10 },
|
||||||
|
{ label: '12 秒', key: 12 },
|
||||||
|
{ label: '15 秒', key: 15 }
|
||||||
|
],
|
||||||
|
resolutions: ['480p', '720p', '1080p'],
|
||||||
|
defaultResolution: '1080p',
|
||||||
|
defaultParams: { ratio: '720x1280', duration: 10, resolution: '1080p' }
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Chat/LLM models | 对话模型
|
||||||
|
export const CHAT_MODELS = [
|
||||||
|
{ 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'] }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Image size options | 图片尺寸选项
|
||||||
|
export const IMAGE_SIZE_OPTIONS = [
|
||||||
|
{ label: '自动', key: 'auto' },
|
||||||
|
{ label: '竖图 2:3', key: '1024x1536' },
|
||||||
|
{ label: '方图 1:1', key: '1024x1024' },
|
||||||
|
{ label: '横图 3:2', key: '1536x1024' },
|
||||||
|
...ARK_SEEDREAM_SIZE_OPTIONS
|
||||||
|
]
|
||||||
|
|
||||||
|
// Image quality options | 图片质量选项
|
||||||
|
export const IMAGE_QUALITY_OPTIONS = [
|
||||||
|
{ label: '标准', key: 'standard' },
|
||||||
|
{ label: '高清', key: 'hd' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 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: '8 秒', key: 8 },
|
||||||
|
{ label: '10 秒', key: 10 },
|
||||||
|
{ label: '12 秒', key: 12 },
|
||||||
|
{ label: '15 秒', key: 15 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Default values | 默认值
|
||||||
|
export const DEFAULT_IMAGE_MODEL = 'doubao-seedream-4-5-251128'
|
||||||
|
export const DEFAULT_VIDEO_MODEL = 'seedance'
|
||||||
|
export const DEFAULT_CHAT_MODEL = 'gpt-4o-mini'
|
||||||
|
export const DEFAULT_IMAGE_SIZE = '2048x2048'
|
||||||
|
export const DEFAULT_VIDEO_RATIO = '720x1280'
|
||||||
|
export const DEFAULT_VIDEO_DURATION = 10
|
||||||
|
|
||||||
|
// Get model by key | 根据 key 获取模型
|
||||||
|
export const getModelByName = (key) => {
|
||||||
|
const allModels = [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS]
|
||||||
|
return allModels.find(m => m.key === key)
|
||||||
|
}
|
||||||
272
web/canvas-app/src/config/providers.js
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* API Provider Adapters | API 渠道适配器
|
||||||
|
* 适配不同 API 提供商的请求参数和响应格式
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 渠道适配配置
|
||||||
|
export const PROVIDERS = {
|
||||||
|
chatfire: {
|
||||||
|
label: 'SKG 内部',
|
||||||
|
defaultBaseUrl: '/api',
|
||||||
|
// 端点路径
|
||||||
|
endpoints: {
|
||||||
|
chat: '/v1/chat/completions',
|
||||||
|
image: '/v1/images/generations',
|
||||||
|
video: '/v1/video/generations',
|
||||||
|
videoQuery: '/v1/video/task/{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 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) => {
|
||||||
|
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: {
|
||||||
|
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 = () => {
|
||||||
|
return Object.entries(PROVIDERS)
|
||||||
|
.filter(([key]) => key !== 'default')
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
label: value.label
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取默认渠道
|
||||||
|
export const getDefaultProvider = () => {
|
||||||
|
return PROVIDERS.default || 'chatfire'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取渠道的默认 Base URL
|
||||||
|
export const getDefaultBaseUrl = (providerKey) => {
|
||||||
|
const config = getProviderConfig(providerKey)
|
||||||
|
return config.defaultBaseUrl || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取渠道配置
|
||||||
|
export const getProviderConfig = (providerKey) => {
|
||||||
|
return PROVIDERS[providerKey] || PROVIDERS[PROVIDERS.default]
|
||||||
|
}
|
||||||
32
web/canvas-app/src/config/suggestions.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export const QUICK_SUGGESTION_GROUPS = [
|
||||||
|
['魔法森林', '三只小猫', '多角度分镜', '夏日田野'],
|
||||||
|
['雨夜街摊', '产品特写', '水花慢镜', '极简桌面'],
|
||||||
|
['无人物街景', '夜市霓虹', '电商白底', '咖啡窗边'],
|
||||||
|
['插画封面', '厨房晨光', '3D 产品', '海边慢步'],
|
||||||
|
['樱花小路', '玻璃花房', '露营夜灯', '复古厨房'],
|
||||||
|
['雪山清晨', '海边黄昏', '森林木屋', '城市天台'],
|
||||||
|
['未来展厅', '透明材质', '金属微光', '柔和阴影'],
|
||||||
|
['香水静物', '珠宝近景', '护肤瓶身', '白底套图'],
|
||||||
|
['手作陶杯', '咖啡拉花', '甜品橱窗', '面包出炉'],
|
||||||
|
['雨后街角', '地铁站台', '便利店夜', '书店暖光'],
|
||||||
|
['儿童绘本', '水彩动物', '云朵小岛', '童话城堡'],
|
||||||
|
['动漫少女', '机甲少年', '赛博街区', '霓虹背光'],
|
||||||
|
['古风庭院', '宋式茶席', '竹林小径', '月下湖面'],
|
||||||
|
['户外露营', '徒步山路', '公路日落', '湖边野餐'],
|
||||||
|
['宠物写真', '猫咪午睡', '小狗奔跑', '兔子花园'],
|
||||||
|
['办公桌面', '键盘特写', '创意白板', '会议晨光'],
|
||||||
|
['运动瞬间', '瑜伽清晨', '跑步剪影', '泳池水花'],
|
||||||
|
['科技发布', '产品旋转', '参数分镜', '开箱镜头'],
|
||||||
|
['家居客厅', '卧室暖灯', '窗边绿植', '阳台微风'],
|
||||||
|
['餐桌俯拍', '火锅热气', '寿司吧台', '水果切面'],
|
||||||
|
['微距花瓣', '水滴叶片', '蝴蝶停留', '晨露草地'],
|
||||||
|
['沙漠公路', '银河帐篷', '极光雪原', '热气球'],
|
||||||
|
['电影海报', '悬疑走廊', '逆光人物', '红蓝光影'],
|
||||||
|
['产品拆解', '材质对比', '功能三镜', '使用场景'],
|
||||||
|
['小镇集市', '老街门牌', '木质招牌', '雨伞人群'],
|
||||||
|
['空镜转场', '慢推镜头', '俯拍街区', '环绕拍摄'],
|
||||||
|
['品牌主图', '社媒封面', '直播背景', '短片开场'],
|
||||||
|
['草莓蛋糕', '柠檬汽水', '冰块特写', '夏日餐桌'],
|
||||||
|
['山谷溪流', '雾气森林', '日出云海', '秋叶小路'],
|
||||||
|
['无脸模特', '侧脸剪影', '背影行走', '虚拟角色']
|
||||||
|
]
|
||||||
1236
web/canvas-app/src/config/workflows.js
Normal file
29
web/canvas-app/src/hooks/index.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Hooks Entry | Hooks 入口
|
||||||
|
* Exports all hooks for easy import
|
||||||
|
*/
|
||||||
|
|
||||||
|
// API Configuration Hook | API 配置 Hook
|
||||||
|
export { useApiConfig } from './useApiConfig'
|
||||||
|
|
||||||
|
// Model Configuration Hook | 模型配置 Hook
|
||||||
|
export { useModelConfig } from './useModelConfig'
|
||||||
|
|
||||||
|
// Provider Hook | 渠道管理 Hook
|
||||||
|
export { useProvider } from './useProvider'
|
||||||
|
|
||||||
|
// API Operation Hooks | API 操作 Hooks
|
||||||
|
export {
|
||||||
|
useApiState,
|
||||||
|
useChat,
|
||||||
|
useImageGeneration,
|
||||||
|
useVideoGeneration,
|
||||||
|
readVideoTask,
|
||||||
|
useApi
|
||||||
|
} from './useApi'
|
||||||
|
|
||||||
|
// Workflow Orchestrator Hook | 工作流编排 Hook
|
||||||
|
export { useWorkflowOrchestrator } from './useWorkflowOrchestrator'
|
||||||
|
|
||||||
|
// Local media cache Hook | 本地媒体缓存 Hook
|
||||||
|
export { useCachedMediaUrl } from './useCachedMediaUrl'
|
||||||
328
web/canvas-app/src/hooks/useApi.js
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import { ref, reactive, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api'
|
||||||
|
|
||||||
|
const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`
|
||||||
|
|
||||||
|
const toAssetUrl = (path) => {
|
||||||
|
if (!path) return ''
|
||||||
|
if (/^(https?:|blob:|data:)/i.test(path)) return path
|
||||||
|
return apiUrl(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseApiError = async (response, fallback) => {
|
||||||
|
const text = await response.text().catch(() => '')
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text)
|
||||||
|
return parsed?.detail || parsed?.error || fallback
|
||||||
|
} catch {
|
||||||
|
return text || fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestJson = async (path, init = {}) => {
|
||||||
|
const response = await fetch(apiUrl(path), {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(init.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
|
...(init.headers || {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await parseApiError(response, `${path} ${response.status}`))
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrlToFile = (dataUrl, filename = 'reference.jpg') => {
|
||||||
|
const [meta, payload] = dataUrl.split(',')
|
||||||
|
const mime = /data:([^;]+)/.exec(meta)?.[1] || 'image/jpeg'
|
||||||
|
const binary = atob(payload || '')
|
||||||
|
const bytes = new Uint8Array(binary.length)
|
||||||
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
||||||
|
return new File([bytes], filename, { type: mime })
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageSourceToFile = async (source, filename = 'reference.jpg') => {
|
||||||
|
if (!source) return null
|
||||||
|
if (source instanceof File) return source
|
||||||
|
if (typeof source !== 'string') return null
|
||||||
|
if (source.startsWith('data:')) return dataUrlToFile(source, filename)
|
||||||
|
const url = source.startsWith('/jobs/') ? apiUrl(source) : source
|
||||||
|
const response = await fetch(url, { credentials: 'include' })
|
||||||
|
if (!response.ok) throw new Error(`读取参考图失败 ${response.status}`)
|
||||||
|
const blob = await response.blob()
|
||||||
|
return new File([blob], filename, { type: blob.type || 'image/jpeg' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCreativeImageJob = async (file = null) => {
|
||||||
|
if (file) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return requestJson('/creative/jobs/image', { method: 'POST', body: form })
|
||||||
|
}
|
||||||
|
return requestJson('/creative/jobs/image', { method: 'POST', body: JSON.stringify({}) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadReferenceFrame = async (jobId, file) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return requestJson(`/jobs/${jobId}/frames/upload`, { method: 'POST', body: form })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadCanvasImage = async (file) => {
|
||||||
|
if (!file) throw new Error('请选择图片文件')
|
||||||
|
const job = await createCreativeImageJob(file)
|
||||||
|
const frame = job.frames?.[0]
|
||||||
|
if (!frame?.url) throw new Error('图片已上传但未返回可用地址')
|
||||||
|
return {
|
||||||
|
url: toAssetUrl(frame.url),
|
||||||
|
jobId: job.id,
|
||||||
|
frameIdx: frame.index ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadCanvasVideo = async (file) => {
|
||||||
|
if (!file) throw new Error('请选择视频文件')
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
// Persist the upload server-side so the node keeps a stable, reloadable URL
|
||||||
|
// instead of a session-only blob: URL that breaks once the project reloads.
|
||||||
|
const job = await requestJson('/jobs/upload', { method: 'POST', body: form })
|
||||||
|
if (!job?.id) throw new Error('视频已上传但未返回任务地址')
|
||||||
|
return {
|
||||||
|
url: toAssetUrl(`/jobs/${job.id}/video.mp4`),
|
||||||
|
jobId: job.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newestGeneratedImage = (job, frameIdx = 0) => {
|
||||||
|
const frame = (job.frames || []).find(item => item.index === frameIdx) || job.frames?.[0]
|
||||||
|
return [...(frame?.generated_images || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const newestGeneratedVideo = (job) => (
|
||||||
|
[...(job.generated_videos || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
const parseVideoTaskId = (pollTaskId) => {
|
||||||
|
const match = /^skg:([^:]+):([^:]+)$/.exec(String(pollTaskId || ''))
|
||||||
|
if (!match) {
|
||||||
|
const err = new Error('未知视频任务类型')
|
||||||
|
err.terminal = true
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return { jobId: match[1], videoId: match[2] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readVideoTask = async (pollTaskId) => {
|
||||||
|
const { jobId, videoId } = parseVideoTaskId(pollTaskId)
|
||||||
|
// Canvas-level video sync reads one snapshot at a time instead of owning a long node-local poll.
|
||||||
|
const job = await requestJson(`/jobs/${jobId}`, { method: 'GET' })
|
||||||
|
const item = (job.generated_videos || []).find(v => v.id === videoId)
|
||||||
|
if (!item) {
|
||||||
|
const err = new Error('视频任务不存在')
|
||||||
|
err.terminal = true
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
jobId,
|
||||||
|
videoId,
|
||||||
|
job,
|
||||||
|
video: item,
|
||||||
|
status: item.status,
|
||||||
|
progress: item.progress || 0,
|
||||||
|
url: item.status === 'completed' ? toAssetUrl(item.url || `/jobs/${jobId}/storyboard-videos/${videoId}.mp4`) : ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeVideoSize = (value) => {
|
||||||
|
const raw = String(value || '').trim().toLowerCase()
|
||||||
|
const map = {
|
||||||
|
'9:16': '720x1280',
|
||||||
|
'9x16': '720x1280',
|
||||||
|
'vertical': '720x1280',
|
||||||
|
'portrait': '720x1280',
|
||||||
|
'16:9': '1280x720',
|
||||||
|
'16x9': '1280x720',
|
||||||
|
'horizontal': '1280x720',
|
||||||
|
'landscape': '1280x720',
|
||||||
|
'1:1': '1024x1024',
|
||||||
|
'1x1': '1024x1024',
|
||||||
|
'3:4': '960x1280',
|
||||||
|
'3x4': '960x1280'
|
||||||
|
}
|
||||||
|
if (/^\d+x\d+$/.test(raw)) return raw
|
||||||
|
return map[raw] || '720x1280'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useApiState = () => {
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
const status = ref('idle')
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
loading.value = false
|
||||||
|
error.value = null
|
||||||
|
status.value = 'idle'
|
||||||
|
}
|
||||||
|
const setLoading = (isLoading) => {
|
||||||
|
loading.value = isLoading
|
||||||
|
status.value = isLoading ? 'running' : status.value
|
||||||
|
}
|
||||||
|
const setError = (err) => {
|
||||||
|
error.value = err
|
||||||
|
status.value = 'error'
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
const setSuccess = () => {
|
||||||
|
status.value = 'success'
|
||||||
|
loading.value = false
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loading, error, status, reset, setLoading, setError, setSuccess }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChat = (options = {}) => {
|
||||||
|
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
|
||||||
|
const messages = ref([])
|
||||||
|
const currentResponse = ref('')
|
||||||
|
let stopped = false
|
||||||
|
|
||||||
|
const send = async (content) => {
|
||||||
|
setLoading(true)
|
||||||
|
stopped = false
|
||||||
|
try {
|
||||||
|
const mode = options.mode || 'chat'
|
||||||
|
const response = await requestJson('/prompt/polish', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: content,
|
||||||
|
system_prompt: options.systemPrompt || '',
|
||||||
|
mode,
|
||||||
|
target_language: options.targetLanguage || (mode === 'chat' ? 'keep' : 'en')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const result = response.text || content
|
||||||
|
if (!stopped) {
|
||||||
|
currentResponse.value = result
|
||||||
|
messages.value.push({ role: 'user', content })
|
||||||
|
messages.value.push({ role: 'assistant', content: result })
|
||||||
|
}
|
||||||
|
setSuccess()
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
setError(err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
stopped = true
|
||||||
|
}
|
||||||
|
const clear = () => {
|
||||||
|
messages.value = []
|
||||||
|
currentResponse.value = ''
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
onUnmounted(() => stop())
|
||||||
|
return { loading, error, status, messages, currentResponse, send, stop, clear, reset }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useImageGeneration = () => {
|
||||||
|
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
|
||||||
|
const images = ref([])
|
||||||
|
const currentImage = ref(null)
|
||||||
|
|
||||||
|
const generate = async (params) => {
|
||||||
|
setLoading(true)
|
||||||
|
images.value = []
|
||||||
|
currentImage.value = null
|
||||||
|
try {
|
||||||
|
const refs = Array.isArray(params.image) ? params.image : (params.image ? [params.image] : [])
|
||||||
|
const firstRef = refs[0] ? await imageSourceToFile(refs[0], 'image-reference.jpg') : null
|
||||||
|
const job = await createCreativeImageJob(firstRef)
|
||||||
|
const updated = await requestJson(`/jobs/${job.id}/frames/0/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: params.prompt || '',
|
||||||
|
model: params.model || 'auto',
|
||||||
|
size: params.size || '1024x1536',
|
||||||
|
mode: firstRef ? 'edit' : 'text'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const generated = newestGeneratedImage(updated, 0)
|
||||||
|
if (!generated?.url) throw new Error('图片生成完成但未返回地址')
|
||||||
|
const result = [{ ...generated, url: toAssetUrl(generated.url), jobId: updated.id, frameIdx: 0 }]
|
||||||
|
images.value = result
|
||||||
|
currentImage.value = result[0]
|
||||||
|
setSuccess()
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
setError(err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loading, error, status, images, currentImage, generate, reset }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVideoGeneration = () => {
|
||||||
|
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
|
||||||
|
const video = ref(null)
|
||||||
|
const taskId = ref(null)
|
||||||
|
const progress = reactive({ attempt: 0, maxAttempts: 180, percentage: 0 })
|
||||||
|
|
||||||
|
const createVideoTaskOnly = async (params) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const firstFile = params.first_frame_image ? await imageSourceToFile(params.first_frame_image, 'first-frame.jpg') : null
|
||||||
|
let job = await createCreativeImageJob(firstFile)
|
||||||
|
let lastFrameIdx = null
|
||||||
|
if (params.last_frame_image) {
|
||||||
|
const lastFile = await imageSourceToFile(params.last_frame_image, 'last-frame.jpg')
|
||||||
|
if (lastFile) {
|
||||||
|
job = await uploadReferenceFrame(job.id, lastFile)
|
||||||
|
lastFrameIdx = Math.max(...(job.frames || []).map(frame => frame.index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updated = await requestJson(`/jobs/${job.id}/frames/0/storyboard/video`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: params.prompt || '',
|
||||||
|
duration: Number(params.dur || params.duration || params.seconds || 10),
|
||||||
|
count: 1,
|
||||||
|
first_image: firstFile ? { kind: 'keyframe', frame_idx: 0 } : null,
|
||||||
|
last_image: lastFrameIdx !== null ? { kind: 'keyframe', frame_idx: lastFrameIdx } : null,
|
||||||
|
model: params.model || 'seedance',
|
||||||
|
size: normalizeVideoSize(params.ratio || params.size),
|
||||||
|
resolution: params.resolution || '720p'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const created = newestGeneratedVideo(updated)
|
||||||
|
if (!created?.id) throw new Error('视频任务已提交但未返回任务编号')
|
||||||
|
const id = `skg:${updated.id}:${created.id}`
|
||||||
|
taskId.value = id
|
||||||
|
status.value = 'polling'
|
||||||
|
setSuccess()
|
||||||
|
return { taskId: id }
|
||||||
|
} catch (err) {
|
||||||
|
setError(err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node-local polling was removed: video status is owned by the Canvas-level
|
||||||
|
// syncPendingVideoNodes() interval (views/Canvas.vue), which reads snapshots via
|
||||||
|
// readVideoTask() and is properly torn down on unmount. A second per-node poll
|
||||||
|
// here just duplicated requests and had no unmount stop.
|
||||||
|
return { loading, error, status, video, taskId, progress, reset, createVideoTaskOnly }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useApi = () => {
|
||||||
|
const chat = useChat()
|
||||||
|
const image = useImageGeneration()
|
||||||
|
const videoGen = useVideoGeneration()
|
||||||
|
return { config: {}, chat, image, video: videoGen }
|
||||||
|
}
|
||||||
26
web/canvas-app/src/hooks/useApiConfig.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal auth/session config.
|
||||||
|
* Upstream model credentials stay on the server and are not configured in this UI.
|
||||||
|
*/
|
||||||
|
export const useApiConfig = () => {
|
||||||
|
const apiKey = ref('internal-session')
|
||||||
|
const baseUrl = ref('/api')
|
||||||
|
const isConfigured = computed(() => true)
|
||||||
|
|
||||||
|
const setApiKey = () => {}
|
||||||
|
const setBaseUrl = () => {}
|
||||||
|
const configure = () => {}
|
||||||
|
const clear = () => {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
isConfigured,
|
||||||
|
setApiKey,
|
||||||
|
setBaseUrl,
|
||||||
|
configure,
|
||||||
|
clear
|
||||||
|
}
|
||||||
|
}
|
||||||
206
web/canvas-app/src/hooks/useCachedMediaUrl.js
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const CACHE_NAME = 'skg-canvas-media-v1'
|
||||||
|
const INDEX_KEY = 'skg-canvas-media-index-v1'
|
||||||
|
const MAX_CACHE_BYTES = 700 * 1024 * 1024
|
||||||
|
const MAX_CACHE_ITEMS = 240
|
||||||
|
const CACHEABLE_PATHS = ['/api/jobs/', '/api/agent-runs/']
|
||||||
|
const inflight = new Map()
|
||||||
|
|
||||||
|
const canUseBrowserCache = () => (
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
&& typeof caches !== 'undefined'
|
||||||
|
&& window.isSecureContext
|
||||||
|
)
|
||||||
|
|
||||||
|
const readIndex = () => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(window.localStorage.getItem(INDEX_KEY) || '{}')
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeIndex = (index) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(INDEX_KEY, JSON.stringify(index))
|
||||||
|
} catch {
|
||||||
|
// The media itself is still in Cache Storage; the index only helps pruning.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeMediaUrl = (source) => {
|
||||||
|
if (!source || typeof source !== 'string') return ''
|
||||||
|
if (/^(blob:|data:)/i.test(source)) return source
|
||||||
|
if (/^https?:\/\//i.test(source)) return source
|
||||||
|
if (source.startsWith('/api/')) return source
|
||||||
|
if (source.startsWith('/jobs/') || source.startsWith('/agent-runs/')) {
|
||||||
|
return `/api${source}`
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKeyFor = (source) => {
|
||||||
|
const normalized = normalizeMediaUrl(source)
|
||||||
|
if (!normalized || /^(blob:|data:)/i.test(normalized)) return ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(normalized, window.location.origin)
|
||||||
|
if (url.origin !== window.location.origin) return ''
|
||||||
|
if (!CACHEABLE_PATHS.some(path => url.pathname.startsWith(path))) return ''
|
||||||
|
return url.href
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseSize = (response) => {
|
||||||
|
const length = Number(response.headers.get('content-length') || 0)
|
||||||
|
return Number.isFinite(length) && length > 0 ? length : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const pruneMediaCache = async (cache, index) => {
|
||||||
|
const entries = Object.entries(index)
|
||||||
|
.sort((a, b) => (b[1]?.lastAccess || 0) - (a[1]?.lastAccess || 0))
|
||||||
|
|
||||||
|
let total = entries.reduce((sum, [, meta]) => sum + Number(meta?.size || 0), 0)
|
||||||
|
const kept = {}
|
||||||
|
|
||||||
|
for (const [key, meta] of entries) {
|
||||||
|
const keepByCount = Object.keys(kept).length < MAX_CACHE_ITEMS
|
||||||
|
const keepBySize = total <= MAX_CACHE_BYTES || !meta?.size
|
||||||
|
if (keepByCount && keepBySize) {
|
||||||
|
kept[key] = meta
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await cache.delete(key)
|
||||||
|
total -= Number(meta?.size || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeIndex(kept)
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
const touchCacheEntry = (key, response, sizeOverride) => {
|
||||||
|
const index = readIndex()
|
||||||
|
const measured = Number(sizeOverride)
|
||||||
|
index[key] = {
|
||||||
|
size: measured > 0 ? measured : (responseSize(response) || index[key]?.size || 0),
|
||||||
|
contentType: response.headers.get('content-type') || index[key]?.contentType || '',
|
||||||
|
lastAccess: Date.now()
|
||||||
|
}
|
||||||
|
writeIndex(index)
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
const warmMediaCache = async (source) => {
|
||||||
|
if (!canUseBrowserCache()) return
|
||||||
|
const key = cacheKeyFor(source)
|
||||||
|
if (!key) return
|
||||||
|
if (inflight.has(key)) return inflight.get(key)
|
||||||
|
|
||||||
|
const run = (async () => {
|
||||||
|
const cache = await caches.open(CACHE_NAME)
|
||||||
|
const cached = await cache.match(key)
|
||||||
|
if (cached) {
|
||||||
|
touchCacheEntry(key, cached)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(key, {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'force-cache'
|
||||||
|
})
|
||||||
|
if (!response.ok) return
|
||||||
|
|
||||||
|
const type = response.headers.get('content-type') || ''
|
||||||
|
if (!/^(image|video|audio)\//i.test(type)) return
|
||||||
|
|
||||||
|
// Measure the real byte size from a clone — videos are usually served with
|
||||||
|
// chunked transfer (no content-length), which would record size=0 and make
|
||||||
|
// the LRU byte cap a no-op (everything looks "free" to keep).
|
||||||
|
const measureClone = response.clone()
|
||||||
|
await cache.put(key, response.clone())
|
||||||
|
let realSize = 0
|
||||||
|
try {
|
||||||
|
realSize = (await measureClone.blob()).size
|
||||||
|
} catch {
|
||||||
|
realSize = 0
|
||||||
|
}
|
||||||
|
const index = touchCacheEntry(key, response, realSize)
|
||||||
|
await pruneMediaCache(cache, index)
|
||||||
|
})().finally(() => {
|
||||||
|
inflight.delete(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
inflight.set(key, run)
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedObjectUrl = async (source) => {
|
||||||
|
if (!canUseBrowserCache()) return ''
|
||||||
|
const key = cacheKeyFor(source)
|
||||||
|
if (!key) return ''
|
||||||
|
|
||||||
|
const cache = await caches.open(CACHE_NAME)
|
||||||
|
const cached = await cache.match(key)
|
||||||
|
if (!cached) return ''
|
||||||
|
|
||||||
|
touchCacheEntry(key, cached)
|
||||||
|
const blob = await cached.blob()
|
||||||
|
if (!blob.size) return ''
|
||||||
|
return URL.createObjectURL(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCachedMediaUrl = (sourceGetter) => {
|
||||||
|
const cachedUrl = ref('')
|
||||||
|
const sourceUrl = computed(() => normalizeMediaUrl(sourceGetter() || ''))
|
||||||
|
let activeObjectUrl = ''
|
||||||
|
let token = 0
|
||||||
|
|
||||||
|
const clearObjectUrl = () => {
|
||||||
|
if (activeObjectUrl) {
|
||||||
|
URL.revokeObjectURL(activeObjectUrl)
|
||||||
|
activeObjectUrl = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
sourceUrl,
|
||||||
|
async (url) => {
|
||||||
|
token += 1
|
||||||
|
const currentToken = token
|
||||||
|
clearObjectUrl()
|
||||||
|
cachedUrl.value = url
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localUrl = await cachedObjectUrl(url)
|
||||||
|
if (currentToken !== token) {
|
||||||
|
if (localUrl) URL.revokeObjectURL(localUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (localUrl) {
|
||||||
|
activeObjectUrl = localUrl
|
||||||
|
cachedUrl.value = localUrl
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// only fall back to the raw URL if the source hasn't changed underneath us
|
||||||
|
if (currentToken === token) cachedUrl.value = url
|
||||||
|
}
|
||||||
|
|
||||||
|
warmMediaCache(url).catch(() => {})
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(clearObjectUrl)
|
||||||
|
|
||||||
|
return {
|
||||||
|
cachedUrl,
|
||||||
|
sourceUrl,
|
||||||
|
warmCache: () => warmMediaCache(sourceUrl.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
387
web/canvas-app/src/hooks/useModelConfig.js
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
/**
|
||||||
|
* Model Config Hook | 模型配置 Hook
|
||||||
|
* Manages model configuration with local storage persistence
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { STORAGE_KEYS } from '@/utils'
|
||||||
|
import { useProvider } from './useProvider'
|
||||||
|
import {
|
||||||
|
CHAT_MODELS,
|
||||||
|
IMAGE_MODELS,
|
||||||
|
VIDEO_MODELS,
|
||||||
|
DEFAULT_CHAT_MODEL,
|
||||||
|
DEFAULT_IMAGE_MODEL,
|
||||||
|
DEFAULT_VIDEO_MODEL
|
||||||
|
} from '@/config/models'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查模型是否支持指定渠道
|
||||||
|
* @param {Object} model - 模型配置
|
||||||
|
* @param {string} provider - 渠道名称
|
||||||
|
* @returns {boolean} 是否支持
|
||||||
|
*/
|
||||||
|
const isModelSupported = (model, provider) => {
|
||||||
|
// 如果没有 provider 字段,默认支持所有渠道
|
||||||
|
if (!model.provider) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 如果有 provider 字段,检查是否包含指定渠道
|
||||||
|
return model.provider.includes(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored JSON value from localStorage
|
||||||
|
*/
|
||||||
|
const getStoredJson = (key, defaultValue = []) => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(key)
|
||||||
|
return stored ? JSON.parse(stored) : defaultValue
|
||||||
|
} catch {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set stored JSON value to localStorage
|
||||||
|
*/
|
||||||
|
const setStoredJson = (key, value) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value))
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored string value from localStorage
|
||||||
|
*/
|
||||||
|
const getStored = (key, defaultValue = '') => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key) || defaultValue
|
||||||
|
} catch {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set stored string value to localStorage
|
||||||
|
*/
|
||||||
|
const setStored = (key, value) => {
|
||||||
|
try {
|
||||||
|
if (value) {
|
||||||
|
localStorage.setItem(key, value)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getValidStoredModel = (key, defaultValue, builtInModels) => {
|
||||||
|
const stored = getStored(key, defaultValue)
|
||||||
|
return builtInModels.some(model => model.key === stored) ? stored : defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared reactive state (singleton pattern)
|
||||||
|
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, []))
|
||||||
|
|
||||||
|
// 按渠道存储的自定义模型 | 结构: { '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', {}))
|
||||||
|
|
||||||
|
const selectedChatModel = ref(getStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, DEFAULT_CHAT_MODEL))
|
||||||
|
const selectedImageModel = ref(getValidStoredModel(STORAGE_KEYS.SELECTED_IMAGE_MODEL, DEFAULT_IMAGE_MODEL, IMAGE_MODELS))
|
||||||
|
const selectedVideoModel = ref(getValidStoredModel(STORAGE_KEYS.SELECTED_VIDEO_MODEL, DEFAULT_VIDEO_MODEL, VIDEO_MODELS))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model Configuration Hook
|
||||||
|
*/
|
||||||
|
export const useModelConfig = () => {
|
||||||
|
// Get current provider | 获取当前渠道
|
||||||
|
const { currentProvider } = useProvider()
|
||||||
|
|
||||||
|
// Combined models (built-in + custom, including provider-specific custom models)
|
||||||
|
const allChatModels = computed(() => [
|
||||||
|
...CHAT_MODELS.map(m => ({ ...m, isCustom: false })),
|
||||||
|
...customChatModels.value.map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true
|
||||||
|
})),
|
||||||
|
// 添加当前渠道的自定义模型
|
||||||
|
...(customChatModelsByProvider.value[currentProvider.value] || []).map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
provider: [currentProvider.value]
|
||||||
|
}))
|
||||||
|
])
|
||||||
|
|
||||||
|
const allImageModels = computed(() =>
|
||||||
|
IMAGE_MODELS.map(m => ({ ...m, isCustom: false }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const allVideoModels = computed(() =>
|
||||||
|
VIDEO_MODELS.map(m => ({ ...m, isCustom: false }))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Available models filtered by provider | 根据渠道过滤的可用模型
|
||||||
|
const availableChatModels = computed(() =>
|
||||||
|
allChatModels.value.filter(m => isModelSupported(m, currentProvider.value))
|
||||||
|
)
|
||||||
|
|
||||||
|
const availableImageModels = computed(() =>
|
||||||
|
allImageModels.value.filter(m => isModelSupported(m, currentProvider.value))
|
||||||
|
)
|
||||||
|
|
||||||
|
const availableVideoModels = computed(() =>
|
||||||
|
allVideoModels.value.filter(m => isModelSupported(m, currentProvider.value))
|
||||||
|
)
|
||||||
|
|
||||||
|
// All models (including models from all providers, not filtered) | 所有模型(不按渠道过滤)
|
||||||
|
const allAvailableChatModels = computed(() => allChatModels.value)
|
||||||
|
const allAvailableImageModels = computed(() => allImageModels.value)
|
||||||
|
const allAvailableVideoModels = computed(() => allVideoModels.value)
|
||||||
|
|
||||||
|
// 获取指定渠道的模型(包括内置 + 该渠道自定义)
|
||||||
|
const getModelsByProvider = (provider) => {
|
||||||
|
const chat = [
|
||||||
|
...CHAT_MODELS.filter(m => isModelSupported(m, provider)).map(m => ({ ...m, isCustom: false })),
|
||||||
|
...(customChatModelsByProvider.value[provider] || []).map(m => ({
|
||||||
|
label: m.label || m.key,
|
||||||
|
key: m.key,
|
||||||
|
isCustom: true,
|
||||||
|
provider: [provider]
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
const image = IMAGE_MODELS
|
||||||
|
.filter(m => isModelSupported(m, provider))
|
||||||
|
.map(m => ({ ...m, isCustom: false }))
|
||||||
|
const video = VIDEO_MODELS
|
||||||
|
.filter(m => isModelSupported(m, provider))
|
||||||
|
.map(m => ({ ...m, isCustom: false }))
|
||||||
|
return { chat, image, video }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch and persist changes
|
||||||
|
watch(customChatModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, val), { deep: true })
|
||||||
|
watch(customImageModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, val), { deep: true })
|
||||||
|
watch(customVideoModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, val), { deep: true })
|
||||||
|
|
||||||
|
// Watch and persist by provider changes
|
||||||
|
watch(customChatModelsByProvider, (val) => {
|
||||||
|
const key = STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER || 'custom-chat-models-by-provider'
|
||||||
|
setStoredJson(key, val)
|
||||||
|
}, { deep: true })
|
||||||
|
watch(customImageModelsByProvider, (val) => {
|
||||||
|
const key = STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER || 'custom-image-models-by-provider'
|
||||||
|
setStoredJson(key, val)
|
||||||
|
}, { deep: true })
|
||||||
|
watch(customVideoModelsByProvider, (val) => {
|
||||||
|
const key = STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER || 'custom-video-models-by-provider'
|
||||||
|
setStoredJson(key, val)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(selectedChatModel, (val) => setStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, val))
|
||||||
|
watch(selectedImageModel, (val) => setStored(STORAGE_KEYS.SELECTED_IMAGE_MODEL, val))
|
||||||
|
watch(selectedVideoModel, (val) => setStored(STORAGE_KEYS.SELECTED_VIDEO_MODEL, val))
|
||||||
|
|
||||||
|
// Add custom model
|
||||||
|
const addCustomChatModel = (modelKey, label = '') => {
|
||||||
|
if (!modelKey || customChatModels.value.some(m => m.key === modelKey)) return false
|
||||||
|
customChatModels.value.push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomImageModel = (modelKey, label = '') => {
|
||||||
|
if (!modelKey || customImageModels.value.some(m => m.key === modelKey)) return false
|
||||||
|
customImageModels.value.push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomVideoModel = (modelKey, label = '') => {
|
||||||
|
if (!modelKey || customVideoModels.value.some(m => m.key === modelKey)) return false
|
||||||
|
customVideoModels.value.push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove custom model
|
||||||
|
const removeCustomChatModel = (modelKey) => {
|
||||||
|
const idx = customChatModels.value.findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customChatModels.value.splice(idx, 1)
|
||||||
|
if (selectedChatModel.value === modelKey) {
|
||||||
|
selectedChatModel.value = DEFAULT_CHAT_MODEL
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomImageModel = (modelKey) => {
|
||||||
|
const idx = customImageModels.value.findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customImageModels.value.splice(idx, 1)
|
||||||
|
if (selectedImageModel.value === modelKey) {
|
||||||
|
selectedImageModel.value = DEFAULT_IMAGE_MODEL
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomVideoModel = (modelKey) => {
|
||||||
|
const idx = customVideoModels.value.findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customVideoModels.value.splice(idx, 1)
|
||||||
|
if (selectedVideoModel.value === modelKey) {
|
||||||
|
selectedVideoModel.value = DEFAULT_VIDEO_MODEL
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按渠道添加自定义模型
|
||||||
|
const addCustomChatModelByProvider = (modelKey, provider, label = '') => {
|
||||||
|
if (!modelKey) return false
|
||||||
|
if (!customChatModelsByProvider.value[provider]) {
|
||||||
|
customChatModelsByProvider.value[provider] = []
|
||||||
|
}
|
||||||
|
if (customChatModelsByProvider.value[provider].some(m => m.key === modelKey)) return false
|
||||||
|
customChatModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomImageModelByProvider = (modelKey, provider, label = '') => {
|
||||||
|
if (!modelKey) return false
|
||||||
|
if (!customImageModelsByProvider.value[provider]) {
|
||||||
|
customImageModelsByProvider.value[provider] = []
|
||||||
|
}
|
||||||
|
if (customImageModelsByProvider.value[provider].some(m => m.key === modelKey)) return false
|
||||||
|
customImageModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomVideoModelByProvider = (modelKey, provider, label = '') => {
|
||||||
|
if (!modelKey) return false
|
||||||
|
if (!customVideoModelsByProvider.value[provider]) {
|
||||||
|
customVideoModelsByProvider.value[provider] = []
|
||||||
|
}
|
||||||
|
if (customVideoModelsByProvider.value[provider].some(m => m.key === modelKey)) return false
|
||||||
|
customVideoModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按渠道删除自定义模型
|
||||||
|
const removeCustomChatModelByProvider = (modelKey, provider) => {
|
||||||
|
if (!customChatModelsByProvider.value[provider]) return false
|
||||||
|
const idx = customChatModelsByProvider.value[provider].findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customChatModelsByProvider.value[provider].splice(idx, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomImageModelByProvider = (modelKey, provider) => {
|
||||||
|
if (!customImageModelsByProvider.value[provider]) return false
|
||||||
|
const idx = customImageModelsByProvider.value[provider].findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customImageModelsByProvider.value[provider].splice(idx, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomVideoModelByProvider = (modelKey, provider) => {
|
||||||
|
if (!customVideoModelsByProvider.value[provider]) return false
|
||||||
|
const idx = customVideoModelsByProvider.value[provider].findIndex(m => m.key === modelKey)
|
||||||
|
if (idx > -1) {
|
||||||
|
customVideoModelsByProvider.value[provider].splice(idx, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get model by key
|
||||||
|
const getChatModel = (key) => allChatModels.value.find(m => m.key === key)
|
||||||
|
const getImageModel = (key) => allImageModels.value.find(m => m.key === key)
|
||||||
|
const getVideoModel = (key) => allVideoModels.value.find(m => m.key === key)
|
||||||
|
|
||||||
|
// Clear all custom models
|
||||||
|
const clearCustomModels = () => {
|
||||||
|
customChatModels.value = []
|
||||||
|
customImageModels.value = []
|
||||||
|
customVideoModels.value = []
|
||||||
|
selectedChatModel.value = DEFAULT_CHAT_MODEL
|
||||||
|
selectedImageModel.value = DEFAULT_IMAGE_MODEL
|
||||||
|
selectedVideoModel.value = DEFAULT_VIDEO_MODEL
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// All models (built-in + custom)
|
||||||
|
allChatModels,
|
||||||
|
allImageModels,
|
||||||
|
allVideoModels,
|
||||||
|
|
||||||
|
// Available models filtered by provider | 根据渠道过滤的可用模型
|
||||||
|
availableChatModels,
|
||||||
|
availableImageModels,
|
||||||
|
availableVideoModels,
|
||||||
|
|
||||||
|
// All models (including models from all providers, not filtered) | 所有模型(不按渠道过滤)
|
||||||
|
allAvailableChatModels,
|
||||||
|
allAvailableImageModels,
|
||||||
|
allAvailableVideoModels,
|
||||||
|
|
||||||
|
// Custom models only
|
||||||
|
customChatModels,
|
||||||
|
customImageModels,
|
||||||
|
customVideoModels,
|
||||||
|
|
||||||
|
// Selected models
|
||||||
|
selectedChatModel,
|
||||||
|
selectedImageModel,
|
||||||
|
selectedVideoModel,
|
||||||
|
|
||||||
|
// Add methods
|
||||||
|
addCustomChatModel,
|
||||||
|
addCustomImageModel,
|
||||||
|
addCustomVideoModel,
|
||||||
|
|
||||||
|
// Remove methods
|
||||||
|
removeCustomChatModel,
|
||||||
|
removeCustomImageModel,
|
||||||
|
removeCustomVideoModel,
|
||||||
|
|
||||||
|
// Get model
|
||||||
|
getChatModel,
|
||||||
|
getImageModel,
|
||||||
|
getVideoModel,
|
||||||
|
|
||||||
|
// Get models by provider (for ApiSettings)
|
||||||
|
getModelsByProvider,
|
||||||
|
|
||||||
|
// Custom models by provider
|
||||||
|
customChatModelsByProvider,
|
||||||
|
customImageModelsByProvider,
|
||||||
|
customVideoModelsByProvider,
|
||||||
|
|
||||||
|
// Add/Remove by provider methods
|
||||||
|
addCustomChatModelByProvider,
|
||||||
|
addCustomImageModelByProvider,
|
||||||
|
addCustomVideoModelByProvider,
|
||||||
|
removeCustomChatModelByProvider,
|
||||||
|
removeCustomImageModelByProvider,
|
||||||
|
removeCustomVideoModelByProvider,
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
clearCustomModels
|
||||||
|
}
|
||||||
|
}
|
||||||
103
web/canvas-app/src/hooks/useNodeRef.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* 节点引用解析 Hook
|
||||||
|
* 用于解析文本中的 @[nodeId] 引用格式
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析文本中的 @ 引用
|
||||||
|
* @param {string} text - 待解析的文本
|
||||||
|
* @returns {Array<{nodeId: string, name?: string, order: number}>} 解析出的引用列表
|
||||||
|
*/
|
||||||
|
export function parseMentions(text) {
|
||||||
|
if (!text) return []
|
||||||
|
|
||||||
|
const mentions = []
|
||||||
|
// 匹配 @[nodeId] 或 @[nodeId|name] 格式
|
||||||
|
const regex = /@\[([^\]|]+)(?:\|([^\]]+))?\]/g
|
||||||
|
let match
|
||||||
|
let order = 0
|
||||||
|
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
mentions.push({
|
||||||
|
nodeId: match[1],
|
||||||
|
name: match[2] || null,
|
||||||
|
order: order++
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return mentions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文本是否包含对指定节点的 @ 引用
|
||||||
|
* @param {string} text - 待检查的文本
|
||||||
|
* @param {string} nodeId - 节点ID
|
||||||
|
* @returns {boolean} 是否包含引用
|
||||||
|
*/
|
||||||
|
export function hasMention(text, nodeId) {
|
||||||
|
const mentions = parseMentions(text)
|
||||||
|
return mentions.some(m => m.nodeId === nodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文本中提取对指定节点的引用
|
||||||
|
* @param {string} text - 待解析的文本
|
||||||
|
* @param {string} nodeId - 节点ID
|
||||||
|
* @returns {Array<{nodeId: string, name?: string, order: number}>} 匹配的引用
|
||||||
|
*/
|
||||||
|
export function getMentionsToNode(text, nodeId) {
|
||||||
|
const mentions = parseMentions(text)
|
||||||
|
return mentions.filter(m => m.nodeId === nodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理文本中的 @ 引用标记,保留引用名称(如果有)
|
||||||
|
* @param {string} text - 待清理的文本
|
||||||
|
* @param {string} placeholder - 替换引用的占位符,默认空字符串
|
||||||
|
* @returns {string} 清理后的文本
|
||||||
|
*/
|
||||||
|
export function cleanMentions(text, placeholder = '') {
|
||||||
|
if (!text) return ''
|
||||||
|
return text.replace(/@\[([^\]|]+)(?:\|([^\]]+))?\]/g, (_, nodeId, name) => {
|
||||||
|
return name || placeholder
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在文本中插入 @ 引用
|
||||||
|
* @param {string} text - 原文本
|
||||||
|
* @param {string} nodeId - 节点ID
|
||||||
|
* @param {string} name - 显示名称(可选)
|
||||||
|
* @param {number} position - 插入位置(默认末尾)
|
||||||
|
* @returns {string} 插入引用后的文本
|
||||||
|
*/
|
||||||
|
export function insertMention(text, nodeId, name = null, position = -1) {
|
||||||
|
const mention = name ? `@[${nodeId}|${name}]` : `@[${nodeId}]`
|
||||||
|
|
||||||
|
if (position < 0 || position >= text.length) {
|
||||||
|
return text + mention
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.slice(0, position) + mention + text.slice(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文本中移除指定节点的 @ 引用
|
||||||
|
* @param {string} text - 原文本
|
||||||
|
* @param {string} nodeId - 节点ID
|
||||||
|
* @returns {string} 移除引用后的文本
|
||||||
|
*/
|
||||||
|
export function removeMention(text, nodeId) {
|
||||||
|
if (!text) return ''
|
||||||
|
return text.replace(new RegExp(`@\\[${nodeId}(?:\\|[^\\]]+)?\\]`, 'g'), '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文本中所有 @ 引用的节点ID列表(去重)
|
||||||
|
* @param {string} text - 待解析的文本
|
||||||
|
* @returns {string[]} 节点ID列表
|
||||||
|
*/
|
||||||
|
export function getMentionedNodeIds(text) {
|
||||||
|
const mentions = parseMentions(text)
|
||||||
|
return [...new Set(mentions.map(m => m.nodeId))]
|
||||||
|
}
|
||||||
124
web/canvas-app/src/hooks/useProvider.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Provider Hook | 渠道管理 Hook
|
||||||
|
* 管理当前选中的 API 渠道,提供请求/响应适配功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { PROVIDERS, getProviderList, getDefaultProvider, getProviderConfig } from '@/config/providers'
|
||||||
|
|
||||||
|
// 存储键名
|
||||||
|
const STORAGE_KEY = 'api-provider'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored value from localStorage | 从 localStorage 获取存储值
|
||||||
|
*/
|
||||||
|
const getStored = (key, defaultValue = '') => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key) || defaultValue
|
||||||
|
} catch {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set stored value to localStorage | 设置存储值到 localStorage
|
||||||
|
*/
|
||||||
|
const setStored = (key, value) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove stored value from localStorage | 从 localStorage 移除存储值
|
||||||
|
*/
|
||||||
|
const removeStored = (key) => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取存储的渠道
|
||||||
|
*/
|
||||||
|
const getStoredProvider = () => {
|
||||||
|
return getStored(STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider Hook | 渠道管理 Hook
|
||||||
|
*/
|
||||||
|
export const useProvider = () => {
|
||||||
|
// 当前选中的渠道
|
||||||
|
const currentProvider = ref(getStoredProvider() || getDefaultProvider())
|
||||||
|
|
||||||
|
// 渠道列表
|
||||||
|
const providerList = getProviderList()
|
||||||
|
|
||||||
|
// 当前渠道配置
|
||||||
|
const providerConfig = computed(() => getProviderConfig(currentProvider.value))
|
||||||
|
|
||||||
|
// 当前渠道标签
|
||||||
|
const providerLabel = computed(() => providerConfig.value.label || currentProvider.value)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前渠道
|
||||||
|
*/
|
||||||
|
const setProvider = (provider) => {
|
||||||
|
if (PROVIDERS[provider]) {
|
||||||
|
currentProvider.value = provider
|
||||||
|
setStored(STORAGE_KEY, provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除渠道配置
|
||||||
|
*/
|
||||||
|
const clearProvider = () => {
|
||||||
|
currentProvider.value = getDefaultProvider()
|
||||||
|
removeStored(STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 适配请求参数
|
||||||
|
* @param {string} type - 请求类型:'chat' | 'image' | 'video'
|
||||||
|
* @param {Object} params - 原始请求参数
|
||||||
|
*/
|
||||||
|
const adaptRequest = (type, params) => {
|
||||||
|
const config = providerConfig.value
|
||||||
|
if (config.requestAdapter && config.requestAdapter[type]) {
|
||||||
|
return config.requestAdapter[type](params)
|
||||||
|
}
|
||||||
|
// 如果没有适配器,返回原始参数
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 适配响应数据
|
||||||
|
* @param {string} type - 响应类型:'chat' | 'image' | 'video'
|
||||||
|
* @param {Object} response - 原始响应数据
|
||||||
|
*/
|
||||||
|
const adaptResponse = (type, response) => {
|
||||||
|
const config = providerConfig.value
|
||||||
|
if (config.responseAdapter && config.responseAdapter[type]) {
|
||||||
|
return config.responseAdapter[type](response)
|
||||||
|
}
|
||||||
|
// 如果没有适配器,返回原始响应
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentProvider,
|
||||||
|
providerList,
|
||||||
|
providerConfig,
|
||||||
|
providerLabel,
|
||||||
|
setProvider,
|
||||||
|
clearProvider,
|
||||||
|
adaptRequest,
|
||||||
|
adaptResponse
|
||||||
|
}
|
||||||
|
}
|
||||||