Compare commits
117 Commits
a27dcbda8d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d82c8d22b | |||
| cfe963a2c7 | |||
| 549082ace3 | |||
| 88d598303c | |||
| 3f5dfdc465 | |||
| 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 |
@@ -24,3 +24,5 @@ data
|
||||
.env.local
|
||||
.env.production
|
||||
deploy/.env.production
|
||||
deploy/.env.local
|
||||
data-local
|
||||
|
||||
5
.gitignore
vendored
@@ -15,9 +15,11 @@ __pycache__/
|
||||
.logs/
|
||||
.pids/
|
||||
deploy/.env.production
|
||||
deploy/.env.local
|
||||
deploy/.htpasswd
|
||||
secrets/
|
||||
.backups/
|
||||
data-local/
|
||||
|
||||
# api
|
||||
api/.venv/
|
||||
@@ -28,7 +30,10 @@ prompt_library/*
|
||||
!prompt_library/.gitkeep
|
||||
_trash/
|
||||
output/
|
||||
.playwright-cli/
|
||||
|
||||
# web
|
||||
web/.next/
|
||||
web/out/
|
||||
web/public/canvas/
|
||||
.pnpm-store/
|
||||
|
||||
@@ -1,148 +1,107 @@
|
||||
# 项目接力
|
||||
|
||||
- 生成时间:May 23, 2026 at 23:37
|
||||
- 项目:SKG Marketing Studio / SKG 营销内容工作台
|
||||
- 生成时间:May 31, 2026 at 15:25
|
||||
- 项目:SKG 营销内容生产平台
|
||||
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||
- 状态:active
|
||||
- 主链接:https://marketing.skg.com
|
||||
|
||||
## 最近助手会话概览
|
||||
|
||||
- Claude:a9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
|
||||
- Codex:019e4d33-ad65-7673-934e-815226984ea6 · 时间未知
|
||||
- Cursor:未找到匹配当前项目的最近会话
|
||||
- Claude:eb894273-808b-439c-b4b9-840ad2553d0b · 时间未知
|
||||
- Codex:019e63ac-1984-7a42-8c65-ffd7ab146968 · 时间未知
|
||||
|
||||
## Claude 最近会话
|
||||
|
||||
- Session ID:a9e0449c-d9cb-4a2a-bb16-16596dfb552a
|
||||
- Transcript:/Users/kangwan/.claude/projects/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/a9e0449c-d9cb-4a2a-bb16-16596dfb552a.jsonl
|
||||
- Session ID:eb894273-808b-439c-b4b9-840ad2553d0b
|
||||
- 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-二创验证
|
||||
- 分支:main
|
||||
- 敏感字段:已对 token / key / password / secret 做脱敏
|
||||
|
||||
### 最近用户要求
|
||||
|
||||
- 你现在是在 Claude 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 到当前项目目录
|
||||
- 如果让你去设计。转换层 这个你会想怎么去弄
|
||||
- 就是需要生成主体元素的6视图,因为很多时候需求方都不清楚自己要什么,
|
||||
- <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 最近回复
|
||||
|
||||
> 好问题。我先把转换层"到底是干嘛的"想清楚,再说怎么设计——因为之前它被反复改又清空,大概率是因为没锁定它的定位。
|
||||
>
|
||||
> ## 一、它的本质不是"聊天生图",是"定主体的确认台"
|
||||
>
|
||||
> 转换层夹在「参考帧池 → 转换层 → 主体元素」中间,真正的活儿是:**拿源视频里一个验证过的主体,二创成一个全新的、我们自己的主体**,输出是右侧"统一多角度套图"。
|
||||
>
|
||||
> 所以它要解决的核心矛盾就两个:
|
||||
> 1. **意图收敛**:这张参考帧,用户是想原样复刻、换真人、卡通化,还是只借元素?产品怎么出现?
|
||||
> 2. **花钱前的闸门**:`gpt-image-2` 单视图 60s、一套 6 视图很慢很贵,不能用户随口一句就直接烧一整套。所以"出 prompt → 确认 → 才生成"这个 gate 是刚需。
|
||||
>
|
||||
> 把它当"聊天机器人"做就会很难受(每次都得多轮对话);把它当"确认台"做就顺了。
|
||||
>
|
||||
> ## 二、关键设计判断:确认的不是"一张图的 prompt",是"主体设定(bible)"
|
||||
>
|
||||
> 你们后端已经有 `pack bible` / source brief 这套——一套图靠同一份 bible 保证多角度是**同一个主体**。
|
||||
>
|
||||
> 那 UI 上闸门就应该让用户**确认这份主体设定**,而不是确认某一张图。一旦确认:
|
||||
> - **数量 = 从这份锁定设定里渲染几个角度**
|
||||
> - 多角度统一 = 自动成立(因为大家共享同一 bible)
|
||||
>
|
||||
> 这一条想通了,"多角度风格统一"就不是额外功能,而是数据结构的自然结果。
|
||||
>
|
||||
> ## 三、流程:对话是"可选精修",不是"强制多轮"
|
||||
>
|
||||
> 你之前给 Codex 的描述(读图→问→答→弹 prompt→确认→生成)方向是对的,我只改一点:**别强制聊天**。常见情况下用户知道自己要什么,应该 3 下点完;需要讨论时聊天才出现。
|
||||
>
|
||||
> ```
|
||||
> ┌─ 转换层 ───────────────────┐
|
||||
> │ [参考帧缩略图] [+换/加帧] │ 顶部:当前参考
|
||||
> │ AI读图:白色筋膜枪·握把有手 │ 一行机读,不啰嗦
|
||||
> │ │
|
||||
> │ 想怎…
|
||||
> Deploy fact recorded. `.project.json` needs no change (no URL changes this round). Committing the RULES.md update and pushing:
|
||||
|
||||
> 明白,这一句把设计的重心整个挪了。需求方不清楚自己要什么 → 那转换层就**不能问"你想怎么改"**(空白框对说不出需求的人是负担),它得**先替他想好,给他东西去反应**。
|
||||
> 完成。全部上线并验证。
|
||||
>
|
||||
> 这其实就是你那套讲解稿里的"快速到 60 分,不从零起步"——机器先给一个站在验证过爆款上的初稿,人在 60 分上挑、上改,而不是对着空白发愁。**同一个原则,直接套到转换层。**
|
||||
> ## 最终交付
|
||||
>
|
||||
> ## 修正后的定位:Agent 主动提案,需求方只做"选/推"
|
||||
> **已推送** (Gitea, `4bcca76 → 22421eb`):
|
||||
> - `3ed3f72` fix(api) · `b56d517` fix(canvas) · `6201ee9` fix(web) · `3572dde` chore · `22421eb` docs(deploy fact)
|
||||
>
|
||||
> 我上一轮说的"别强制聊天、给个 chip + 自由描述框"——对**知道自己要什么**的人对,对**说不清的人是错的**。删掉。改成:
|
||||
> **已部署生产** (`76.13.31.179:/opt/skg-marketing-studio`,`deploy-prod-safe.sh`):
|
||||
> - 部署前备份:`skg-marketing-preserve-20260529181045.tgz`(+ pg_dump)
|
||||
> - web/api 镜像在**服务器**构建成功(本地的 Google Fonts 失败纯属本机网络环境,与代码无关)
|
||||
>
|
||||
> ```
|
||||
> ┌─ 转换层 ────────────────────┐
|
||||
> │ [参考帧] AI读图:白色筋膜枪·手持 │
|
||||
> │ │
|
||||
> │ 我建议这 3 个方向(选一个): │ ← agent 主动出,不问
|
||||
> │ ┌─────────────────────────┐ │
|
||||
> │ │ A 原样复刻 · 稳 缩略│ │
|
||||
> │ │ B 换暖木纹高端款 · 提质感 缩略│ │ ← 每个都已是可生成的成品设定
|
||||
> │ │ C 卡通吉祥物版 · 抓眼球 缩略│ │
|
||||
> │ └─────────────────────────┘ │
|
||||
> │ [就用 A 生成 6 视图 →] │ ← 默认动作,零思考一键走
|
||||
> │ 不对?跟我说一句…(可选) │ ← 想拨一下才打字
|
||||
> └───────────────────────────────┘
|
||||
> ↓
|
||||
> 右侧主体元素:6 视图 queued → 逐视角回填
|
||||
> ```
|
||||
> **生产验证**(`verify-prod-docker.sh` + 容器内复验):
|
||||
>
|
||||
> 核心三点:
|
||||
> - **进帧即出提案**。参考帧一进来,agent 自动读图 + 直接抛 2~3 个**已经成型、点了就能生成**的方向(每个背后都是一份完整主体设定 bible),而不是一个等用户填的空框。
|
||||
> - **最小动作 = 一次点击认可**。说不清没关系,默认有个推荐项,点"就用这个"直接出 6 视图。想微调才说话("再高端点…
|
||||
> | 检查 | 结果 |
|
||||
> |---|---|
|
||||
> | 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 最近会话
|
||||
|
||||
- Session ID:019e4d33-ad65-7673-934e-815226984ea6
|
||||
- Transcript:/Users/kangwan/.codex/sessions/2026/05/22/rollout-2026-05-22T09-01-32-019e4d33-ad65-7673-934e-815226984ea6.jsonl
|
||||
- Session ID:019e63ac-1984-7a42-8c65-ffd7ab146968
|
||||
- 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-二创验证
|
||||
- 分支:main
|
||||
- 敏感字段:已对 token / key / password / secret 做脱敏
|
||||
|
||||
### 最近用户要求
|
||||
|
||||
- 音频解析 也需需要有中文识别 和多语言识别
|
||||
- 脚本那边是自动识别语音识别的内容然后生成脚本文案么?
|
||||
- 分镜那边
|
||||
- # 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…
|
||||
- 你现在是在 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 最近回复
|
||||
|
||||
> 对,问题聚焦在分镜这边就更清楚了:我已经看了分镜面板当前实现,下面按“现在实际怎么跑”说。
|
||||
> 已读到项目交接核心:最近不是要继续执行旧任务,而是先接住上下文;当前工作区也只有交接/日志类未提交变更。再补一个只读的提交/远端/差异概览,避免把 handoff 里的仓库状态当成最新事实。
|
||||
|
||||
> 分镜这边现在是“半自动”,不是完全端到端自动重写整片脚本。
|
||||
> 已完成只读承接。
|
||||
>
|
||||
> 当前链路是:
|
||||
> 我已按顺序读了 [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),并补了只读仓库状态检查。没有改文件、没有启动服务、没有继续旧任务。
|
||||
>
|
||||
> 1. 音频解析完成后,`job.transcript` 里有逐句时间轴:原语言文案 + 中文镜像。
|
||||
> 2. 分镜面板会自动按每个 transcript 段生成一行分镜,保留原音频时间点和原文案依据。
|
||||
> 3. 每行会先自动套默认三字段:
|
||||
> - `文案`
|
||||
> - `场景一句话`
|
||||
> - `人物 + 产品 + 动作`
|
||||
> 4. 这些默认值目前主要是按段落位置和角色分类生成,比如 hook / pain / proof / solution / cta,不是每句都先让大模型深度理解原文后重写。
|
||||
> 5. 当你点单行生成视频候选或同步规划时,才会走 `quickPlanStoryboard`,把当前三字段、参考帧、产品信息扩成完整分镜 prompt。
|
||||
> 6. 你改中文字段后,系统会自动优化对应英文主字段,英文用于后续生视频。
|
||||
> 当前状态:
|
||||
> - 分支:`main`
|
||||
> - 远端:`origin -> ssh://git@git.kang-kang.com:22222/kangwan/20260512-skg-tk.git`
|
||||
> - 状态:`main...origin/main [ahead 4]`
|
||||
> - 未提交变更:`.memory/assistant-handoff.md`、`.memory/worklog.json`,看起来是承接/日志类变更
|
||||
> - 最新提交:`0eb775d docs: compact user guide pdf layout`
|
||||
>
|
||||
> 所以准确说:分镜会自动读取语音识别结果来切分时间轴、显示原文依据、初始化分镜行;但“根据识别内容自动生成更贴合原视频语义的新脚本文案”这层现在还不够强,偏模板化。
|
||||
>
|
||||
> 如果你想要的效果是:音频一识别完,分镜区就自动根据原音频内容生成每句对应的新 SKG 脚本文案,那需要再补一层“transcript -> 三字段候选”的自动脚本生成逻辑。这个方向是合理的。
|
||||
|
||||
## Cursor 最近会话
|
||||
|
||||
- 未在本机找到匹配这个项目的 Cursor transcript。
|
||||
> 我接住的上下文:
|
||||
> - 当前项目是 SKG 营销内容生产平台,生产入口 `https://marketing.skg.com`,根域名登录后进个人画布。
|
||||
> - 生产已收口到飞书免登录,密码登录停用;Postgres 已用于画布项目、用户索引、任务索引、资源索引等持久化。
|
||||
> - 普通开发默认必须本地 Docker 验证并形成语义 commit;没有你明确说“可以推送 / 上推 / 部署”前,不推 Gitea、不跑生…
|
||||
|
||||
## 当前仓库状态
|
||||
|
||||
- 当前分支:main
|
||||
- 未提交变更:1 项
|
||||
- 最近提交:docs: record multilingual asr deployment
|
||||
- 最近提交:docs: record 2026-05-30 stability/security hardening deploy
|
||||
- 变更文件:
|
||||
- M .memory/worklog.json
|
||||
|
||||
## 统一接力要求
|
||||
|
||||
- 对话立项只用 Claude / Codex;Cursor 只用于项目目录已经创建之后的 GUI 开发承接。
|
||||
- Claude / Codex 终端承接:先阅读本文件,再结合 `.project.json`、`RULES.md`、`AGENTS.md` 和当前仓库状态理解项目进度。
|
||||
- Cursor GUI 承接:只打开当前项目根目录,不打开 `~/Projects`、`~/Projects/business` 或 `/Users/kangwan`。
|
||||
- 不要要求用户重新手工粘贴 Claude / Codex / Cursor 总结,缺口直接从代码、日志和 handoff 文件补。
|
||||
- 不要要求用户重新手工粘贴 Claude / Codex 总结,缺口直接从代码、日志和 handoff 文件补。
|
||||
- 如果最近助手会话里有明确未完成项,只把它当作候选待办,不要自动继续执行。
|
||||
- 当前目标是建立上下文并等待用户下一条明确指令,不要自行开始修改。
|
||||
|
||||
BIN
.memory/screenshots/seedream-default-local.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
3948
.memory/worklog.json
@@ -33,7 +33,13 @@
|
||||
"type" : "api_key"
|
||||
},
|
||||
{
|
||||
"description" : "生产网页登录备用账号;飞书免登录为主路径,备用账号密码只放服务器 \/root\/skg-marketing-studio-login.txt,后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET",
|
||||
"description" : "xAI \/ Grok Imagine Video 独立视频通道 API Key;生产只放服务器 deploy\/.env.production 的 XAI_VIDEO_API_KEY,本地开发放 api\/.env 或 deploy\/.env.local,不入库",
|
||||
"name" : "XAI_VIDEO_API_KEY",
|
||||
"storage" : "api\/.env \/ deploy\/.env.local \/ deploy\/.env.production",
|
||||
"type" : "api_key"
|
||||
},
|
||||
{
|
||||
"description" : "生产网页登录备用账号已停用,当前只允许飞书免登录;如需紧急恢复,需在服务器 deploy\/.env.production 显式开启 PASSWORD_AUTH_ENABLED=true。备用账号密码只放服务器 \/root\/skg-marketing-studio-login.txt,后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET",
|
||||
"name" : "WEB_LOGIN",
|
||||
"storage" : "\/root\/skg-marketing-studio-login.txt \/ deploy\/.env.production",
|
||||
"type" : "web_login"
|
||||
@@ -43,11 +49,17 @@
|
||||
"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",
|
||||
"name" : "SKG 营销内容工作台",
|
||||
"name" : "SKG 营销内容生产平台",
|
||||
"ownership" : "company",
|
||||
"pin_order" : 1778664997,
|
||||
"pinned" : true,
|
||||
@@ -64,13 +76,13 @@
|
||||
}
|
||||
],
|
||||
"quick_login" : {
|
||||
"label" : "SKG 营销内容工作台",
|
||||
"label" : "SKG 营销内容生产平台",
|
||||
"password" : "",
|
||||
"url" : "https:\/\/marketing.skg.com\/login\/",
|
||||
"username" : "飞书免登录;备用账号见 credentials.WEB_LOGIN"
|
||||
"username" : "仅飞书免登录;密码登录已停用"
|
||||
},
|
||||
"stack" : [
|
||||
"Next.js + Python(yt-dlp\/ffmpeg) + OpenAI-compatible LLM + GPT Image 2 + Azure OpenAI TTS + Seedance\/Kling\/Veo video gateway"
|
||||
"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",
|
||||
"urls" : [
|
||||
|
||||
13
AGENTS.md
@@ -11,11 +11,21 @@
|
||||
|
||||
- 开发任务结束前必须执行并汇报 `git status -sb`
|
||||
- 功能、修复、规则或部署元数据变更完成后,必须创建人工语义 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`
|
||||
- `.memory/worklog.json` 是辅助日志,不代替人工语义 commit 和 Gitea 远端记录
|
||||
- 不能推送时,必须说明当前分支、本地领先/落后数量、最新未推送 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
|
||||
|
||||
- 任何任务只要新增、删除或修改公网地址,必须在同一次任务里更新 `.project.json`
|
||||
@@ -26,6 +36,7 @@
|
||||
|
||||
## Completion Gate
|
||||
|
||||
- 普通代码修改完成后,默认收口在本地 Docker 验证和本地 commit;生产推送 / 部署必须等用户明确确认。
|
||||
- 部署完成后,不允许在 `.project.json` 缺少最新公网链接的状态下结束任务
|
||||
- 部署完成后,必须同步更新 `RULES.md` 的部署事实
|
||||
- 如果只更新了代码但没回写部署元数据,这个任务不算完成
|
||||
|
||||
@@ -5,7 +5,8 @@ WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
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 ./
|
||||
|
||||
|
||||
72
RULES.md
@@ -1,23 +1,64 @@
|
||||
# SKG 营销内容工作台
|
||||
# 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/`)
|
||||
- 后台停止:`./scripts/stop-dev-background.sh`
|
||||
- 前端 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,重任务用)
|
||||
- 注意:后端不要带 `--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` 七步管线拆解
|
||||
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
|
||||
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
|
||||
- 当前产品方向(2026-05-24 重设计):默认首页彻底从“信息流广告复刻管线”切换为多人通用的 SKG 营销内容创作平台,服务约 6 名公司成员同时使用。主路径是文生图、图生图、文生视频、图生视频和营销图文方案生成;用户登录后只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离。首页结构为左侧创作入口 + 参考图 + 我的任务,中间创作台,右侧当前任务结果;任务详情页固定为 `/detail/?job=<id>`,沉淀参考图、生成图、视频候选、提示词和图文方案,并支持继续生成、删除和复用。旧 TK 复刻工作台和 Agent Cut 一键出片保留为高级入口,不再作为默认工作台或默认理解框架。
|
||||
- 当前产品方向(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)
|
||||
- Grok 创建重试与 AI 润色修复生产部署(2026-06-04):`88d5983`(AI 润色可用模型 fallback)和 `549082a`(Grok/xAI 视频创建阶段瞬时错误重试)已推送 Gitea 并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前生产备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260604064742.tgz`。生产 Docker 重建后脚本验证通过(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`);补验 `https://marketing.skg.com/login/` 返回 200,`https://marketing.skg.com/api/health` 未登录返回 401。生产 API 容器内 `/health` 确认 `xai_video_configured=true`、`xai_video_model=grok-imagine-video`、`xai_video_create_path=/v1/videos/generations`、`video_create_retry_attempts=3`、`video_create_retry_backoff_seconds=2.0`,视频模型列表中 `xai / Grok Imagine Video` 仍为 `available=true`。
|
||||
- Grok 创建重试生产真实生成验收(2026-06-04):部署后在生产 API 容器内用临时飞书测试身份走真实 HTTP API 创建 job `213509450eab`,触发 Grok video `5202f39c9827`,模型 `grok-imagine-video`,xAI provider id `b59a8fa0-b294-9e0c-a4bf-a0b349876eba`;轮询到 `completed` 后 `/jobs/213509450eab/storyboard-videos/5202f39c9827.mp4` 返回 `206 video/mp4`,`content-range bytes 0-1023/2274409`,确认生产创建、轮询、下载链路真实通畅。
|
||||
- Grok Imagine Video 生产接入(2026-06-03):`3f21672` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前生产备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260603155046.tgz`。本次先在服务器 `deploy/.env.production` 补齐 `VIDEO_MODEL_XAI=grok-imagine-video`、`XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai`、`XAI_VIDEO_API_KEY`、创建/轮询路径,补配置前已生成备份 `deploy/.env.production.xai-backup-20260603155008`,真实 key 不入库。生产 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 容器确认 `/health` 能力中 `xai_video_configured=true`,视频模型列表含 `seedance`、`seedance_hd`、`xai:grok-imagine-video`。生产真实 HTTP 验收已直接创建测试 job `1c3e63e5791b` 并触发 video `71fab5ea21d4`,xAI provider id `e33bcb9e-d3dc-9a7c-92c4-480b4879f438`,最终 `/jobs/1c3e63e5791b/storyboard-videos/71fab5ea21d4.mp4` 返回 `206 video/mp4`、`content-range bytes 0-1023/1125433`,确认创建、轮询、MP4 下载链路通畅。
|
||||
- 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`)。
|
||||
@@ -54,25 +95,29 @@
|
||||
- 最近部署验证(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/canvas/`(仅兼容跳转到根域名)
|
||||
- API / 后端:`https://marketing.skg.com/api`
|
||||
- 代码仓库 / Gitea:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
|
||||
- 文档 / 解析:`docs/source-analysis.html`(项目内独立文档,不公开挂主应用路由)
|
||||
- 管理后台:待定
|
||||
- 服务器目录:`/opt/skg-marketing-studio`
|
||||
- 生产部署唯一入口:`./scripts/deploy-prod-safe.sh`(先在服务器备份 `deploy/.env.production`、`data/jobs`、资源库和 `secrets`,再用受保护 rsync 同步代码,最后 Docker 重建并运行 `verify-prod-docker.sh`)
|
||||
- 本地 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/`。
|
||||
- 生产部署唯一入口:`./scripts/deploy-prod-safe.sh`(先在服务器备份 `deploy/.env.production`、`data/jobs`、资源库和 `secrets`,如 Postgres 容器存在则额外导出 `pg_dump`,再用受保护 rsync 同步代码,最后 Docker 重建并运行 `verify-prod-docker.sh`)
|
||||
- 生产容器重建命令:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build`;只允许脚本内部或明确只重启容器时使用,不允许再用裸 `rsync --delete` 手动同步。
|
||||
- 独立预览容器重建命令:服务器 `/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 静态导出;`/login/`、`/_next/`、`/assets/`、`/skg-logo-black.svg`、`/oasis-source/` 等登录页必需静态资源公开访问;未登录访问工作台跳转 `/login/`,`/api/` 通过 Nginx `auth_request` 校验 FastAPI 会话 Cookie 后反代到 `skg-marketing-api:4291`;Traefik 通过 `coolify` 外部网络接入 80/443
|
||||
- 生产架构:`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`
|
||||
- 持久化目录:服务器 `./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` 并需要在飞书开放平台应用安全设置中登记。原账号密码登录保留为备用入口,用户名写下方快捷登录,密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt`,`WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`。开启 `AUTH_DATA_ISOLATION_ENABLED=true` 后,新建任务、素材任务和一键出片记录按登录用户隔离;历史无 owner 的旧任务只对备用账号可见,飞书用户互不可见。
|
||||
- 登录凭证:生产入口只允许飞书免登录;飞书 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/`
|
||||
- 主路径:飞书免登录
|
||||
- 密码登录:生产已停用
|
||||
- 备用用户名:`skg`
|
||||
- 备用密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
|
||||
- 说明:当前是生产入口应用内登录页;飞书 App Secret、数据库密码、API Key、服务器 root 密码不要写这里
|
||||
@@ -91,7 +136,7 @@
|
||||
- Gitea 网页仓库:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
|
||||
- 每次开发结束前必须执行并汇报 `git status -sb` 和变更范围
|
||||
- 代码、规则、部署或元数据变更必须形成 `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 兼容网关,用于翻译、文案改写、音频分析等文本/多模态理解模型调用
|
||||
@@ -107,7 +152,8 @@
|
||||
- `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`
|
||||
- `GPT_TEXT_MODEL`:GPT 文本 / 视觉默认模型,默认 `gpt-4o`;用于兜底修正旧 Gemini 覆盖值
|
||||
- `REWRITE_MODEL`:通用改写/分镜描述模型,默认 `gpt-4o`;如果旧环境仍写 `gemini-*`,后端会自动改用 `GPT_TEXT_MODEL`
|
||||
- `REWRITE_MODEL`:通用改写/分镜描述主模型,当前用于 AI 润色时默认 `gpt-4o-mini`;如果主模型不可用,`/prompt/polish` 会继续尝试 `REWRITE_MODEL_FALLBACKS`
|
||||
- `REWRITE_MODEL_FALLBACKS`:AI 润色备用模型列表,逗号分隔,默认 `gpt-4o-mini,gemini-2.5-flash`;只有全部失败时才允许返回本地模板 fallback
|
||||
- `VISION_MODEL`:关键帧画面理解模型,默认 `gpt-4o`;如果旧环境仍写 `gemini-*`,后端会自动改用 `GPT_TEXT_MODEL`
|
||||
- `AUDIO_REWRITE_MODEL`:后续音频口播改写模型,默认跟随 `REWRITE_MODEL`;如果旧环境仍写 `gemini-*`,后端会自动改用 `REWRITE_MODEL`
|
||||
- `AUDIO_PRODUCT_BRIEF`:音频口播改写时注入的 SKG 产品卖点
|
||||
@@ -117,18 +163,22 @@
|
||||
- `IMAGE_FALLBACK_ENABLED` / `IMAGE_FALLBACK_MODEL`:图片主模型故障兜底;当前允许在 `gpt-image-2` 超时、429、5xx 或网络错误时临时使用 `gemini-3-pro-image-preview`,400/401/403/404 和参数错误不兜底
|
||||
- `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`,如生图报 DNS / ConnectError,可在本地 `api/.env` 配置后重启后端。`/health` 只回传是否配置代理,不回传代理地址。
|
||||
- `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_TTS_MODEL` / `AZURE_TTS_VOICE_ID` / `AZURE_TTS_VOICE_POOL` / `AZURE_TTS_PATH` / `AZURE_TTS_PATHS`:Azure OpenAI TTS 模型、默认音色、音色池和 OpenAI 协议语音路径;后端会按 `AZURE_TTS_PATHS` 依次尝试,便于区分路径不对和整条语音服务不可用
|
||||
- `POE_API_KEY` / `VIDEO_API_KEY`:视频生成通道 Key,只能放本地环境变量
|
||||
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产备用网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库。即使只开飞书免登录,也必须配置 `WEB_AUTH_SESSION_SECRET` 用于签名会话 Cookie。
|
||||
- `POE_API_KEY` / `VIDEO_API_KEY`:默认视频生成通道 Key,只能放本地环境变量;如果显式配置了 `VIDEO_API_BASE_URL`,必须同时配置 `VIDEO_API_KEY` 才会在 `/health` 暴露该默认视频通道,不能用通用 `LLM_API_KEY` 冒充视频 key。
|
||||
- `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。
|
||||
- `VIDEO_CREATE_RETRY_ATTEMPTS` / `VIDEO_CREATE_RETRY_BACKOFF_SECONDS`:视频创建请求的瞬时错误重试配置,默认 Grok/xAI 创建阶段遇到连接重置、超时、429 或 5xx 时最多尝试 3 次,基础退避 2 秒;400/401/403/404 等参数或权限错误不重试,避免掩盖真实配置问题。
|
||||
- `PASSWORD_AUTH_ENABLED`:生产密码登录总开关;当前固定为 `false`,只允许飞书免登录。若应急恢复密码入口,必须显式改成 `true` 并重启 API。
|
||||
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产备用网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库。当前密码入口被 `PASSWORD_AUTH_ENABLED=false` 禁用;即使只开飞书免登录,也必须配置 `WEB_AUTH_SESSION_SECRET` 用于签名会话 Cookie。
|
||||
- `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
|
||||
- 生产环境变量:服务器只使用 `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.
|
||||
@@ -9,6 +9,8 @@ WEB_AUTH_SESSION_SECRET=
|
||||
WEB_AUTH_COOKIE_NAME=skg_marketing_session
|
||||
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=
|
||||
@@ -28,7 +30,8 @@ LOCAL_ASR_MODEL=mlx-community/whisper-tiny
|
||||
LOCAL_ASR_TIMEOUT_SECONDS=180
|
||||
TRANSLATE_MODEL=gemini-2.5-flash
|
||||
GPT_TEXT_MODEL=gpt-4o
|
||||
REWRITE_MODEL=gpt-4o
|
||||
REWRITE_MODEL=gpt-4o-mini
|
||||
REWRITE_MODEL_FALLBACKS=gemini-2.5-flash
|
||||
VISION_MODEL=gpt-4o
|
||||
PRODUCT_VIEW_MODEL=gpt-image-2
|
||||
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||
@@ -37,17 +40,30 @@ 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
|
||||
SUBJECT_ASSET_IMAGE_MODEL=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_HTTP_PROXY=
|
||||
YTDLP_COOKIES_FILE=
|
||||
YTDLP_COOKIES_FROM_BROWSER=
|
||||
VIDEO_MODEL=seedance
|
||||
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_VEO3=veo-3.1-fast
|
||||
|
||||
@@ -83,6 +99,13 @@ POE_API_KEY=
|
||||
# VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id}
|
||||
# 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。
|
||||
VIDEO_API_BASE_URL=
|
||||
VIDEO_API_KEY=
|
||||
@@ -92,6 +115,8 @@ VIDEO_STATUS_PATH=/videos/{id}
|
||||
VIDEO_CONTENT_PATH=/videos/{id}/content
|
||||
VIDEO_DURATION_FIELD=seconds
|
||||
VIDEO_POLL_TIMEOUT_SECONDS=900
|
||||
VIDEO_CREATE_RETRY_ATTEMPTS=3
|
||||
VIDEO_CREATE_RETRY_BACKOFF_SECONDS=2
|
||||
|
||||
# 工作目录
|
||||
KEYFRAME_COUNT=12
|
||||
|
||||
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)
|
||||
2570
api/main.py
@@ -7,6 +7,8 @@ yt-dlp==2026.3.17
|
||||
openai==1.55.3
|
||||
httpx==0.27.2
|
||||
requests==2.32.5
|
||||
psycopg[binary]==3.2.3
|
||||
psycopg-pool==3.2.4
|
||||
imagehash==4.3.1
|
||||
Pillow>=11.0
|
||||
numpy>=2.0
|
||||
|
||||
106
deploy/.env.local.example
Normal file
@@ -0,0 +1,106 @@
|
||||
# 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-mini
|
||||
REWRITE_MODEL_FALLBACKS=gemini-2.5-flash
|
||||
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
|
||||
VIDEO_CREATE_RETRY_ATTEMPTS=3
|
||||
VIDEO_CREATE_RETRY_BACKOFF_SECONDS=2
|
||||
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=
|
||||
@@ -9,13 +9,22 @@ KEYFRAME_COUNT=12
|
||||
CORS_ORIGINS=https://marketing.skg.com
|
||||
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.
|
||||
PASSWORD_AUTH_ENABLED=false
|
||||
WEB_AUTH_USERNAME=skg
|
||||
WEB_AUTH_PASSWORD=
|
||||
WEB_AUTH_SESSION_SECRET=
|
||||
WEB_AUTH_COOKIE_NAME=skg_marketing_session
|
||||
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
|
||||
@@ -47,7 +56,8 @@ FASTER_WHISPER_DEVICE=cpu
|
||||
FASTER_WHISPER_COMPUTE_TYPE=int8
|
||||
TRANSLATE_MODEL=gemini-2.5-flash
|
||||
GPT_TEXT_MODEL=gpt-4o
|
||||
REWRITE_MODEL=gpt-4o
|
||||
REWRITE_MODEL=gpt-4o-mini
|
||||
REWRITE_MODEL_FALLBACKS=gemini-2.5-flash
|
||||
VISION_MODEL=gpt-4o
|
||||
PRODUCT_VIEW_MODEL=gpt-image-2
|
||||
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||
@@ -56,11 +66,20 @@ 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
|
||||
# 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.
|
||||
AI_HTTP_PROXY=
|
||||
|
||||
@@ -89,10 +108,16 @@ 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_KLING=kling-omni
|
||||
VIDEO_MODEL_VEO3=veo-3.1-fast
|
||||
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
|
||||
VIDEO_CREATE_RETRY_ATTEMPTS=3
|
||||
VIDEO_CREATE_RETRY_BACKOFF_SECONDS=2
|
||||
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=
|
||||
|
||||
@@ -106,6 +106,14 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /canvas {
|
||||
return 308 /;
|
||||
}
|
||||
|
||||
location ~ ^/canvas/(.*)$ {
|
||||
return 308 /$1$is_args$args;
|
||||
}
|
||||
|
||||
location = /skg-logo-black.svg {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri =404;
|
||||
@@ -122,7 +130,7 @@ server {
|
||||
}
|
||||
|
||||
location @login_redirect {
|
||||
return 302 /login/;
|
||||
return 302 /login/?next=$request_uri;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
build:
|
||||
context: .
|
||||
@@ -13,6 +31,7 @@ services:
|
||||
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
|
||||
volumes:
|
||||
- ./data/jobs:/data/jobs
|
||||
@@ -22,6 +41,9 @@ services:
|
||||
- ./data/_trash:/data/_trash
|
||||
- ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- skg-marketing-internal
|
||||
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
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: .
|
||||
@@ -13,6 +31,7 @@ services:
|
||||
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
|
||||
@@ -22,6 +41,9 @@ services:
|
||||
- ./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:
|
||||
|
||||
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
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>
|
||||
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"
|
||||
@@ -29,7 +29,12 @@ ssh "$HOST" "set -euo pipefail
|
||||
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)
|
||||
"
|
||||
|
||||
@@ -45,11 +50,20 @@ rsync -az --delete \
|
||||
--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' \
|
||||
@@ -58,6 +72,8 @@ rsync -az --delete \
|
||||
--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/"
|
||||
|
||||
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
|
||||
@@ -13,17 +13,32 @@ ssh "$HOST" "cd '$APP_DIR' && \
|
||||
echo \"ERROR: local API/dev URL leaked into web static bundle\" >&2
|
||||
exit 1
|
||||
fi
|
||||
for p in / /login/ /_next/does-not-exist.js /api/health; do
|
||||
code=\$(curl -sS -o /tmp/skg-smoke.out -w \"%{http_code}\" \"http://127.0.0.1\$p\")
|
||||
case \"\$p:\$code\" in
|
||||
/:302|/login/:200|/_next/does-not-exist.js:404|/api/health:401) echo \"web:\$p \$code\" ;;
|
||||
*) echo \"ERROR: unexpected web route status \$p \$code\" >&2; head -c 200 /tmp/skg-smoke.out >&2; exit 1 ;;
|
||||
esac
|
||||
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; print(\\\"api:health ok\\\")\"
|
||||
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\\\")\"
|
||||
'"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
CheckCircle2,
|
||||
@@ -73,8 +73,14 @@ export default function AgentPage() {
|
||||
const [error, setError] = useState("")
|
||||
const terminalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const previews = useMemo(() => files.map((file) => ({ file, url: URL.createObjectURL(file) })), [files])
|
||||
useEffect(() => () => previews.forEach((item) => URL.revokeObjectURL(item.url)), [previews])
|
||||
// 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" })
|
||||
|
||||
@@ -116,11 +116,16 @@ export default function DetailPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!job || !runningVideo) return
|
||||
let failures = 0
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
setJob(await getJob(job.id))
|
||||
failures = 0
|
||||
} catch {
|
||||
window.clearInterval(timer)
|
||||
// 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)
|
||||
|
||||
@@ -12,8 +12,8 @@ const _playfairDisplay = Playfair_Display({
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SKG 营销内容工作台",
|
||||
description: "SKG AI 图片、视频和文案创作台",
|
||||
title: "SKG",
|
||||
description: "SKG AI 图片、视频和图文内容生产入口",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import type { FormEvent } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
@@ -21,8 +21,20 @@ type AuthConfig = {
|
||||
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() {
|
||||
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null)
|
||||
const [nextPath] = useState(loginNextPath)
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [remember, setRemember] = useState(true)
|
||||
@@ -31,6 +43,7 @@ export default function LoginPage() {
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const [status, setStatus] = useState<LoginStatus>("idle")
|
||||
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 })
|
||||
const autoFeishuAttemptedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -48,21 +61,42 @@ export default function LoginPage() {
|
||||
}, [])
|
||||
|
||||
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 centerX = window.innerWidth / 2
|
||||
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 })
|
||||
if (frame) return // coalesce to at most one state update per animation frame
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = 0
|
||||
const centerX = window.innerWidth / 2
|
||||
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)
|
||||
return () => window.removeEventListener("pointermove", onPointerMove)
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onPointerMove)
|
||||
if (frame) window.cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [])
|
||||
|
||||
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(() => {
|
||||
if (status === "success") return "success"
|
||||
if (hasError) return "error"
|
||||
@@ -92,7 +126,7 @@ export default function LoginPage() {
|
||||
}
|
||||
setStatus("success")
|
||||
window.setTimeout(() => {
|
||||
window.location.href = "/"
|
||||
window.location.href = nextPath
|
||||
}, 420)
|
||||
} catch {
|
||||
setStatus("idle")
|
||||
@@ -102,7 +136,7 @@ export default function LoginPage() {
|
||||
|
||||
function onFeishuLogin() {
|
||||
setStatus("loading")
|
||||
window.location.href = "/api/auth/feishu/start?next=/"
|
||||
window.location.href = `/api/auth/feishu/start?next=${encodeURIComponent(nextPath)}`
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -113,7 +147,6 @@ export default function LoginPage() {
|
||||
<section className="login-auth-panel login-source-auth-panel login-source-combo-panel rounded-[8px]">
|
||||
<div className="login-top-brand" aria-hidden="true">
|
||||
<img className="login-top-brand__logo" src="/skg-logo-black.svg" alt="" />
|
||||
<span className="login-top-brand__system">营销内容工作台</span>
|
||||
</div>
|
||||
<div className="login-source-character-strip" aria-hidden="true">
|
||||
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
|
||||
@@ -127,7 +160,7 @@ export default function LoginPage() {
|
||||
onClick={onFeishuLogin}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
飞书免登录
|
||||
飞书登录
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
949
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'
|
||||
329
web/canvas-app/src/hooks/useApi.js
Normal file
@@ -0,0 +1,329 @@
|
||||
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 || '',
|
||||
model: options.model || '',
|
||||
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
|
||||
}
|
||||
}
|
||||
1050
web/canvas-app/src/hooks/useWorkflowOrchestrator.js
Normal file
15
web/canvas-app/src/main.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Main entry point | 主入口
|
||||
*/
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
24
web/canvas-app/src/router/index.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Router configuration | 路由配置
|
||||
*/
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('../views/Home.vue')
|
||||
},
|
||||
{
|
||||
path: '/p/:id?',
|
||||
name: 'Canvas',
|
||||
component: () => import('../views/Canvas.vue')
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/'),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
10
web/canvas-app/src/stores/api.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* API Store | API 状态存储
|
||||
* Pure global state - internal session config lives in hooks/useApiConfig.js
|
||||
* 纯全局状态 - 内部会话配置位于 hooks/useApiConfig.js
|
||||
*/
|
||||
|
||||
// Re-export from hook for backward compatibility | 为向后兼容重新导出
|
||||
export { useApiConfig } from '../hooks/useApiConfig'
|
||||
|
||||
// For components that need direct access to config state | 用于需要直接访问配置状态的组件
|
||||
575
web/canvas-app/src/stores/canvas.js
Normal file
@@ -0,0 +1,575 @@
|
||||
/**
|
||||
* Canvas store | 画布状态管理
|
||||
* Manages nodes, edges and canvas state
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { updateProjectCanvas, getProjectCanvas } from './projects'
|
||||
import { IMAGE_MODELS, VIDEO_MODELS, CHAT_MODELS, DEFAULT_IMAGE_MODEL, DEFAULT_VIDEO_MODEL, DEFAULT_CHAT_MODEL } from '../config/models'
|
||||
|
||||
// Node ID counter | 节点ID计数器
|
||||
let nodeId = 0
|
||||
const getNodeId = () => `node_${nodeId++}`
|
||||
|
||||
// Current project ID | 当前项目ID
|
||||
export const currentProjectId = ref(null)
|
||||
|
||||
// Nodes and edges | 节点和边
|
||||
export const nodes = ref([])
|
||||
export const edges = ref([])
|
||||
|
||||
// Viewport state | 视口状态
|
||||
export const canvasViewport = ref({ x: 100, y: 50, zoom: 0.8 })
|
||||
|
||||
// Selected node | 选中的节点
|
||||
export const selectedNode = ref(null)
|
||||
|
||||
// Auto-save flag | 自动保存标志
|
||||
let autoSaveEnabled = false
|
||||
let saveTimeout = null
|
||||
|
||||
// History for undo/redo | 撤销/重做历史
|
||||
const history = ref([])
|
||||
const historyIndex = ref(-1)
|
||||
const MAX_HISTORY = 50
|
||||
let isRestoring = false
|
||||
|
||||
// Position change threshold for history | 位置变化阈值
|
||||
const POSITION_THRESHOLD = 10
|
||||
|
||||
const DEFAULT_NODE_DIMENSIONS = {
|
||||
text: { width: 320, height: 220 },
|
||||
image: { width: 320, height: 260 },
|
||||
imageConfig: { width: 320, height: 280 },
|
||||
video: { width: 320, height: 220 },
|
||||
videoConfig: { width: 320, height: 260 },
|
||||
llmConfig: { width: 360, height: 360 },
|
||||
default: { width: 320, height: 240 }
|
||||
}
|
||||
|
||||
const normalizeDimensions = (type, dimensions = {}) => {
|
||||
const fallback = DEFAULT_NODE_DIMENSIONS[type] || DEFAULT_NODE_DIMENSIONS.default
|
||||
const width = Number(dimensions.width)
|
||||
const height = Number(dimensions.height)
|
||||
|
||||
return {
|
||||
width: Number.isFinite(width) && width > 0 ? width : fallback.width,
|
||||
height: Number.isFinite(height) && height > 0 ? height : fallback.height
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeNodeForCanvas = (node) => ({
|
||||
...node,
|
||||
dimensions: normalizeDimensions(node.type, node.dimensions)
|
||||
})
|
||||
|
||||
// Batch operation tracking | 批量操作跟踪
|
||||
let isBatchOperation = false
|
||||
let batchStartState = null
|
||||
|
||||
/**
|
||||
* Save current state to history | 保存当前状态到历史
|
||||
* @param {boolean} force - Force save even if batch operation | 强制保存,即使在批量操作中
|
||||
*/
|
||||
const saveToHistory = (force = false) => {
|
||||
if (isRestoring) return
|
||||
|
||||
// If in batch operation and not forced, don't save | 如果在批量操作中且未强制保存,则不保存
|
||||
if (isBatchOperation && !force) return
|
||||
|
||||
const state = {
|
||||
nodes: JSON.parse(JSON.stringify(nodes.value)),
|
||||
edges: JSON.parse(JSON.stringify(edges.value))
|
||||
}
|
||||
|
||||
// Remove future history if we're not at the end | 如果不在末尾,删除未来历史
|
||||
if (historyIndex.value < history.value.length - 1) {
|
||||
history.value = history.value.slice(0, historyIndex.value + 1)
|
||||
}
|
||||
|
||||
// Add new state | 添加新状态
|
||||
history.value.push(state)
|
||||
|
||||
// Limit history size | 限制历史大小
|
||||
if (history.value.length > MAX_HISTORY) {
|
||||
history.value.shift()
|
||||
} else {
|
||||
historyIndex.value++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start batch operation | 开始批量操作
|
||||
* Records the starting state for batch operations
|
||||
*/
|
||||
export const startBatchOperation = () => {
|
||||
isBatchOperation = true
|
||||
batchStartState = {
|
||||
nodes: JSON.parse(JSON.stringify(nodes.value)),
|
||||
edges: JSON.parse(JSON.stringify(edges.value))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End batch operation and save to history | 结束批量操作并保存到历史
|
||||
* Compares with start state to decide if save is needed
|
||||
*/
|
||||
export const endBatchOperation = () => {
|
||||
if (!isBatchOperation || !batchStartState) {
|
||||
isBatchOperation = false
|
||||
return
|
||||
}
|
||||
|
||||
// Check if there are significant changes | 检查是否有显著变化
|
||||
const hasSignificantChanges = checkSignificantChanges(batchStartState, {
|
||||
nodes: nodes.value,
|
||||
edges: edges.value
|
||||
})
|
||||
|
||||
if (hasSignificantChanges) {
|
||||
saveToHistory(true)
|
||||
}
|
||||
|
||||
isBatchOperation = false
|
||||
batchStartState = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if changes are significant enough to save | 检查变化是否足够显著需要保存
|
||||
* @param {object} oldState - Previous state | 之前的状态
|
||||
* @param {object} newState - New state | 新状态
|
||||
* @returns {boolean} - Whether changes should be saved | 是否应该保存变化
|
||||
*/
|
||||
const checkSignificantChanges = (oldState, newState) => {
|
||||
const oldNodes = oldState.nodes || []
|
||||
const newNodes = newState.nodes || []
|
||||
|
||||
// Check for added or removed nodes | 检查添加或删除的节点
|
||||
if (oldNodes.length !== newNodes.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for new nodes (by comparing IDs) | 检查新节点
|
||||
const oldNodeIds = new Set(oldNodes.map(n => n.id))
|
||||
const newNodeIds = new Set(newNodes.map(n => n.id))
|
||||
|
||||
// Nodes added | 添加的节点
|
||||
for (const id of newNodeIds) {
|
||||
if (!oldNodeIds.has(id)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Nodes removed | 删除的节点
|
||||
for (const id of oldNodeIds) {
|
||||
if (!newNodeIds.has(id)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check position changes for existing nodes | 检查现有节点的位置变化
|
||||
for (const newNode of newNodes) {
|
||||
const oldNode = oldNodes.find(n => n.id === newNode.id)
|
||||
if (oldNode) {
|
||||
const dx = Math.abs(newNode.position.x - oldNode.position.x)
|
||||
const dy = Math.abs(newNode.position.y - oldNode.position.y)
|
||||
|
||||
// If any node moved beyond threshold, save | 如果任何节点移动超过阈值,则保存
|
||||
if (dx > POSITION_THRESHOLD || dy > POSITION_THRESHOLD) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for edge changes | 检查边的变化
|
||||
const oldEdges = oldState.edges || []
|
||||
const newEdges = newState.edges || []
|
||||
|
||||
if (oldEdges.length !== newEdges.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const createNode = (type, position, data = {}, rootProps = {}, now = Date.now()) => {
|
||||
const id = getNodeId()
|
||||
const { dimensions, ...nodeRootProps } = rootProps
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
...nodeRootProps,
|
||||
dimensions: normalizeDimensions(type, dimensions),
|
||||
data: {
|
||||
...getDefaultNodeData(type),
|
||||
...data,
|
||||
createdAt: data.createdAt || now,
|
||||
updatedAt: data.updatedAt || now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add a new node | 添加新节点
|
||||
export const addNode = (type, position = { x: 100, y: 100 }, data = {}, rootProps = {}) => {
|
||||
const newNode = createNode(type, position, data, rootProps)
|
||||
nodes.value.push(newNode)
|
||||
saveToHistory() // Save after adding node | 添加节点后保存
|
||||
return newNode.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple nodes in batch | 批量添加多个节点
|
||||
* Uses batch operation to group all node additions into one history entry
|
||||
* @param {Array} nodeSpecs - Array of node specs [{ type, position, data }, ...]
|
||||
* @param {boolean} autoBatch - Whether to auto-manage batch operation (default: true)
|
||||
* @returns {Array} - Array of created node IDs | 创建的节点ID数组
|
||||
*/
|
||||
export const addNodes = (nodeSpecs, autoBatch = true) => {
|
||||
if (!nodeSpecs || nodeSpecs.length === 0) return []
|
||||
|
||||
// Start batch operation if auto | 如果自动管理则开始批量操作
|
||||
if (autoBatch) {
|
||||
startBatchOperation()
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const newNodes = nodeSpecs.map(spec => {
|
||||
const { type, position = { x: 100, y: 100 }, data = {}, rootProps = {} } = spec
|
||||
return createNode(type, position, data, rootProps, now)
|
||||
})
|
||||
|
||||
nodes.value.push(...newNodes)
|
||||
|
||||
// End batch operation if auto | 如果自动管理则结束批量操作并保存到历史
|
||||
if (autoBatch) {
|
||||
endBatchOperation()
|
||||
}
|
||||
|
||||
return newNodes.map(node => node.id)
|
||||
}
|
||||
|
||||
// Get default data for node type | 获取节点类型的默认数据
|
||||
const getDefaultNodeData = (type) => {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
return {
|
||||
content: '',
|
||||
label: '文本输入',
|
||||
publicProps: {} // 公共属性(可被 @ 引用)
|
||||
}
|
||||
case 'imageConfig': {
|
||||
const imageModel = IMAGE_MODELS.find(m => m.key === DEFAULT_IMAGE_MODEL) || IMAGE_MODELS[0]
|
||||
return {
|
||||
prompt: '',
|
||||
model: DEFAULT_IMAGE_MODEL,
|
||||
size: imageModel?.defaultParams?.size || '1x1',
|
||||
quality: imageModel?.defaultParams?.quality || 'standard',
|
||||
label: '文生图'
|
||||
}
|
||||
}
|
||||
case 'videoConfig': {
|
||||
const videoModel = VIDEO_MODELS.find(m => m.key === DEFAULT_VIDEO_MODEL) || VIDEO_MODELS[0]
|
||||
return {
|
||||
prompt: '',
|
||||
ratio: videoModel?.defaultParams?.ratio || '16:9',
|
||||
duration: videoModel?.defaultParams?.duration || 5,
|
||||
model: DEFAULT_VIDEO_MODEL,
|
||||
label: '图生视频'
|
||||
}
|
||||
}
|
||||
case 'video':
|
||||
return {
|
||||
url: '',
|
||||
duration: 0,
|
||||
label: '视频节点'
|
||||
}
|
||||
case 'image':
|
||||
return {
|
||||
url: '',
|
||||
label: '图片节点',
|
||||
publicProps: { name: '图片' } // 公共属性(可被 @ 引用)
|
||||
}
|
||||
case 'llmConfig':
|
||||
return {
|
||||
systemPrompt: '',
|
||||
model: DEFAULT_CHAT_MODEL,
|
||||
outputFormat: 'text',
|
||||
outputContent: '',
|
||||
label: 'LLM文本生成',
|
||||
publicProps: {} // 公共属性(可被 @ 引用)
|
||||
}
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// Update node data | 更新节点数据
|
||||
export const updateNode = (id, data) => {
|
||||
nodes.value = nodes.value.map(node =>
|
||||
node.id === id ? { ...node, data: { ...node.data, ...data } } : node
|
||||
)
|
||||
}
|
||||
|
||||
// Remove node | 删除节点
|
||||
export const removeNode = (id) => {
|
||||
nodes.value = nodes.value.filter(node => node.id !== id)
|
||||
edges.value = edges.value.filter(edge => edge.source !== id && edge.target !== id)
|
||||
saveToHistory() // Save after removing node | 删除节点后保存
|
||||
}
|
||||
|
||||
// Duplicate node | 复制节点
|
||||
export const duplicateNode = (id) => {
|
||||
const sourceNode = nodes.value.find(node => node.id === id)
|
||||
if (!sourceNode) return null
|
||||
|
||||
const newId = getNodeId()
|
||||
|
||||
// Calculate max z-index | 计算最大层级
|
||||
const maxZIndex = Math.max(0, ...nodes.value.map(n => n.zIndex || 0))
|
||||
|
||||
const newNode = {
|
||||
id: newId,
|
||||
type: sourceNode.type,
|
||||
position: {
|
||||
x: sourceNode.position.x + 50,
|
||||
y: sourceNode.position.y + 50
|
||||
},
|
||||
data: { ...sourceNode.data },
|
||||
zIndex: maxZIndex + 1
|
||||
}
|
||||
nodes.value.push(newNode)
|
||||
saveToHistory() // Save after duplicating node | 复制节点后保存
|
||||
return newId
|
||||
}
|
||||
|
||||
// Add edge | 添加边
|
||||
export const addEdge = (params) => {
|
||||
const newEdge = {
|
||||
id: `edge_${params.source}_${params.target}`,
|
||||
...params
|
||||
}
|
||||
edges.value.push(newEdge)
|
||||
saveToHistory() // Save after adding edge | 添加连线后保存
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple edges in batch | 批量添加多条边
|
||||
* Uses batch operation to group all edge additions into one history entry
|
||||
* @param {Array} edgeSpecs - Array of edge specs [{ source, target, sourceHandle, targetHandle, type, data }, ...]
|
||||
* @param {boolean} autoBatch - Whether to auto-manage batch operation (default: true)
|
||||
* @returns {Array} - Array of created edge IDs | 创建的边ID数组
|
||||
*/
|
||||
export const addEdges = (edgeSpecs, autoBatch = true) => {
|
||||
if (!edgeSpecs || edgeSpecs.length === 0) return []
|
||||
|
||||
// Start batch operation if auto | 如果自动管理则开始批量操作
|
||||
if (autoBatch) {
|
||||
startBatchOperation()
|
||||
}
|
||||
|
||||
const newEdges = edgeSpecs.map(params => ({
|
||||
id: `edge_${params.source}_${params.target}`,
|
||||
...params
|
||||
}))
|
||||
|
||||
edges.value.push(...newEdges)
|
||||
|
||||
// End batch operation if auto | 如果自动管理则结束批量操作并保存到历史
|
||||
if (autoBatch) {
|
||||
endBatchOperation()
|
||||
}
|
||||
|
||||
return newEdges.map(edge => edge.id)
|
||||
}
|
||||
|
||||
// Update edge data | 更新边数据
|
||||
export const updateEdge = (id, data) => {
|
||||
edges.value = edges.value.map(edge =>
|
||||
edge.id === id ? { ...edge, data: { ...edge.data, ...data } } : edge
|
||||
)
|
||||
saveToHistory() // Save after updating edge | 更新连线后保存
|
||||
}
|
||||
|
||||
// Remove edge | 删除边
|
||||
export const removeEdge = (id) => {
|
||||
edges.value = edges.value.filter(edge => edge.id !== id)
|
||||
saveToHistory() // Save after removing edge | 删除连线后保存
|
||||
}
|
||||
|
||||
// Clear canvas | 清空画布
|
||||
export const clearCanvas = () => {
|
||||
nodes.value = []
|
||||
edges.value = []
|
||||
nodeId = 0
|
||||
}
|
||||
|
||||
// Initialize with sample data | 使用示例数据初始化
|
||||
export const initSampleData = () => {
|
||||
clearCanvas()
|
||||
|
||||
// Add text node | 添加文本节点
|
||||
addNode('text', { x: 150, y: 150 }, {
|
||||
content: '一只金毛寻回犬在草地上奔跑,摇着尾巴,脸上带着快乐的表情。它的毛发在阳光下闪耀,眼神充满了对自由的渴望,全身散发着阳光、友善的气息。',
|
||||
label: '文本输入'
|
||||
})
|
||||
|
||||
// Add image config node | 添加文生图配置节点
|
||||
addNode('imageConfig', { x: 450, y: 150 }, {
|
||||
prompt: '',
|
||||
model: 'auto',
|
||||
ratio: '16:9 | 4张 | 高清',
|
||||
label: '文生图'
|
||||
})
|
||||
|
||||
// Add edge between nodes | 添加节点之间的边
|
||||
addEdge({
|
||||
source: 'node_0',
|
||||
target: 'node_1',
|
||||
sourceHandle: 'right',
|
||||
targetHandle: 'left'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Load project data | 加载项目数据
|
||||
* @param {string} projectId - Project ID | 项目ID
|
||||
*/
|
||||
export const loadProject = (projectId) => {
|
||||
autoSaveEnabled = false
|
||||
isRestoring = true
|
||||
currentProjectId.value = projectId
|
||||
|
||||
const canvasData = getProjectCanvas(projectId)
|
||||
|
||||
if (canvasData) {
|
||||
// Restore nodes | 恢复节点
|
||||
nodes.value = (canvasData.nodes || []).map(normalizeNodeForCanvas)
|
||||
edges.value = canvasData.edges || []
|
||||
canvasViewport.value = canvasData.viewport || { x: 100, y: 50, zoom: 0.8 }
|
||||
|
||||
// Update node ID counter | 更新节点ID计数器
|
||||
const maxId = nodes.value.reduce((max, node) => {
|
||||
const match = node.id.match(/node_(\d+)/)
|
||||
if (match) {
|
||||
return Math.max(max, parseInt(match[1], 10))
|
||||
}
|
||||
return max
|
||||
}, -1)
|
||||
nodeId = maxId + 1
|
||||
} else {
|
||||
// Empty project | 空项目
|
||||
clearCanvas()
|
||||
}
|
||||
|
||||
// Initialize history with current state | 用当前状态初始化历史
|
||||
history.value = [{
|
||||
nodes: JSON.parse(JSON.stringify(nodes.value)),
|
||||
edges: JSON.parse(JSON.stringify(edges.value))
|
||||
}]
|
||||
historyIndex.value = 0
|
||||
|
||||
// Enable auto-save after loading | 加载后启用自动保存
|
||||
setTimeout(() => {
|
||||
autoSaveEnabled = true
|
||||
isRestoring = false
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current project | 保存当前项目
|
||||
*/
|
||||
export const saveProject = () => {
|
||||
if (!currentProjectId.value) return
|
||||
updateProjectCanvas(currentProjectId.value, {
|
||||
nodes: nodes.value,
|
||||
edges: edges.value,
|
||||
viewport: canvasViewport.value
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced auto-save | 防抖动自动保存
|
||||
*/
|
||||
const debouncedSave = () => {
|
||||
if (!autoSaveEnabled || !currentProjectId.value) return
|
||||
|
||||
if (saveTimeout) {
|
||||
clearTimeout(saveTimeout)
|
||||
}
|
||||
|
||||
saveTimeout = setTimeout(() => {
|
||||
saveProject()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update viewport and save | 更新视口并保存
|
||||
*/
|
||||
export const updateViewport = (viewport) => {
|
||||
canvasViewport.value = viewport
|
||||
debouncedSave()
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo last action | 撤销上一步操作
|
||||
*/
|
||||
export const undo = () => {
|
||||
if (historyIndex.value <= 0) {
|
||||
window.$message?.info('没有可撤销的操作')
|
||||
return false
|
||||
}
|
||||
|
||||
historyIndex.value--
|
||||
restoreState(history.value[historyIndex.value])
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Redo last undone action | 重做上一步撤销的操作
|
||||
*/
|
||||
export const redo = () => {
|
||||
if (historyIndex.value >= history.value.length - 1) {
|
||||
window.$message?.info('没有可重做的操作')
|
||||
return false
|
||||
}
|
||||
|
||||
historyIndex.value++
|
||||
restoreState(history.value[historyIndex.value])
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore state from history | 从历史恢复状态
|
||||
*/
|
||||
const restoreState = (state) => {
|
||||
isRestoring = true
|
||||
nodes.value = JSON.parse(JSON.stringify(state.nodes))
|
||||
edges.value = JSON.parse(JSON.stringify(state.edges))
|
||||
setTimeout(() => {
|
||||
isRestoring = false
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if can undo | 检查是否可以撤销
|
||||
*/
|
||||
export const canUndo = () => historyIndex.value > 0
|
||||
|
||||
/**
|
||||
* Check if can redo | 检查是否可以重做
|
||||
*/
|
||||
export const canRedo = () => historyIndex.value < history.value.length - 1
|
||||
|
||||
/**
|
||||
* Manually save current state to history | 手动保存当前状态到历史
|
||||
* Used for edge deletions and other operations not covered by automatic saves
|
||||
*/
|
||||
export const manualSaveHistory = () => {
|
||||
saveToHistory()
|
||||
}
|
||||
|
||||
// Watch for changes and auto-save (only save to project, not history) | 监听变化并自动保存(仅保存项目,不保存历史)
|
||||
watch([nodes, edges], () => {
|
||||
debouncedSave()
|
||||
}, { deep: true })
|
||||
242
web/canvas-app/src/stores/models.js
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Model Store | 模型状态管理
|
||||
* Built-in models + custom models from local storage | 开源版内置模型 + 本地存储自定义模型
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
IMAGE_MODELS,
|
||||
VIDEO_MODELS,
|
||||
CHAT_MODELS,
|
||||
SEEDREAM_SIZE_OPTIONS,
|
||||
ARK_SEEDREAM_SIZE_OPTIONS,
|
||||
SEEDREAM_4K_SIZE_OPTIONS,
|
||||
SEEDREAM_QUALITY_OPTIONS,
|
||||
SEEDANCE_RESOLUTION_OPTIONS,
|
||||
VIDEO_RATIO_LIST,
|
||||
VIDEO_RATIO_OPTIONS,
|
||||
VIDEO_DURATION_OPTIONS,
|
||||
DEFAULT_IMAGE_MODEL,
|
||||
DEFAULT_VIDEO_MODEL,
|
||||
DEFAULT_CHAT_MODEL,
|
||||
DEFAULT_IMAGE_SIZE,
|
||||
DEFAULT_VIDEO_RATIO,
|
||||
DEFAULT_VIDEO_DURATION
|
||||
} from '@/config/models'
|
||||
import { useModelConfig } from '@/hooks/useModelConfig'
|
||||
import { useModelStore } from './pinia'
|
||||
|
||||
// Loading state (always false for built-in models) | 加载状态
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Get model config hook | 获取模型配置 hook
|
||||
const getModelConfigHook = () => {
|
||||
try {
|
||||
return useModelConfig()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getPiniaModelStore = () => {
|
||||
try {
|
||||
return useModelStore()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize models (no-op for built-in) | 初始化模型
|
||||
*/
|
||||
export const loadAllModels = async () => {
|
||||
const modelStore = getPiniaModelStore()
|
||||
if (modelStore) {
|
||||
await modelStore.loadRuntimeModels?.()
|
||||
return [...modelStore.allImageModels, ...modelStore.allVideoModels, ...modelStore.allChatModels]
|
||||
}
|
||||
const modelConfig = getModelConfigHook()
|
||||
if (modelConfig) {
|
||||
return [...modelConfig.allImageModels.value, ...modelConfig.allVideoModels.value, ...modelConfig.allChatModels.value]
|
||||
}
|
||||
return [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model config by name | 根据名称获取模型配置
|
||||
*/
|
||||
export const getModelConfig = (modelKey) => {
|
||||
const modelStore = getPiniaModelStore()
|
||||
if (modelStore) {
|
||||
return modelStore.getImageModel(modelKey) ||
|
||||
modelStore.getVideoModel(modelKey) ||
|
||||
modelStore.getChatModel(modelKey)
|
||||
}
|
||||
const modelConfig = getModelConfigHook()
|
||||
if (modelConfig) {
|
||||
return modelConfig.getImageModel(modelKey) ||
|
||||
modelConfig.getVideoModel(modelKey) ||
|
||||
modelConfig.getChatModel(modelKey)
|
||||
}
|
||||
const allModels = [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS]
|
||||
return allModels.find(m => m.key === modelKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get size options for image model | 获取图片模型尺寸选项
|
||||
* Returns options based on model's sizes array and quality
|
||||
*/
|
||||
export const getModelSizeOptions = (modelKey, quality = 'standard') => {
|
||||
const model = getModelConfig(modelKey) || IMAGE_MODELS.find(m => m.key === modelKey)
|
||||
|
||||
if (model?.sizeOptions) {
|
||||
return model.sizeOptions
|
||||
}
|
||||
|
||||
// If model has getSizesByQuality function, use it | 如果模型有 getSizesByQuality 函数,使用它
|
||||
if (model?.getSizesByQuality) {
|
||||
return model.getSizesByQuality(quality)
|
||||
}
|
||||
|
||||
if (!model?.sizes) return SEEDREAM_SIZE_OPTIONS
|
||||
|
||||
// Convert sizes array to dropdown options | 转换 sizes 数组为下拉选项
|
||||
const sizeOptions = quality === '4k' ? SEEDREAM_4K_SIZE_OPTIONS : SEEDREAM_SIZE_OPTIONS
|
||||
return model.sizes.map(size => {
|
||||
const option = sizeOptions.find(o => o.key === size)
|
||||
return option || { label: size, key: size }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quality options for image model | 获取图片模型画质选项
|
||||
*/
|
||||
export const getModelQualityOptions = (modelKey) => {
|
||||
const model = getModelConfig(modelKey) || IMAGE_MODELS.find(m => m.key === modelKey)
|
||||
return model?.qualities || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ratio options for video model | 获取视频模型比例选项
|
||||
* Returns options based on model's ratios array
|
||||
*/
|
||||
export const getModelRatioOptions = (modelKey) => {
|
||||
const model = getModelConfig(modelKey) || VIDEO_MODELS.find(m => m.key === modelKey)
|
||||
if (!model?.ratios) return VIDEO_RATIO_OPTIONS
|
||||
|
||||
// Convert ratios array to dropdown options | 转换 ratios 数组为下拉选项
|
||||
return model.ratios.map(ratio => {
|
||||
const option = VIDEO_RATIO_LIST.find(o => o.key === ratio)
|
||||
return option || { label: ratio, key: ratio }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get duration options for video model | 获取视频模型时长选项
|
||||
* Returns options based on model's durs array
|
||||
*/
|
||||
export const getModelDurationOptions = (modelKey) => {
|
||||
const model = getModelConfig(modelKey) || VIDEO_MODELS.find(m => m.key === modelKey)
|
||||
if (!model?.durs) return VIDEO_DURATION_OPTIONS
|
||||
|
||||
// durs is already in { label, key } format | durs 已经是 { label, key } 格式
|
||||
return model.durs
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resolution options for video model | 获取视频模型分辨率选项
|
||||
* Returns options based on model's resolutions array
|
||||
*/
|
||||
export const getModelResolutionOptions = (modelKey) => {
|
||||
const model = getModelConfig(modelKey) || VIDEO_MODELS.find(m => m.key === modelKey)
|
||||
if (model?.resolutionOptions?.length) {
|
||||
return model.resolutionOptions
|
||||
}
|
||||
if (!model?.resolutions) return SEEDANCE_RESOLUTION_OPTIONS
|
||||
|
||||
return model.resolutions.map(res => {
|
||||
const option = SEEDANCE_RESOLUTION_OPTIONS.find(o => o.key === res)
|
||||
return option || { label: res, key: res }
|
||||
})
|
||||
}
|
||||
|
||||
// Dropdown options (built-in + custom) | 下拉选项(内置 + 自定义)- 根据渠道过滤
|
||||
export const imageModelOptions = computed(() => {
|
||||
const modelConfig = getModelConfigHook()
|
||||
return modelConfig ? modelConfig.availableImageModels.value : IMAGE_MODELS
|
||||
})
|
||||
|
||||
export const videoModelOptions = computed(() => {
|
||||
const modelConfig = getModelConfigHook()
|
||||
return modelConfig ? modelConfig.availableVideoModels.value : VIDEO_MODELS
|
||||
})
|
||||
|
||||
export const chatModelOptions = computed(() => {
|
||||
const modelConfig = getModelConfigHook()
|
||||
return modelConfig ? modelConfig.availableChatModels.value : CHAT_MODELS
|
||||
})
|
||||
|
||||
// All model options (not filtered by provider) | 所有模型选项(不按渠道过滤)
|
||||
export const allImageModelOptions = computed(() => {
|
||||
const modelConfig = getModelConfigHook()
|
||||
return modelConfig ? modelConfig.allAvailableImageModels.value : IMAGE_MODELS
|
||||
})
|
||||
|
||||
export const allVideoModelOptions = computed(() => {
|
||||
const modelConfig = getModelConfigHook()
|
||||
return modelConfig ? modelConfig.allAvailableVideoModels.value : VIDEO_MODELS
|
||||
})
|
||||
|
||||
export const allChatModelOptions = computed(() => {
|
||||
const modelConfig = getModelConfigHook()
|
||||
return modelConfig ? modelConfig.allAvailableChatModels.value : CHAT_MODELS
|
||||
})
|
||||
|
||||
// Simple select options (for n-select) | 简单选择选项
|
||||
export const imageModelSelectOptions = computed(() =>
|
||||
imageModelOptions.value.map(m => ({ label: m.label, value: m.key }))
|
||||
)
|
||||
|
||||
export const videoModelSelectOptions = computed(() =>
|
||||
videoModelOptions.value.map(m => ({ label: m.label, value: m.key }))
|
||||
)
|
||||
|
||||
export const chatModelSelectOptions = computed(() =>
|
||||
chatModelOptions.value.map(m => ({ label: m.label, value: m.key }))
|
||||
)
|
||||
|
||||
// All select options (not filtered by provider) | 所有选择选项(不按渠道过滤)
|
||||
export const allImageModelSelectOptions = computed(() =>
|
||||
allImageModelOptions.value.map(m => ({ label: m.label, value: m.key }))
|
||||
)
|
||||
|
||||
export const allVideoModelSelectOptions = computed(() =>
|
||||
allVideoModelOptions.value.map(m => ({ label: m.label, value: m.key }))
|
||||
)
|
||||
|
||||
export const allChatModelSelectOptions = computed(() =>
|
||||
allChatModelOptions.value.map(m => ({ label: m.label, value: m.key }))
|
||||
)
|
||||
|
||||
// Export model arrays (reactive with custom models) | 导出模型数组(响应式,包含自定义模型)
|
||||
export const imageModels = computed(() => imageModelOptions.value)
|
||||
export const videoModels = computed(() => videoModelOptions.value)
|
||||
export const chatModels = computed(() => chatModelOptions.value)
|
||||
|
||||
// Export defaults | 导出默认值
|
||||
export {
|
||||
DEFAULT_IMAGE_MODEL,
|
||||
DEFAULT_VIDEO_MODEL,
|
||||
DEFAULT_CHAT_MODEL,
|
||||
DEFAULT_IMAGE_SIZE,
|
||||
DEFAULT_VIDEO_RATIO,
|
||||
DEFAULT_VIDEO_DURATION
|
||||
}
|
||||
|
||||
// Export options | 导出选项
|
||||
export { SEEDREAM_SIZE_OPTIONS, SEEDREAM_4K_SIZE_OPTIONS, SEEDREAM_QUALITY_OPTIONS, SEEDANCE_RESOLUTION_OPTIONS, VIDEO_RATIO_OPTIONS, VIDEO_DURATION_OPTIONS }
|
||||
export { ARK_SEEDREAM_SIZE_OPTIONS }
|
||||
|
||||
// Export state | 导出状态
|
||||
export { loading, error }
|
||||
6
web/canvas-app/src/stores/pinia/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Pinia Stores | Pinia 状态管理
|
||||
* 统一导出所有 Pinia stores
|
||||
*/
|
||||
|
||||
export { useModelStore } from './models'
|
||||
714
web/canvas-app/src/stores/pinia/models.js
Normal file
@@ -0,0 +1,714 @@
|
||||
/**
|
||||
* Pinia Store: Model Config | 模型配置 Store
|
||||
* 管理模型配置、渠道切换和模型选择
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
CHAT_MODELS,
|
||||
IMAGE_MODELS,
|
||||
VIDEO_MODELS,
|
||||
DEFAULT_CHAT_MODEL,
|
||||
DEFAULT_IMAGE_MODEL,
|
||||
DEFAULT_VIDEO_MODEL
|
||||
} from '@/config/models'
|
||||
import { PROVIDERS, getProviderList, getDefaultProvider, getProviderConfig, getDefaultBaseUrl } from '@/config/providers'
|
||||
|
||||
// 存储键名
|
||||
const STORAGE_KEYS = {
|
||||
PROVIDER: 'api-provider',
|
||||
CUSTOM_CHAT_MODELS: 'custom-chat-models',
|
||||
CUSTOM_IMAGE_MODELS: 'custom-image-models',
|
||||
CUSTOM_VIDEO_MODELS: 'custom-video-models',
|
||||
SELECTED_CHAT_MODEL: 'selected-chat-model',
|
||||
SELECTED_IMAGE_MODEL: 'selected-image-model',
|
||||
SELECTED_VIDEO_MODEL: 'selected-video-model',
|
||||
CUSTOM_CHAT_MODELS_BY_PROVIDER: 'custom-chat-models-by-provider',
|
||||
CUSTOM_IMAGE_MODELS_BY_PROVIDER: 'custom-image-models-by-provider',
|
||||
CUSTOM_VIDEO_MODELS_BY_PROVIDER: 'custom-video-models-by-provider',
|
||||
API_KEYS_BY_PROVIDER: 'api-keys-by-provider',
|
||||
BASE_URLS_BY_PROVIDER: 'base-urls-by-provider'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored value from localStorage
|
||||
*/
|
||||
const getStored = (key, defaultValue = '') => {
|
||||
try {
|
||||
return localStorage.getItem(key) || defaultValue
|
||||
} catch {
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set stored value to localStorage
|
||||
*/
|
||||
const setStored = (key, value) => {
|
||||
try {
|
||||
if (value) {
|
||||
localStorage.setItem(key, value)
|
||||
} else {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const removeStored = (key) => {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
const getValidStoredModel = (key, defaultValue, builtInModels) => {
|
||||
const stored = getStored(key, defaultValue)
|
||||
return builtInModels.some(model => model.key === stored) ? stored : defaultValue
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模型是否支持指定渠道
|
||||
*/
|
||||
const isModelSupported = (model, provider) => {
|
||||
if (!model.provider) {
|
||||
return true
|
||||
}
|
||||
return model.provider.includes(provider)
|
||||
}
|
||||
|
||||
const normalizeRuntimeSizeOptions = (items = []) => {
|
||||
if (!Array.isArray(items)) return []
|
||||
return items
|
||||
.map(item => {
|
||||
const key = item?.value || item?.key || item?.id
|
||||
if (!key) return null
|
||||
return {
|
||||
label: item.label || key,
|
||||
key
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const normalizeRuntimeImageModel = (item) => {
|
||||
const key = item?.id || item?.model
|
||||
if (!key) return null
|
||||
const sizeOptions = normalizeRuntimeSizeOptions(item.size_options)
|
||||
return {
|
||||
label: item.label || item.model || key,
|
||||
key,
|
||||
provider: ['chatfire'],
|
||||
sizes: sizeOptions.map(option => option.key),
|
||||
sizeOptions,
|
||||
qualities: [{ label: '标准', key: 'standard' }],
|
||||
defaultParams: {
|
||||
size: item.default_size || sizeOptions[0]?.key || 'auto',
|
||||
quality: 'standard',
|
||||
style: item.provider === 'ark_seedream' ? 'commercial' : 'vivid'
|
||||
},
|
||||
available: item.available !== false,
|
||||
providerName: item.provider,
|
||||
isRuntime: true
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeRuntimeDurationOptions = (items = []) => {
|
||||
if (!Array.isArray(items)) return []
|
||||
return items
|
||||
.map(item => {
|
||||
const key = typeof item === 'object' ? item?.value || item?.key || item?.id : item
|
||||
if (!key) return null
|
||||
return {
|
||||
label: typeof item === 'object' ? item.label || `${key} 秒` : `${key} 秒`,
|
||||
key
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const normalizeRuntimeResolutionOptions = (items = []) => {
|
||||
if (!Array.isArray(items)) return []
|
||||
return items
|
||||
.map(item => {
|
||||
const key = typeof item === 'object' ? item?.value || item?.key || item?.id : item
|
||||
if (!key) return null
|
||||
return {
|
||||
label: typeof item === 'object' ? item.label || key : key,
|
||||
key
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const normalizeRuntimeVideoModel = (item) => {
|
||||
const key = item?.id || item?.model
|
||||
if (!key) return null
|
||||
const sizeOptions = normalizeRuntimeSizeOptions(item.size_options)
|
||||
const durationOptions = normalizeRuntimeDurationOptions(item.duration_options)
|
||||
const resolutionOptions = normalizeRuntimeResolutionOptions(item.resolution_options)
|
||||
const resolutions = resolutionOptions.length ? resolutionOptions.map(option => option.key) : ['720p']
|
||||
const defaultResolution = item.default_resolution || resolutions[0] || '720p'
|
||||
return {
|
||||
label: item.label || item.model || key,
|
||||
key,
|
||||
provider: ['chatfire'],
|
||||
type: 't2v+i2v',
|
||||
model: item.model,
|
||||
ratios: sizeOptions.map(option => option.key),
|
||||
durs: durationOptions,
|
||||
resolutions,
|
||||
resolutionOptions,
|
||||
defaultResolution,
|
||||
defaultParams: {
|
||||
ratio: sizeOptions[0]?.key || '720x1280',
|
||||
duration: durationOptions[0]?.key || 5,
|
||||
resolution: defaultResolution
|
||||
},
|
||||
available: item.available !== false,
|
||||
isRuntime: true
|
||||
}
|
||||
}
|
||||
|
||||
const mergeModels = (builtInModels, runtimeModels) => {
|
||||
const byKey = new Map()
|
||||
builtInModels.forEach(model => byKey.set(model.key, { ...model, isCustom: false }))
|
||||
runtimeModels.forEach(model => byKey.set(model.key, { ...byKey.get(model.key), ...model, isCustom: false }))
|
||||
return Array.from(byKey.values())
|
||||
}
|
||||
|
||||
export const useModelStore = defineStore('model', () => {
|
||||
// ============ Provider 状态 | Provider State ============
|
||||
|
||||
// 当前选中的渠道
|
||||
const storedProvider = getStored(STORAGE_KEYS.PROVIDER)
|
||||
const currentProvider = ref(PROVIDERS[storedProvider] ? storedProvider : getDefaultProvider())
|
||||
|
||||
// 渠道列表
|
||||
const providerList = computed(() => 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_KEYS.PROVIDER, provider)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除渠道配置
|
||||
const clearProvider = () => {
|
||||
currentProvider.value = getDefaultProvider()
|
||||
removeStored(STORAGE_KEYS.PROVIDER)
|
||||
}
|
||||
|
||||
// 适配请求参数
|
||||
const adaptRequest = (type, params) => {
|
||||
const config = providerConfig.value
|
||||
if (config.requestAdapter && config.requestAdapter[type]) {
|
||||
return config.requestAdapter[type](params)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// 适配响应数据
|
||||
const adaptResponse = (type, response) => {
|
||||
const config = providerConfig.value
|
||||
if (config.responseAdapter && config.responseAdapter[type]) {
|
||||
return config.responseAdapter[type](response)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// ============ Custom Models 状态 | Custom Models State ============
|
||||
|
||||
// 全局自定义模型(不区分渠道)
|
||||
const customChatModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, []))
|
||||
const customImageModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, []))
|
||||
const customVideoModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, []))
|
||||
|
||||
// 按渠道存储的自定义模型 | 结构: { 'skg': [{key, label}] }
|
||||
const customChatModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER, {}))
|
||||
const customImageModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER, {}))
|
||||
const customVideoModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER, {}))
|
||||
const runtimeImageModels = ref([])
|
||||
const runtimeVideoModels = ref([])
|
||||
const runtimeVideoModelsLoaded = ref(false)
|
||||
|
||||
// 选中的模型
|
||||
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))
|
||||
|
||||
// 按渠道存储的 API 配置
|
||||
const apiKeysByProvider = ref(getStoredJson(STORAGE_KEYS.API_KEYS_BY_PROVIDER, {}))
|
||||
const baseUrlsByProvider = ref(getStoredJson(STORAGE_KEYS.BASE_URLS_BY_PROVIDER, {}))
|
||||
|
||||
// 内部模式由服务端会话鉴权,不在浏览器暴露上游模型密钥。
|
||||
const currentApiKey = computed(() => 'internal-session')
|
||||
const currentBaseUrl = computed(() => baseUrlsByProvider.value[currentProvider.value] || getDefaultBaseUrl(currentProvider.value))
|
||||
|
||||
// 设置指定渠道凭据(兼容旧本地状态)
|
||||
const setApiKeyByProvider = (provider, apiKey) => {
|
||||
apiKeysByProvider.value[provider] = apiKey
|
||||
}
|
||||
|
||||
// 设置指定渠道的 Base URL
|
||||
const setBaseUrlByProvider = (provider, baseUrl) => {
|
||||
baseUrlsByProvider.value[provider] = baseUrl
|
||||
}
|
||||
|
||||
// 清除指定渠道的 API 配置
|
||||
const clearApiConfigByProvider = (provider) => {
|
||||
delete apiKeysByProvider.value[provider]
|
||||
delete baseUrlsByProvider.value[provider]
|
||||
}
|
||||
|
||||
// ============ Computed: All Models (built-in + custom + by provider) ============
|
||||
|
||||
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(() =>
|
||||
mergeModels(IMAGE_MODELS, runtimeImageModels.value)
|
||||
)
|
||||
|
||||
const allVideoModels = computed(() =>
|
||||
runtimeVideoModelsLoaded.value
|
||||
? runtimeVideoModels.value
|
||||
: mergeModels(VIDEO_MODELS, runtimeVideoModels.value)
|
||||
)
|
||||
|
||||
// ============ Computed: 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) && m.available !== false)
|
||||
)
|
||||
|
||||
const availableVideoModels = computed(() =>
|
||||
allVideoModels.value.filter(m => isModelSupported(m, currentProvider.value) && m.available !== false)
|
||||
)
|
||||
|
||||
// ============ Computed: Model Options for UI (all models, not filtered by provider) ============
|
||||
|
||||
// 返回适合 n-dropdown 使用的选项格式(全部模型,不按渠道过滤)
|
||||
const allImageModelOptions = computed(() =>
|
||||
allImageModels.value.map(m => ({
|
||||
label: m.label,
|
||||
key: m.key,
|
||||
disabled: false
|
||||
}))
|
||||
)
|
||||
|
||||
const allVideoModelOptions = computed(() =>
|
||||
allVideoModels.value.map(m => ({
|
||||
label: m.label,
|
||||
key: m.key,
|
||||
disabled: false
|
||||
}))
|
||||
)
|
||||
|
||||
const allChatModelOptions = computed(() =>
|
||||
allChatModels.value.map(m => ({
|
||||
label: m.label,
|
||||
key: m.key
|
||||
}))
|
||||
)
|
||||
|
||||
// ============ Computed: Model Options for UI (filtered by provider - deprecated, use all* instead) ============
|
||||
|
||||
// 返回适合 n-dropdown 使用的选项格式
|
||||
const imageModelOptions = computed(() =>
|
||||
availableImageModels.value.map(m => ({
|
||||
label: m.label,
|
||||
key: m.key,
|
||||
disabled: m.available === false
|
||||
}))
|
||||
)
|
||||
|
||||
const videoModelOptions = computed(() =>
|
||||
availableVideoModels.value.map(m => ({
|
||||
label: m.label,
|
||||
key: m.key
|
||||
}))
|
||||
)
|
||||
|
||||
const chatModelOptions = computed(() =>
|
||||
availableChatModels.value.map(m => ({
|
||||
label: m.label,
|
||||
key: m.key
|
||||
}))
|
||||
)
|
||||
|
||||
// ============ Methods: Add/Remove Custom Models ============
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ============ Methods: Get Model Config ============
|
||||
|
||||
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)
|
||||
|
||||
const loadRuntimeModels = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/health', { credentials: 'include' })
|
||||
if (!response.ok) return false
|
||||
const data = await response.json()
|
||||
const imageOptions = data?.models?.image_options || []
|
||||
runtimeImageModels.value = imageOptions
|
||||
.filter(item => item?.id && item.id !== 'auto')
|
||||
.map(normalizeRuntimeImageModel)
|
||||
.filter(Boolean)
|
||||
const videoOptions = data?.models?.video_options || []
|
||||
runtimeVideoModels.value = videoOptions
|
||||
.filter(item => item?.id && item.available !== false)
|
||||
.map(normalizeRuntimeVideoModel)
|
||||
.filter(Boolean)
|
||||
runtimeVideoModelsLoaded.value = true
|
||||
if (!availableVideoModels.value.some(model => model.key === selectedVideoModel.value)) {
|
||||
selectedVideoModel.value = availableVideoModels.value[0]?.key || DEFAULT_VIDEO_MODEL
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('[model store] runtime model load failed', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Methods: Get API Endpoints ============
|
||||
|
||||
// 获取图片端点
|
||||
const getImageEndpoint = () => {
|
||||
const endpoint = providerConfig.value.endpoints?.image || '/images/generations'
|
||||
return `${currentBaseUrl.value}${endpoint}`
|
||||
}
|
||||
|
||||
// 获取视频生成端点
|
||||
const getVideoEndpoint = () => {
|
||||
const endpoint = providerConfig.value.endpoints?.video || '/videos'
|
||||
return `${currentBaseUrl.value}${endpoint}`
|
||||
}
|
||||
|
||||
// 获取视频任务查询端点
|
||||
const getVideoTaskEndpoint = () => {
|
||||
const config = providerConfig.value
|
||||
// 优先使用 videoQuery 端点,支持 {taskId} 占位符替换
|
||||
let endpoint = config.endpoints?.videoQuery || config.endpoints?.video || '/videos'
|
||||
return `${currentBaseUrl.value}${endpoint}`
|
||||
}
|
||||
|
||||
// 获取聊天端点(支持参考图片)
|
||||
const getChatEndpoint = () => {
|
||||
const endpoint = providerConfig.value?.endpoints?.chat || '/chat/completions'
|
||||
return `${currentBaseUrl.value}${endpoint}`
|
||||
}
|
||||
|
||||
// ============ Methods: Get Models By Provider ============
|
||||
|
||||
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 = allImageModels.value
|
||||
.filter(m => isModelSupported(m, provider))
|
||||
.map(m => ({ ...m, isCustom: false }))
|
||||
const video = allVideoModels.value
|
||||
.filter(m => isModelSupported(m, provider))
|
||||
.map(m => ({ ...m, isCustom: false }))
|
||||
return { chat, image, video }
|
||||
}
|
||||
|
||||
// ============ Methods: Add/Remove Custom Models By Provider ============
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 清除所有自定义模型
|
||||
const clearCustomModels = () => {
|
||||
customChatModels.value = []
|
||||
customImageModels.value = []
|
||||
customVideoModels.value = []
|
||||
selectedChatModel.value = DEFAULT_CHAT_MODEL
|
||||
selectedImageModel.value = DEFAULT_IMAGE_MODEL
|
||||
selectedVideoModel.value = DEFAULT_VIDEO_MODEL
|
||||
}
|
||||
|
||||
// ============ Watch & Persist ============
|
||||
|
||||
// 监听并持久化自定义模型
|
||||
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(customChatModelsByProvider, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER, val), { deep: true })
|
||||
watch(customImageModelsByProvider, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER, val), { deep: true })
|
||||
watch(customVideoModelsByProvider, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER, 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))
|
||||
|
||||
// 监听并持久化 API 配置
|
||||
watch(apiKeysByProvider, (val) => setStoredJson(STORAGE_KEYS.API_KEYS_BY_PROVIDER, val), { deep: true })
|
||||
watch(baseUrlsByProvider, (val) => setStoredJson(STORAGE_KEYS.BASE_URLS_BY_PROVIDER, val), { deep: true })
|
||||
|
||||
return {
|
||||
// Provider
|
||||
currentProvider,
|
||||
providerList,
|
||||
providerConfig,
|
||||
providerLabel,
|
||||
setProvider,
|
||||
clearProvider,
|
||||
adaptRequest,
|
||||
adaptResponse,
|
||||
|
||||
// All models (built-in + custom)
|
||||
allChatModels,
|
||||
allImageModels,
|
||||
allVideoModels,
|
||||
runtimeImageModels,
|
||||
runtimeVideoModels,
|
||||
runtimeVideoModelsLoaded,
|
||||
|
||||
// Available models filtered by provider
|
||||
availableChatModels,
|
||||
availableImageModels,
|
||||
availableVideoModels,
|
||||
|
||||
// Model options for UI (dropdown format)
|
||||
imageModelOptions,
|
||||
videoModelOptions,
|
||||
chatModelOptions,
|
||||
|
||||
// All model options (not filtered by provider)
|
||||
allImageModelOptions,
|
||||
allVideoModelOptions,
|
||||
allChatModelOptions,
|
||||
|
||||
// Selected models
|
||||
selectedChatModel,
|
||||
selectedImageModel,
|
||||
selectedVideoModel,
|
||||
|
||||
// Custom models
|
||||
customChatModels,
|
||||
customImageModels,
|
||||
customVideoModels,
|
||||
|
||||
// Custom models by provider
|
||||
customChatModelsByProvider,
|
||||
customImageModelsByProvider,
|
||||
customVideoModelsByProvider,
|
||||
|
||||
// Add/Remove methods
|
||||
addCustomChatModel,
|
||||
addCustomImageModel,
|
||||
addCustomVideoModel,
|
||||
removeCustomChatModel,
|
||||
removeCustomImageModel,
|
||||
removeCustomVideoModel,
|
||||
|
||||
// Add/Remove by provider methods
|
||||
addCustomChatModelByProvider,
|
||||
addCustomImageModelByProvider,
|
||||
addCustomVideoModelByProvider,
|
||||
removeCustomChatModelByProvider,
|
||||
removeCustomImageModelByProvider,
|
||||
removeCustomVideoModelByProvider,
|
||||
|
||||
// Get model
|
||||
getChatModel,
|
||||
getImageModel,
|
||||
getVideoModel,
|
||||
loadRuntimeModels,
|
||||
|
||||
// Get API endpoints
|
||||
getImageEndpoint,
|
||||
getVideoEndpoint,
|
||||
getVideoTaskEndpoint,
|
||||
getChatEndpoint,
|
||||
|
||||
// Get models by provider
|
||||
getModelsByProvider,
|
||||
|
||||
// Clear all custom models
|
||||
clearCustomModels,
|
||||
|
||||
// API Config by provider
|
||||
currentApiKey,
|
||||
currentBaseUrl,
|
||||
apiKeysByProvider,
|
||||
baseUrlsByProvider,
|
||||
setApiKeyByProvider,
|
||||
setBaseUrlByProvider,
|
||||
clearApiConfigByProvider
|
||||
}
|
||||
})
|
||||
513
web/canvas-app/src/stores/projects.js
Normal file
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* Projects store | 项目状态管理
|
||||
* Manages projects with localStorage persistence
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
// Storage key | 存储键
|
||||
const STORAGE_KEY = 'ai-canvas-projects'
|
||||
|
||||
// Generate unique ID | 生成唯一ID
|
||||
const generateId = () => `project_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// Projects list | 项目列表
|
||||
export const projects = ref([])
|
||||
|
||||
// Current project ID | 当前项目ID
|
||||
export const currentProjectId = ref(null)
|
||||
|
||||
export const projectSyncStatus = ref('idle')
|
||||
export const projectSyncError = ref('')
|
||||
|
||||
const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api'
|
||||
const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
const remoteSaveTimers = new Map()
|
||||
const remoteSaveSignatures = new Map()
|
||||
let initPromise = null
|
||||
let remoteAvailable = false
|
||||
|
||||
// Current project | 当前项目
|
||||
export const currentProject = computed(() => {
|
||||
return projects.value.find(p => p.id === currentProjectId.value) || null
|
||||
})
|
||||
|
||||
const dateToSeconds = (value) => {
|
||||
if (value instanceof Date) return value.getTime() / 1000
|
||||
const parsed = new Date(value)
|
||||
return Number.isFinite(parsed.getTime()) ? parsed.getTime() / 1000 : Date.now() / 1000
|
||||
}
|
||||
|
||||
const secondsToDate = (value) => {
|
||||
if (value instanceof Date) return value
|
||||
const num = Number(value || 0)
|
||||
return new Date(num > 100000000000 ? num : num * 1000)
|
||||
}
|
||||
|
||||
const projectFromApi = (item) => ({
|
||||
id: item.id,
|
||||
name: item.name || '未命名项目',
|
||||
thumbnail: item.thumbnail || '',
|
||||
visibility: item.visibility || 'private',
|
||||
ownerId: item.owner_id || '',
|
||||
ownerName: item.owner_name || '',
|
||||
ownerEmail: item.owner_email || '',
|
||||
ownerProvider: item.owner_provider || '',
|
||||
version: item.version || 1,
|
||||
createdAt: secondsToDate(item.created_at),
|
||||
updatedAt: secondsToDate(item.updated_at),
|
||||
canvasData: item.canvas_data || {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 100, y: 50, zoom: 0.8 }
|
||||
}
|
||||
})
|
||||
|
||||
const projectToApi = (project) => ({
|
||||
id: project.id,
|
||||
name: project.name || '未命名项目',
|
||||
thumbnail: project.thumbnail || '',
|
||||
visibility: project.visibility || 'private',
|
||||
canvas_data: cleanProjectForStorage(project).canvasData || {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 100, y: 50, zoom: 0.8 }
|
||||
},
|
||||
created_at: dateToSeconds(project.createdAt),
|
||||
updated_at: dateToSeconds(project.updatedAt),
|
||||
source: 'canvas'
|
||||
})
|
||||
|
||||
const remoteProjectSignature = (project) => {
|
||||
const payload = projectToApi(project)
|
||||
delete payload.updated_at
|
||||
return JSON.stringify(payload)
|
||||
}
|
||||
|
||||
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) {
|
||||
const text = await response.text().catch(() => '')
|
||||
throw new Error(text || `${path} ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const mergeProjectLists = (localItems, remoteItems) => {
|
||||
const byId = new Map()
|
||||
for (const item of remoteItems) byId.set(item.id, item)
|
||||
for (const item of localItems) {
|
||||
const existing = byId.get(item.id)
|
||||
if (!existing || dateToSeconds(item.updatedAt) > dateToSeconds(existing.updatedAt)) {
|
||||
byId.set(item.id, item)
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => dateToSeconds(b.updatedAt) - dateToSeconds(a.updatedAt))
|
||||
}
|
||||
|
||||
/**
|
||||
* Load projects from localStorage | 从 localStorage 加载项目
|
||||
*/
|
||||
export const loadProjects = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
// Convert date strings back to Date objects | 将日期字符串转换回 Date 对象
|
||||
projects.value = parsed.map(p => ({
|
||||
...p,
|
||||
createdAt: new Date(p.createdAt),
|
||||
updatedAt: new Date(p.updatedAt)
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load projects:', err)
|
||||
projects.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const saveRemoteProjectNow = async (project) => {
|
||||
if (!project?.id) return null
|
||||
const signature = remoteProjectSignature(project)
|
||||
if (remoteSaveSignatures.get(project.id) === signature) return null
|
||||
const response = await requestJson(`/canvas-projects/${encodeURIComponent(project.id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(projectToApi(project))
|
||||
})
|
||||
remoteSaveSignatures.set(project.id, signature)
|
||||
return response.item ? projectFromApi(response.item) : null
|
||||
}
|
||||
|
||||
const scheduleRemoteSave = (project, delay = 2000) => {
|
||||
if (!remoteAvailable || !project?.id) return
|
||||
if (remoteSaveTimers.has(project.id)) {
|
||||
clearTimeout(remoteSaveTimers.get(project.id))
|
||||
}
|
||||
remoteSaveTimers.set(project.id, setTimeout(async () => {
|
||||
remoteSaveTimers.delete(project.id)
|
||||
try {
|
||||
projectSyncStatus.value = 'syncing'
|
||||
await saveRemoteProjectNow(project)
|
||||
projectSyncStatus.value = 'synced'
|
||||
projectSyncError.value = ''
|
||||
} catch (err) {
|
||||
projectSyncStatus.value = 'error'
|
||||
projectSyncError.value = err.message || '项目同步失败'
|
||||
console.warn('Failed to sync project:', err)
|
||||
}
|
||||
}, delay))
|
||||
}
|
||||
|
||||
const importLocalProjectsToServer = async (localItems) => {
|
||||
if (!localItems.length) return []
|
||||
const payload = { projects: localItems.map(projectToApi) }
|
||||
const response = await requestJson('/canvas-projects/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
return (response.items || []).map(projectFromApi)
|
||||
}
|
||||
|
||||
export const loadRemoteProjects = async () => {
|
||||
try {
|
||||
projectSyncStatus.value = 'syncing'
|
||||
const localItems = [...projects.value]
|
||||
const response = await requestJson('/canvas-projects')
|
||||
remoteAvailable = true
|
||||
const remoteItems = (response.items || []).map(projectFromApi)
|
||||
const importedItems = await importLocalProjectsToServer(localItems)
|
||||
const merged = mergeProjectLists(localItems, [...remoteItems, ...importedItems])
|
||||
projects.value = merged
|
||||
saveProjects({ remote: false })
|
||||
projectSyncStatus.value = 'synced'
|
||||
projectSyncError.value = ''
|
||||
return merged
|
||||
} catch (err) {
|
||||
remoteAvailable = false
|
||||
projectSyncStatus.value = 'error'
|
||||
projectSyncError.value = err.message || '项目同步失败'
|
||||
console.warn('Remote project sync unavailable:', err)
|
||||
return projects.value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean node data for storage | 清理节点数据用于存储
|
||||
* Removes base64 data URLs to reduce storage size | 移除 base64 数据减小存储大小
|
||||
*/
|
||||
const cleanNodeForStorage = (node) => {
|
||||
if (!node.data) return node
|
||||
|
||||
const cleanedData = { ...node.data }
|
||||
|
||||
// Remove base64 data | 移除 base64 数据
|
||||
if (cleanedData.base64) {
|
||||
delete cleanedData.base64
|
||||
}
|
||||
|
||||
// If url is a base64 data URL, keep it only if it's from external source | 如果 url 是 base64,只有外部来源才保留
|
||||
if (cleanedData.url?.startsWith?.('data:')) {
|
||||
// For uploaded images, we can't persist them in localStorage | 上传的图片无法持久化到 localStorage
|
||||
delete cleanedData.url
|
||||
}
|
||||
|
||||
// blob: object URLs are session-only and break on reload — never persist them | blob: 仅会话内有效,重载即失效,不持久化
|
||||
if (cleanedData.url?.startsWith?.('blob:')) {
|
||||
delete cleanedData.url
|
||||
}
|
||||
|
||||
// Remove mask data | 移除蒙版数据
|
||||
if (cleanedData.maskData) {
|
||||
delete cleanedData.maskData
|
||||
}
|
||||
|
||||
return { ...node, data: cleanedData }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean project for storage | 清理项目用于存储
|
||||
*/
|
||||
const cleanProjectForStorage = (project) => {
|
||||
return {
|
||||
...project,
|
||||
canvasData: project.canvasData ? {
|
||||
...project.canvasData,
|
||||
nodes: project.canvasData.nodes?.map(cleanNodeForStorage) || []
|
||||
} : project.canvasData,
|
||||
// Remove base64 thumbnails | 移除 base64 缩略图
|
||||
thumbnail: project.thumbnail?.startsWith?.('data:') ? '' : project.thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save projects to localStorage | 保存项目到 localStorage
|
||||
* Handles QuotaExceededError by compressing data | 通过压缩数据处理配额超限错误
|
||||
*/
|
||||
export const saveProjects = ({ remote = false } = {}) => {
|
||||
// Always clean data before saving | 保存前始终清理数据
|
||||
const cleanedProjects = projects.value.map(cleanProjectForStorage)
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(cleanedProjects))
|
||||
} catch (err) {
|
||||
if (err.name === 'QuotaExceededError') {
|
||||
console.warn('localStorage quota exceeded, attempting aggressive cleanup...')
|
||||
|
||||
// Remove thumbnails and limit old projects | 移除缩略图并限制旧项目
|
||||
const minimalProjects = cleanedProjects.map((project, index) => ({
|
||||
...project,
|
||||
thumbnail: '', // Remove all thumbnails | 移除所有缩略图
|
||||
// Keep only essential canvas data for older projects | 旧项目只保留基本画布数据
|
||||
canvasData: index > 10 ? { nodes: [], edges: [], viewport: project.canvasData?.viewport } : project.canvasData
|
||||
}))
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(minimalProjects))
|
||||
console.log('Saved with aggressive cleanup')
|
||||
window.$message?.warning('存储空间不足,已自动清理部分数据')
|
||||
} catch (retryErr) {
|
||||
console.error('Still failed after aggressive cleanup:', retryErr)
|
||||
// Last resort: only keep first 5 projects | 最后手段:只保留前5个项目
|
||||
try {
|
||||
const essentialProjects = minimalProjects.slice(0, 5)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(essentialProjects))
|
||||
projects.value = projects.value.slice(0, 5)
|
||||
window.$message?.warning('存储空间严重不足,已保留最近 5 个项目')
|
||||
} catch (finalErr) {
|
||||
console.error('Cannot save even minimal data:', finalErr)
|
||||
window.$message?.error('存储失败,请清理浏览器存储空间')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to save projects:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (remote) {
|
||||
for (const project of projects.value) scheduleRemoteSave(project)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new project | 创建新项目
|
||||
* @param {string} name - Project name | 项目名称
|
||||
* @returns {string} - New project ID | 新项目ID
|
||||
*/
|
||||
export const createProject = (name = '未命名项目') => {
|
||||
const id = generateId()
|
||||
const now = new Date()
|
||||
|
||||
const newProject = {
|
||||
id,
|
||||
name,
|
||||
thumbnail: '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
// Canvas data | 画布数据
|
||||
canvasData: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 100, y: 50, zoom: 0.8 }
|
||||
}
|
||||
}
|
||||
|
||||
projects.value = [newProject, ...projects.value]
|
||||
saveProjects()
|
||||
scheduleRemoteSave(newProject, 0)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Update project | 更新项目
|
||||
* @param {string} id - Project ID | 项目ID
|
||||
* @param {object} data - Update data | 更新数据
|
||||
*/
|
||||
export const updateProject = (id, data) => {
|
||||
const index = projects.value.findIndex(p => p.id === id)
|
||||
if (index === -1) return false
|
||||
|
||||
projects.value[index] = {
|
||||
...projects.value[index],
|
||||
...data,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
|
||||
// Move to top of list | 移动到列表顶部
|
||||
const [updated] = projects.value.splice(index, 1)
|
||||
projects.value = [updated, ...projects.value]
|
||||
|
||||
saveProjects()
|
||||
scheduleRemoteSave(updated)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Update project canvas data | 更新项目画布数据
|
||||
* @param {string} id - Project ID | 项目ID
|
||||
* @param {object} canvasData - Canvas data (nodes, edges, viewport) | 画布数据
|
||||
*/
|
||||
export const updateProjectCanvas = (id, canvasData) => {
|
||||
const project = projects.value.find(p => p.id === id)
|
||||
if (!project) return false
|
||||
|
||||
project.canvasData = {
|
||||
...project.canvasData,
|
||||
...canvasData
|
||||
}
|
||||
project.updatedAt = new Date()
|
||||
|
||||
// Auto-update thumbnail from last edited image/video node | 自动从最后编辑的图片/视频节点更新缩略图
|
||||
if (canvasData.nodes) {
|
||||
const mediaNodes = canvasData.nodes
|
||||
.filter(node => (node.type === 'image' || node.type === 'video') && node.data?.url)
|
||||
.sort((a, b) => {
|
||||
// Sort by last updated time | 按最后更新时间排序
|
||||
const aTime = a.data?.updatedAt || a.data?.createdAt || 0
|
||||
const bTime = b.data?.updatedAt || b.data?.createdAt || 0
|
||||
return bTime - aTime
|
||||
})
|
||||
if (mediaNodes.length > 0) {
|
||||
const latestNode = mediaNodes[0]
|
||||
// Use thumbnail for video nodes, url for image nodes | 视频节点使用缩略图,图片节点使用 URL
|
||||
if (latestNode.type === 'video') {
|
||||
project.thumbnail = latestNode.data.thumbnail || latestNode.data.url
|
||||
} else {
|
||||
project.thumbnail = latestNode.data.url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveProjects()
|
||||
scheduleRemoteSave(project)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project canvas data | 获取项目画布数据
|
||||
* @param {string} id - Project ID | 项目ID
|
||||
* @returns {object|null} - Canvas data or null | 画布数据或空
|
||||
*/
|
||||
export const getProjectCanvas = (id) => {
|
||||
const project = projects.value.find(p => p.id === id)
|
||||
return project?.canvasData || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete project | 删除项目
|
||||
* @param {string} id - Project ID | 项目ID
|
||||
*/
|
||||
export const deleteProject = (id) => {
|
||||
projects.value = projects.value.filter(p => p.id !== id)
|
||||
saveProjects()
|
||||
if (remoteAvailable) {
|
||||
requestJson(`/canvas-projects/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
.catch(err => console.warn('Failed to delete remote project:', err))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate project | 复制项目
|
||||
* @param {string} id - Source project ID | 源项目ID
|
||||
* @returns {string|null} - New project ID or null | 新项目ID或空
|
||||
*/
|
||||
export const duplicateProject = (id) => {
|
||||
const source = projects.value.find(p => p.id === id)
|
||||
if (!source) return null
|
||||
|
||||
const newId = generateId()
|
||||
const now = new Date()
|
||||
|
||||
const newProject = {
|
||||
...JSON.parse(JSON.stringify(source)), // Deep clone | 深拷贝
|
||||
id: newId,
|
||||
name: `${source.name} (副本)`,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
|
||||
projects.value = [newProject, ...projects.value]
|
||||
saveProjects()
|
||||
scheduleRemoteSave(newProject, 0)
|
||||
|
||||
return newId
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename project | 重命名项目
|
||||
* @param {string} id - Project ID | 项目ID
|
||||
* @param {string} name - New name | 新名称
|
||||
*/
|
||||
export const renameProject = (id, name) => {
|
||||
return updateProject(id, { name })
|
||||
}
|
||||
|
||||
/**
|
||||
* Update project thumbnail | 更新项目缩略图
|
||||
* @param {string} id - Project ID | 项目ID
|
||||
* @param {string} thumbnail - Thumbnail URL (base64 or URL) | 缩略图URL
|
||||
*/
|
||||
export const updateProjectThumbnail = (id, thumbnail) => {
|
||||
return updateProject(id, { thumbnail })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sorted projects | 获取排序后的项目列表
|
||||
* @param {string} sortBy - Sort field (updatedAt, createdAt, name) | 排序字段
|
||||
* @param {string} order - Sort order (asc, desc) | 排序顺序
|
||||
*/
|
||||
export const getSortedProjects = (sortBy = 'updatedAt', order = 'desc') => {
|
||||
return computed(() => {
|
||||
const sorted = [...projects.value]
|
||||
sorted.sort((a, b) => {
|
||||
let valueA = a[sortBy]
|
||||
let valueB = b[sortBy]
|
||||
|
||||
if (valueA instanceof Date) {
|
||||
valueA = valueA.getTime()
|
||||
valueB = valueB.getTime()
|
||||
}
|
||||
|
||||
if (typeof valueA === 'string') {
|
||||
valueA = valueA.toLowerCase()
|
||||
valueB = valueB.toLowerCase()
|
||||
}
|
||||
|
||||
if (order === 'asc') {
|
||||
return valueA > valueB ? 1 : -1
|
||||
} else {
|
||||
return valueA < valueB ? 1 : -1
|
||||
}
|
||||
})
|
||||
return sorted
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize projects store | 初始化项目存储
|
||||
*/
|
||||
export const initProjectsStore = async () => {
|
||||
if (initPromise) return initPromise
|
||||
initPromise = (async () => {
|
||||
loadProjects()
|
||||
await loadRemoteProjects()
|
||||
return projects.value
|
||||
})()
|
||||
return initPromise
|
||||
}
|
||||
|
||||
// Export for debugging | 导出用于调试
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__aiCanvasProjects = {
|
||||
projects,
|
||||
loadProjects,
|
||||
saveProjects,
|
||||
createProject,
|
||||
deleteProject
|
||||
}
|
||||
}
|
||||
25
web/canvas-app/src/stores/theme.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Theme store | 主题状态管理
|
||||
* Handles dark/light mode switching
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
// Get initial theme from localStorage or system preference | 从本地存储或系统偏好获取初始主题
|
||||
const getInitialTheme = () => {
|
||||
const stored = localStorage.getItem('theme')
|
||||
if (stored) return stored === 'dark'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
export const isDark = ref(getInitialTheme())
|
||||
|
||||
// Watch and apply theme changes | 监听并应用主题变化
|
||||
watch(isDark, (value) => {
|
||||
document.documentElement.classList.toggle('dark', value)
|
||||
localStorage.setItem('theme', value ? 'dark' : 'light')
|
||||
}, { immediate: true })
|
||||
|
||||
// Toggle theme | 切换主题
|
||||
export const toggleTheme = () => {
|
||||
isDark.value = !isDark.value
|
||||
}
|
||||