120 Commits

Author SHA1 Message Date
3f216727bb fix: download xai video outputs reliably 2026-06-03 23:21:22 +08:00
b1aab451ef docs: require real generation verification 2026-06-03 23:08:19 +08:00
ff0bfaa8b2 fix: gate video models by runtime availability 2026-06-03 17:48:49 +08:00
d038f1b2f4 feat: add xai video model 2026-06-03 16:59:43 +08:00
e14acee2a7 auto-save 2026-06-01 11:08 (~2) 2026-06-01 11:08:43 +08:00
538bfb8f59 chore: add huobao upstream watch 2026-06-01 11:06:24 +08:00
22421eb117 docs: record 2026-05-30 stability/security hardening deploy
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:16:36 +08:00
3572ddebef chore: update session worklog/handoff
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:04:59 +08:00
6201ee9a7d fix(web): tolerant polling, objectURL cleanup, throttled pointermove
- home/detail video pollers no longer clearInterval on a single transient error
  (give up only after 10 consecutive failures), matching the agent page
- agent page creates preview objectURLs inside useEffect so each has a matching
  revoke under strict-mode double-invocation
- login pointermove throttled via rAF and skipped on coarse pointers
- source-analysis.html: changelog entry for this hardening pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:04:59 +08:00
b56d5177e5 fix(canvas): persist video uploads and fix media cache/polling
- VideoNode upload now goes through backend (/jobs/upload via uploadCanvasVideo)
  for a stable reloadable URL instead of a session-only blob: that leaked and
  broke on reload; cleanNodeForStorage also strips blob: URLs
- useCachedMediaUrl: record real blob.size (chunked videos reported 0, making the
  LRU byte cap a no-op); guard the catch path with the race token
- useApi: send credentials when reading reference images; drop the node-level
  video poll that duplicated the Canvas-level syncPendingVideoNodes loop
- request.js: 60s timeout (was ~8.3h) + withCredentials
- remove dead getVideoTaskStatus/pollVideoTask that ignored taskId

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:04:59 +08:00
3ed3f721eb fix(api): harden subprocess/SSRF/concurrency and add db pool
- run(): add timeout (download 600s via DOWNLOAD_TIMEOUT_SECONDS, else 300s);
  TimeoutExpired now kills the child and fails the job instead of hanging forever
- create_job: validate_source_url() rejects file://, private/loopback/link-local
  IPs and off-allowlist hosts (SOURCE_URL_ALLOWED_HOSTS) — closes SSRF/local-read
- per-job RLock guards save_state/update/update_generated_video and the retry
  check-and-set so concurrent video workers can't clobber state.json
- db: psycopg_pool connection pool (graceful fallback if unavailable); write
  failures surfaced via logging.error instead of silent print
- read-only media GET routes use job_path() (no mkdir) to stop empty-dir spam
- wrap remaining Image.open() in with-blocks to avoid fd leaks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:04:59 +08:00
56ea8aef11 auto-save 2026-05-29 16:54 (~2) 2026-05-29 16:54:40 +08:00
854947a239 feat: cache canvas media locally 2026-05-28 15:43:54 +08:00
4bcca76098 fix: prevent video generate button drag capture 2026-05-28 15:26:41 +08:00
5c6476fe1a fix: restore video generation config guard 2026-05-28 10:03:44 +08:00
47b7073514 docs: add launch usage guide 2026-05-28 09:50:57 +08:00
fd5aefe1b9 auto-save 2026-05-28 09:38 (+1, ~1) 2026-05-28 09:38:41 +08:00
e97dc032d5 auto-save 2026-05-28 09:33 (~2) 2026-05-28 09:33:11 +08:00
538b24a2fd auto-save 2026-05-27 23:08 (~5) 2026-05-27 23:08:22 +08:00
d7f72f6b42 auto-save 2026-05-27 23:01 (~5) 2026-05-27 23:02:52 +08:00
b6a7e7b4b8 chore: harden production deploy scripts 2026-05-27 22:20:48 +08:00
0c30fb9091 auto-save 2026-05-27 18:13 (~3) 2026-05-27 18:13:45 +08:00
13d9057318 auto-save 2026-05-27 18:08 (~2) 2026-05-27 18:08:07 +08:00
dab4bde28f auto-save 2026-05-27 17:51 (~4) 2026-05-27 17:51:41 +08:00
6ac548a937 auto-save 2026-05-27 17:29 (~3) 2026-05-27 17:29:45 +08:00
fb939b8fcf auto-save 2026-05-27 17:24 (~4) 2026-05-27 17:24:16 +08:00
9ab541796b auto-save 2026-05-27 17:18 (~9) 2026-05-27 17:18:45 +08:00
8999fe0baf auto-save 2026-05-27 17:13 (~2) 2026-05-27 17:13:16 +08:00
ec38215dd5 fix: persist uploaded canvas reference images 2026-05-27 15:54:22 +08:00
685a6c4d64 docs: record canvas click performance optimization 2026-05-27 15:33:10 +08:00
52e7a01a7e auto-save 2026-05-27 15:26 (~3) 2026-05-27 15:28:29 +08:00
fdef7f77e1 auto-save 2026-05-27 15:20 (~3) 2026-05-27 15:20:42 +08:00
3035efcceb docs: codify marketing product baseline 2026-05-27 15:06:31 +08:00
f3c0500b60 fix: auto-sync completed canvas videos 2026-05-27 14:59:39 +08:00
284296d3e9 auto-save 2026-05-27 14:58 (+2, ~2) 2026-05-27 14:58:42 +08:00
3c146d64a0 auto-save 2026-05-27 14:53 (+1, ~6) 2026-05-27 14:53:13 +08:00
22398c1483 auto-save 2026-05-27 14:47 (~2) 2026-05-27 14:47:45 +08:00
a699899323 auto-save 2026-05-27 14:42 (~2) 2026-05-27 14:42:16 +08:00
5046e2304e auto-save 2026-05-27 14:36 (~3) 2026-05-27 14:36:48 +08:00
934bdd1fa2 auto-save 2026-05-26 19:32 (~2) 2026-05-26 19:33:03 +08:00
e0df6a5d0f auto-save 2026-05-26 17:47 (~2) 2026-05-26 17:47:35 +08:00
0eb775dff3 docs: compact user guide pdf layout 2026-05-26 17:26:44 +08:00
6d32b63eab docs: export user guide pdf 2026-05-26 16:11:44 +08:00
fe92c7943d docs: add employee user guide 2026-05-26 15:56:54 +08:00
8d5311c60a chore: add local docker validation workflow 2026-05-26 14:50:00 +08:00
ef9b8312ec docs: record image config restore deployment 2026-05-26 14:05:05 +08:00
bdb7226642 revert: restore original image generation config 2026-05-26 14:02:35 +08:00
ffdb60c463 docs: record Gemini image size deployment 2026-05-26 13:09:56 +08:00
56a23847a1 fix: align Gemini image sizes with official presets 2026-05-26 13:05:27 +08:00
cb0659fa00 docs: record image config deployment 2026-05-26 12:24:48 +08:00
5d047af346 feat: expose image quality and pixel sizes 2026-05-26 12:22:21 +08:00
47300b8fa6 docs: record AI polish deployment 2026-05-26 11:52:03 +08:00
f5be97b9e7 fix: make AI polish intent-aware 2026-05-26 11:50:01 +08:00
13fa5a08da docs: record personal workflow deployment 2026-05-26 11:21:25 +08:00
5290812353 feat: add personal canvas workflows 2026-05-26 11:18:28 +08:00
bbd1f08f7c docs: record expanded suggestions deployment 2026-05-26 10:51:10 +08:00
7f3a6cc429 fix: expand canvas suggestions 2026-05-26 10:48:39 +08:00
054f082323 docs: record suggestion rotation deployment 2026-05-26 10:42:22 +08:00
d01fdc5508 fix: rotate short canvas suggestions 2026-05-26 10:38:51 +08:00
97f617197c docs: record person guard deployment 2026-05-26 10:25:13 +08:00
daec523dec fix: gate AI character safety words 2026-05-26 10:23:00 +08:00
a3a9ed90e2 docs: record neutral polish deployment 2026-05-26 10:11:49 +08:00
509bd9b594 fix: make AI polish neutral 2026-05-26 10:08:22 +08:00
c415cd0aba auto-save 2026-05-26 10:00 (~2) 2026-05-26 10:00:47 +08:00
591bc37990 docs: record video error deployment 2026-05-26 09:43:02 +08:00
579e538aa7 fix: explain video generation failures 2026-05-26 09:41:03 +08:00
836a33e85b auto-save 2026-05-26 09:38 (~2) 2026-05-26 09:39:00 +08:00
e0330bfb28 chore: migrate legacy password data to Feishu owner 2026-05-26 09:08:30 +08:00
8aeeee6838 chore: disable password login in production 2026-05-26 08:40:41 +08:00
79696b7d3f auto-save 2026-05-26 08:38 (~6) 2026-05-26 08:38:40 +08:00
c5ddfedf03 feat: add Postgres-backed company persistence 2026-05-26 07:06:38 +08:00
d803d65c0c auto-save 2026-05-26 07:04 (~4) 2026-05-26 07:04:39 +08:00
c9d8fa7139 auto-save 2026-05-26 06:53 (~2) 2026-05-26 06:53:30 +08:00
4104bbe5d5 auto-save 2026-05-26 00:19 (~5) 2026-05-26 00:19:06 +08:00
544087cf9d auto-save 2026-05-26 00:13 (~8) 2026-05-26 00:13:17 +08:00
089a30d970 auto-save 2026-05-26 00:07 (+1, ~3) 2026-05-26 00:07:48 +08:00
830afac720 docs: record Feishu OAuth enablement 2026-05-25 23:51:50 +08:00
327cd2b113 auto-save 2026-05-25 23:51 (~3) 2026-05-25 23:51:29 +08:00
96f19a49d1 auto-save 2026-05-25 23:18 (~2) 2026-05-25 23:18:45 +08:00
8278de44cb docs: record model option deployment 2026-05-25 19:02:28 +08:00
84d9de6b30 fix: align canvas model options with backend 2026-05-25 18:56:20 +08:00
103907ca3a docs: record restored canvas deployment 2026-05-25 18:32:10 +08:00
cce9779a8a fix: restore upstream canvas capabilities 2026-05-25 18:28:11 +08:00
8bb4c96556 docs: record root canvas deploy 2026-05-25 18:02:15 +08:00
e767d2b388 fix: make canvas the root generation experience 2026-05-25 17:57:23 +08:00
fb9dc17b42 fix: remove canvas prompt suggestions 2026-05-25 17:36:14 +08:00
378d151b14 docs: record logo-only brand deploy 2026-05-25 17:31:24 +08:00
2a1ceeec3e fix: simplify visible brand to skg logo 2026-05-25 17:26:44 +08:00
7d98de0df3 docs: record naming deploy 2026-05-25 17:13:52 +08:00
2192f15beb fix: clarify generation product naming 2026-05-25 17:10:22 +08:00
f21254fa82 docs: record canvas deploy 2026-05-25 16:57:22 +08:00
2d19560dd3 feat: add internal skg infinite canvas 2026-05-25 16:52:53 +08:00
c425b82415 docs: record video queue deploy 2026-05-25 15:59:01 +08:00
779e9b342b feat: queue video generation per user 2026-05-25 15:55:43 +08:00
f49d4b248c docs: record playable video result deploy 2026-05-25 15:21:48 +08:00
b2d84dce5c fix: make home video results playable 2026-05-25 15:17:21 +08:00
a82069f26a docs: record feishu auto-login deploy 2026-05-25 15:10:59 +08:00
486a682320 feat: auto-start feishu login in client 2026-05-25 15:05:26 +08:00
d246563dc1 docs: record blank creative job deploy 2026-05-25 14:48:53 +08:00
a02c5eb48c fix: tolerate blank creative job requests 2026-05-25 14:46:36 +08:00
a69ab8106b docs: record size-duration production deploy 2026-05-25 14:28:54 +08:00
e77e77fada fix: align generation size and duration options 2026-05-25 14:23:09 +08:00
fa64f95911 docs: record model-choice production deploy 2026-05-25 11:05:12 +08:00
dcc8abc812 feat: expose generation model choices 2026-05-25 11:02:13 +08:00
6ba84a7603 feat: reduce home to single generation composer 2026-05-25 10:42:03 +08:00
7b4351fe55 fix: keep simplified home responsive 2026-05-25 10:31:28 +08:00
eca5213dab feat: simplify home like jimeng generate 2026-05-25 10:29:55 +08:00
976b318432 auto-save 2026-05-25 10:27 (~2) 2026-05-25 10:27:52 +08:00
04d80c133a auto-save 2026-05-25 10:16 (~2) 2026-05-25 10:16:59 +08:00
3b1d7645d1 fix: normalize media prompts and patent views 2026-05-25 09:38:19 +08:00
f8c51b5ef6 fix: enforce orthographic top bottom subject views 2026-05-25 09:27:31 +08:00
8e60c7dff9 auto-save 2026-05-25 09:05 (~2) 2026-05-25 09:05:53 +08:00
a27dcbda8d docs: record redesigned production deployment 2026-05-24 01:57:40 +08:00
828b86d187 feat: redesign marketing creation workspace 2026-05-24 01:48:17 +08:00
c1eddda59e docs: record pre-redesign backup 2026-05-24 01:23:16 +08:00
04a822ac79 feat: add feishu multi-user auth 2026-05-24 00:31:06 +08:00
90dde14ac3 chore: ignore local verification artifacts 2026-05-24 00:00:58 +08:00
3146266383 feat: redesign creative studio entry 2026-05-23 23:55:05 +08:00
0e55945352 auto-save 2026-05-23 23:45 (~2) 2026-05-23 23:45:06 +08:00
d551c45006 auto-save 2026-05-23 23:36 (~2) 2026-05-23 23:39:41 +08:00
119 changed files with 26937 additions and 3911 deletions

View File

@@ -24,3 +24,5 @@ data
.env.local .env.local
.env.production .env.production
deploy/.env.production deploy/.env.production
deploy/.env.local
data-local

7
.gitignore vendored
View File

@@ -15,8 +15,11 @@ __pycache__/
.logs/ .logs/
.pids/ .pids/
deploy/.env.production deploy/.env.production
deploy/.env.local
deploy/.htpasswd deploy/.htpasswd
secrets/ secrets/
.backups/
data-local/
# api # api
api/.venv/ api/.venv/
@@ -26,7 +29,11 @@ asset_library/*
prompt_library/* prompt_library/*
!prompt_library/.gitkeep !prompt_library/.gitkeep
_trash/ _trash/
output/
.playwright-cli/
# web # web
web/.next/ web/.next/
web/out/ web/out/
web/public/canvas/
.pnpm-store/

View File

@@ -1,166 +1,107 @@
# 项目接力 # 项目接力
- 生成时间May 22, 2026 at 09:01 - 生成时间May 31, 2026 at 15:25
- 项目SKG Marketing Studio / SKG 营销内容工作 - 项目SKG 营销内容生产平
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证 - 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 状态active - 状态active
- 主链接https://marketing.skg.com - 主链接https://marketing.skg.com
## 最近助手会话概览 ## 最近助手会话概览
- Claudea9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知 - Claudeeb894273-808b-439c-b4b9-840ad2553d0b · 时间未知
- Codex019e49d1-d114-7d52-8cf0-fbb4585f5ce6 · 时间未知 - Codex019e63ac-1984-7a42-8c65-ffd7ab146968 · 时间未知
- Cursor未找到匹配当前项目的最近会话
## Claude 最近会话 ## Claude 最近会话
- Session IDa9e0449c-d9cb-4a2a-bb16-16596dfb552a - Session IDeb894273-808b-439c-b4b9-840ad2553d0b
- Transcript/Users/kangwan/.claude/projects/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/a9e0449c-d9cb-4a2a-bb16-16596dfb552a.jsonl - Transcript/Users/kangwan/.claude/projects/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/eb894273-808b-439c-b4b9-840ad2553d0b.jsonl
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证 - 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 分支main - 分支main
- 敏感字段:已对 token / key / password / secret 做脱敏 - 敏感字段:已对 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 到当前项目目录 - <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>
- 如果让你去设计。转换层 这个你会想怎么去弄 - 继续
- 就是需要生成主体元素的6视图因为很多时候需求方都不清楚自己要什么 - <task-notification> <task-id>b2merdsp4</task-id> <tool-use-id>toolu_012dVaF8oA6bNGWwNtnMP95D</tool-use-id> <output-file>/private/tmp/claude-501/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/eb894273-808b-439c-b4b9-840ad2553d0b/tasks/b2merdsp4.output</output-file> <status>completed</status> <summary>Background command "生产安全部署(备份+rsync+服务器构建+验证)" completed (exit code 0)</summary> </task-notification>
### Claude 最近回复 ### Claude 最近回复
> 好问题。我先把转换层"到底是干嘛的"想清楚,再说怎么设计——因为之前它被反复改又清空,大概率是因为没锁定它的定位。 > Deploy fact recorded. `.project.json` needs no change (no URL changes this round). Committing the RULES.md update and pushing:
>
> ## 一、它的本质不是"聊天生图",是"定主体的确认台"
>
> 转换层夹在「参考帧池 → 转换层 → 主体元素」中间,真正的活儿是:**拿源视频里一个验证过的主体,二创成一个全新的、我们自己的主体**,输出是右侧"统一多角度套图"。
>
> 所以它要解决的核心矛盾就两个:
> 1. **意图收敛**:这张参考帧,用户是想原样复刻、换真人、卡通化,还是只借元素?产品怎么出现?
> 2. **花钱前的闸门**:`gpt-image-2` 单视图 60s、一套 6 视图很慢很贵,不能用户随口一句就直接烧一整套。所以"出 prompt → 确认 → 才生成"这个 gate 是刚需。
>
> 把它当"聊天机器人"做就会很难受(每次都得多轮对话);把它当"确认台"做就顺了。
>
> ## 二、关键设计判断:确认的不是"一张图的 prompt",是"主体设定(bible)"
>
> 你们后端已经有 `pack bible` / source brief 这套——一套图靠同一份 bible 保证多角度是**同一个主体**。
>
> 那 UI 上闸门就应该让用户**确认这份主体设定**,而不是确认某一张图。一旦确认:
> - **数量 = 从这份锁定设定里渲染几个角度**
> - 多角度统一 = 自动成立(因为大家共享同一 bible)
>
> 这一条想通了,"多角度风格统一"就不是额外功能,而是数据结构的自然结果。
>
> ## 三、流程:对话是"可选精修",不是"强制多轮"
>
> 你之前给 Codex 的描述(读图→问→答→弹 prompt→确认→生成)方向是对的,我只改一点:**别强制聊天**。常见情况下用户知道自己要什么,应该 3 下点完;需要讨论时聊天才出现。
>
> ```
> ┌─ 转换层 ───────────────────┐
> │ [参考帧缩略图] [+换/加帧] │ 顶部:当前参考
> │ AI读图:白色筋膜枪·握把有手 │ 一行机读,不啰嗦
> │ │
> │ 想怎…
> 明白,这一句把设计的重心整个挪了。需求方不清楚自己要什么 → 那转换层就**不能问"你想怎么改"**(空白框对说不出需求的人是负担),它得**先替他想好,给他东西去反应** > 完成。全部上线并验证
> >
> 这其实就是你那套讲解稿里的"快速到 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 失败纯属本机网络环境,与代码无关)
> >
> ``` > **生产验证**(`verify-prod-docker.sh` + 容器内复验):
> ┌─ 转换层 ────────────────────┐
> │ [参考帧] AI读图:白色筋膜枪·手持 │
> │ │
> │ 我建议这 3 个方向(选一个): │ ← agent 主动出,不问
> │ ┌─────────────────────────┐ │
> │ │ A 原样复刻 · 稳 缩略│ │
> │ │ B 换暖木纹高端款 · 提质感 缩略│ │ ← 每个都已是可生成的成品设定
> │ │ C 卡通吉祥物版 · 抓眼球 缩略│ │
> │ └─────────────────────────┘ │
> │ [就用 A 生成 6 视图 →] │ ← 默认动作,零思考一键走
> │ 不对?跟我说一句…(可选) │ ← 想拨一下才打字
> └───────────────────────────────┘
>
> 右侧主体元素:6 视图 queued → 逐视角回填
> ```
> >
> 核心三点: > | 检查 | 结果 |
> - **进帧即出提案**。参考帧一进来,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 最近会话 ## Codex 最近会话
- Session ID019e49d1-d114-7d52-8cf0-fbb4585f5ce6 - Session ID019e63ac-1984-7a42-8c65-ffd7ab146968
- Transcript/Users/kangwan/.codex/sessions/2026/05/21/rollout-2026-05-21T17-15-47-019e49d1-d114-7d52-8cf0-fbb4585f5ce6.jsonl - Transcript/Users/kangwan/.codex/sessions/2026/05/26/rollout-2026-05-26T17-44-43-019e63ac-1984-7a42-8c65-ffd7ab146968.jsonl
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证 - 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 分支main - 分支main
- 敏感字段:已对 token / key / password / secret 做脱敏 - 敏感字段:已对 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 最近回复 ### Codex 最近回复
> 生产部署正在跑第二轮远端备份和同步已经完成Web 镜像在构建;我等脚本验证结束 > 已读到项目交接核心:最近不是要继续执行旧任务,而是先接住上下文;当前工作区也只有交接/日志类未提交变更。再补一个只读的提交/远端/差异概览,避免把 handoff 里的仓库状态当成最新事实
> 对,这个点你说得对。生成的视频本来就会写进当前 job 的 `generated_videos`,完成后自动回填 mp4不应该让用户理解成还要点“保存视频” > 已完成只读承接
> >
> 我已经改完并上线: > 我已按顺序读了 [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),并补了只读仓库状态检查。没有改文件、没有启动服务、没有继续旧任务。
> >
> - 分镜视频候选轨现在显示 `自动保存` > 当前状态:
> - 视频候选普通点击只负责打开预览 > - 分支:`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 营销内容生产平台,生产入口 `https://marketing.skg.com`,根域名登录后进个人画布。
> - `web/npm run build` 通过 > - 生产已收口到飞书免登录密码登录停用Postgres 已用于画布项目、用户索引、任务索引、资源索引等持久化。
> - 已推送 Gitea > - 普通开发默认必须本地 Docker 验证并形成语义 commit没有你明确说“可以推送 / 上推 / 部署”前,不推 Gitea、不跑生…
> - `4efb2ce fix: make storyboard video clicks previewable`
> - `eb4347a fix: clarify storyboard videos auto-save`
> - 已执行 `./scripts/deploy-prod-safe.sh` 上线,生产验证通过:`/login/ 200`、`/api/health 401`、容器内 `api:health ok`
>
> 当前 `git status -sb`
>
> ```text
> ## main...origin/main
> M .memory/worklog.json
> ```
>
> 只剩自动日志 `.memory/worklog.json`,业务代码和文档都已提交并推送。
>
> 补充:`npm run lint` 现在跑不了,因为项目里没有可执行的 `eslint``tsc --noEmit` 仍有一批既有的 dashboard/ui 旧类型错误,不是这次改动引入的。
>
> <oai-mem-citation>
> <citation_entries>
> MEMORY.md:214-286|note=[used SKG worktable context and docs update expectation]
> </citation_entries>
> <rollout_ids>
> 019e2fef-97a7-7523-a1ff-051a8c6…
## Cursor 最近会话
- 未在本机找到匹配这个项目的 Cursor transcript。
## 当前仓库状态 ## 当前仓库状态
- 当前分支main - 当前分支main
- 未提交变更1 项 - 未提交变更1 项
- 最近提交:fix: clarify storyboard videos auto-save - 最近提交:docs: record 2026-05-30 stability/security hardening deploy
- 变更文件: - 变更文件:
- M .memory/worklog.json - M .memory/worklog.json
## 统一接力要求 ## 统一接力要求
- 对话立项只用 Claude / CodexCursor 只用于项目目录已经创建之后的 GUI 开发承接。
- Claude / Codex 终端承接:先阅读本文件,再结合 `.project.json``RULES.md``AGENTS.md` 和当前仓库状态理解项目进度。 - Claude / Codex 终端承接:先阅读本文件,再结合 `.project.json``RULES.md``AGENTS.md` 和当前仓库状态理解项目进度。
- Cursor GUI 承接:只打开当前项目根目录,不打开 `~/Projects``~/Projects/business``/Users/kangwan` - 不要要求用户重新手工粘贴 Claude / Codex 总结,缺口直接从代码、日志和 handoff 文件补
- 不要要求用户重新手工粘贴 Claude / Codex / Cursor 总结,缺口直接从代码、日志和 handoff 文件补。
- 如果最近助手会话里有明确未完成项,只把它当作候选待办,不要自动继续执行。 - 如果最近助手会话里有明确未完成项,只把它当作候选待办,不要自动继续执行。
- 当前目标是建立上下文并等待用户下一条明确指令,不要自行开始修改。 - 当前目标是建立上下文并等待用户下一条明确指令,不要自行开始修改。

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -33,15 +33,27 @@
"type" : "api_key" "type" : "api_key"
}, },
{ {
"description" : "生产网页登录;用户名写 RULES.md密码只放服务器 \/root\/skg-marketing-studio-login.txt后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET", "description" : "生产网页登录备用账号已停用,当前只允许飞书免登录;如需紧急恢复,需在服务器 deploy\/.env.production 显式开启 PASSWORD_AUTH_ENABLED=true。备用账号密码只放服务器 \/root\/skg-marketing-studio-login.txt后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET",
"name" : "WEB_LOGIN", "name" : "WEB_LOGIN",
"storage" : "\/root\/skg-marketing-studio-login.txt \/ deploy\/.env.production", "storage" : "\/root\/skg-marketing-studio-login.txt \/ deploy\/.env.production",
"type" : "web_login" "type" : "web_login"
},
{
"description" : "飞书免登录 OAuth 应用配置App ID 和 App Secret 只放服务器 deploy\/.env.production本地开发放 api\/.env不入库回调地址为 https:\/\/marketing.skg.com\/api\/auth\/feishu\/callback",
"name" : "FEISHU_OAUTH",
"storage" : "api\/.env \/ deploy\/.env.production \/ 飞书开放平台",
"type" : "oauth_app"
},
{
"description" : "Postgres 服务端持久化配置,用于画布项目、用户索引、任务索引、资源索引和审计日志;生产密码只放服务器 deploy\/.env.production 的 POSTGRES_PASSWORD\/DATABASE_URL不入库",
"name" : "POSTGRES_DATABASE",
"storage" : "deploy\/.env.production \/ docker-compose.prod.yml \/ 服务器 data\/postgres",
"type" : "database"
} }
], ],
"description" : "SKG 信息流广告快速复刻工作台:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频路提取原文案\/字幕、中文翻译、讲话人、语速节奏、背景音乐\/环境声\/音效;视觉路自动抽 12 张动作\/节奏参考帧,转换层按真人重构、卡通重构、元素重构、自主描述四个方向生成全新主体 6 视图,再汇合产品素材池、分镜口播和视频候选生成。", "description" : "SKG 营销内容生产平台:根域名 https:\/\/marketing.skg.com 登录后直接进入个人生成画布,终端可见品牌位只保留 SKG logo。主路径为文生图、文生视频、图生视频每个登录用户只看到自己的任务和结果。画布项目已接入服务端 Postgres 持久化,浏览器 localStorage 只作为缓存和首次导入来源;图片\/视频资产继续写入当前用户自己的后端 job旧 TK 复刻\/一键出片能力保留为高级入口。",
"kind" : "app", "kind" : "app",
"name" : "SKG Marketing Studio \/ SKG 营销内容工作台", "name" : "SKG 营销内容生产平台",
"ownership" : "company", "ownership" : "company",
"pin_order" : 1778664997, "pin_order" : 1778664997,
"pinned" : true, "pinned" : true,
@@ -58,13 +70,13 @@
} }
], ],
"quick_login" : { "quick_login" : {
"label" : "SKG Marketing Studio \/ SKG 营销内容工作台", "label" : "SKG 营销内容生产平台",
"password" : "c413cdc5bbbf2ca042", "password" : "",
"url" : "https:\/\/marketing.skg.com", "url" : "https:\/\/marketing.skg.com\/login\/",
"username" : "skg" "username" : "仅飞书免登录;密码登录已停用"
}, },
"stack" : [ "stack" : [
"Next.js + Python(yt-dlp\/ffmpeg) + OpenAI-compatible LLM + GPT Image 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", "status" : "active",
"urls" : [ "urls" : [

View File

@@ -1,4 +1,4 @@
# SKG AI 素材管线 - TK 二创验证 Agent Rules # SKG 营销内容工作台 Agent Rules
## Must Read First ## Must Read First
@@ -11,11 +11,21 @@
- 开发任务结束前必须执行并汇报 `git status -sb` - 开发任务结束前必须执行并汇报 `git status -sb`
- 功能、修复、规则或部署元数据变更完成后,必须创建人工语义 commit`auto-save` 只算安全快照 - 功能、修复、规则或部署元数据变更完成后,必须创建人工语义 commit`auto-save` 只算安全快照
- Gitea 是主远端,`origin` 必须指向 Gitea能联网和鉴权时必须推送完成提交 - 默认先在本地 Docker 完整验证:`./scripts/start-local-docker.sh` 后运行 `./scripts/verify-local-docker.sh`;用户明确确认“可以推送 / 上推 / 部署”前,不要 `git push`,也不要运行生产部署脚本。
- 涉及模型接入、API Key、图片生成或视频生成链路修复时如果用户已经提供本地可用的 base/key 且没有明确禁止真实调用Agent 必须直接触发一次最小真实生成做端到端验证不能只停在配置检查、dry-run 或让用户手动点测;汇报时说明实际触发的模型、入口、任务 ID/生成 URL 或失败原因,以及是否产生了真实调用消耗。
- Gitea 是主远端,`origin` 必须指向 Gitea只有在用户明确确认推送后才把已验证的人工语义 commit 推送到 Gitea。
- 当前主分支为 `main`Gitea 仓库为 `https://git.kang-kang.com/kangwan/20260512-skg-tk` - 当前主分支为 `main`Gitea 仓库为 `https://git.kang-kang.com/kangwan/20260512-skg-tk`
- `.memory/worklog.json` 是辅助日志,不代替人工语义 commit 和 Gitea 远端记录 - `.memory/worklog.json` 是辅助日志,不代替人工语义 commit 和 Gitea 远端记录
- 不能推送时,必须说明当前分支、本地领先/落后数量、最新未推送 commit 和失败原因 - 不能推送时,必须说明当前分支、本地领先/落后数量、最新未推送 commit 和失败原因
## Product Baseline Contract
- 最终产品基线是 `https://marketing.skg.com` 登录后的个人生成画布;本地开发和验收必须尽量复刻这套线上运行形态。
- 根域名生产入口由 `web/canvas-app/` 的 Vue / Vite 画布产物承载;旧 React 首页、旧 TK 复刻工作台、Agent Cut 和详情页能力只能作为高级/兼容/回滚参考,不能当默认产品基线。
- 之后所有升级、修复和产品判断,优先围绕根域名画布、当前 `/api`、Postgres 持久化、飞书登录和 owner 隔离展开。
- 本地同构环境优先使用 Docker`docker-compose.local.yml` + `deploy/.env.local` + `data-local/`;不要用本地 dev server 的偶然行为代替生产形态判断。
- 遇到线上用户 bug先只读确认生产事实用户身份、owner、job、canvas project、generated asset、日志时间线必要时把最小可复现数据拉到本地 Docker 环境复现和修复,不能直接在生产库里试错。
## Deployment Metadata Contract ## Deployment Metadata Contract
- 任何任务只要新增、删除或修改公网地址,必须在同一次任务里更新 `.project.json` - 任何任务只要新增、删除或修改公网地址,必须在同一次任务里更新 `.project.json`
@@ -26,6 +36,7 @@
## Completion Gate ## Completion Gate
- 普通代码修改完成后,默认收口在本地 Docker 验证和本地 commit生产推送 / 部署必须等用户明确确认。
- 部署完成后,不允许在 `.project.json` 缺少最新公网链接的状态下结束任务 - 部署完成后,不允许在 `.project.json` 缺少最新公网链接的状态下结束任务
- 部署完成后,必须同步更新 `RULES.md` 的部署事实 - 部署完成后,必须同步更新 `RULES.md` 的部署事实
- 如果只更新了代码但没回写部署元数据,这个任务不算完成 - 如果只更新了代码但没回写部署元数据,这个任务不算完成

View File

@@ -5,7 +5,8 @@ WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
COPY web/package.json web/pnpm-lock.yaml ./ COPY web/package.json web/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile COPY web/canvas-app/package.json web/canvas-app/pnpm-lock.yaml ./canvas-app/
RUN pnpm install --frozen-lockfile && cd canvas-app && pnpm install --frozen-lockfile
COPY web ./ COPY web ./

View File

@@ -1,23 +1,62 @@
# SKG AI 素材管线 - TK 二创验证 # SKG 营销内容生产平台
## 启动 ## 启动
- 本地 Docker 启动:`./scripts/start-local-docker.sh`(默认 Web `http://localhost:4390`、API `http://localhost:4391`、Postgres 数据在 `data-local/postgres`;首次会从 `deploy/.env.local.example` 生成 gitignored 的 `deploy/.env.local`
- 本地 Docker 验证:`./scripts/verify-local-docker.sh`(检查容器、`/login/`、未登录 `/api/health`、容器内 `/health` + Postgres
- 本地真实生成验收涉及模型接入、API Key、图片生成或视频生成链路修复时已配置可用 base/key 且用户未明确禁止真实调用,就必须由 Agent 在本地 Docker / 当前画布中直接触发一次最小真实生成,记录模型、入口、任务 ID/生成 URL 或失败原因;不要只让用户自己手动点测。
- 本地 Docker 停止:`./scripts/stop-local-docker.sh`
- 后台启动(不弹 Terminal`./scripts/start-dev-background.sh`(通过 macOS launchd 后台托管;前端 4290 + 后端 4291日志写入 `.logs/` - 后台启动(不弹 Terminal`./scripts/start-dev-background.sh`(通过 macOS launchd 后台托管;前端 4290 + 后端 4291日志写入 `.logs/`
- 后台停止:`./scripts/stop-dev-background.sh` - 后台停止:`./scripts/stop-dev-background.sh`
- 前端 dev`cd web && npm run dev`Next.js 16端口 4290 - 前端 dev`cd web && npm run dev`Next.js 16端口 4290
- 画布 dev`cd web && npm run dev:canvas`Vue / Vite端口 4292生产构建会作为根域名工作台输出
- 后端 dev`cd api && uvicorn main:app --host 127.0.0.1 --port 4291`FastAPI端口 4291重任务用 - 后端 dev`cd api && uvicorn main:app --host 127.0.0.1 --port 4291`FastAPI端口 4291重任务用
- 注意:后端不要带 `--reload` 跑长下载 / 抽帧 / 音频任务reload 会等待后台任务结束,导致 4291 端口占用但新请求卡住。 - 注意:后端不要带 `--reload` 跑长下载 / 抽帧 / 音频任务reload 会等待后台任务结束,导致 4291 端口占用但新请求卡住。
- 发布流程新规则2026-05-26所有修改默认先在本地 Docker 跑通并验证,确认可用后只保留本地 commit只有用户明确说“可以推送 / 上推 / 部署”时,才允许 `git push` 或执行生产部署。
## 最终产品和排查基线
- 最终产品入口固定为 `https://marketing.skg.com`;登录后根路径进入个人生成画布,之后所有升级和修复都以这套线上画布为主线。
- 当前本地基线必须尽量和线上一致:优先用 `./scripts/start-local-docker.sh` 启动 Web/API/Postgres`./scripts/verify-local-docker.sh` 验证;只在需要快速定位前端细节时才临时使用 `npm run dev``npm run dev:canvas`
- 旧 React 单对话框首页、旧 TK 复刻工作台、Agent Cut 和详情页功能保留为高级能力、兼容入口或代码参考;除非明确要求恢复,否则不要把它们当作新需求默认落点。
- 用户线上 bug 的默认处理顺序:确认用户和时间线 → 只读查询生产 API 容器内 `DATABASE_URL` / Postgres 与相关 job state → 必要时复制最小可复现数据到本地 Docker → 本地修复和验证 → 本地 commit → 等用户确认后再推送或部署。
- 排查线上数据时不要手动写生产库,不要覆盖服务器 `deploy/.env.production``data/``secrets/``/data/jobs`;需要恢复用户数据时先备份并给出影响范围。
## 立项决策快索引 ## 立项决策快索引
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解 - 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md` - 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`
- 第一冲刺:步骤 1-4下载 / 拆轨 / 关键帧 / ASR+翻译) - 第一冲刺:步骤 1-4下载 / 拆轨 / 关键帧 / ASR+翻译)
- 当前产品方向2026-05-20 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”;工作台已取消 1800x1000 固定画布和整页缩放,改为正常流式桌面容器,宽度跟随浏览器展开,只保留 1280px 最低操作宽度防止核心表格被压烂,不再通过应用层 `zoom` 把整页缩小导致文字发虚。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路自动识别中文、英文和其他多语言原音频文案/字幕,统一补齐中文镜像,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区主体链路改为“上方参考帧池 + 转换层、下方主体元素结果栏”:参考帧池竖向排列;转换层是轻量对话式生图确认区,参考图可通过左侧缩略图 `+`、参考帧拖拽、胶片拖拽或本地图片拖入进入转换层,用户选择 GPT/Gemini 套件后先分析参考图;识别结果里的特征 chip 只作为“保留元素”本地选择,点亮=保留、再点取消,点击不立即请求模型,随下一条发送消息提交;用户再在下方发送区发送复刻/创新/卡通和画面要求,界面只保留生成要求输入框、张数控件和提示词就绪状态,不展示当前要求摘要、保留元素副本、收起记录计数或重复模型确认话术,生成数量通过发送区旁边的张数控件控制;后端返回英文出图 prompt 后不再自动弹窗,发送区主按钮直接切换为“确认生成 N 张”,用户点击才生成对应数量的统一多角度套图。主体元素结果栏在转换层下方横向展示套图输出、文件夹分组、单张重生、删除和 hover 预览,空态只保留紧凑提示,不再挤占右侧整列。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不再作为客户默认闸门 - 当前产品方向2026-05-26 Postgres 持久化版):默认入口是多人通用的 SKG 营销内容生产平台,`https://marketing.skg.com` 登录后直接进入个人生成画布,`/canvas/` 只作为旧链接兼容跳转到根域名。终端可见品牌位只放 SKG logo不在主界面展示“生图生视频”“SKG 生成画布”或长系统名。画布本体尽量恢复 `chatfire-AI/huobao-canvas` 的成熟交互不再削成三模式单输入框保留首页推荐词、画布底部推荐词、AI 润色、自动执行、公共工作流、我的工作流、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载等上游能力多角度分镜、故事板、图转视频、绘本等工作流按上游结构创建节点。API 接入是例外:生成调用继续走本项目后端 `/api` 和当前登录 Cookie不要求员工在浏览器配置个人 API KeyAI 润色只扩写用户明确写出的主体、品牌、产品、平台、动作和镜头,用户没写 `SKG` 时绝不主动加入 SKG也不能把未知主体润成人物或强行润成无人物上传/生成的参考图如果本来就有人物,应在视频提示词里按 AI 生成的虚拟角色、非真人、非公众人物处理,继续允许 AI 人像素材参与图生视频;图片/视频模型选择只显示后端已经接通的媒体模型,不能让浏览器本地自定义或旧缓存模型进入生成下拉;生图配置恢复最初简单版,图片模型显示 `auto``gpt-image-2``gemini-3-pro-image-preview`,尺寸只显示 `auto``1024x1536``1024x1024``1536x1024`画质只保留标准项。API 设置弹窗只保留模型/端点配置外观,不能出现上游注册链接或外部品牌。用户登录后仍只看到自己的任务、结果、详情页、画布项目和个人工作流模板,继续沿用后端 owner 隔离;画布项目和我的工作流以服务端 Postgres 为主持久化,浏览器 `localStorage` 只作为项目缓存和首次导入来源,图片/视频资产按登录用户写入后端 job。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架
## 部署事实 ## 部署事实
- 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik - 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik
- 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 独立预览服务器:`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` - 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-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-22`6427935` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260522012756.tgz`,生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`)。部署后已把生产私有 `deploy/.env.production` 明确固定为多语言本地 ASR 路径并重启 API`ASR_LANGUAGE=auto``FASTER_WHISPER_MODEL=base``ASR_REMOTE_ENABLED=false``ASR_LOCAL_FALLBACK_ENABLED=true``ASR_AUDIO_FALLBACK_ENABLED=false`;复验 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过,容器内 `/health` 确认 `asr_language=auto``faster_whisper=base`
- 最近部署验证2026-05-21`8458dac` 已按“先本地 Docker、再上传部署”流程上线。上线前在本机 Docker 构建 `skg-marketing-studio-web:latest` / `skg-marketing-studio-api:latest`,并用本地 Compose 容器验证通过:`web:/ 302``web:/login/ 200``web:/_next/does-not-exist.js 404``web:/api/health 401``api:health ok``api:ytdlp_cookie_args []`、静态 bundle 包含 `未来健康 · 营销内容工作台``信息流广告复刻生产`,且未发现本地 API/dev URL 泄漏。随后通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260521070327.tgz`,生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。 - 最近部署验证2026-05-21`8458dac` 已按“先本地 Docker、再上传部署”流程上线。上线前在本机 Docker 构建 `skg-marketing-studio-web:latest` / `skg-marketing-studio-api:latest`,并用本地 Compose 容器验证通过:`web:/ 302``web:/login/ 200``web:/_next/does-not-exist.js 404``web:/api/health 401``api:health ok``api:ytdlp_cookie_args []`、静态 bundle 包含 `未来健康 · 营销内容工作台``信息流广告复刻生产`,且未发现本地 API/dev URL 泄漏。随后通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260521070327.tgz`,生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。
- 最近部署验证2026-05-20`6597db3` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520151033.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/c48f07b9aef1cd29.js` 已包含 `min-w-[1280px]``max-w-[1920px]`,未再命中旧的 `h-[1000px]``w-[1800px]``BOARD_SCALE_PRESETS``boardScale`;对应工作台取消固定画布缩放,按浏览器正常流式布局渲染。 - 最近部署验证2026-05-20`6597db3` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520151033.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/c48f07b9aef1cd29.js` 已包含 `min-w-[1280px]``max-w-[1920px]`,未再命中旧的 `h-[1000px]``w-[1800px]``BOARD_SCALE_PRESETS``boardScale`;对应工作台取消固定画布缩放,按浏览器正常流式布局渲染。
@@ -53,27 +92,32 @@
- 最近部署验证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`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` - 最近部署验证2026-05-20`7e763cf` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层改为参考帧分析 + 对话生成提示词 + 弹窗确认后再生成主体套图;右侧主体元素输出逻辑保持不变。部署时发现服务器 `WEB_AUTH_*` 环境变量缺失导致 `/auth/check` 503已从 `/root/skg-marketing-studio-login.txt` 和新 session secret 恢复服务器 `deploy/.env.production` 后重启验证通过;后续同步生产代码必须继续排除服务器真实 `deploy/.env.production`
- 主站 / 前端:`https://marketing.skg.com` - 主站 / 前端:`https://marketing.skg.com`
- 旧画布路径:`https://marketing.skg.com/canvas/`(仅兼容跳转到根域名)
- API / 后端:`https://marketing.skg.com/api` - API / 后端:`https://marketing.skg.com/api`
- 代码仓库 / Gitea`https://git.kang-kang.com/kangwan/20260512-skg-tk` - 代码仓库 / Gitea`https://git.kang-kang.com/kangwan/20260512-skg-tk`
- 文档 / 解析:`docs/source-analysis.html`(项目内独立文档,不公开挂主应用路由) - 文档 / 解析:`docs/source-analysis.html`(项目内独立文档,不公开挂主应用路由)
- 管理后台:待定 - 管理后台:待定
- 服务器目录:`/opt/skg-marketing-studio` - 服务器目录:`/opt/skg-marketing-studio`
- 生产部署唯一入口:`./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` 手动同步。 - 生产容器重建命令:`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 反代并复用应用内登录校验。 - 独立预览容器重建命令:服务器 `/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` 作为上线依据。 - 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`,除非明确只想强制单一语种。 - 当前音频解析:`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` - 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`
- 登录凭证:用户名写下方快捷登录;密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt`,生产环境变量 `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production` - 登录凭证:生产入口只允许飞书免登录;飞书 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`,否则会清空案例、登录和模型配置。 - 禁止手动裸 `rsync --delete` 到服务器;必须使用 `./scripts/deploy-prod-safe.sh`。如遇极端情况必须手动同步,命令必须同时包含 protect/exclude`.git``.memory``.logs``.pids``data``jobs``secrets``api/jobs``api/.env``api/.env.local``api/.env.production``deploy/.env.production``web/node_modules``web/.next``web/out`。不要把本地 `api/.env``deploy/.env.production` 覆盖到 `/opt/skg-marketing-studio`,也不要删除服务器 `data/jobs`,否则会清空案例、登录和模型配置。
## 快捷登录 ## 快捷登录
- 登录地址:`https://marketing.skg.com/login/` - 登录地址:`https://marketing.skg.com/login/`
- 用户名:`skg` - 主路径:飞书免登录
- 密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库) - 密码登录:生产已停用
- 说明当前是生产入口应用内登录页数据库密码、API Key、服务器 root 密码不要写这里 - 备用用户名:`skg`
- 备用密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
- 说明:当前是生产入口应用内登录页;飞书 App Secret、数据库密码、API Key、服务器 root 密码不要写这里
## 元数据回写清单 ## 元数据回写清单
- 新增或变更公网地址后,必须同步更新 `.project.json.urls` - 新增或变更公网地址后,必须同步更新 `.project.json.urls`
@@ -89,7 +133,7 @@
- Gitea 网页仓库:`https://git.kang-kang.com/kangwan/20260512-skg-tk` - Gitea 网页仓库:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
- 每次开发结束前必须执行并汇报 `git status -sb` 和变更范围 - 每次开发结束前必须执行并汇报 `git status -sb` 和变更范围
- 代码、规则、部署或元数据变更必须形成 `feat:``fix:``docs:``chore:``release:` 等人工语义 commit`auto-save` 只算安全快照 - 代码、规则、部署或元数据变更必须形成 `feat:``fix:``docs:``chore:``release:` 等人工语义 commit`auto-save` 只算安全快照
- 能联网和鉴权时必须 `git push origin main`如果不能推送,最终回复必须写清楚当前分支、领先/落后数量、最新未推送 commit 和失败原因 - 用户明确确认“可以推送 / 上推 / 部署”前,不允许 `git push` 或生产部署;用户确认后,能联网和鉴权时 `git push origin main`如果届时不能推送,最终回复必须写清楚当前分支、领先/落后数量、最新未推送 commit 和失败原因
## 环境变量 ## 环境变量
- `LLM_BASE_URL` / `LLM_API_KEY`OpenAI 兼容网关,用于翻译、文案改写、音频分析等文本/多模态理解模型调用 - `LLM_BASE_URL` / `LLM_API_KEY`OpenAI 兼容网关,用于翻译、文案改写、音频分析等文本/多模态理解模型调用
@@ -115,13 +159,21 @@
- `IMAGE_FALLBACK_ENABLED` / `IMAGE_FALLBACK_MODEL`:图片主模型故障兜底;当前允许在 `gpt-image-2` 超时、429、5xx 或网络错误时临时使用 `gemini-3-pro-image-preview`400/401/403/404 和参数错误不兜底 - `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 兜底;成功恢复后自动清空失败计数 - `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` 会让主体套图只走所选模型 - `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 文件属于敏感登录态,只能放本机或服务器私有路径,不允许入库。 - `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` 会被忽略 - `VOICE_PROVIDER`:配音通道,服务端固定使用 `azure_openai`;旧环境若写 `minimax` 会被忽略
- `AZURE_OPENAI_BASE_URL` / `AZURE_OPENAI_API_KEY`:微软 Azure OpenAI 协议配音网关;本地未单独配置 Key 时回退复用 `LLM_API_KEY` - `AZURE_OPENAI_BASE_URL` / `AZURE_OPENAI_API_KEY`:微软 Azure OpenAI 协议配音网关;本地未单独配置 Key 时回退复用 `LLM_API_KEY`
- `AZURE_TTS_MODEL` / `AZURE_TTS_VOICE_ID` / `AZURE_TTS_VOICE_POOL` / `AZURE_TTS_PATH` / `AZURE_TTS_PATHS`Azure OpenAI TTS 模型、默认音色、音色池和 OpenAI 协议语音路径;后端会按 `AZURE_TTS_PATHS` 依次尝试,便于区分路径不对和整条语音服务不可用 - `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只能放本地环境变量 - `POE_API_KEY` / `VIDEO_API_KEY`默认视频生成通道 Key只能放本地环境变量;如果显式配置了 `VIDEO_API_BASE_URL`,必须同时配置 `VIDEO_API_KEY` 才会在 `/health` 暴露该默认视频通道,不能用通用 `LLM_API_KEY` 冒充视频 key。
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库 - `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。
- `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 个;同一用户连续提交会排队,其他用户仍可获得执行机会。当前队列不依赖 RedisAPI 容器重启会把未完成视频标记为失败并提示重新生成。
- `FFMPEG_BIN` / `FFPROBE_BIN`:可选本地媒体二进制路径;本机 Homebrew ffmpeg 动态库损坏时,后端会自动跳过不可用的 PATH 版本并尝试本机静态 ffmpeg 备选,生产仍建议使用系统 ffmpeg/ffprobe - `FFMPEG_BIN` / `FFPROBE_BIN`:可选本地媒体二进制路径;本机 Homebrew ffmpeg 动态库损坏时,后端会自动跳过不可用的 PATH 版本并尝试本机静态 ffmpeg 备选,生产仍建议使用系统 ffmpeg/ffprobe
- 生产环境变量:服务器只使用 `deploy/.env.production`,模板为 `deploy/.env.production.example`;真实 Key 不入库 - 生产环境变量:服务器只使用 `deploy/.env.production`,模板为 `deploy/.env.production.example`;真实 Key 不入库
- 同步生产代码时必须排除服务器真实 `deploy/.env.production`,只同步 `deploy/.env.production.example`网页登录密码、session secret、ASR/API Key 只保留在服务器环境文件和 `/root/skg-marketing-studio-login.txt` - 同步生产代码时必须排除服务器真实 `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
View 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.

View File

@@ -8,6 +8,18 @@ WEB_AUTH_PASSWORD=
WEB_AUTH_SESSION_SECRET= WEB_AUTH_SESSION_SECRET=
WEB_AUTH_COOKIE_NAME=skg_marketing_session WEB_AUTH_COOKIE_NAME=skg_marketing_session
WEB_AUTH_COOKIE_SECURE=false WEB_AUTH_COOKIE_SECURE=false
AUTH_DATA_ISOLATION_ENABLED=true
VIDEO_QUEUE_MAX_CONCURRENT=2
VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1
# 飞书免登录OAuth。生产回调地址需同步配置到飞书开放平台应用安全设置。
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_REDIRECT_URI=https://marketing.skg.com/api/auth/feishu/callback
FEISHU_OAUTH_SCOPE=
FEISHU_ALLOWED_EMAIL_DOMAINS=
FEISHU_ALLOWED_EMAILS=
FEISHU_ALLOWED_TENANT_KEYS=
# 模型分工 # 模型分工
ASR_MODEL=whisper-1 ASR_MODEL=whisper-1
@@ -27,17 +39,30 @@ IMAGE_MODEL=gpt-image-2
IMAGE_REQUEST_TIMEOUT_SECONDS=60 IMAGE_REQUEST_TIMEOUT_SECONDS=60
IMAGE_FALLBACK_ENABLED=true IMAGE_FALLBACK_ENABLED=true
IMAGE_FALLBACK_MODEL=gemini-3-pro-image-preview 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_FAILURE_THRESHOLD=2
IMAGE_CIRCUIT_COOLDOWN_SECONDS=600 IMAGE_CIRCUIT_COOLDOWN_SECONDS=600
GPT_IMAGE_MODEL=gpt-image-2 GPT_IMAGE_MODEL=gpt-image-2
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2 SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2,gemini-3-pro-image-preview SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2,gemini-3-pro-image-preview
# 火山方舟 Seedream 图片模型。真实 key 只填本地/服务器 .env不提交到 git。
ARK_SEEDREAM_ENABLED=true
ARK_IMAGE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
ARK_IMAGE_API_KEY=
ARK_SEEDREAM_IMAGE_MODEL=doubao-seedream-4-5-251128
# 可选:本地网络需要代理访问 ai.skg.com 时配置launchd 不一定继承 shell 代理变量。 # 可选:本地网络需要代理访问 ai.skg.com 时配置launchd 不一定继承 shell 代理变量。
AI_HTTP_PROXY= AI_HTTP_PROXY=
YTDLP_COOKIES_FILE= YTDLP_COOKIES_FILE=
YTDLP_COOKIES_FROM_BROWSER= YTDLP_COOKIES_FROM_BROWSER=
VIDEO_MODEL=seedance VIDEO_MODEL=seedance
VIDEO_MODEL_SEEDANCE=seedance-2-fast VIDEO_MODEL_SEEDANCE=seedance-2-fast
VIDEO_MODEL_XAI=grok-imagine-video
# Kling / Veo aliases are only exposed when the active video gateway is Poe or another verified compatible gateway.
VIDEO_MODEL_KLING=kling-omni VIDEO_MODEL_KLING=kling-omni
VIDEO_MODEL_VEO3=veo-3.1-fast VIDEO_MODEL_VEO3=veo-3.1-fast
@@ -73,6 +98,13 @@ POE_API_KEY=
# VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id} # VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id}
# VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content # VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content
# #
# SKG xAI/Grok Imagine 视频网关。真实 key 只填本地/服务器私有 .env。
XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai
XAI_VIDEO_API_KEY=
XAI_VIDEO_CREATE_PATH=/v1/videos/generations
XAI_VIDEO_STATUS_PATH=/v1/videos/{id}
XAI_VIDEO_CONTENT_PATH=
#
# 自定义视频网关覆盖;留空时如配置 POE_API_KEY则走 Poe。 # 自定义视频网关覆盖;留空时如配置 POE_API_KEY则走 Poe。
VIDEO_API_BASE_URL= VIDEO_API_BASE_URL=
VIDEO_API_KEY= VIDEO_API_KEY=

950
api/db.py Normal file
View 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)

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@ yt-dlp==2026.3.17
openai==1.55.3 openai==1.55.3
httpx==0.27.2 httpx==0.27.2
requests==2.32.5 requests==2.32.5
psycopg[binary]==3.2.3
psycopg-pool==3.2.4
imagehash==4.3.1 imagehash==4.3.1
Pillow>=11.0 Pillow>=11.0
numpy>=2.0 numpy>=2.0

103
deploy/.env.local.example Normal file
View File

@@ -0,0 +1,103 @@
# Local Docker only. Copy to deploy/.env.local or run scripts/start-local-docker.sh.
# Real production secrets stay in deploy/.env.production on the VPS.
# Local ports
LOCAL_WEB_PORT=4390
LOCAL_API_PORT=4391
# Local Postgres
POSTGRES_DB=skg_marketing_local
POSTGRES_USER=skg_marketing
POSTGRES_PASSWORD=skg_marketing_local_password
# Local password login for Docker smoke tests
PASSWORD_AUTH_ENABLED=true
WEB_AUTH_USERNAME=skg
WEB_AUTH_PASSWORD=local-skg
WEB_AUTH_SESSION_SECRET=local-docker-session-secret-change-me
WEB_AUTH_COOKIE_NAME=skg_marketing_local_session
WEB_AUTH_COOKIE_SECURE=false
AUTH_DATA_ISOLATION_ENABLED=true
# Feishu can be filled locally if OAuth needs to be tested from localhost.
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_REDIRECT_URI=http://localhost:4390/api/auth/feishu/callback
FEISHU_OAUTH_SCOPE=
FEISHU_ALLOWED_EMAIL_DOMAINS=
FEISHU_ALLOWED_EMAILS=
FEISHU_ALLOWED_TENANT_KEYS=
# SKG AI gateway. Leave blank when only testing UI/login/database locally.
LLM_BASE_URL=https://ai.skg.com/ezlink/v1
LLM_API_KEY=
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
IMAGE_API_KEY=
IMAGE_MODEL=gpt-image-2
IMAGE_REQUEST_TIMEOUT_SECONDS=60
IMAGE_FALLBACK_ENABLED=true
IMAGE_FALLBACK_MODEL=gemini-3-pro-image-preview
IMAGE_FALLBACK_MODELS=gemini-3-pro-image-preview
IMAGE_EXTRA_MODELS=
# Optional JSON model overrides. Use api_key_env/base_url_env; do not put real keys in git.
# IMAGE_MODEL_CONFIGS_JSON={"custom-model":{"label":"Custom Image","base_url_env":"CUSTOM_IMAGE_BASE_URL","api_key_env":"CUSTOM_IMAGE_API_KEY","provider":"openai","sizes":["1024x1024"],"default_size":"1024x1024"}}
IMAGE_CIRCUIT_FAILURE_THRESHOLD=2
IMAGE_CIRCUIT_COOLDOWN_SECONDS=600
GPT_IMAGE_MODEL=gpt-image-2
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2,gemini-3-pro-image-preview
ARK_SEEDREAM_ENABLED=true
ARK_IMAGE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
ARK_IMAGE_API_KEY=
ARK_SEEDREAM_IMAGE_MODEL=doubao-seedream-4-5-251128
AI_HTTP_PROXY=
# Text/vision/audio model names
GPT_TEXT_MODEL=gpt-4o
REWRITE_MODEL=gpt-4o
VISION_MODEL=gpt-4o
TRANSLATE_MODEL=gemini-2.5-flash
ASR_BASE_URL=https://ai.skg.com/azure/v1
ASR_API_KEY=
ASR_MODEL=gpt-4o-transcribe
ASR_LANGUAGE=auto
ASR_REMOTE_ENABLED=false
ASR_LOCAL_FALLBACK_ENABLED=true
ASR_AUDIO_FALLBACK_ENABLED=false
ASR_FALLBACK_MODEL=gemini-2.5-flash
ASR_TIMEOUT_SECONDS=45
FASTER_WHISPER_MODEL=tiny
FASTER_WHISPER_DEVICE=cpu
FASTER_WHISPER_COMPUTE_TYPE=int8
# Video generation. Fill VIDEO_API_KEY only when testing real video generation locally.
VIDEO_API_BASE_URL=https://ai.skg.com/doubao
VIDEO_API_KEY=
VIDEO_MODEL=seedance
VIDEO_MODEL_SEEDANCE=doubao-seedance-2-0-fast-260128
VIDEO_MODEL_XAI=grok-imagine-video
VIDEO_CREATE_PATHS=/api/v3/contents/generations/tasks
VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id}
VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content
VIDEO_DURATION_FIELD=seconds
VIDEO_POLL_TIMEOUT_SECONDS=900
XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai
XAI_VIDEO_API_KEY=
XAI_VIDEO_CREATE_PATH=/v1/videos/generations
XAI_VIDEO_STATUS_PATH=/v1/videos/{id}
XAI_VIDEO_CONTENT_PATH=
# Azure OpenAI TTS. Leave blank unless testing voice locally.
AUDIO_REWRITE_MODEL=gemini-2.5-pro
VOICE_PROVIDER=azure_openai
AZURE_OPENAI_BASE_URL=https://ai.skg.com/azure
AZURE_OPENAI_API_KEY=
AZURE_TTS_MODEL=gpt-4o-mini-tts
AZURE_TTS_VOICE_ID=alloy
AZURE_TTS_VOICE_POOL=alloy,verse,shimmer
AZURE_TTS_PATH=/audio/speech
AZURE_TTS_PATHS=/audio/speech,/v1/audio/speech
# Optional TikTok cookies. Keep files local and out of git.
YTDLP_COOKIES_FILE=
YTDLP_COOKIES_FROM_BROWSER=

View File

@@ -9,12 +9,32 @@ KEYFRAME_COUNT=12
CORS_ORIGINS=https://marketing.skg.com CORS_ORIGINS=https://marketing.skg.com
API_PORT=4291 API_PORT=4291
# Company persistence database. Real password and DATABASE_URL live only on server.
POSTGRES_DB=skg_marketing
POSTGRES_USER=skg_marketing
POSTGRES_PASSWORD=
DATABASE_URL=postgresql://skg_marketing:CHANGE_ME@postgres:5432/skg_marketing
# Web login. Keep real password and session secret only on the server. # Web login. Keep real password and session secret only on the server.
PASSWORD_AUTH_ENABLED=false
WEB_AUTH_USERNAME=skg WEB_AUTH_USERNAME=skg
WEB_AUTH_PASSWORD= WEB_AUTH_PASSWORD=
WEB_AUTH_SESSION_SECRET= WEB_AUTH_SESSION_SECRET=
WEB_AUTH_COOKIE_NAME=skg_marketing_session WEB_AUTH_COOKIE_NAME=skg_marketing_session
WEB_AUTH_COOKIE_SECURE=true WEB_AUTH_COOKIE_SECURE=true
AUTH_DATA_ISOLATION_ENABLED=true
VIDEO_QUEUE_MAX_CONCURRENT=2
VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1
# Feishu OAuth login. Register this callback in the Feishu developer console:
# https://marketing.skg.com/api/auth/feishu/callback
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_REDIRECT_URI=https://marketing.skg.com/api/auth/feishu/callback
FEISHU_OAUTH_SCOPE=
FEISHU_ALLOWED_EMAIL_DOMAINS=
FEISHU_ALLOWED_EMAILS=
FEISHU_ALLOWED_TENANT_KEYS=
# SKG AI gateway, OpenAI-compatible # SKG AI gateway, OpenAI-compatible
LLM_BASE_URL=https://ai.skg.com/ezlink/v1 LLM_BASE_URL=https://ai.skg.com/ezlink/v1
@@ -45,11 +65,20 @@ IMAGE_MODEL=gpt-image-2
IMAGE_REQUEST_TIMEOUT_SECONDS=60 IMAGE_REQUEST_TIMEOUT_SECONDS=60
IMAGE_FALLBACK_ENABLED=true IMAGE_FALLBACK_ENABLED=true
IMAGE_FALLBACK_MODEL=gemini-3-pro-image-preview 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_FAILURE_THRESHOLD=2
IMAGE_CIRCUIT_COOLDOWN_SECONDS=600 IMAGE_CIRCUIT_COOLDOWN_SECONDS=600
GPT_IMAGE_MODEL=gpt-image-2 GPT_IMAGE_MODEL=gpt-image-2
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2 SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2,gemini-3-pro-image-preview SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2,gemini-3-pro-image-preview
# Optional Ark Seedream image channel. Keep the real key only in deploy/.env.production on the VPS.
ARK_SEEDREAM_ENABLED=true
ARK_IMAGE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
ARK_IMAGE_API_KEY=
ARK_SEEDREAM_IMAGE_MODEL=doubao-seedream-4-5-251128
# Optional outbound proxy for AI gateway calls. Leave blank on normal VPS networking. # Optional outbound proxy for AI gateway calls. Leave blank on normal VPS networking.
AI_HTTP_PROXY= AI_HTTP_PROXY=
@@ -78,10 +107,14 @@ VIDEO_API_BASE_URL=https://ai.skg.com/doubao
VIDEO_API_KEY= VIDEO_API_KEY=
VIDEO_MODEL=seedance VIDEO_MODEL=seedance
VIDEO_MODEL_SEEDANCE=doubao-seedance-2-0-fast-260128 VIDEO_MODEL_SEEDANCE=doubao-seedance-2-0-fast-260128
VIDEO_MODEL_KLING=kling-omni VIDEO_MODEL_XAI=grok-imagine-video
VIDEO_MODEL_VEO3=veo-3.1-fast
VIDEO_CREATE_PATHS=/api/v3/contents/generations/tasks VIDEO_CREATE_PATHS=/api/v3/contents/generations/tasks
VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id} VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id}
VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content
VIDEO_DURATION_FIELD=seconds VIDEO_DURATION_FIELD=seconds
VIDEO_POLL_TIMEOUT_SECONDS=900 VIDEO_POLL_TIMEOUT_SECONDS=900
XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai
XAI_VIDEO_API_KEY=
XAI_VIDEO_CREATE_PATH=/v1/videos/generations
XAI_VIDEO_STATUS_PATH=/v1/videos/{id}
XAI_VIDEO_CONTENT_PATH=

View File

@@ -20,6 +20,20 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /api/auth/ {
proxy_pass http://skg-marketing-api:4291/auth/;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_connect_timeout 60s;
}
location = /api/auth/login { location = /api/auth/login {
proxy_pass http://skg-marketing-api:4291/auth/login; proxy_pass http://skg-marketing-api:4291/auth/login;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -92,6 +106,14 @@ server {
try_files $uri =404; try_files $uri =404;
} }
location = /canvas {
return 308 /;
}
location ~ ^/canvas/(.*)$ {
return 308 /$1$is_args$args;
}
location = /skg-logo-black.svg { location = /skg-logo-black.svg {
root /usr/share/nginx/html; root /usr/share/nginx/html;
try_files $uri =404; try_files $uri =404;
@@ -108,7 +130,7 @@ server {
} }
location @login_redirect { location @login_redirect {
return 302 /login/; return 302 /login/?next=$request_uri;
} }
location / { location / {

85
docker-compose.local.yml Normal file
View 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

View File

@@ -1,6 +1,24 @@
name: skg-marketing-studio name: skg-marketing-studio
services: services:
postgres:
image: postgres:16-alpine
container_name: skg-marketing-postgres
environment:
POSTGRES_DB: ${POSTGRES_DB:-skg_marketing}
POSTGRES_USER: ${POSTGRES_USER:-skg_marketing}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
volumes:
- ./data/postgres:/var/lib/postgresql/data
restart: unless-stopped
networks:
- skg-marketing-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skg_marketing} -d ${POSTGRES_DB:-skg_marketing}"]
interval: 10s
timeout: 5s
retries: 10
api: api:
build: build:
context: . context: .
@@ -13,6 +31,7 @@ services:
AGENT_RUNS_DIR: /data/agent_runs AGENT_RUNS_DIR: /data/agent_runs
ASSET_LIBRARY_DIR: /data/asset_library ASSET_LIBRARY_DIR: /data/asset_library
PROMPT_LIBRARY_DIR: /data/prompt_library PROMPT_LIBRARY_DIR: /data/prompt_library
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
CORS_ORIGINS: https://marketing.skg.com CORS_ORIGINS: https://marketing.skg.com
volumes: volumes:
- ./data/jobs:/data/jobs - ./data/jobs:/data/jobs
@@ -22,6 +41,9 @@ services:
- ./data/_trash:/data/_trash - ./data/_trash:/data/_trash
- ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt - ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt
restart: unless-stopped restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
networks: networks:
- skg-marketing-internal - skg-marketing-internal

View File

@@ -1,6 +1,24 @@
name: skg-agent-cut name: skg-agent-cut
services: 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: api:
build: build:
context: . context: .
@@ -13,6 +31,7 @@ services:
AGENT_RUNS_DIR: /data/agent_runs AGENT_RUNS_DIR: /data/agent_runs
ASSET_LIBRARY_DIR: /data/asset_library ASSET_LIBRARY_DIR: /data/asset_library
PROMPT_LIBRARY_DIR: /data/prompt_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 CORS_ORIGINS: http://2.24.28.41:4290,http://localhost:4290
volumes: volumes:
- ./data/jobs:/data/jobs - ./data/jobs:/data/jobs
@@ -22,6 +41,9 @@ services:
- ./data/_trash:/data/_trash - ./data/_trash:/data/_trash
- ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt - ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt
restart: unless-stopped restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
networks: networks:
skg-agent-internal: skg-agent-internal:
aliases: aliases:

View 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 Fast720p5 秒
- 需要更高清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. 下载图片和视频素材。
如果以上流程都正常,就可以开始用于日常营销素材探索。

Binary file not shown.

View File

@@ -0,0 +1,76 @@
# 2026-05-24 Pre-Redesign Backup
This record was created before the full platform redesign that replaces the old video-recreation-first product flow.
## Code Restore Point
- Branch at backup time: `main`
- Commit at backup time: `04a822ac7903aa249b491c68c80b3cd98d88ae85`
- Remote: `ssh://git@git.kang-kang.com:22222/kangwan/20260512-skg-tk.git`
- Pushed tag: `backup/pre-redesign-20260524-012047`
Restore tracked code to the exact pre-redesign point:
```bash
git fetch origin --tags
git reset --hard backup/pre-redesign-20260524-012047
```
Only run the reset when intentionally replacing the current working tree.
## Local Data Snapshot
Backup directory:
```text
.backups/pre-redesign-20260524-012047/
```
Snapshot contents:
- `api-jobs/` from `api/jobs/`
- `api-product_library/` from `api/product_library/`
- `api-character_library/` from `api/character_library/`
- `api-asset_library/` from `api/asset_library/`
- `api-prompt_library/` from `api/prompt_library/`
- `asset_library/` from `asset_library/`
- `prompt_library/` from `prompt_library/`
- `jobs/` from `jobs/`
- `_trash/` from `_trash/`
- `output/` from `output/`
- `source-04a822ac7903.tgz`
- `dirty-worktree.patch`
Verification at backup time:
- Full backup size: `328M`
- `api/jobs` source task directories: `12`
- `api-jobs` backup task directories: `12`
- `api-jobs` backup size: `132M`
- `api-product_library` backup size: `9.6M`
- `api-character_library` backup size: `91M`
## Data Restore By Overwrite
Use `ditto` to copy the backed up data over the current runtime data directories:
```bash
ditto .backups/pre-redesign-20260524-012047/api-jobs api/jobs
ditto .backups/pre-redesign-20260524-012047/api-product_library api/product_library
ditto .backups/pre-redesign-20260524-012047/api-character_library api/character_library
ditto .backups/pre-redesign-20260524-012047/api-asset_library api/asset_library
ditto .backups/pre-redesign-20260524-012047/api-prompt_library api/prompt_library
ditto .backups/pre-redesign-20260524-012047/asset_library asset_library
ditto .backups/pre-redesign-20260524-012047/prompt_library prompt_library
ditto .backups/pre-redesign-20260524-012047/jobs jobs
ditto .backups/pre-redesign-20260524-012047/_trash _trash
ditto .backups/pre-redesign-20260524-012047/output output
```
`ditto` overwrites matching files but does not delete extra files that were created after the backup. If an exact replacement is required later, remove or move the target directory first, then run the matching `ditto` command.
## Notes
- `.backups/` is ignored by Git and remains local.
- The pushed Git tag protects the source-code restore point even if the local `.backups/` folder is moved.
- The only dirty working-tree change at backup time was `.memory/worklog.json`; its diff was saved as `dirty-worktree.patch`.

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

639
docs/user-guide.html Normal file
View 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
View 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"

View File

@@ -29,7 +29,12 @@ ssh "$HOST" "set -euo pipefail
cat /tmp/skg-backup-warnings.log >&2 || true cat /tmp/skg-backup-warnings.log >&2 || true
exit 1 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-preserve-*.tgz' -type f -printf '%T@ %p\n' | sort -nr | tail -n +8 | cut -d' ' -f2- | xargs -r rm -f
find '$BACKUP_DIR' -name 'skg-marketing-postgres-*.sql.gz' -type f -printf '%T@ %p\n' | sort -nr | tail -n +8 | cut -d' ' -f2- | xargs -r rm -f
echo backup:\$(ls -t '$BACKUP_DIR'/skg-marketing-preserve-*.tgz | head -1) echo backup:\$(ls -t '$BACKUP_DIR'/skg-marketing-preserve-*.tgz | head -1)
" "
@@ -45,11 +50,20 @@ rsync -az --delete \
--filter='P /api/.env.production' \ --filter='P /api/.env.production' \
--exclude='/.git/' \ --exclude='/.git/' \
--exclude='/.memory/' \ --exclude='/.memory/' \
--exclude='/.backups/' \
--exclude='/.logs/' \ --exclude='/.logs/' \
--exclude='/.pids/' \ --exclude='/.pids/' \
--exclude='/.playwright-mcp/' \
--exclude='/.DS_Store' \
--exclude='*.log' \
--exclude='__pycache__/' \
--exclude='*.pyc' \
--exclude='/data/' \ --exclude='/data/' \
--exclude='/data-local/' \
--exclude='/jobs/' \ --exclude='/jobs/' \
--exclude='/output/' \
--exclude='/secrets/' \ --exclude='/secrets/' \
--exclude='/api/.venv/' \
--exclude='/api/jobs/' \ --exclude='/api/jobs/' \
--exclude='/api/.env' \ --exclude='/api/.env' \
--exclude='/api/.env.local' \ --exclude='/api/.env.local' \
@@ -58,6 +72,8 @@ rsync -az --delete \
--exclude='/web/node_modules/' \ --exclude='/web/node_modules/' \
--exclude='/web/.next/' \ --exclude='/web/.next/' \
--exclude='/web/out/' \ --exclude='/web/out/' \
--exclude='/web/canvas-app/node_modules/' \
--exclude='/web/canvas-app/dist/' \
--exclude='/node_modules/' \ --exclude='/node_modules/' \
--exclude='内部分享-口播脚本.md' \ --exclude='内部分享-口播脚本.md' \
./ "$HOST:$APP_DIR/" ./ "$HOST:$APP_DIR/"

View 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"

View 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-二创验证 &amp;&amp; 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
View 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
View 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
View 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

View File

@@ -13,17 +13,32 @@ ssh "$HOST" "cd '$APP_DIR' && \
echo \"ERROR: local API/dev URL leaked into web static bundle\" >&2 echo \"ERROR: local API/dev URL leaked into web static bundle\" >&2
exit 1 exit 1
fi fi
for p in / /login/ /_next/does-not-exist.js /api/health; do check_route() {
code=\$(curl -sS -o /tmp/skg-smoke.out -w \"%{http_code}\" \"http://127.0.0.1\$p\") p=\"\$1\"
case \"\$p:\$code\" in expected=\"\$2\"
/:302|/login/:200|/_next/does-not-exist.js:404|/api/health:401) echo \"web:\$p \$code\" ;; attempts=\"\${3:-30}\"
*) echo \"ERROR: unexpected web route status \$p \$code\" >&2; head -c 200 /tmp/skg-smoke.out >&2; exit 1 ;; i=1
esac 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 done
' && \ ' && \
docker exec skg-marketing-api sh -lc ' docker exec skg-marketing-api sh -lc '
set -e set -e
test ! -f /app/.env || { echo \"ERROR: /app/.env leaked into API image\" >&2; exit 1; } 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())\" 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\\\")\"
'" '"

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { import {
ArrowDownToLine, ArrowDownToLine,
CheckCircle2, CheckCircle2,
@@ -73,8 +73,14 @@ export default function AgentPage() {
const [error, setError] = useState("") const [error, setError] = useState("")
const terminalRef = useRef<HTMLDivElement>(null) const terminalRef = useRef<HTMLDivElement>(null)
const previews = useMemo(() => files.map((file) => ({ file, url: URL.createObjectURL(file) })), [files]) // create object URLs inside the effect (not during render) so every URL has a
useEffect(() => () => previews.forEach((item) => URL.revokeObjectURL(item.url)), [previews]) // 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(() => { useEffect(() => {
fetch(`${API_BASE}/agent-runs?limit=8`, { cache: "no-store" }) fetch(`${API_BASE}/agent-runs?limit=8`, { cache: "no-store" })

548
web/app/detail/page.tsx Normal file
View File

@@ -0,0 +1,548 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import {
ArrowLeft,
Clapperboard,
Copy,
FileText,
Image as ImageIcon,
Loader2,
Play,
RefreshCw,
Sparkles,
Wand2,
} from "lucide-react"
import { Toaster, toast } from "sonner"
import { MediaAssetTile } from "@/components/media-asset-tile"
import {
apiAssetUrl,
deleteGeneratedImage,
deleteGeneratedVideo,
generateCreativeCopy,
generateImage,
generateStoryboardVideo,
getJob,
listJobs,
type CreativeCopyVariant,
type GeneratedImage,
type GeneratedVideo,
type Job,
type JobSummary,
} from "@/lib/api"
type ImageItem = GeneratedImage & { frameIdx: number }
type BusyTask = "image" | "video" | "copy" | "load" | null
function cx(...items: Array<string | false | null | undefined>) {
return items.filter(Boolean).join(" ")
}
function jobTitle(item: Job | JobSummary | null) {
if (!item) return "未选择任务"
const raw = item.url.replace(/^creative:\/\//, "").replace(/^upload:\/\//, "")
return raw || item.id
}
function sourceFrameSrc(job: Job | null) {
return job?.frames?.[0]?.url ? apiAssetUrl(job.frames[0].url) : ""
}
function videoSrc(job: Job, video: GeneratedVideo) {
return apiAssetUrl(video.url || `/jobs/${job.id}/storyboard-videos/${video.id}.mp4`)
}
function imageItems(job: Job | null): ImageItem[] {
if (!job) return []
return job.frames
.flatMap((frame) => (frame.generated_images ?? []).map((image) => ({ ...image, frameIdx: frame.index })))
.sort((a, b) => b.created_at - a.created_at)
}
function createdLabel(ts?: number) {
if (!ts) return ""
return new Date(ts * 1000).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })
}
export default function DetailPage() {
const [jobId, setJobId] = useState("")
const [job, setJob] = useState<Job | null>(null)
const [recentJobs, setRecentJobs] = useState<JobSummary[]>([])
const [prompt, setPrompt] = useState("")
const [product, setProduct] = useState("SKG 颈部按摩仪")
const [audience, setAudience] = useState("久坐办公、低头刷手机的人群")
const [tone, setTone] = useState("真实、直接、有购买理由")
const [seconds, setSeconds] = useState(12)
const [copyVariants, setCopyVariants] = useState<CreativeCopyVariant[]>([])
const [busy, setBusy] = useState<BusyTask>(null)
const [error, setError] = useState("")
const images = useMemo(() => imageItems(job), [job])
const videos = useMemo(() => job?.generated_videos ?? [], [job])
const runningVideo = videos.some((item) => item.status === "queued" || item.status === "in_progress")
const loadJob = useCallback(async (id: string) => {
if (!id) return
setBusy("load")
setError("")
try {
const loaded = await getJob(id)
setJob(loaded)
setJobId(id)
window.history.replaceState(null, "", `/detail/?job=${id}`)
} catch (e) {
const message = e instanceof Error ? e.message : "读取任务失败"
setError(message)
toast.error(message)
} finally {
setBusy(null)
}
}, [])
const refreshJobs = useCallback(async () => {
try {
setRecentJobs(await listJobs(20))
} catch {
setRecentJobs([])
}
}, [])
useEffect(() => {
const id = new URLSearchParams(window.location.search).get("job") || ""
setJobId(id)
refreshJobs()
if (id) loadJob(id)
}, [loadJob, refreshJobs])
useEffect(() => {
if (!job || !runningVideo) return
let failures = 0
const timer = window.setInterval(async () => {
try {
setJob(await getJob(job.id))
failures = 0
} catch {
// one transient 5xx / network blip must not freeze progress forever;
// only give up after sustained failures
failures += 1
if (failures >= 10) window.clearInterval(timer)
}
}, 2600)
return () => window.clearInterval(timer)
}, [job, runningVideo])
const requireJobAndPrompt = () => {
if (!job) {
toast.error("先选择任务")
return false
}
if (!prompt.trim()) {
toast.error("先写生成要求")
return false
}
return true
}
const runImage = async () => {
if (!requireJobAndPrompt() || !job) return
setBusy("image")
setError("")
try {
setJob(await generateImage(job.id, 0, {
prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Tone: ${tone}.`,
mode: sourceFrameSrc(job) ? "edit" : "text",
}))
toast.success("图片已生成")
} catch (e) {
const message = e instanceof Error ? e.message : "生图失败"
setError(message)
toast.error(message)
} finally {
setBusy(null)
}
}
const runVideo = async () => {
if (!requireJobAndPrompt() || !job) return
setBusy("video")
setError("")
try {
setJob(await generateStoryboardVideo(job.id, 0, {
prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Tone: ${tone}. Keep the SKG product shape stable and visible.`,
duration: seconds,
count: 1,
first_image: { kind: "keyframe", frame_idx: 0 },
size: "720x1280",
}))
toast.success("视频已提交")
} catch (e) {
const message = e instanceof Error ? e.message : "生视频失败"
setError(message)
toast.error(message)
} finally {
setBusy(null)
}
}
const runCopy = async () => {
const goal = prompt.trim() || `${product} ${audience}`
setBusy("copy")
setError("")
try {
const result = await generateCreativeCopy({ goal, product, audience, tone, seconds })
setCopyVariants(result.variants)
toast.success("图文方案已生成")
} catch (e) {
const message = e instanceof Error ? e.message : "写文案失败"
setError(message)
toast.error(message)
} finally {
setBusy(null)
}
}
const deleteImage = async (image: ImageItem) => {
if (!job) return
try {
setJob(await deleteGeneratedImage(job.id, image.frameIdx, image.id))
toast.success("图片已删除")
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败")
}
}
const deleteVideo = async (video: GeneratedVideo) => {
if (!job) return
try {
setJob(await deleteGeneratedVideo(job.id, video.id))
toast.success("视频已删除")
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败")
}
}
const copyText = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
toast.success("已复制")
} catch {
toast.error("复制失败")
}
}
return (
<main className="min-h-screen bg-[#eef2ec] text-[#17201d]">
<Toaster richColors position="top-center" />
<div className="mx-auto grid min-h-screen w-full max-w-[1760px] grid-rows-[auto_minmax(0,1fr)] px-4 py-4 sm:px-6">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[#d8dfd4] pb-4">
<div className="flex items-center gap-3">
<a
href="/"
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-[#cbd6d0] bg-white text-[#35443f] transition hover:border-[#0f766e]/60"
aria-label="返回工作台"
title="返回工作台"
>
<ArrowLeft className="h-5 w-5" />
</a>
<div>
<h1 className="text-xl font-semibold tracking-normal"></h1>
<p className="mt-1 max-w-[620px] truncate text-sm text-[#66746e]">{jobTitle(job)}</p>
</div>
</div>
<button
type="button"
onClick={() => jobId && loadJob(jobId)}
disabled={!jobId || !!busy}
className="inline-flex h-9 items-center gap-2 rounded-md border border-[#cbd6d0] bg-white px-3 text-sm font-semibold text-[#35443f] transition hover:border-[#0f766e]/60 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy === "load" ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</button>
</header>
<section className="grid min-h-0 gap-4 py-4 xl:grid-cols-[300px_minmax(0,1fr)_420px]">
<aside className="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-3">
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
<h2 className="text-sm font-semibold"></h2>
<div className="mt-3 grid gap-2 text-sm">
<div className="flex justify-between gap-3 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2">
<span className="text-[#66746e]">ID</span>
<span className="font-mono text-xs">{job?.id || jobId || "-"}</span>
</div>
<div className="flex justify-between gap-3 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2">
<span className="text-[#66746e]"></span>
<span>{images.length}</span>
</div>
<div className="flex justify-between gap-3 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2">
<span className="text-[#66746e]"></span>
<span>{videos.length}</span>
</div>
</div>
{sourceFrameSrc(job) ? (
<div className="mt-3">
<MediaAssetTile
src={sourceFrameSrc(job)}
alt="reference"
objectFit="contain"
previewObjectFit="contain"
className="aspect-[4/5] w-full rounded-md"
label="参考图"
meta={job?.frames?.[0]?.index ?? 0}
/>
</div>
) : null}
</section>
<section className="min-h-0 rounded-lg border border-[#d8dfd4] bg-white p-3">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-semibold"></h2>
<button
type="button"
onClick={refreshJobs}
className="rounded p-1 text-[#66746e] hover:bg-[#f0f3ee] hover:text-[#17201d]"
aria-label="刷新任务列表"
title="刷新任务列表"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="grid max-h-[540px] gap-2 overflow-y-auto pr-1">
{recentJobs.map((item) => (
<button
key={item.id}
type="button"
onClick={() => loadJob(item.id)}
className={cx(
"grid grid-cols-[48px_minmax(0,1fr)] gap-2 rounded-md border bg-[#f7f9f5] p-1.5 text-left transition hover:border-[#0f766e]/60",
job?.id === item.id ? "border-[#0f766e] ring-2 ring-[#0f766e]/10" : "border-[#d8dfd4]",
)}
>
<MediaAssetTile
src={apiAssetUrl(item.thumbnail)}
alt=""
objectFit="cover"
className="aspect-square rounded"
disablePreview={!item.thumbnail}
/>
<span className="min-w-0">
<span className="block truncate text-xs font-semibold">{jobTitle(item)}</span>
<span className="block text-[11px] text-[#66746e]">{item.frame_count} · {item.video_count} </span>
</span>
</button>
))}
{!recentJobs.length ? (
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-5 text-center text-xs text-[#66746e]"></div>
) : null}
</div>
</section>
</aside>
<section className="min-h-0 overflow-y-auto rounded-lg border border-[#d8dfd4] bg-white p-4">
{job ? (
<div className="grid gap-6">
<section className="grid gap-3">
<div className="flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-[#0f766e]" />
<h2 className="text-base font-semibold"></h2>
</div>
{images.length ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{images.map((image) => (
<div key={image.id} className="grid gap-1.5">
<MediaAssetTile
src={apiAssetUrl(image.url)}
alt="generated image"
objectFit="contain"
previewObjectFit="contain"
className="aspect-[4/5] w-full rounded-md"
label={image.model}
meta={image.mode}
onDelete={() => deleteImage(image)}
/>
<div className="truncate text-[11px] text-[#66746e]">{createdLabel(image.created_at)}</div>
</div>
))}
</div>
) : (
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-4 py-8 text-center text-sm text-[#66746e]"></div>
)}
</section>
<section className="grid gap-3">
<div className="flex items-center gap-2">
<Clapperboard className="h-4 w-4 text-[#ea5b2d]" />
<h2 className="text-base font-semibold"></h2>
</div>
{videos.length ? (
<div className="grid gap-3 sm:grid-cols-2">
{videos.map((video) => (
<div key={video.id} className="grid gap-1.5 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-2">
<MediaAssetTile
kind="video"
src={video.status === "completed" ? videoSrc(job, video) : undefined}
poster={apiAssetUrl(video.poster_url)}
objectFit="cover"
previewObjectFit="contain"
className="aspect-video w-full rounded-md"
label={video.model}
meta={`${video.status} · ${Math.round(video.progress)}%`}
busy={video.status === "queued" || video.status === "in_progress"}
onDelete={() => deleteVideo(video)}
/>
<div className="h-1 overflow-hidden rounded-full bg-[#e4ebe6]">
<div className="h-full rounded-full bg-[#ea5b2d]" style={{ width: `${Math.max(4, video.progress)}%` }} />
</div>
{video.error ? <div className="text-xs text-rose-700">{video.error}</div> : null}
</div>
))}
</div>
) : (
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-4 py-8 text-center text-sm text-[#66746e]"></div>
)}
</section>
<section className="grid gap-3">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-[#2563eb]" />
<h2 className="text-base font-semibold"></h2>
</div>
{copyVariants.length ? (
<div className="grid gap-3 lg:grid-cols-3">
{copyVariants.map((variant, index) => (
<article key={`${variant.title}-${index}`} className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-3">
<div className="mb-2 flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold">{variant.title || `方案 ${index + 1}`}</h3>
<button
type="button"
onClick={() => copyText([variant.hook_zh, variant.script_zh, variant.caption_zh].filter(Boolean).join("\n\n"))}
className="rounded p-1 text-[#66746e] hover:bg-white hover:text-[#17201d]"
aria-label="复制文案"
title="复制文案"
>
<Copy className="h-4 w-4" />
</button>
</div>
<p className="text-sm leading-6 text-[#243530]">{variant.hook_zh}</p>
<pre className="mt-2 max-h-44 overflow-y-auto whitespace-pre-wrap rounded bg-white p-2 text-xs leading-5 text-[#42524c]">{variant.script_zh}</pre>
</article>
))}
</div>
) : (
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-4 py-8 text-center text-sm text-[#66746e]"></div>
)}
</section>
</div>
) : (
<div className="flex min-h-[520px] items-center justify-center rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] text-sm text-[#66746e]">
</div>
)}
</section>
<aside className="grid min-h-0 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
<h2 className="text-sm font-semibold"></h2>
<div className="mt-3 grid gap-3">
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#52635d]"></span>
<input
value={product}
onChange={(event) => setProduct(event.target.value)}
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#52635d]"></span>
<input
value={audience}
onChange={(event) => setAudience(event.target.value)}
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#52635d]"></span>
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="继续生成一组更高端的营销图,或者提交一条快速视频要求。"
className="min-h-40 resize-none rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-3 text-sm leading-6 outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#52635d]"></span>
<input
value={tone}
onChange={(event) => setTone(event.target.value)}
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#52635d]"></span>
<select
value={seconds}
onChange={(event) => setSeconds(Number(event.target.value))}
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
>
{[8, 12, 15, 20, 30, 45].map((value) => <option key={value} value={value}>{value} </option>)}
</select>
</label>
{error ? <div className="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div> : null}
</div>
</section>
<section className="grid gap-2 rounded-lg border border-[#d8dfd4] bg-white p-3">
<button
type="button"
onClick={runImage}
disabled={!job || !!busy}
className="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-[#0f766e] text-sm font-semibold text-white transition hover:bg-[#115e59] disabled:cursor-not-allowed disabled:opacity-60"
>
{busy === "image" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wand2 className="h-4 w-4" />}
</button>
<button
type="button"
onClick={runVideo}
disabled={!job || !!busy}
className="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-[#ea5b2d] text-sm font-semibold text-white transition hover:bg-[#d94f25] disabled:cursor-not-allowed disabled:opacity-60"
>
{busy === "video" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
</button>
<button
type="button"
onClick={runCopy}
disabled={!!busy}
className="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-[#cbd6d0] bg-[#f7f9f5] text-sm font-semibold text-[#35443f] transition hover:border-[#9ba9a2] disabled:cursor-not-allowed disabled:opacity-60"
>
{busy === "copy" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
</button>
</section>
<section className="min-h-0 rounded-lg border border-[#d8dfd4] bg-white p-3">
<h2 className="mb-2 text-sm font-semibold"></h2>
<div className="grid max-h-[360px] gap-2 overflow-y-auto pr-1">
{[...images.slice(0, 4).map((item) => item.prompt), ...videos.slice(0, 4).map((item) => item.prompt)]
.filter(Boolean)
.map((item, index) => (
<button
key={`${index}-${item.slice(0, 20)}`}
type="button"
onClick={() => setPrompt(item)}
className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-2 text-left text-xs leading-5 text-[#42524c] transition hover:border-[#0f766e]/60"
>
{item.slice(0, 180)}
</button>
))}
{!images.length && !videos.length ? (
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-5 text-center text-xs text-[#66746e]"></div>
) : null}
</div>
</section>
</aside>
</section>
</div>
</main>
)
}

View File

@@ -12,8 +12,8 @@ const _playfairDisplay = Playfair_Display({
}) })
export const metadata: Metadata = { export const metadata: Metadata = {
title: "SKG TK 二创工作台", title: "SKG",
description: "SKG AI 素材生产管线 · 节点工作流", description: "SKG AI 图片、视频和图文内容生产入口",
} }
export default function RootLayout({ export default function RootLayout({

View File

@@ -1,9 +1,10 @@
"use client" "use client"
import type { FormEvent } from "react" import type { FormEvent } from "react"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { import {
ArrowRight, ArrowRight,
Building2,
CheckCircle2, CheckCircle2,
Eye, Eye,
EyeOff, EyeOff,
@@ -14,8 +15,26 @@ import { AnimatedLoginCharacters, type LoginCharacterMood } from "@/components/l
import { OasisCanvas } from "@/components/login/oasis-canvas" import { OasisCanvas } from "@/components/login/oasis-canvas"
type LoginStatus = "idle" | "loading" | "success" type LoginStatus = "idle" | "loading" | "success"
type AuthConfig = {
auth_configured?: boolean
password_enabled?: boolean
feishu_enabled?: boolean
}
function normalizeNextPath(value: string | null | undefined) {
const next = (value || "/").trim() || "/"
if (!next.startsWith("/") || next.startsWith("//")) return "/"
return next
}
function loginNextPath() {
if (typeof window === "undefined") return "/"
return normalizeNextPath(new URLSearchParams(window.location.search).get("next"))
}
export default function LoginPage() { export default function LoginPage() {
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null)
const [nextPath] = useState(loginNextPath)
const [username, setUsername] = useState("") const [username, setUsername] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [remember, setRemember] = useState(true) const [remember, setRemember] = useState(true)
@@ -24,20 +43,59 @@ export default function LoginPage() {
const [hasError, setHasError] = useState(false) const [hasError, setHasError] = useState(false)
const [status, setStatus] = useState<LoginStatus>("idle") const [status, setStatus] = useState<LoginStatus>("idle")
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 }) const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 })
const autoFeishuAttemptedRef = useRef(false)
useEffect(() => { useEffect(() => {
let cancelled = false
fetch("/api/auth/config", { cache: "no-store", credentials: "include" })
.then((res) => res.ok ? res.json() : null)
.then((data) => {
if (!cancelled && data) setAuthConfig(data)
})
.catch(() => {
if (!cancelled) setAuthConfig(null)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
// skip touch / coarse pointers — the eye-follow effect is pointless there and
// would thrash state (and battery) on scroll-driven pointer events
if (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches) return
let frame = 0
const onPointerMove = (event: PointerEvent) => { const onPointerMove = (event: PointerEvent) => {
const centerX = window.innerWidth / 2 if (frame) return // coalesce to at most one state update per animation frame
const centerY = window.innerHeight / 2 frame = window.requestAnimationFrame(() => {
const nextX = Math.max(-1, Math.min(1, (event.clientX - centerX) / centerX)) frame = 0
const nextY = Math.max(-1, Math.min(1, (event.clientY - centerY) / centerY)) const centerX = window.innerWidth / 2
setEyeOffset({ x: nextX * 8, y: nextY * 5.5 }) const centerY = window.innerHeight / 2
const nextX = Math.max(-1, Math.min(1, (event.clientX - centerX) / centerX))
const nextY = Math.max(-1, Math.min(1, (event.clientY - centerY) / centerY))
setEyeOffset({ x: nextX * 8, y: nextY * 5.5 })
})
} }
window.addEventListener("pointermove", onPointerMove) window.addEventListener("pointermove", onPointerMove)
return () => window.removeEventListener("pointermove", onPointerMove) return () => {
window.removeEventListener("pointermove", onPointerMove)
if (frame) window.cancelAnimationFrame(frame)
}
}, []) }, [])
const disabled = status === "loading" || status === "success" const disabled = status === "loading" || status === "success"
const feishuEnabled = Boolean(authConfig?.feishu_enabled)
const passwordEnabled = authConfig?.password_enabled ?? true
useEffect(() => {
if (!feishuEnabled || status !== "idle" || autoFeishuAttemptedRef.current) return
const attemptKey = `skg-feishu-auto-login:${nextPath}`
if (window.sessionStorage.getItem(attemptKey) === "1") return
window.sessionStorage.setItem(attemptKey, "1")
autoFeishuAttemptedRef.current = true
setStatus("loading")
window.location.href = `/api/auth/feishu/start?next=${encodeURIComponent(nextPath)}`
}, [feishuEnabled, nextPath, status])
const mood: LoginCharacterMood = useMemo(() => { const mood: LoginCharacterMood = useMemo(() => {
if (status === "success") return "success" if (status === "success") return "success"
@@ -50,6 +108,7 @@ export default function LoginPage() {
async function onSubmit(event: FormEvent<HTMLFormElement>) { async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
setHasError(false) setHasError(false)
if (!passwordEnabled) return
if (!username.trim() || !password) { if (!username.trim() || !password) {
setHasError(true) setHasError(true)
return return
@@ -67,7 +126,7 @@ export default function LoginPage() {
} }
setStatus("success") setStatus("success")
window.setTimeout(() => { window.setTimeout(() => {
window.location.href = "/" window.location.href = nextPath
}, 420) }, 420)
} catch { } catch {
setStatus("idle") setStatus("idle")
@@ -75,6 +134,11 @@ export default function LoginPage() {
} }
} }
function onFeishuLogin() {
setStatus("loading")
window.location.href = `/api/auth/feishu/start?next=${encodeURIComponent(nextPath)}`
}
return ( return (
<main className="login-page login-page--oasis login-page--source relative min-h-screen overflow-hidden text-white"> <main className="login-page login-page--oasis login-page--source relative min-h-screen overflow-hidden text-white">
<OasisCanvas /> <OasisCanvas />
@@ -83,13 +147,34 @@ export default function LoginPage() {
<section className="login-auth-panel login-source-auth-panel login-source-combo-panel rounded-[8px]"> <section className="login-auth-panel login-source-auth-panel login-source-combo-panel rounded-[8px]">
<div className="login-top-brand" aria-hidden="true"> <div className="login-top-brand" aria-hidden="true">
<img className="login-top-brand__logo" src="/skg-logo-black.svg" alt="" /> <img className="login-top-brand__logo" src="/skg-logo-black.svg" alt="" />
<span className="login-top-brand__system"></span>
</div> </div>
<div className="login-source-character-strip" aria-hidden="true"> <div className="login-source-character-strip" aria-hidden="true">
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} /> <AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
</div> </div>
<form className="login-source-form-pane w-full" onSubmit={onSubmit}> <form className="login-source-form-pane w-full" onSubmit={onSubmit}>
<div className="space-y-3"> {feishuEnabled ? (
<button
className="mb-3 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
type="button"
disabled={disabled}
onClick={onFeishuLogin}
>
<Building2 className="h-4 w-4" />
<ArrowRight className="h-4 w-4" />
</button>
) : null}
{feishuEnabled && passwordEnabled ? (
<div className="mb-3 flex items-center gap-3 text-xs text-white/35">
<span className="h-px flex-1 bg-white/10" />
<span></span>
<span className="h-px flex-1 bg-white/10" />
</div>
) : null}
{passwordEnabled ? (
<div className="space-y-3">
<label className="block"> <label className="block">
<span className="flex h-11 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#d6b36a] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#d6b36a]/25"> <span className="flex h-11 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#d6b36a] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#d6b36a]/25">
<UserRound className="h-4 w-4 text-white/45" /> <UserRound className="h-4 w-4 text-white/45" />
@@ -135,9 +220,11 @@ export default function LoginPage() {
</button> </button>
</span> </span>
</label> </label>
</div> </div>
) : null}
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60"> {passwordEnabled ? (
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
<input <input
className="h-4 w-4 rounded border-white/20 bg-black/30 accent-[#c89b3c]" className="h-4 w-4 rounded border-white/20 bg-black/30 accent-[#c89b3c]"
type="checkbox" type="checkbox"
@@ -146,7 +233,8 @@ export default function LoginPage() {
onChange={(event) => setRemember(event.target.checked)} onChange={(event) => setRemember(event.target.checked)}
/> />
<span></span> <span></span>
</label> </label>
) : null}
{status === "success" ? ( {status === "success" ? (
<div className="mt-3"> <div className="mt-3">
@@ -156,13 +244,15 @@ export default function LoginPage() {
</div> </div>
) : null} ) : null}
<button {passwordEnabled ? (
<button
className="mt-4 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70" className="mt-4 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
type="submit" type="submit"
disabled={disabled} disabled={disabled}
> >
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</button> </button>
) : null}
</form> </form>
</section> </section>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
node_modules
.git
.DS_Store
*.log
.env*

24
web/canvas-app/.gitignore vendored Normal file
View 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
View 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
View 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>

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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

View 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>

View 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 || ''
}

View 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' } : {}
})
}

View File

@@ -0,0 +1,8 @@
/**
* API Index | API 索引
* Simplified for open source version | 开源版简化版
*/
export * from './image'
export * from './video'
export * from './chat'

View 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'
})

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 898 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 KiB

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// 替换 @[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>

View 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>

View 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>

View 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)
}

View 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]
}

View File

@@ -0,0 +1,32 @@
export const QUICK_SUGGESTION_GROUPS = [
['魔法森林', '三只小猫', '多角度分镜', '夏日田野'],
['雨夜街摊', '产品特写', '水花慢镜', '极简桌面'],
['无人物街景', '夜市霓虹', '电商白底', '咖啡窗边'],
['插画封面', '厨房晨光', '3D 产品', '海边慢步'],
['樱花小路', '玻璃花房', '露营夜灯', '复古厨房'],
['雪山清晨', '海边黄昏', '森林木屋', '城市天台'],
['未来展厅', '透明材质', '金属微光', '柔和阴影'],
['香水静物', '珠宝近景', '护肤瓶身', '白底套图'],
['手作陶杯', '咖啡拉花', '甜品橱窗', '面包出炉'],
['雨后街角', '地铁站台', '便利店夜', '书店暖光'],
['儿童绘本', '水彩动物', '云朵小岛', '童话城堡'],
['动漫少女', '机甲少年', '赛博街区', '霓虹背光'],
['古风庭院', '宋式茶席', '竹林小径', '月下湖面'],
['户外露营', '徒步山路', '公路日落', '湖边野餐'],
['宠物写真', '猫咪午睡', '小狗奔跑', '兔子花园'],
['办公桌面', '键盘特写', '创意白板', '会议晨光'],
['运动瞬间', '瑜伽清晨', '跑步剪影', '泳池水花'],
['科技发布', '产品旋转', '参数分镜', '开箱镜头'],
['家居客厅', '卧室暖灯', '窗边绿植', '阳台微风'],
['餐桌俯拍', '火锅热气', '寿司吧台', '水果切面'],
['微距花瓣', '水滴叶片', '蝴蝶停留', '晨露草地'],
['沙漠公路', '银河帐篷', '极光雪原', '热气球'],
['电影海报', '悬疑走廊', '逆光人物', '红蓝光影'],
['产品拆解', '材质对比', '功能三镜', '使用场景'],
['小镇集市', '老街门牌', '木质招牌', '雨伞人群'],
['空镜转场', '慢推镜头', '俯拍街区', '环绕拍摄'],
['品牌主图', '社媒封面', '直播背景', '短片开场'],
['草莓蛋糕', '柠檬汽水', '冰块特写', '夏日餐桌'],
['山谷溪流', '雾气森林', '日出云海', '秋叶小路'],
['无脸模特', '侧脸剪影', '背影行走', '虚拟角色']
]

File diff suppressed because it is too large Load Diff

View 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'

View File

@@ -0,0 +1,328 @@
import { ref, reactive, onUnmounted } from 'vue'
const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api'
const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`
const toAssetUrl = (path) => {
if (!path) return ''
if (/^(https?:|blob:|data:)/i.test(path)) return path
return apiUrl(path)
}
const parseApiError = async (response, fallback) => {
const text = await response.text().catch(() => '')
try {
const parsed = JSON.parse(text)
return parsed?.detail || parsed?.error || fallback
} catch {
return text || fallback
}
}
const requestJson = async (path, init = {}) => {
const response = await fetch(apiUrl(path), {
...init,
headers: {
...(init.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(init.headers || {})
}
})
if (!response.ok) {
throw new Error(await parseApiError(response, `${path} ${response.status}`))
}
return response.json()
}
const dataUrlToFile = (dataUrl, filename = 'reference.jpg') => {
const [meta, payload] = dataUrl.split(',')
const mime = /data:([^;]+)/.exec(meta)?.[1] || 'image/jpeg'
const binary = atob(payload || '')
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
return new File([bytes], filename, { type: mime })
}
const imageSourceToFile = async (source, filename = 'reference.jpg') => {
if (!source) return null
if (source instanceof File) return source
if (typeof source !== 'string') return null
if (source.startsWith('data:')) return dataUrlToFile(source, filename)
const url = source.startsWith('/jobs/') ? apiUrl(source) : source
const response = await fetch(url, { credentials: 'include' })
if (!response.ok) throw new Error(`读取参考图失败 ${response.status}`)
const blob = await response.blob()
return new File([blob], filename, { type: blob.type || 'image/jpeg' })
}
const createCreativeImageJob = async (file = null) => {
if (file) {
const form = new FormData()
form.append('file', file)
return requestJson('/creative/jobs/image', { method: 'POST', body: form })
}
return requestJson('/creative/jobs/image', { method: 'POST', body: JSON.stringify({}) })
}
const uploadReferenceFrame = async (jobId, file) => {
const form = new FormData()
form.append('file', file)
return requestJson(`/jobs/${jobId}/frames/upload`, { method: 'POST', body: form })
}
export const uploadCanvasImage = async (file) => {
if (!file) throw new Error('请选择图片文件')
const job = await createCreativeImageJob(file)
const frame = job.frames?.[0]
if (!frame?.url) throw new Error('图片已上传但未返回可用地址')
return {
url: toAssetUrl(frame.url),
jobId: job.id,
frameIdx: frame.index ?? 0
}
}
export const uploadCanvasVideo = async (file) => {
if (!file) throw new Error('请选择视频文件')
const form = new FormData()
form.append('file', file)
// Persist the upload server-side so the node keeps a stable, reloadable URL
// instead of a session-only blob: URL that breaks once the project reloads.
const job = await requestJson('/jobs/upload', { method: 'POST', body: form })
if (!job?.id) throw new Error('视频已上传但未返回任务地址')
return {
url: toAssetUrl(`/jobs/${job.id}/video.mp4`),
jobId: job.id
}
}
const newestGeneratedImage = (job, frameIdx = 0) => {
const frame = (job.frames || []).find(item => item.index === frameIdx) || job.frames?.[0]
return [...(frame?.generated_images || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
}
const newestGeneratedVideo = (job) => (
[...(job.generated_videos || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
)
const parseVideoTaskId = (pollTaskId) => {
const match = /^skg:([^:]+):([^:]+)$/.exec(String(pollTaskId || ''))
if (!match) {
const err = new Error('未知视频任务类型')
err.terminal = true
throw err
}
return { jobId: match[1], videoId: match[2] }
}
export const readVideoTask = async (pollTaskId) => {
const { jobId, videoId } = parseVideoTaskId(pollTaskId)
// Canvas-level video sync reads one snapshot at a time instead of owning a long node-local poll.
const job = await requestJson(`/jobs/${jobId}`, { method: 'GET' })
const item = (job.generated_videos || []).find(v => v.id === videoId)
if (!item) {
const err = new Error('视频任务不存在')
err.terminal = true
throw err
}
return {
jobId,
videoId,
job,
video: item,
status: item.status,
progress: item.progress || 0,
url: item.status === 'completed' ? toAssetUrl(item.url || `/jobs/${jobId}/storyboard-videos/${videoId}.mp4`) : ''
}
}
const normalizeVideoSize = (value) => {
const raw = String(value || '').trim().toLowerCase()
const map = {
'9:16': '720x1280',
'9x16': '720x1280',
'vertical': '720x1280',
'portrait': '720x1280',
'16:9': '1280x720',
'16x9': '1280x720',
'horizontal': '1280x720',
'landscape': '1280x720',
'1:1': '1024x1024',
'1x1': '1024x1024',
'3:4': '960x1280',
'3x4': '960x1280'
}
if (/^\d+x\d+$/.test(raw)) return raw
return map[raw] || '720x1280'
}
export const useApiState = () => {
const loading = ref(false)
const error = ref(null)
const status = ref('idle')
const reset = () => {
loading.value = false
error.value = null
status.value = 'idle'
}
const setLoading = (isLoading) => {
loading.value = isLoading
status.value = isLoading ? 'running' : status.value
}
const setError = (err) => {
error.value = err
status.value = 'error'
loading.value = false
}
const setSuccess = () => {
status.value = 'success'
loading.value = false
error.value = null
}
return { loading, error, status, reset, setLoading, setError, setSuccess }
}
export const useChat = (options = {}) => {
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
const messages = ref([])
const currentResponse = ref('')
let stopped = false
const send = async (content) => {
setLoading(true)
stopped = false
try {
const mode = options.mode || 'chat'
const response = await requestJson('/prompt/polish', {
method: 'POST',
body: JSON.stringify({
text: content,
system_prompt: options.systemPrompt || '',
mode,
target_language: options.targetLanguage || (mode === 'chat' ? 'keep' : 'en')
})
})
const result = response.text || content
if (!stopped) {
currentResponse.value = result
messages.value.push({ role: 'user', content })
messages.value.push({ role: 'assistant', content: result })
}
setSuccess()
return result
} catch (err) {
setError(err)
throw err
}
}
const stop = () => {
stopped = true
}
const clear = () => {
messages.value = []
currentResponse.value = ''
reset()
}
onUnmounted(() => stop())
return { loading, error, status, messages, currentResponse, send, stop, clear, reset }
}
export const useImageGeneration = () => {
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
const images = ref([])
const currentImage = ref(null)
const generate = async (params) => {
setLoading(true)
images.value = []
currentImage.value = null
try {
const refs = Array.isArray(params.image) ? params.image : (params.image ? [params.image] : [])
const firstRef = refs[0] ? await imageSourceToFile(refs[0], 'image-reference.jpg') : null
const job = await createCreativeImageJob(firstRef)
const updated = await requestJson(`/jobs/${job.id}/frames/0/generate`, {
method: 'POST',
body: JSON.stringify({
prompt: params.prompt || '',
model: params.model || 'auto',
size: params.size || '1024x1536',
mode: firstRef ? 'edit' : 'text'
})
})
const generated = newestGeneratedImage(updated, 0)
if (!generated?.url) throw new Error('图片生成完成但未返回地址')
const result = [{ ...generated, url: toAssetUrl(generated.url), jobId: updated.id, frameIdx: 0 }]
images.value = result
currentImage.value = result[0]
setSuccess()
return result
} catch (err) {
setError(err)
throw err
}
}
return { loading, error, status, images, currentImage, generate, reset }
}
export const useVideoGeneration = () => {
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
const video = ref(null)
const taskId = ref(null)
const progress = reactive({ attempt: 0, maxAttempts: 180, percentage: 0 })
const createVideoTaskOnly = async (params) => {
setLoading(true)
try {
const firstFile = params.first_frame_image ? await imageSourceToFile(params.first_frame_image, 'first-frame.jpg') : null
let job = await createCreativeImageJob(firstFile)
let lastFrameIdx = null
if (params.last_frame_image) {
const lastFile = await imageSourceToFile(params.last_frame_image, 'last-frame.jpg')
if (lastFile) {
job = await uploadReferenceFrame(job.id, lastFile)
lastFrameIdx = Math.max(...(job.frames || []).map(frame => frame.index))
}
}
const updated = await requestJson(`/jobs/${job.id}/frames/0/storyboard/video`, {
method: 'POST',
body: JSON.stringify({
prompt: params.prompt || '',
duration: Number(params.dur || params.duration || params.seconds || 10),
count: 1,
first_image: firstFile ? { kind: 'keyframe', frame_idx: 0 } : null,
last_image: lastFrameIdx !== null ? { kind: 'keyframe', frame_idx: lastFrameIdx } : null,
model: params.model || 'seedance',
size: normalizeVideoSize(params.ratio || params.size),
resolution: params.resolution || '720p'
})
})
const created = newestGeneratedVideo(updated)
if (!created?.id) throw new Error('视频任务已提交但未返回任务编号')
const id = `skg:${updated.id}:${created.id}`
taskId.value = id
status.value = 'polling'
setSuccess()
return { taskId: id }
} catch (err) {
setError(err)
throw err
}
}
// Node-local polling was removed: video status is owned by the Canvas-level
// syncPendingVideoNodes() interval (views/Canvas.vue), which reads snapshots via
// readVideoTask() and is properly torn down on unmount. A second per-node poll
// here just duplicated requests and had no unmount stop.
return { loading, error, status, video, taskId, progress, reset, createVideoTaskOnly }
}
export const useApi = () => {
const chat = useChat()
const image = useImageGeneration()
const videoGen = useVideoGeneration()
return { config: {}, chat, image, video: videoGen }
}

View 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
}
}

View 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)
}
}

View 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
}
}

View 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))]
}

View 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
}
}

File diff suppressed because it is too large Load Diff

View 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')

View 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

View 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 | 用于需要直接访问配置状态的组件

View 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 })

View 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 }

View File

@@ -0,0 +1,6 @@
/**
* Pinia Stores | Pinia 状态管理
* 统一导出所有 Pinia stores
*/
export { useModelStore } from './models'

View 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
}
})

View 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
}
}

Some files were not shown because too many files have changed in this diff Show More