81 Commits

Author SHA1 Message Date
2d82c8d22b docs: record production grok smoke test 2026-06-04 14:54:30 +08:00
cfe963a2c7 docs: record grok retry production deploy 2026-06-04 14:50:23 +08:00
549082ace3 fix: retry transient xai video creation failures 2026-06-04 14:17:12 +08:00
88d598303c fix: route ai polish through available models 2026-06-04 10:13:31 +08:00
3f5dfdc465 docs: record xai video production deploy 2026-06-03 23:57:46 +08:00
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
64 changed files with 7752 additions and 2383 deletions

View File

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

3
.gitignore vendored
View File

@@ -15,9 +15,11 @@ __pycache__/
.logs/
.pids/
deploy/.env.production
deploy/.env.local
deploy/.htpasswd
secrets/
.backups/
data-local/
# api
api/.venv/
@@ -28,6 +30,7 @@ prompt_library/*
!prompt_library/.gitkeep
_trash/
output/
.playwright-cli/
# web
web/.next/

View File

@@ -1,6 +1,6 @@
# 项目接力
- 生成时间May 25, 2026 at 23:14
- 生成时间May 31, 2026 at 15:25
- 项目SKG 营销内容生产平台
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 状态active
@@ -8,156 +8,100 @@
## 最近助手会话概览
- Claudea9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
- Codex019e55d1-ccf1-7a90-805f-3bd4799a49cd · 时间未知
- Cursor未找到匹配当前项目的最近会话
- Claudeeb894273-808b-439c-b4b9-840ad2553d0b · 时间未知
- Codex019e63ac-1984-7a42-8c65-ffd7ab146968 · 时间未知
## Claude 最近会话
- Session IDa9e0449c-d9cb-4a2a-bb16-16596dfb552a
- Transcript/Users/kangwan/.claude/projects/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/a9e0449c-d9cb-4a2a-bb16-16596dfb552a.jsonl
- Session IDeb894273-808b-439c-b4b9-840ad2553d0b
- Transcript/Users/kangwan/.claude/projects/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/eb894273-808b-439c-b4b9-840ad2553d0b.jsonl
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 分支main
- 敏感字段:已对 token / key / password / secret 做脱敏
### 最近用户要求
- 你现在是在 Claude CLI 里的项目承接会话。 这不是对话立项台;项目目录已经存在,当前目标是接住上下文并等待用户下一步。 项目路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证 承接步骤: - 先读取 `.memory/assistant-handoff.md` - 再阅读 `.project.json``RULES.md``AGENTS.md` - 结合当前仓库状态建立上下文 要求: - 不要要求用户重新粘贴 Claude / Codex / Cursor 的总结 - 先理解最近进度、未完成项和下一步候选 - 不要立即修改代码、继续上一个任务或自行执行命令 - 等待用户给出下一条明确指令后再开始动手 - 如果后续需要 Cursor提醒用户从看板打开 Cursor GUI 到当前项目目录
- 如果让你去设计。转换层 这个你会想怎么去弄
- 就是需要生成主体元素的6视图因为很多时候需求方都不清楚自己要什么
- <task-notification> <task-id>b0600axey</task-id> <tool-use-id>toolu_01WmFAqcK9tAmbAo5pyeYMHE</tool-use-id> <output-file>/private/tmp/claude-501/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/eb894273-808b-439c-b4b9-840ad2553d0b/tasks/b0600axey.output</output-file> <status>completed</status> <summary>Background command "构建并启动本地 Docker" completed (exit code 0)</summary> </task-notification>
- 继续
- <task-notification> <task-id>b2merdsp4</task-id> <tool-use-id>toolu_012dVaF8oA6bNGWwNtnMP95D</tool-use-id> <output-file>/private/tmp/claude-501/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/eb894273-808b-439c-b4b9-840ad2553d0b/tasks/b2merdsp4.output</output-file> <status>completed</status> <summary>Background command "生产安全部署(备份+rsync+服务器构建+验证)" completed (exit code 0)</summary> </task-notification>
### Claude 最近回复
> 好问题。我先把转换层"到底是干嘛的"想清楚,再说怎么设计——因为之前它被反复改又清空,大概率是因为没锁定它的定位。
>
> ## 一、它的本质不是"聊天生图",是"定主体的确认台"
>
> 转换层夹在「参考帧池 → 转换层 → 主体元素」中间,真正的活儿是:**拿源视频里一个验证过的主体,二创成一个全新的、我们自己的主体**,输出是右侧"统一多角度套图"。
>
> 所以它要解决的核心矛盾就两个:
> 1. **意图收敛**:这张参考帧,用户是想原样复刻、换真人、卡通化,还是只借元素?产品怎么出现?
> 2. **花钱前的闸门**:`gpt-image-2` 单视图 60s、一套 6 视图很慢很贵,不能用户随口一句就直接烧一整套。所以"出 prompt → 确认 → 才生成"这个 gate 是刚需。
>
> 把它当"聊天机器人"做就会很难受(每次都得多轮对话);把它当"确认台"做就顺了。
>
> ## 二、关键设计判断:确认的不是"一张图的 prompt",是"主体设定(bible)"
>
> 你们后端已经有 `pack bible` / source brief 这套——一套图靠同一份 bible 保证多角度是**同一个主体**。
>
> 那 UI 上闸门就应该让用户**确认这份主体设定**,而不是确认某一张图。一旦确认:
> - **数量 = 从这份锁定设定里渲染几个角度**
> - 多角度统一 = 自动成立(因为大家共享同一 bible)
>
> 这一条想通了,"多角度风格统一"就不是额外功能,而是数据结构的自然结果。
>
> ## 三、流程:对话是"可选精修",不是"强制多轮"
>
> 你之前给 Codex 的描述(读图→问→答→弹 prompt→确认→生成)方向是对的,我只改一点:**别强制聊天**。常见情况下用户知道自己要什么,应该 3 下点完;需要讨论时聊天才出现。
>
> ```
> ┌─ 转换层 ───────────────────┐
> │ [参考帧缩略图] [+换/加帧] │ 顶部:当前参考
> │ AI读图:白色筋膜枪·握把有手 │ 一行机读,不啰嗦
> │ │
> │ 想怎…
> Deploy fact recorded. `.project.json` needs no change (no URL changes this round). Committing the RULES.md update and pushing:
> 明白,这一句把设计的重心整个挪了。需求方不清楚自己要什么 → 那转换层就**不能问"你想怎么改"**(空白框对说不出需求的人是负担),它得**先替他想好,给他东西去反应**
> 完成。全部上线并验证
>
> 这其实就是你那套讲解稿里的"快速到 60 分,不从零起步"——机器先给一个站在验证过爆款上的初稿,人在 60 分上挑、上改,而不是对着空白发愁。**同一个原则,直接套到转换层。**
> ## 最终交付
>
> ## 修正后的定位:Agent 主动提案,需求方只做"选/推"
> **已推送** (Gitea, `4bcca76 → 22421eb`):
> - `3ed3f72` fix(api) · `b56d517` fix(canvas) · `6201ee9` fix(web) · `3572dde` chore · `22421eb` docs(deploy fact)
>
> 我上一轮说的"别强制聊天、给个 chip + 自由描述框"——对**知道自己要什么**的人对,对**说不清的人是错的**。删掉。改成:
> **已部署生产** (`76.13.31.179:/opt/skg-marketing-studio`,`deploy-prod-safe.sh`):
> - 部署前备份:`skg-marketing-preserve-20260529181045.tgz`(+ pg_dump)
> - web/api 镜像在**服务器**构建成功(本地的 Google Fonts 失败纯属本机网络环境,与代码无关)
>
> ```
> ┌─ 转换层 ────────────────────┐
> │ [参考帧] AI读图:白色筋膜枪·手持 │
> │ │
> │ 我建议这 3 个方向(选一个): │ ← agent 主动出,不问
> │ ┌─────────────────────────┐ │
> │ │ A 原样复刻 · 稳 缩略│ │
> │ │ B 换暖木纹高端款 · 提质感 缩略│ │ ← 每个都已是可生成的成品设定
> │ │ C 卡通吉祥物版 · 抓眼球 缩略│ │
> │ └─────────────────────────┘ │
> │ [就用 A 生成 6 视图 →] │ ← 默认动作,零思考一键走
> │ 不对?跟我说一句…(可选) │ ← 想拨一下才打字
> └───────────────────────────────┘
>
> 右侧主体元素:6 视图 queued → 逐视角回填
> ```
> **生产验证**(`verify-prod-docker.sh` + 容器内复验):
>
> 核心三点:
> - **进帧即出提案**。参考帧一进来,agent 自动读图 + 直接抛 2~3 个**已经成型、点了就能生成**的方向(每个背后都是一份完整主体设定 bible),而不是一个等用户填的空框。
> - **最小动作 = 一次点击认可**。说不清没关系,默认有个推荐项,点"就用这个"直接出 6 视图。想微调才说话("再高端点…
> | 检查 | 结果 |
> |---|---|
> | web/api/postgres 容器 | Up |
> | `/` 302 · `/login/` 200 · `_next` 404 · `/api/health` 401 | ✅ |
> | `web:no_local_api_refs` · `api:ytdlp_cookie_args []` | ✅ |
> | `api:health ok db connected` | ✅ |
> | `psycopg_pool 3.2.4` 生效 | ✅ |
> | SSRF:`file://`/`169.254.169.254`/`evil.com` → 400,`tiktok.com` 放行 | ✅ |
> | `run()` timeout=300 · `DOWNLOAD_TIMEOUT_SECONDS=600` | ✅ |
>
> **元数据**:`RULES.md` 部署事实已回写并推送;`.project.json` 无 URL 变化,无需改。
>
> ## 上线后留意
>
> - **新增 env**(都有安全默认值,可不配):`…
## Codex 最近会话
- Session ID019e55d1-ccf1-7a90-805f-3bd4799a49cd
- Transcript/Users/kangwan/.codex/sessions/2026/05/24/rollout-2026-05-24T01-11-13-019e55d1-ccf1-7a90-805f-3bd4799a49cd.jsonl
- Session ID019e63ac-1984-7a42-8c65-ffd7ab146968
- Transcript/Users/kangwan/.codex/sessions/2026/05/26/rollout-2026-05-26T17-44-43-019e63ac-1984-7a42-8c65-ffd7ab146968.jsonl
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 分支main
- 敏感字段:已对 token / key / password / secret 做脱敏
### 最近用户要求
- api没关系 其他的麻烦你直接给我恢复 别削弱
- ok 你现在看下api接入情况 有哪些模型可以直接接入好
- OK 接入吧
- # 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 最近回复
> 线上验收结果已经明确:`/health` 现在只返回 3 个图片模型和 1 个视频模型,前端包里也没有旧模型名。我把这次部署证据写回 `RULES.md` 和源码解析页,然后再做一个文档提交
> 已读到项目交接核心:最近不是要继续执行旧任务,而是先接住上下文;当前工作区也只有交接/日志类未提交变更。再补一个只读的提交/远端/差异概览,避免把 handoff 里的仓库状态当成最新事实
> 已接入并上线
> 完成只读承接。
>
> 现在生产画布里媒体模型已经收口为真实可用项:
> 我已按顺序读了 [assistant-handoff.md](/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/.memory/assistant-handoff.md)、[.project.json](/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/.project.json)、[RULES.md](/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/RULES.md)、[AGENTS.md](/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/AGENTS.md),并补了只读仓库状态检查。没有改文件、没有启动服务、没有继续旧任务。
>
> 图片
> `auto`
> `GPT Image 2`
> `Gemini 图片`
> 当前状态
> - 分支:`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`
>
> 视频
> `Seedance 2.0 Fast`,真实模型是 `doubao-seedance-2-0-fast-260128`
>
> 同时把旧上游模型从生成下拉里移除了,包括 `Nano Banana`、`Seedream`、旧 `Seedance 1.x`、`Sora`。`Kling / Veo` 这次没有假接入,因为当前后端没有独立可用通道,只有指向同一个 Seedance 的别名,已经在 `/health` 里按真实模型去重。
>
> 已部署到生产:`https://marketing.skg.com`
>
> 验证过:
> `npm run build` 通过。
> `./scripts/deploy-prod-safe.sh` 通过。
> 生产备份:`/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525105910.tgz`
> 生产 `/health` 返回:
> 图片模型 `auto,gpt-image-2,gemini-3-pro-image-preview`
> 视频模型仅 `seedance / Seedance 2.0 Fast`
> 视频时长 `5,8,10,12,15`
> 图片尺寸 `auto,1024x1536,1024x1024,1536x1024`
> 视频画幅 `720x1280,1280x720,1024x1024,960x1280`
>
> 提交也推了:
> `84d9de6 fix: align canvas model options with backend`
> `8278de4 docs: record model option deployment`
>
> 当前 `git status -sb` 只有本地 `.memory/worklog.json` 脏文件,代码和文档都已推到 `origin/main`。
## Cursor 最近会话
- 未在本机找到匹配这个项目的 Cursor transcript。
> 我接住的上下文
> - 当前项目是 SKG 营销内容生产平台,生产入口 `https://marketing.skg.com`,根域名登录后进个人画布。
> - 生产已收口到飞书免登录密码登录停用Postgres 已用于画布项目、用户索引、任务索引、资源索引等持久化。
> - 普通开发默认必须本地 Docker 验证并形成语义 commit没有你明确说“可以推送 / 上推 / 部署”前,不推 Gitea、不跑生…
## 当前仓库状态
- 当前分支main
- 未提交变更1 项
- 最近提交docs: record model option deployment
- 最近提交docs: record 2026-05-30 stability/security hardening deploy
- 变更文件:
- M .memory/worklog.json
## 统一接力要求
- 对话立项只用 Claude / CodexCursor 只用于项目目录已经创建之后的 GUI 开发承接。
- Claude / Codex 终端承接:先阅读本文件,再结合 `.project.json``RULES.md``AGENTS.md` 和当前仓库状态理解项目进度。
- Cursor GUI 承接:只打开当前项目根目录,不打开 `~/Projects``~/Projects/business``/Users/kangwan`
- 不要要求用户重新手工粘贴 Claude / Codex / Cursor 总结,缺口直接从代码、日志和 handoff 文件补。
- 不要要求用户重新手工粘贴 Claude / Codex 总结,缺口直接从代码、日志和 handoff 文件补
- 如果最近助手会话里有明确未完成项,只把它当作候选待办,不要自动继续执行。
- 当前目标是建立上下文并等待用户下一条明确指令,不要自行开始修改。

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,13 @@
"type" : "api_key"
},
{
"description" : "生产网页登录备用账号;飞书免登录为主路径,备用账号密码只放服务器 \/root\/skg-marketing-studio-login.txt后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET",
"description" : "xAI \/ Grok Imagine Video 独立视频通道 API Key生产只放服务器 deploy\/.env.production 的 XAI_VIDEO_API_KEY本地开发放 api\/.env 或 deploy\/.env.local不入库",
"name" : "XAI_VIDEO_API_KEY",
"storage" : "api\/.env \/ deploy\/.env.local \/ deploy\/.env.production",
"type" : "api_key"
},
{
"description" : "生产网页登录备用账号已停用,当前只允许飞书免登录;如需紧急恢复,需在服务器 deploy\/.env.production 显式开启 PASSWORD_AUTH_ENABLED=true。备用账号密码只放服务器 \/root\/skg-marketing-studio-login.txt后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET",
"name" : "WEB_LOGIN",
"storage" : "\/root\/skg-marketing-studio-login.txt \/ deploy\/.env.production",
"type" : "web_login"
@@ -43,9 +49,15 @@
"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 营销内容生产平台:根域名 https:\/\/marketing.skg.com 登录后直接进入个人生成画布,终端可见品牌位只保留 SKG logo。主路径为文生图、文生视频、图生视频每个登录用户只看到自己的任务和结果。画布用于整理多次生成结果,图片\/视频资产继续写入当前用户自己的后端 job旧 TK 复刻\/一键出片能力保留为高级入口。",
"description" : "SKG 营销内容生产平台:根域名 https:\/\/marketing.skg.com 登录后直接进入个人生成画布,终端可见品牌位只保留 SKG logo。主路径为文生图、文生视频、图生视频每个登录用户只看到自己的任务和结果。画布项目已接入服务端 Postgres 持久化,浏览器 localStorage 只作为缓存和首次导入来源;图片\/视频资产继续写入当前用户自己的后端 job旧 TK 复刻\/一键出片能力保留为高级入口。",
"kind" : "app",
"name" : "SKG 营销内容生产平台",
"ownership" : "company",
@@ -67,10 +79,10 @@
"label" : "SKG 营销内容生产平台",
"password" : "",
"url" : "https:\/\/marketing.skg.com\/login\/",
"username" : "飞书免登录;备用账号见 credentials.WEB_LOGIN"
"username" : "飞书免登录;密码登录已停用"
},
"stack" : [
"Next.js + Python(yt-dlp\/ffmpeg) + OpenAI-compatible LLM + GPT Image 2 + Azure OpenAI TTS + Seedance\/Kling\/Veo video gateway"
"Next.js + Vue\/Vite canvas + FastAPI + Postgres + Python(yt-dlp\/ffmpeg) + OpenAI-compatible LLM + GPT Image 2 + Azure OpenAI TTS + Seedance\/Kling\/Veo video gateway"
],
"status" : "active",
"urls" : [

View File

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

View File

@@ -1,25 +1,52 @@
# SKG 营销内容生产平台
## 启动
- 本地 Docker 启动:`./scripts/start-local-docker.sh`(默认 Web `http://localhost:4390`、API `http://localhost:4391`、Postgres 数据在 `data-local/postgres`;首次会从 `deploy/.env.local.example` 生成 gitignored 的 `deploy/.env.local`
- 本地 Docker 验证:`./scripts/verify-local-docker.sh`(检查容器、`/login/`、未登录 `/api/health`、容器内 `/health` + Postgres
- 本地真实生成验收涉及模型接入、API Key、图片生成或视频生成链路修复时已配置可用 base/key 且用户未明确禁止真实调用,就必须由 Agent 在本地 Docker / 当前画布中直接触发一次最小真实生成,记录模型、入口、任务 ID/生成 URL 或失败原因;不要只让用户自己手动点测。
- 本地 Docker 停止:`./scripts/stop-local-docker.sh`
- 后台启动(不弹 Terminal`./scripts/start-dev-background.sh`(通过 macOS launchd 后台托管;前端 4290 + 后端 4291日志写入 `.logs/`
- 后台停止:`./scripts/stop-dev-background.sh`
- 前端 dev`cd web && npm run dev`Next.js 16端口 4290
- 画布 dev`cd web && npm run dev:canvas`Vue / Vite端口 4292生产构建会作为根域名工作台输出
- 后端 dev`cd api && uvicorn main:app --host 127.0.0.1 --port 4291`FastAPI端口 4291重任务用
- 注意:后端不要带 `--reload` 跑长下载 / 抽帧 / 音频任务reload 会等待后台任务结束,导致 4291 端口占用但新请求卡住。
- 发布流程新规则2026-05-26所有修改默认先在本地 Docker 跑通并验证,确认可用后只保留本地 commit只有用户明确说“可以推送 / 上推 / 部署”时,才允许 `git push` 或执行生产部署。
## 最终产品和排查基线
- 最终产品入口固定为 `https://marketing.skg.com`;登录后根路径进入个人生成画布,之后所有升级和修复都以这套线上画布为主线。
- 当前本地基线必须尽量和线上一致:优先用 `./scripts/start-local-docker.sh` 启动 Web/API/Postgres`./scripts/verify-local-docker.sh` 验证;只在需要快速定位前端细节时才临时使用 `npm run dev``npm run dev:canvas`
- 旧 React 单对话框首页、旧 TK 复刻工作台、Agent Cut 和详情页功能保留为高级能力、兼容入口或代码参考;除非明确要求恢复,否则不要把它们当作新需求默认落点。
- 用户线上 bug 的默认处理顺序:确认用户和时间线 → 只读查询生产 API 容器内 `DATABASE_URL` / Postgres 与相关 job state → 必要时复制最小可复现数据到本地 Docker → 本地修复和验证 → 本地 commit → 等用户确认后再推送或部署。
- 排查线上数据时不要手动写生产库,不要覆盖服务器 `deploy/.env.production``data/``secrets/``/data/jobs`;需要恢复用户数据时先备份并给出影响范围。
## 立项决策快索引
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`
- 第一冲刺:步骤 1-4下载 / 拆轨 / 关键帧 / ASR+翻译)
- 当前产品方向2026-05-25 上游画布能力恢复版):默认入口是多人通用的 SKG 营销内容生产平台,`https://marketing.skg.com` 登录后直接进入个人生成画布,`/canvas/` 只作为旧链接兼容跳转到根域名。终端可见品牌位只放 SKG logo不在主界面展示“生图生视频”“SKG 生成画布”或长系统名。画布本体尽量恢复 `chatfire-AI/huobao-canvas` 的成熟交互不再削成三模式单输入框保留首页推荐词、画布底部推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载等上游能力多角度分镜、故事板、图转视频、绘本等工作流按上游结构创建节点。API 接入是例外:生成调用继续走本项目后端 `/api` 和当前登录 Cookie不要求员工在浏览器配置个人 API Key图片/视频模型选择只显示后端已经接通的媒体模型,不能让浏览器本地自定义或旧缓存模型进入生成下拉。API 设置弹窗只保留模型/端点配置外观,不能出现上游注册链接或外部品牌。用户登录后仍只看到自己的任务、结果详情页,继续沿用后端 owner 隔离;每个浏览器的画布项目先保存在本地 localStorage,图片/视频资产按登录用户写入后端 job。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。
- 当前产品方向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
- Grok 创建重试与 AI 润色修复生产部署2026-06-04`88d5983`AI 润色可用模型 fallback`549082a`Grok/xAI 视频创建阶段瞬时错误重试)已推送 Gitea 并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前生产备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260604064742.tgz`。生产 Docker 重建后脚本验证通过web/api/postgres Up、`web:no_local_api_refs``/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、`api:ytdlp_cookie_args []``api:health ok db connected`);补验 `https://marketing.skg.com/login/` 返回 200`https://marketing.skg.com/api/health` 未登录返回 401。生产 API 容器内 `/health` 确认 `xai_video_configured=true``xai_video_model=grok-imagine-video``xai_video_create_path=/v1/videos/generations``video_create_retry_attempts=3``video_create_retry_backoff_seconds=2.0`,视频模型列表中 `xai / Grok Imagine Video` 仍为 `available=true`
- Grok 创建重试生产真实生成验收2026-06-04部署后在生产 API 容器内用临时飞书测试身份走真实 HTTP API 创建 job `213509450eab`,触发 Grok video `5202f39c9827`,模型 `grok-imagine-video`xAI provider id `b59a8fa0-b294-9e0c-a4bf-a0b349876eba`;轮询到 `completed``/jobs/213509450eab/storyboard-videos/5202f39c9827.mp4` 返回 `206 video/mp4``content-range bytes 0-1023/2274409`,确认生产创建、轮询、下载链路真实通畅。
- Grok Imagine Video 生产接入2026-06-03`3f21672` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前生产备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260603155046.tgz`。本次先在服务器 `deploy/.env.production` 补齐 `VIDEO_MODEL_XAI=grok-imagine-video``XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai``XAI_VIDEO_API_KEY`、创建/轮询路径,补配置前已生成备份 `deploy/.env.production.xai-backup-20260603155008`,真实 key 不入库。生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过web/api/postgres Up、`web:no_local_api_refs``/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、`api:ytdlp_cookie_args []``api:health ok db connected`);生产 API 容器确认 `/health` 能力中 `xai_video_configured=true`,视频模型列表含 `seedance``seedance_hd``xai:grok-imagine-video`。生产真实 HTTP 验收已直接创建测试 job `1c3e63e5791b` 并触发 video `71fab5ea21d4`xAI provider id `e33bcb9e-d3dc-9a7c-92c4-480b4879f438`,最终 `/jobs/1c3e63e5791b/storyboard-videos/71fab5ea21d4.mp4` 返回 `206 video/mp4``content-range bytes 0-1023/1125433`确认创建、轮询、MP4 下载链路通畅。
- Agent Cut 独立预览服务器:`2.24.28.41`Ubuntu 24.04 / Docker Compose / 裸端口 `4290`),部署目录 `/opt/skg-marketing-studio`Compose 入口 `docker-compose.standalone.yml`,访问地址 `http://2.24.28.41:4290/agent/`。该入口用于“一分钟二创出片终端”预览:用户只提交 TikTok 链接和产品图,后端 `AgentRun` 状态机负责下载、抽帧、规划、生成、自动重跑、审片和合成。
- Agent Cut 独立预览验证2026-05-21已在 `2.24.28.41``/opt/skg-marketing-studio``docker-compose.standalone.yml` 启动 `skg-agent-api` / `skg-agent-web`;独立 compose 通过网络别名兼容 Nginx 的 `skg-marketing-api` upstream。该裸 IP HTTP 入口的服务器 `deploy/.env.production` 需要 `WEB_AUTH_COOKIE_SECURE=false`;本次已补齐 `WEB_AUTH_*` 后重启验证通过:未登录 `/agent/` 返回 302 到 `/login/`,登录后 `/agent/` 返回 200`/api/agent-runs` 返回数组,容器内 `/health` 返回 `ok:true``auth_configured:true`
- 稳定性/安全加固2026-05-30`3572dde`(含 `3ed3f72` fix(api) / `b56d517` fix(canvas) / `6201ee9` fix(web))已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260529181045.tgz`。本次后端:`run()` 子进程加超时(下载 `DOWNLOAD_TIMEOUT_SECONDS` 默认 600s、其余 300s超时 kill 并标 failed、新增 `validate_source_url()` SSRF 白名单(拒绝 `file://`/私有·环回·链路本地 IP域名走 `SOURCE_URL_ALLOWED_HOSTS`默认主流短视频平台、per-job `RLock` 保护 `save_state`/`update`/`update_generated_video` 与 retry 的 check-and-set、`db.py` 改用 `psycopg_pool` 连接池且写失败 `logging.error` 暴露、只读媒体 GET 改用不创建目录的 `job_path()`、多处 `Image.open()``with` 防 fd 泄漏;新增后端依赖 `psycopg-pool`(未装自动回退)。前端:画布 VideoNode 上传改走后端 `/jobs/upload` 拿稳定 URL 并在 `cleanNodeForStorage``blob:``useCachedMediaUrl` 用真实 `blob.size` 统计缓存并补 catch 竞态校验、读参考图补 credentials、删除与 Canvas 层重复的节点级视频轮询与 `api/video.js` 死代码、`request.js` timeout 改 60s+withCredentials首页/详情页视频轮询改为容错(连续失败 10 次才停、agent 页预览 objectURL 移入 effect、登录页 pointermove rAF 节流。飞书登录自动跳转行为按确认保留不动。本地 `python3 -m py_compile api/main.py api/db.py``cd web && pnpm build`canvas + next通过本机 Docker web 镜像因 next/font 拉取 Google Fonts 受限未重建,生产服务器构建正常)。生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过web/api/postgres Up、`web:no_local_api_refs``/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、`api:ytdlp_cookie_args []``api:health ok db connected`);生产 API 容器内复验 `psycopg_pool 3.2.4` 生效、`validate_source_url``file://`/`169.254.169.254`/`evil.com` 返回 400 而 `tiktok.com` 放行、`run()` 默认 timeout=300、`DOWNLOAD_TIMEOUT_SECONDS=600`。新增可选 env`DOWNLOAD_TIMEOUT_SECONDS``SOURCE_URL_ALLOWED_HOSTS``DB_POOL_MAX_SIZE`
- 我的工作流云端模板2026-05-26`5290812` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526031841.tgz`。本次新增 Postgres 表 `canvas_workflows``/canvas-workflows` 个人模板接口,画布工作流面板“我的工作流”可保存当前节点结构、刷新列表、删除模板,并在插回画布时重新生成节点 ID、按视口重排、重连边保存前会清理已生成图片/视频、任务进度、错误和 LLM 输出等运行态。本地验证 `python3 -m py_compile api/main.py api/db.py``cd web && npm run build` 通过;生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`),生产 web 静态 bundle 命中 `保存当前工作流``canvas-workflows`API 容器查询 `to_regclass('public.canvas_workflows')` 返回 `canvas_workflows`
- 生图配置恢复2026-05-26`bdb7226` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526060255.tgz`。本次按用户要求恢复最初简单生图配置:图片模型为 `auto,gpt-image-2,gemini-3-pro-image-preview`,尺寸只保留 `auto,1024x1536,1024x1024,1536x1024`,画质恢复为单一标准项,撤回低/中/高画质、自定义尺寸、Gemini 1K/2K/4K 长列表和取消自动模型的改动。脚本内首次验证在容器启动 4 秒时遇到根路径临时 500随后复跑 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`);生产 API 容器确认 `auto -> ['gpt-image-2','gemini-3-pro-image-preview']`,生产 web 静态包未命中 `supportsCustomSize``1536×2752``自定义 1088`
- 最近部署验证2026-05-25`84d9de6` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,画布图片/视频模型选择收口到当前后端真实可用媒体模型。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525105910.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。补验:外部访问 `https://marketing.skg.com/` 未登录返回 302 到 `/login/?next=/``https://marketing.skg.com/p/test` 未登录返回 302 到 `/login/?next=/p/test`;容器内 `/health` 返回 `image_options=auto,gpt-image-2,gemini-3-pro-image-preview``video_options=seedance:Seedance 2.0 Fast:doubao-seedance-2-0-fast-260128``video_duration_options=5,8,10,12,15`,图片尺寸为 `auto,1024x1536,1024x1024,1536x1024`,视频画幅为 `720x1280,1280x720,1024x1024,960x1280`;生产静态 bundle 命中 `GPT Image 2 / Gemini 图片 / Seedance 2.0 Fast / 1024x1536 / 720x1280`,未命中 `Nano Banana / Seedream / doubao-seedream / doubao-seedance-1 / sora-2 / Kling / Veo 3`
- 生产配置验证2026-05-25 23:49 CST已在服务器 `/opt/skg-marketing-studio/deploy/.env.production` 补齐飞书 OAuth 应用配置,并仅重建 `skg-marketing-api` 使环境变量生效;敏感 App Secret 不入库。验证结果:`https://marketing.skg.com/api/auth/config` 返回 `feishu_enabled=true``password_enabled=true``data_isolation_enabled=true``GET https://marketing.skg.com/api/auth/feishu/start?next=/` 返回 302 跳转到飞书授权页;容器内 `/health` 返回 `auth_modes.feishu=True`
- 最近部署验证2026-05-26`c9d8fa7` 对应 Postgres 持久化代码已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`。生产新增 `skg-marketing-postgres` 容器,数据库持久化在服务器 `./data/postgres``DATABASE_URL` / `POSTGRES_PASSWORD` 只写服务器 `deploy/.env.production`。部署前脚本备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525225145.tgz`;生产 Docker 重建后脚本内验证通过web/API/Postgres 容器 Up、Postgres healthy、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok db connected``api:ytdlp_cookie_args []`)。文档/元数据同步后又执行 `./scripts/deploy-prod-safe.sh --no-build`,实际走过 Postgres `pg_dump` 备份路径并生成 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525230444.tgz`,复验同样通过。补验:容器内 `/health` 返回 `database.enabled=true``database.connected=true``/api/auth/config` 返回 `feishu_enabled=true``password_enabled=true``data_isolation_enabled=true`;画布项目 API 可创建、读取、软删除记录;数据库索引计数为 users=1、jobs=26、assets=129、canvas_active=0、canvas_deleted=1、audit=2。
- 生产登录收口2026-05-26已在服务器 `/opt/skg-marketing-studio/deploy/.env.production` 设置 `PASSWORD_AUTH_ENABLED=false` 并通过 `./scripts/deploy-prod-safe.sh` 重建生产。部署前脚本备份到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526003816.tgz`;脚本内首次验证在容器启动 3 秒时遇到根路径临时 500随后复跑 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`)。公网复验:`/api/auth/config` 返回 `password_enabled=false``feishu_enabled=true``data_isolation_enabled=true``GET /api/auth/feishu/start?next=/` 返回 302 到飞书授权页;`POST /api/auth/login` 返回 503 `账号密码登录未配置`
- 旧密码账号归属迁移2026-05-26已把旧共享密码账号 `password:skg` 下的 22 个 job、3 个画布项目和对应生成资产索引迁到飞书用户 `万康``feishu:ou_78276b4fd9dd818d8f70bc00d03ddbdf`)。迁移前已备份数据库和 `data/jobs``/opt/skg-marketing-studio-backups/skg-marketing-owner-migration-20260526010622.sql.gz``/opt/skg-marketing-studio-backups/skg-marketing-owner-migration-jobs-20260526010622.tgz`。复验:`job_index` 中该飞书用户 24 个 job`canvas_projects` 中该飞书用户 3 个未删除私有画布,生成资产索引为 image completed=11、video completed=11、video failed=1无 owner 的 4 个更早旧 job 保持未迁移,后续再确认归属。
- 视频错误提示收口2026-05-26`579e538` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526014111.tgz`。本次把 Seedance / Doubao 视频上游错误转换为员工可读中文后再写入 `GeneratedVideo.error`,例如 `InputImageSensitiveContentDetected.PrivacyInformation` 会提示参考图含清晰人物或疑似真实人脸,需要换无脸首帧、裁切或模糊人物脸;原始上游错误只保留在 API 日志。脚本内首次验证在容器启动 3 秒时遇到根路径临时 500随后复跑 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`),并在生产 API 容器内确认隐私风控码会返回中文解释。
- AI 润色中性化2026-05-26`509bd9b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526020846.tgz`。本次把画布 `AI 润色`、LLM 节点和自动执行意图分析从 SKG 广告文案接口 `/creative/copy` 拆出,新增中性 `POST /prompt/polish`:只优化用户已经写明的主体、品牌、产品、地点、风格和镜头,不主动添加 SKG、按摩产品、TikTok/Reels 广告话术、标题或 hashtag`/creative/copy` 继续保留给明确的 SKG 营销文案场景。脚本内验证通过web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`),并在生产 API 容器内确认普通雨夜街头摊位提示词经 `/prompt/polish` 兜底输出不包含 SKG、massage 或 TikTok。
- AI 润色人物安全词分流2026-05-26`daec523` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526022320.tgz`。本次在 `/prompt/polish` 增加人物意图检测:原提示词没有人物语义或明确写无人时,润色只补“保持 object-only / scene-only / product-only 构图,不新增 people、faces、bodies、hands、avatars、characters、crowds”原提示词明确有人像、模特、角色、数字人或脸时才补“fully fictional synthetic AI character / virtual avatar / not based on any real person”。脚本内验证通过web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`),并在生产 API 容器内确认雨夜章鱼烧摊位不会出现虚构角色安全词,年轻女生人像会出现虚构 AI 角色安全词。
- AI 润色意图校验和参考图人物提示2026-05-26`f5be97b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526035016.tgz`。本次把 `/prompt/polish` 改为清理旧模板尾巴、分类人物/无人/物体/场景/动物/未知主体并做冲突修复:不主动加入 SKG、产品、平台、广告语境或人物也不把未知主体强行润成无人物同时 `/storyboard/video` 最终入队前会给参考图请求追加条件提示,说明参考图里若有人物、脸、身体、手、头像或角色,应按 AI 生成的虚拟角色、非真人、非公众人物处理,允许员工继续用 AI 人像素材做图生视频。部署脚本内首次验证在容器启动 3 秒时遇到根路径临时 500随后复跑 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`),并在生产 API 容器内确认参考图提示词追加和 `InputImageSensitiveContentDetected.PrivacyInformation` 中文错误解释已生效。
- 推荐词轮换2026-05-26`d01fdc5` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526023923.tgz`。本次把画布和首页推荐词从固定数组改为 4 个一组的短词池,刷新按钮绑定为切换下一组;推荐栏固定单行高度并截断过长 chip避免把底部输入框顶高。脚本内验证通过web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`),并在生产 web 容器静态 bundle 中确认命中 `换一组推荐``魔法森林``无人物街景` 等新文案。
- 推荐词扩展2026-05-26`7f3a6cc` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526024847.tgz`。本次新增 `web/canvas-app/src/config/suggestions.js`,把首页和画布推荐词统一为 30 组 / 120 个短词共享池,每次仍显示 4 个并按组轮换,保持单行不顶起 composer。本地验证 `groups=30``items=120`、最长词 5 个字符;本地 `npm run build` 和生产 Docker 构建通过,`./scripts/verify-prod-docker.sh` 复验通过web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`),并在生产 web 容器静态 bundle 中确认命中 `银河帐篷`
- 最近部署验证2026-05-25`cce9779` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,恢复 `chatfire-AI/huobao-canvas` 上游画布能力但保留 SKG 后端 `/api` 接入。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525102857.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。补验:外部访问 `https://marketing.skg.com/` 未登录返回 302 到 `/login/?next=/``https://marketing.skg.com/canvas/` 返回 308 到 `/``https://marketing.skg.com/p/test` 未登录返回 302 到 `/login/?next=/p/test`;容器内静态 bundle 命中 `AI 润色 / 自动执行 / 推荐: / 首帧 / 尾帧 / 多角度分镜 / 儿童绘本 / 工作流模板 / 批量下载素材`未命中上游注册链接、火宝欢迎文案、GitHub 入口或 `/huobao-canvas`
- 最近部署验证2026-05-25`e767d2b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,生产根域名改为直接进入个人生成画布,`/canvas/` 仅作为旧链接 308 跳转到 `/`。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525095839.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。补验:容器内 `/usr/share/nginx/html/index.html` 为 Vue 画布产物,引用 `/assets/index-CioZwOvT.js` 且 title 为 `SKG`;静态 bundle 命中 `文生图 / 文生视频 / 图生视频`,未命中 `首帧生视频 / 首尾帧生视频 / 上传首帧 / 上传尾帧 / 推荐:`;外部访问 `https://marketing.skg.com/` 未登录返回 302 到 `/login/?next=/``https://marketing.skg.com/canvas/` 返回 308 到 `/``/p/test` 未登录返回 302 到 `/login/?next=/p/test`
- 最近部署验证2026-05-25`2a1ceee` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,可见品牌位从文字命名收敛为 logo-only首页、登录页和画布首页只显示 SKG logo网页 title 和画布 title 为 `SKG`,首页入口按钮文案为“画布”。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525092749.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。容器内静态产物复验:`index.html` 包含 `<title>SKG</title>``/skg-logo-black.svg`,首页入口包含“画布”,登录页只保留 logo当前 `_next``/canvas` 产物未再命中 `SKG 生图生视频``SKG 生成画布``营销内容生产平台``内容生产画布` 等旧可见文案。
@@ -74,20 +101,23 @@
- 文档 / 解析:`docs/source-analysis.html`(项目内独立文档,不公开挂主应用路由)
- 管理后台:待定
- 服务器目录:`/opt/skg-marketing-studio`
- 生产部署唯一入口:`./scripts/deploy-prod-safe.sh`(先在服务器备份 `deploy/.env.production``data/jobs`、资源库和 `secrets`,再用受保护 rsync 同步代码,最后 Docker 重建并运行 `verify-prod-docker.sh`
- 本地 Docker 是生产前默认验收口径:`./scripts/start-local-docker.sh` 构建本地 Web/API/Postgres`./scripts/verify-local-docker.sh` 通过后才允许进入推送/部署讨论。本地 Docker 使用 `docker-compose.local.yml``deploy/.env.local``data-local/`,不能读取或覆盖生产 `deploy/.env.production`服务器 `data/` `secrets/`
- 生产部署唯一入口:`./scripts/deploy-prod-safe.sh`(先在服务器备份 `deploy/.env.production``data/jobs`、资源库和 `secrets`,如 Postgres 容器存在则额外导出 `pg_dump`,再用受保护 rsync 同步代码,最后 Docker 重建并运行 `verify-prod-docker.sh`
- 生产容器重建命令:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build`;只允许脚本内部或明确只重启容器时使用,不允许再用裸 `rsync --delete` 手动同步。
- 独立预览容器重建命令:服务器 `/opt/skg-marketing-studio` 下执行 `docker compose -f docker-compose.standalone.yml --env-file deploy/.env.production up -d --build`Web 暴露 `0.0.0.0:4290->80`,后端仅在 compose 内部网络暴露,`/api/` 由 Web 容器 Nginx 反代并复用应用内登录校验。
- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出与根域名 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`Traefik 通过 `coolify` 外部网络接入 80/443
- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出与根域名 Vue / Vite 画布静态应用;构建时先生成画布,再 Next 静态导出,最后用画布产物覆盖 `web/out/index.html``/assets/`,使登录后的 `/` 直接进入画布;`/canvas/` 只做 308 兼容跳转到 `/``/login/``/_next/``/assets/``/skg-logo-black.svg``/oasis-source/` 等登录页必需静态资源公开访问;未登录访问工作台跳转 `/login/``/api/` 通过 Nginx `auth_request` 校验 FastAPI 会话 Cookie 后反代到 `skg-marketing-api:4291`FastAPI 通过内网 `DATABASE_URL` 连接 `skg-marketing-postgres:5432`Postgres 不对公网暴露;Traefik 通过 `coolify` 外部网络接入 80/443
- Web 验收必须以生产 Docker 形态为准:前端是 `next export` 静态产物 + Nginx不是 `next dev` / `next start`。任何 Web 改动部署后必须运行 `./scripts/verify-prod-docker.sh`,确认 `/login/``/_next/``/api/health`、本地 API 地址泄漏和 API 镜像 `.env` 污染检查通过;不能只用本地 `npm run build` 作为上线依据。
- 未经用户明确确认,不允许推送 Gitea 或部署生产;完成开发任务时报告本地 Docker 验证结果、当前分支、本地领先数量和待推送 commit。
- 当前音频解析:`https://ai.skg.com/azure/v1``gpt-4o-transcribe` 当前返回 `DeploymentNotFound`,且官方 Azure OpenAI transcription 路径探测也未返回可用部署;生产临时复制本地成功策略,直接使用容器内多语言 `faster-whisper` 真实转写,默认语种为 `auto`,支持中文、英文和其他多语言原文识别,关闭 Gemini 多模态音频兜底。拿到真实 Azure ASR deployment 名后再恢复 `ASR_REMOTE_ENABLED=true`,并保持 `ASR_LANGUAGE` 为空或 `auto`,除非明确只想强制单一语种。
- 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`;全局资源中心持久化在 `./data/asset_library``./data/prompt_library``./data/_trash`
- 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`;全局资源中心持久化在 `./data/asset_library``./data/prompt_library``./data/_trash`Postgres 数据目录为服务器 `./data/postgres`,部署脚本通过 `pg_dump` 产出 `/opt/skg-marketing-studio-backups/skg-marketing-postgres-*.sql.gz`
- TikTok 下载登录态:公开视频默认不带 cookies 直接下载,生产环境变量必须显式保持 `YTDLP_COOKIES_FILE=``YTDLP_COOKIES_FROM_BROWSER=` 为空,防止容器读取不存在的浏览器 cookies。只有 TikTok 明确要求登录态时,才使用服务器私有 cookies 文件 `./secrets/tiktok_cookies.txt` 挂载到 API 容器 `/run/secrets/tiktok_cookies.txt` 并配置 `YTDLP_COOKIES_FILE=/run/secrets/tiktok_cookies.txt``yt-dlp` 会在任务结束时回写 cookies因此不要把该挂载设为只读不要使用云端浏览器读取方案也不要把 cookies 入库。生产容器严禁使用 `YTDLP_COOKIES_FROM_BROWSER=chrome`
- 登录凭证:生产入口飞书免登录为主;飞书 OAuth 的 `FEISHU_APP_ID` / `FEISHU_APP_SECRET` 只放服务器 `deploy/.env.production`,回调地址固定为 `https://marketing.skg.com/api/auth/feishu/callback` 并需要在飞书开放平台应用安全设置中登记。登录页读取 `/api/auth/config` 后,如果检测到飞书客户端并且 `feishu_enabled=true`,会自动跳转 `/api/auth/feishu/start`,普通浏览器仍保留“飞书免登录”按钮和备用账号。原账号密码登录保留为备用入口,用户名写下方快捷登录,密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt``WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`。开启 `AUTH_DATA_ISOLATION_ENABLED=true` 后,新建任务、素材任务和一键出片记录按登录用户隔离;历史无 owner 的旧任务只对备用账号可见,飞书用户互不可见
- 登录凭证:生产入口只允许飞书免登录;飞书 OAuth 的 `FEISHU_APP_ID` / `FEISHU_APP_SECRET` 只放服务器 `deploy/.env.production`,回调地址固定为 `https://marketing.skg.com/api/auth/feishu/callback` 并需要在飞书开放平台应用安全设置中登记。登录页读取 `/api/auth/config` 后,如果检测到飞书客户端并且 `feishu_enabled=true`,会自动跳转 `/api/auth/feishu/start`,普通浏览器显示“飞书免登录”按钮;生产 `PASSWORD_AUTH_ENABLED=false` 时账号密码表单不展示,`POST /auth/login` 不可用,旧密码 Cookie 会失效。原账号密码只作为紧急备用配置保留在服务器 `/root/skg-marketing-studio-login.txt``deploy/.env.production`,如需临时恢复必须显式改为 `PASSWORD_AUTH_ENABLED=true` 并重启 API。开启 `AUTH_DATA_ISOLATION_ENABLED=true` 后,新建任务、素材任务和一键出片记录按登录用户隔离;历史无 owner 的旧任务不再通过密码账号访问,后续应走迁移/认领
- 禁止手动裸 `rsync --delete` 到服务器;必须使用 `./scripts/deploy-prod-safe.sh`。如遇极端情况必须手动同步,命令必须同时包含 protect/exclude`.git``.memory``.logs``.pids``data``jobs``secrets``api/jobs``api/.env``api/.env.local``api/.env.production``deploy/.env.production``web/node_modules``web/.next``web/out`。不要把本地 `api/.env``deploy/.env.production` 覆盖到 `/opt/skg-marketing-studio`,也不要删除服务器 `data/jobs`,否则会清空案例、登录和模型配置。
## 快捷登录
- 登录地址:`https://marketing.skg.com/login/`
- 主路径:飞书免登录
- 密码登录:生产已停用
- 备用用户名:`skg`
- 备用密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
- 说明:当前是生产入口应用内登录页;飞书 App Secret、数据库密码、API Key、服务器 root 密码不要写这里
@@ -106,7 +136,7 @@
- Gitea 网页仓库:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
- 每次开发结束前必须执行并汇报 `git status -sb` 和变更范围
- 代码、规则、部署或元数据变更必须形成 `feat:``fix:``docs:``chore:``release:` 等人工语义 commit`auto-save` 只算安全快照
- 能联网和鉴权时必须 `git push origin main`如果不能推送,最终回复必须写清楚当前分支、领先/落后数量、最新未推送 commit 和失败原因
- 用户明确确认“可以推送 / 上推 / 部署”前,不允许 `git push` 或生产部署;用户确认后,能联网和鉴权时 `git push origin main`如果届时不能推送,最终回复必须写清楚当前分支、领先/落后数量、最新未推送 commit 和失败原因
## 环境变量
- `LLM_BASE_URL` / `LLM_API_KEY`OpenAI 兼容网关,用于翻译、文案改写、音频分析等文本/多模态理解模型调用
@@ -122,7 +152,8 @@
- `LOCAL_ASR_BIN` / `LOCAL_ASR_MODEL` / `LOCAL_ASR_TIMEOUT_SECONDS`:本机 ASR 兜底,默认使用 `/opt/homebrew/bin/mlx_whisper` + `mlx-community/whisper-tiny`,用于当前 SKG 网关 `/audio/transcriptions` 不可用时生成真实逐句时间轴
- `TRANSLATE_MODEL`:字幕翻译模型,默认 `gemini-2.5-flash`
- `GPT_TEXT_MODEL`GPT 文本 / 视觉默认模型,默认 `gpt-4o`;用于兜底修正旧 Gemini 覆盖值
- `REWRITE_MODEL`:通用改写/分镜描述模型,默认 `gpt-4o`;如果旧环境仍写 `gemini-*`,后端会自动改用 `GPT_TEXT_MODEL`
- `REWRITE_MODEL`:通用改写/分镜描述模型,当前用于 AI 润色时默认 `gpt-4o-mini`;如果主模型不可用,`/prompt/polish` 会继续尝试 `REWRITE_MODEL_FALLBACKS`
- `REWRITE_MODEL_FALLBACKS`AI 润色备用模型列表,逗号分隔,默认 `gpt-4o-mini,gemini-2.5-flash`;只有全部失败时才允许返回本地模板 fallback
- `VISION_MODEL`:关键帧画面理解模型,默认 `gpt-4o`;如果旧环境仍写 `gemini-*`,后端会自动改用 `GPT_TEXT_MODEL`
- `AUDIO_REWRITE_MODEL`:后续音频口播改写模型,默认跟随 `REWRITE_MODEL`;如果旧环境仍写 `gemini-*`,后端会自动改用 `REWRITE_MODEL`
- `AUDIO_PRODUCT_BRIEF`:音频口播改写时注入的 SKG 产品卖点
@@ -132,13 +163,16 @@
- `IMAGE_FALLBACK_ENABLED` / `IMAGE_FALLBACK_MODEL`:图片主模型故障兜底;当前允许在 `gpt-image-2` 超时、429、5xx 或网络错误时临时使用 `gemini-3-pro-image-preview`400/401/403/404 和参数错误不兜底
- `IMAGE_CIRCUIT_FAILURE_THRESHOLD` / `IMAGE_CIRCUIT_COOLDOWN_SECONDS`:短时熔断配置,默认 `gpt-image-2` 连续 2 次上游类失败后 600 秒内直接走 Gemini 兜底;成功恢复后自动清空失败计数
- `GPT_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODELS`:保留兼容旧环境变量名;主体 6 视图在转换层默认自动使用 `gpt-image-2`,同一套图内一旦触发 Gemini 兜底,后续视图沿用 Gemini避免一张张等待主模型超时用户显式选择 GPT 或 Gemini 时,`image_model_preference` 会让主体套图只走所选模型
- `AI_HTTP_PROXY` / `IMAGE_HTTP_PROXY`:可选的 AI 网关出站代理;本地 launchd 后台进程不一定继承 shell 的 `http_proxy/https_proxy`,如生图报 DNS / ConnectError可在本地 `api/.env` 配置后重启后端。`/health` 只回传是否配置代理,不回传代理地址。
- `AI_HTTP_PROXY` / `IMAGE_HTTP_PROXY`:可选的 AI 网关出站代理;本地 launchd 后台进程不一定继承 shell 的 `http_proxy/https_proxy`,如生图或生视频 MP4 下载报 DNS / ConnectError / SSL 握手异常,可在本地 `api/.env` 配置后重启后端。本地 Docker 使用 `deploy/.env.local`,宿主机代理要写成 `http://host.docker.internal:端口`,不能写容器内的 `127.0.0.1``/health` 只回传是否配置代理,不回传代理地址。
- `YTDLP_COOKIES_FILE` / `YTDLP_COOKIES_FROM_BROWSER`:可选 TikTok 下载登录态;生产云端固定使用 cookies 文件 `/run/secrets/tiktok_cookies.txt`(宿主机 `./secrets/tiktok_cookies.txt` 挂载进容器),本地开发可临时用浏览器 cookies。cookies 文件属于敏感登录态,只能放本机或服务器私有路径,不允许入库。
- `VOICE_PROVIDER`:配音通道,服务端固定使用 `azure_openai`;旧环境若写 `minimax` 会被忽略
- `AZURE_OPENAI_BASE_URL` / `AZURE_OPENAI_API_KEY`:微软 Azure OpenAI 协议配音网关;本地未单独配置 Key 时回退复用 `LLM_API_KEY`
- `AZURE_TTS_MODEL` / `AZURE_TTS_VOICE_ID` / `AZURE_TTS_VOICE_POOL` / `AZURE_TTS_PATH` / `AZURE_TTS_PATHS`Azure OpenAI TTS 模型、默认音色、音色池和 OpenAI 协议语音路径;后端会按 `AZURE_TTS_PATHS` 依次尝试,便于区分路径不对和整条语音服务不可用
- `POE_API_KEY` / `VIDEO_API_KEY`:视频生成通道 Key只能放本地环境变量
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产备用网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库。即使只开飞书免登录,也必须配置 `WEB_AUTH_SESSION_SECRET` 用于签名会话 Cookie
- `POE_API_KEY` / `VIDEO_API_KEY`默认视频生成通道 Key只能放本地环境变量;如果显式配置了 `VIDEO_API_BASE_URL`,必须同时配置 `VIDEO_API_KEY` 才会在 `/health` 暴露该默认视频通道,不能用通用 `LLM_API_KEY` 冒充视频 key。
- `XAI_VIDEO_API_BASE_URL` / `XAI_VIDEO_API_KEY` / `VIDEO_MODEL_XAI`xAI / Grok Imagine Video 独立视频通道;默认 base 为 `https://ai.skg.com/ezlink/xai`,模型为 `grok-imagine-video`,真实 key 只放本地 `api/.env`、本地 Docker `deploy/.env.local` 或服务器 `deploy/.env.production`,不入库。未配置 `XAI_VIDEO_API_KEY``/health` 会标记 xAI 视频不可用,画布不显示该模型;已配置时即使默认 Doubao/Seedance 视频 key 为空,也可以独立显示和生成 Grok Imagine Video
- `VIDEO_CREATE_RETRY_ATTEMPTS` / `VIDEO_CREATE_RETRY_BACKOFF_SECONDS`:视频创建请求的瞬时错误重试配置,默认 Grok/xAI 创建阶段遇到连接重置、超时、429 或 5xx 时最多尝试 3 次,基础退避 2 秒400/401/403/404 等参数或权限错误不重试,避免掩盖真实配置问题。
- `PASSWORD_AUTH_ENABLED`:生产密码登录总开关;当前固定为 `false`,只允许飞书免登录。若应急恢复密码入口,必须显式改成 `true` 并重启 API。
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产备用网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库。当前密码入口被 `PASSWORD_AUTH_ENABLED=false` 禁用;即使只开飞书免登录,也必须配置 `WEB_AUTH_SESSION_SECRET` 用于签名会话 Cookie。
- `FEISHU_APP_ID` / `FEISHU_APP_SECRET`:飞书免登录 OAuth 应用凭证;只放服务器 `deploy/.env.production` 或本地 `api/.env`,不入库。
- `FEISHU_REDIRECT_URI`:飞书 OAuth 回调地址,生产固定为 `https://marketing.skg.com/api/auth/feishu/callback`
- `FEISHU_OAUTH_SCOPE`:飞书 OAuth 授权范围;默认空值,按飞书应用后台已开权限执行。

View File

@@ -30,7 +30,8 @@ LOCAL_ASR_MODEL=mlx-community/whisper-tiny
LOCAL_ASR_TIMEOUT_SECONDS=180
TRANSLATE_MODEL=gemini-2.5-flash
GPT_TEXT_MODEL=gpt-4o
REWRITE_MODEL=gpt-4o
REWRITE_MODEL=gpt-4o-mini
REWRITE_MODEL_FALLBACKS=gemini-2.5-flash
VISION_MODEL=gpt-4o
PRODUCT_VIEW_MODEL=gpt-image-2
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
@@ -39,17 +40,30 @@ IMAGE_MODEL=gpt-image-2
IMAGE_REQUEST_TIMEOUT_SECONDS=60
IMAGE_FALLBACK_ENABLED=true
IMAGE_FALLBACK_MODEL=gemini-3-pro-image-preview
# 多备用模型用逗号分隔;未设置时兼容 IMAGE_FALLBACK_MODEL。
IMAGE_FALLBACK_MODELS=gemini-3-pro-image-preview
# 可选:把其它 OpenAI-compatible 图片模型加入 /health 和前端白名单,默认走 IMAGE_BASE_URL/IMAGE_API_KEY。
IMAGE_EXTRA_MODELS=
# 可选JSON 覆盖/扩展模型配置,建议只写 api_key_env不把真实 key 写入 JSON。
# IMAGE_MODEL_CONFIGS_JSON={"custom-model":{"label":"Custom Image","base_url_env":"CUSTOM_IMAGE_BASE_URL","api_key_env":"CUSTOM_IMAGE_API_KEY","provider":"openai","sizes":["1024x1024"],"default_size":"1024x1024"}}
IMAGE_CIRCUIT_FAILURE_THRESHOLD=2
IMAGE_CIRCUIT_COOLDOWN_SECONDS=600
GPT_IMAGE_MODEL=gpt-image-2
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2,gemini-3-pro-image-preview
# 火山方舟 Seedream 图片模型。真实 key 只填本地/服务器 .env不提交到 git。
ARK_SEEDREAM_ENABLED=true
ARK_IMAGE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
ARK_IMAGE_API_KEY=
ARK_SEEDREAM_IMAGE_MODEL=doubao-seedream-4-5-251128
# 可选:本地网络需要代理访问 ai.skg.com 时配置launchd 不一定继承 shell 代理变量。
AI_HTTP_PROXY=
YTDLP_COOKIES_FILE=
YTDLP_COOKIES_FROM_BROWSER=
VIDEO_MODEL=seedance
VIDEO_MODEL_SEEDANCE=seedance-2-fast
VIDEO_MODEL_XAI=grok-imagine-video
# Kling / Veo aliases are only exposed when the active video gateway is Poe or another verified compatible gateway.
VIDEO_MODEL_KLING=kling-omni
VIDEO_MODEL_VEO3=veo-3.1-fast
@@ -85,6 +99,13 @@ POE_API_KEY=
# VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id}
# VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content
#
# SKG xAI/Grok Imagine 视频网关。真实 key 只填本地/服务器私有 .env。
XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai
XAI_VIDEO_API_KEY=
XAI_VIDEO_CREATE_PATH=/v1/videos/generations
XAI_VIDEO_STATUS_PATH=/v1/videos/{id}
XAI_VIDEO_CONTENT_PATH=
#
# 自定义视频网关覆盖;留空时如配置 POE_API_KEY则走 Poe。
VIDEO_API_BASE_URL=
VIDEO_API_KEY=
@@ -94,6 +115,8 @@ VIDEO_STATUS_PATH=/videos/{id}
VIDEO_CONTENT_PATH=/videos/{id}/content
VIDEO_DURATION_FIELD=seconds
VIDEO_POLL_TIMEOUT_SECONDS=900
VIDEO_CREATE_RETRY_ATTEMPTS=3
VIDEO_CREATE_RETRY_BACKOFF_SECONDS=2
# 工作目录
KEYFRAME_COUNT=12

950
api/db.py Normal file
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
httpx==0.27.2
requests==2.32.5
psycopg[binary]==3.2.3
psycopg-pool==3.2.4
imagehash==4.3.1
Pillow>=11.0
numpy>=2.0

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

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

View File

@@ -9,7 +9,14 @@ KEYFRAME_COUNT=12
CORS_ORIGINS=https://marketing.skg.com
API_PORT=4291
# Company persistence database. Real password and DATABASE_URL live only on server.
POSTGRES_DB=skg_marketing
POSTGRES_USER=skg_marketing
POSTGRES_PASSWORD=
DATABASE_URL=postgresql://skg_marketing:CHANGE_ME@postgres:5432/skg_marketing
# Web login. Keep real password and session secret only on the server.
PASSWORD_AUTH_ENABLED=false
WEB_AUTH_USERNAME=skg
WEB_AUTH_PASSWORD=
WEB_AUTH_SESSION_SECRET=
@@ -49,7 +56,8 @@ FASTER_WHISPER_DEVICE=cpu
FASTER_WHISPER_COMPUTE_TYPE=int8
TRANSLATE_MODEL=gemini-2.5-flash
GPT_TEXT_MODEL=gpt-4o
REWRITE_MODEL=gpt-4o
REWRITE_MODEL=gpt-4o-mini
REWRITE_MODEL_FALLBACKS=gemini-2.5-flash
VISION_MODEL=gpt-4o
PRODUCT_VIEW_MODEL=gpt-image-2
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
@@ -58,11 +66,20 @@ IMAGE_MODEL=gpt-image-2
IMAGE_REQUEST_TIMEOUT_SECONDS=60
IMAGE_FALLBACK_ENABLED=true
IMAGE_FALLBACK_MODEL=gemini-3-pro-image-preview
IMAGE_FALLBACK_MODELS=gemini-3-pro-image-preview
IMAGE_EXTRA_MODELS=
# Optional JSON model overrides. Use api_key_env/base_url_env; do not put real keys in git.
# IMAGE_MODEL_CONFIGS_JSON={"custom-model":{"label":"Custom Image","base_url_env":"CUSTOM_IMAGE_BASE_URL","api_key_env":"CUSTOM_IMAGE_API_KEY","provider":"openai","sizes":["1024x1024"],"default_size":"1024x1024"}}
IMAGE_CIRCUIT_FAILURE_THRESHOLD=2
IMAGE_CIRCUIT_COOLDOWN_SECONDS=600
GPT_IMAGE_MODEL=gpt-image-2
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2,gemini-3-pro-image-preview
# Optional Ark Seedream image channel. Keep the real key only in deploy/.env.production on the VPS.
ARK_SEEDREAM_ENABLED=true
ARK_IMAGE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
ARK_IMAGE_API_KEY=
ARK_SEEDREAM_IMAGE_MODEL=doubao-seedream-4-5-251128
# Optional outbound proxy for AI gateway calls. Leave blank on normal VPS networking.
AI_HTTP_PROXY=
@@ -91,10 +108,16 @@ VIDEO_API_BASE_URL=https://ai.skg.com/doubao
VIDEO_API_KEY=
VIDEO_MODEL=seedance
VIDEO_MODEL_SEEDANCE=doubao-seedance-2-0-fast-260128
VIDEO_MODEL_KLING=kling-omni
VIDEO_MODEL_VEO3=veo-3.1-fast
VIDEO_MODEL_XAI=grok-imagine-video
VIDEO_CREATE_PATHS=/api/v3/contents/generations/tasks
VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id}
VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content
VIDEO_DURATION_FIELD=seconds
VIDEO_POLL_TIMEOUT_SECONDS=900
VIDEO_CREATE_RETRY_ATTEMPTS=3
VIDEO_CREATE_RETRY_BACKOFF_SECONDS=2
XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai
XAI_VIDEO_API_KEY=
XAI_VIDEO_CREATE_PATH=/v1/videos/generations
XAI_VIDEO_STATUS_PATH=/v1/videos/{id}
XAI_VIDEO_CONTENT_PATH=

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

View File

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

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

@@ -461,6 +461,7 @@
<nav aria-label="页面目录">
<a href="#purpose">为什么有这页</a>
<a href="#how-to-use">怎么用它描述需求</a>
<a href="#product-baseline">最终产品基线</a>
<a href="#runtime">运行与入口</a>
<a href="#pipeline">业务管线</a>
<a href="#source-map">源码结构地图</a>
@@ -486,10 +487,12 @@
<p>
它把“你看到的界面、你想改的功能、实际要动的源码、可能影响的数据和接口”放在同一个地方。
后续描述需求时,可以直接说“改素材输入列 / 音频解析结果 / 分镜工作台 / 候选视频预览下载 / 某个接口行为”,这样改动范围会更准,也更容易追踪每次变更带来的影响。
当前最终产品基线是 <code>https://marketing.skg.com</code> 登录后的个人生成画布;旧版本能力只作为高级入口、兼容入口或源码参考。
</p>
<div class="meta-grid">
<div class="meta"><b>项目路径</b><span>/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证</span></div>
<div class="meta"><b>前端</b><span>Next.js 16 · 端口 4290 · web/app/page.tsx</span></div>
<div class="meta"><b>最终产品</b><span>https://marketing.skg.com · 登录后根路径个人生成画布</span></div>
<div class="meta"><b>前端</b><span>Vue / Vite 画布为生产首屏 · Next.js 16 保留登录/详情/旧能力</span></div>
<div class="meta"><b>后端</b><span>FastAPI · 端口 4291 · api/main.py</span></div>
<div class="meta"><b>本文档位置</b><span>docs/source-analysis.html · 独立文件,不接入应用</span></div>
</div>
@@ -516,6 +519,31 @@
</div>
</section>
<section id="product-baseline" data-search>
<h2>最终产品基线</h2>
<div class="callout good" style="margin-bottom:14px">
<p><strong>以后所有升级默认指向:</strong><code>https://marketing.skg.com</code> 登录后的个人生成画布。这个画布是员工真实使用的最终产品,不是旧 React 首页,也不是早期 TK 复刻工作台。</p>
</div>
<div class="grid-3">
<div class="card">
<h3>线上基线</h3>
<p>生产入口是根域名 <code>/</code>,由 <code>web/canvas-app/</code> 构建产物承载;<code>/canvas/</code> 只做旧链接兼容跳转。飞书登录、owner 隔离、Postgres 画布项目、个人工作流和当前 <code>/api</code> 是默认产品上下文。</p>
</div>
<div class="card">
<h3>本地同构</h3>
<p>本地排查和验收优先用 <code>./scripts/start-local-docker.sh</code> 启动 Web / API / Postgres<code>./scripts/verify-local-docker.sh</code> 验证。临时 dev server 只能帮助定位细节,不能替代生产形态判断。</p>
</div>
<div class="card">
<h3>旧版定位</h3>
<p>旧 React 单对话框首页、旧 TK 复刻工作台、Agent Cut 和详情页仍可作为高级能力、兼容入口、回滚点或代码参考;除非明确说恢复,否则新需求不要默认落到这些旧版本。</p>
</div>
</div>
<div class="card" style="margin-top:14px">
<h3>用户 bug 拉取和修复口径</h3>
<p>遇到线上用户问题时先按只读方式确认用户、时间、URL、job、canvas project、generated asset 和日志时间线;生产数据库优先从 <code>skg-marketing-api</code> 容器读取现有 <code>DATABASE_URL</code> 查询,避免猜测连接方式。需要复现时,只复制最小必要状态到本地 Docker 环境,不在生产库里试错。修复完成后先本地 Docker 验证和本地 commit只有用户明确确认后才推送或部署生产。</p>
</div>
</section>
<section id="runtime" data-search>
<h2>运行与入口</h2>
<table>
@@ -523,6 +551,16 @@
<tr><th>项目</th><th>命令 / 入口</th><th>说明</th></tr>
</thead>
<tbody>
<tr>
<td>本地 Docker 启动</td>
<td><code>./scripts/start-local-docker.sh</code></td>
<td>生产前默认本地验收入口;使用 <code>docker-compose.local.yml</code> 构建 Web / API / Postgres默认 Web 为 <code>http://localhost:4390</code>API 为 <code>http://localhost:4391</code>,本地数据写入 <code>data-local/</code>。首次启动会从 <code>deploy/.env.local.example</code> 生成不入库的 <code>deploy/.env.local</code>,本地登录默认 <code>skg / local-skg</code></td>
</tr>
<tr>
<td>本地 Docker 验证</td>
<td><code>./scripts/verify-local-docker.sh</code></td>
<td>检查本地 Docker 容器、登录页、未登录 API 保护和容器内 <code>/health</code> + Postgres 连接。以后代码改动先通过本地 Docker 验证并形成本地 commit只有用户明确确认后才推送 Gitea 或执行生产部署。</td>
</tr>
<tr>
<td>本地后台启动</td>
<td><code>./scripts/start-dev-background.sh</code></td>
@@ -536,17 +574,17 @@
<tr>
<td>生产站点</td>
<td><code>https://marketing.skg.com</code></td>
<td>公司域名已解析到 VPS <code>76.13.31.179</code>。线上由既有 Coolify / Traefik 负责 HTTPS 入口,项目 <code>web</code> 容器用 Nginx 承载静态前端;登录后根路径直接进入 Vue / Vite 个人生成画布,<code>/canvas/</code> 只作为旧链接兼容跳转到根路径。<code>/login/</code><code>/_next/</code><code>/assets/</code><code>/skg-logo-black.svg</code><code>/oasis-source/</code> 为公开登录页资源,未登录访问工作台跳转 <code>/login/</code>。登录页优先走飞书免登录,回调为 <code>/api/auth/feishu/callback</code>;账号密码保留为备用入口。<code>/api/</code> 通过 <code>auth_request</code> 校验 FastAPI 会话 Cookie 后再反代,后端按 Cookie 里的用户身份隔离 <code>Job</code><code>AgentRun</code> 数据。</td>
<td>公司域名已解析到 VPS <code>76.13.31.179</code>。线上由既有 Coolify / Traefik 负责 HTTPS 入口,项目 <code>web</code> 容器用 Nginx 承载静态前端;登录后根路径直接进入 Vue / Vite 个人生成画布,<code>/canvas/</code> 只作为旧链接兼容跳转到根路径。<code>/login/</code><code>/_next/</code><code>/assets/</code><code>/skg-logo-black.svg</code><code>/oasis-source/</code> 为公开登录页资源,未登录访问工作台跳转 <code>/login/</code>。登录页只允许飞书免登录,回调为 <code>/api/auth/feishu/callback</code>生产通过 <code>PASSWORD_AUTH_ENABLED=false</code> 停用账号密码入口。<code>/api/</code> 通过 <code>auth_request</code> 校验 FastAPI 会话 Cookie 后再反代,后端按 Cookie 里的用户身份隔离 <code>Job</code><code>AgentRun</code> 数据。</td>
</tr>
<tr>
<td>画布构建</td>
<td><code>cd web && npm run build</code></td>
<td>受同一登录保护的 Vue / Vite 画布应用。构建时先执行 <code>pnpm build:canvas</code> 生成 <code>web/canvas-app/dist</code>,再执行 Next 静态导出,最后由 <code>web/scripts/sync-canvas-root.mjs</code> 把画布产物覆盖到 <code>web/out</code> 根目录Nginx 登录校验后的 <code>/</code> fallback 到画布 <code>index.html</code><code>/canvas/</code> 只做 308 兼容跳转。画布项目当前保存在浏览器 <code>localStorage</code>生成出来的图片 / 视频资产通过本项目 <code>/api</code> 写入当前登录用户自己的后端 job。</td>
<td>受同一登录保护的 Vue / Vite 画布应用。构建时先执行 <code>pnpm build:canvas</code> 生成 <code>web/canvas-app/dist</code>,再执行 Next 静态导出,最后由 <code>web/scripts/sync-canvas-root.mjs</code> 把画布产物覆盖到 <code>web/out</code> 根目录Nginx 登录校验后的 <code>/</code> fallback 到画布 <code>index.html</code><code>/canvas/</code> 只做 308 兼容跳转。画布项目以服务端 Postgres 为主持久化,浏览器 <code>localStorage</code> 只作为缓存和首次导入来源;生成出来的图片 / 视频资产通过本项目 <code>/api</code> 写入当前登录用户自己的后端 job。</td>
</tr>
<tr>
<td>生产部署</td>
<td><code>./scripts/deploy-prod-safe.sh</code></td>
<td>服务器目录为 <code>/opt/skg-marketing-studio</code>;后端任务文件挂载到 <code>./data/jobs</code>,全局资源中心挂载到 <code>./data/asset_library</code><code>./data/prompt_library</code><code>./data/_trash</code>,真实 Key 只放服务器 <code>deploy/.env.production</code>。生产部署唯一入口是 <code>deploy-prod-safe.sh</code>:先备份服务器 env、案例资源库,再用 protect/exclude 保护 <code>data/</code><code>jobs/</code><code>secrets/</code><code>deploy/.env.production</code> 后同步代码,最后 Docker 重建并跑 <code>verify-prod-docker.sh</code>。禁止再用裸 <code>rsync --delete</code> 手动同步。</td>
<td>服务器目录为 <code>/opt/skg-marketing-studio</code>;后端任务文件挂载到 <code>./data/jobs</code>,全局资源中心挂载到 <code>./data/asset_library</code><code>./data/prompt_library</code><code>./data/_trash</code>Postgres 数据目录为 <code>./data/postgres</code>真实 Key 和数据库密码只放服务器 <code>deploy/.env.production</code>。生产部署唯一入口是 <code>deploy-prod-safe.sh</code>:先备份服务器 env、案例资源库和 secrets如 Postgres 容器存在则额外导出 <code>pg_dump</code>,再用 protect/exclude 保护 <code>data/</code><code>jobs/</code><code>secrets/</code><code>deploy/.env.production</code> 后同步代码,最后 Docker 重建并跑 <code>verify-prod-docker.sh</code>。禁止再用裸 <code>rsync --delete</code> 手动同步;未得到用户明确确认前,不推送 Gitea、不部署生产</td>
</tr>
<tr>
<td>前端开发服务</td>
@@ -580,17 +618,25 @@
<p><strong>2026-05-25 三模式版:</strong>默认首页再收敛为一个中央对话框,首页和画布底部输入框只让用户选文生图、文生视频、图生视频,然后手写提示词生成。图生视频只显示“上传图片”,不再把首帧 / 首尾帧这类模型实现概念作为主入口;营销图文不再作为首页默认入口。后端 <code>/health</code> 返回可选图片 / 视频模型、图片尺寸、视频画幅和真实可用视频时长,首页按返回值显示模型和规格选择;当前 Doubao / Seedance 生产链路单条最长 15 秒,不向用户暴露 30 秒按钮。</p>
<p><strong>2026-05-25 根域名画布版:</strong><code>https://marketing.skg.com</code> 登录后直接进入个人生成画布,不再先进入 React 单对话框首页再点画布;<code>/canvas/</code> 只保留为旧链接兼容跳转。后续优先少改成熟画布结构,只在必要时改模式文案、生成接入和结果/队列显示。</p>
<p><strong>2026-05-25 上游能力恢复版:</strong>用户明确要求“API 没关系,其他恢复,别削弱”。因此根域名画布恢复 <code>chatfire-AI/huobao-canvas</code> 的成熟节点和工作流结构推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、图片/视频/LLM 配置、多角度分镜、故事板、绘本和批量下载都保留;只继续替换品牌、路由和 API 接入。生成请求仍走 SKG 后端 <code>/api</code> 与登录 Cookie员工不需要个人 API Key。</p>
<p><strong>2026-05-25 媒体模型接入收口:</strong>图片和视频模型选择只暴露当前后端真实可用项:图片为 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>;视频当前只接通 <code>Seedance 2.0 Fast</code>(真实模型 <code>doubao-seedance-2-0-fast-260128</code>)。旧上游的 Nano Banana、Seedream、Kling、Veo 或浏览器本地自定义媒体模型不能进入生成下拉,避免同事选到实际不可用的模型。</p>
<p><strong>2026-05-25 媒体模型接入收口:</strong>图片和视频模型选择只暴露当前后端真实可用项:图片为 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>;视频接通 <code>Seedance 2.0 Fast</code>(真实模型 <code>doubao-seedance-2-0-fast-260128</code>和按独立 key 配置的 <code>Grok Imagine Video</code>(真实模型 <code>grok-imagine-video</code>。旧上游的 Nano Banana、Seedream、Kling、Veo 或浏览器本地自定义媒体模型不能进入生成下拉,避免同事选到实际不可用的模型;旧画布节点若保存了不可用视频模型,会在运行时模型清单加载后自动回退到当前可用项</p>
<p><strong>2026-05-26 公司沉淀版:</strong>画布项目从浏览器本地存储升级为服务端 Postgres 持久化;<code>localStorage</code> 只作为离线缓存和首次导入来源。后端同时建立用户、任务、资源索引和审计表,保留原有 <code>state.json</code> 文件作为任务详情真源,避免一次迁移动到大文件资产结构。</p>
<p><strong>2026-05-26 AI 润色中性化:</strong>画布 <code>AI 润色</code> 不再复用 SKG 广告文案接口 <code>/creative/copy</code>。后端新增 <code>POST /prompt/polish</code>,前端 <code>useChat</code>、根画布输入框、文本节点和自动执行意图分析改走中性提示词/通用生成接口:只优化用户已经给出的主体、风格、镜头和细节,不主动添加 SKG、按摩产品、TikTok 广告话术或用户没有提到的品牌。当前润色链路会先清理上一次润色遗留的模板尾巴,再判断人物/无人/物体/场景/动物/未知主体;原文明确有人时才声明虚构 AI 角色,原文明确无人时才保留无人物约束,原文没写人时不主动造人但也不追加“必须无人物”的模板尾巴;当输入或参考图已经有人物时,按 AI 生成的虚拟角色继续描述,而不是把人物参考图判定为不可用。</p>
<p><strong>2026-05-26 我的工作流云端版:</strong>工作流面板从只有公共模板扩展为“公共工作流 / 我的工作流”两类。当前画布可以保存成当前登录用户自己的云端工作流模板,后续在同一账号的其他电脑或浏览器打开后可插回画布;保存时只沉淀节点结构、连线、配置和提示词,主动清掉已生成图片、视频、任务进度、错误和运行态字段,避免把一次性生成结果误当模板复用。</p>
<p><strong>2026-05-27 画布点击响应优化:</strong>大画布启用 Vue Flow 可见节点渲染,并在载入旧项目时补齐节点尺寸,让几百个节点的项目不再一次性渲染全部节点;新增节点、批量插入节点/边和复制节点改为原地追加,避免单次点击触发整条节点数组重复替换。大项目右下角 MiniMap 只在 120 个节点以内显示,优先保证编辑响应速度。</p>
<p><strong>2026-05-27 上传参考图持久化:</strong>画布图片节点上传本地文件时先写入后端 creative job再把 <code>/api/jobs/...</code> 资产 URL 保存到节点和服务端画布项目;不再把浏览器 <code>data:</code> base64 当作图片地址保存。项目自动保存增加内容签名去重和 2 秒防抖,减少连续点击或节点测量触发的重复 <code>PUT /canvas-projects</code></p>
<p><strong>2026-05-27 图片模型配置化:</strong>图片生成不再把主模型写死为 <code>gpt-image-2</code>。后端通过 <code>IMAGE_MODEL</code><code>IMAGE_FALLBACK_MODELS</code><code>IMAGE_EXTRA_MODELS</code><code>IMAGE_MODEL_CONFIGS_JSON</code> 和 Ark 专用 <code>ARK_IMAGE_BASE_URL</code>/<code>ARK_IMAGE_API_KEY</code>/<code>ARK_SEEDREAM_IMAGE_MODEL</code> 注册模型;默认仍保持 GPT Image 2 + Gemini 兜底,新增可选 <code>doubao-seedream-4-5-251128</code>Seedream 走 <code>/images/generations</code> + <code>reference_images</code> 并使用 2K/4K 尺寸。</p>
<p><strong>2026-05-26 生图配置恢复版:</strong>按用户要求撤回后续“低/中/高画质、自定义尺寸、Gemini 官方 1K/2K/4K 尺寸、取消自动模型”的实验改动,恢复最初简单配置:图片模型为 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>,尺寸只保留 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>,画质回到单一标准项;<code>auto</code> 仍按后端既有策略优先 GPT Image 2必要时由熔断/兜底走 Gemini。</p>
<p><strong>2026-05-28 画布媒体本地缓存:</strong>图片节点和视频节点显示 <code>/api/jobs/...</code> 生成资产时,会先查当前浏览器的 Cache Storage命中后使用本机 <code>blob:</code> 地址展示,未命中则先用服务器 URL 展示并在后台写入本地缓存。后端对登录保护下的 job 图片、视频和音频返回 <code>Cache-Control: private, max-age=2592000, immutable</code>,让每台电脑自己的浏览器磁盘缓存参与加速,避免刷新画布时反复从 VPS 下载同一素材。</p>
</div>
<p>当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 用提示词、推荐词、AI 润色工作流模板创建节点 → 画布自动执行或手动连接图片/视频/文本节点 → 生成结果沉淀在当前个人画布 → 需要时进入详情页继续编辑”。画布不再被削成三模式入口;首帧、尾帧、参考图、图生视频、多角度分镜、故事板和绘本等上游概念按节点能力保留。底层生成仍由 <code>web/canvas-app/src/hooks/useApi.js</code> 适配到本项目 <code>/creative/jobs/image</code><code>/jobs/{id}/frames/{idx}/generate</code><code>/jobs/{id}/frames/{idx}/storyboard/video</code>并按当前登录用户写入个人 job。图片尺寸只显示 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>视频画幅只显示 <code>720x1280</code><code>1280x720</code><code>1024x1024</code><code>960x1280</code>;视频时长只显示 <code>5/8/10/12/15</code> 秒。多人互不影响依赖后端 <code>owner_id</code> 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。</p>
<p>当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 画布项目同步到服务端 Postgres → 用提示词、推荐词、AI 润色、公共工作流或我的工作流创建节点 → 画布自动执行或手动连接图片/视频/文本节点 → 生成结果沉淀在当前个人画布 → 可把当前节点结构保存为我的工作流 → 需要时进入详情页继续编辑”。画布不再被削成三模式入口;首帧、尾帧、参考图、图生视频、多角度分镜、故事板和绘本等上游概念按节点能力保留。底层生成仍由 <code>web/canvas-app/src/hooks/useApi.js</code> 适配到本项目 <code>/creative/jobs/image</code><code>/jobs/{id}/frames/{idx}/generate</code><code>/jobs/{id}/frames/{idx}/storyboard/video</code>AI 润色和通用 LLM 文本生成走 <code>/prompt/polish</code> 并保持中性专业:不主动套入 SKG不主动补产品、平台、广告语境或人物只扩写用户明确写出的主体、动作、场景、镜头、光线和质量细节视频提交若带参考图会在最终提示词中条件声明“参考图里若有人物应按 AI 生成的虚拟角色处理”,避免把 AI 人像素材误当成真实肖像。生成资产按当前登录用户写入个人 job。图片模型由后端运行时配置决定默认 <code>auto</code> 仍优先当前 <code>IMAGE_MODEL</code> 并按 <code>IMAGE_FALLBACK_MODELS</code> 兜底;前端同时提供 GPT/Gemini 旧尺寸和 Seedream 2K/4K 尺寸。视频画幅只显示 <code>720x1280</code><code>1280x720</code><code>1024x1024</code><code>960x1280</code>;视频时长只显示 <code>5/8/10/12/15</code> 秒。多人互不影响依赖后端 <code>owner_id</code>、画布项目 owner、我的工作流 owner 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。</p>
<div class="pipeline">
<div class="step"><div class="num">01</div><h3>个人任务</h3><p><code>GET /jobs</code> 按当前登录用户过滤;旧无 owner 任务只对备用账号可见。</p></div>
<div class="step"><div class="num">02</div><h3>进入画布</h3><p>用户直接在根域名个人画布里操作,上游项目列表、推荐词、节点菜单、工作流模板和批量下载能力保留</p></div>
<div class="step"><div class="num">03</div><h3>组织节点</h3><p>可通过底部 prompt、AI 润色、自动执行、手动添加节点工作流模板创建文本、图片、视频、LLM、配置和参考图节点。</p></div>
<div class="step"><div class="num">02</div><h3>进入画布</h3><p>用户直接在根域名个人画布里操作项目列表优先读取服务端 <code>/canvas-projects</code>,本地旧项目会首次导入</p></div>
<div class="step"><div class="num">03</div><h3>组织节点</h3><p>可通过底部 prompt、AI 润色、自动执行、手动添加节点、公共工作流或我的工作流创建文本、图片、视频、LLM、配置和参考图节点。</p></div>
<div class="step"><div class="num">04</div><h3>参考素材</h3><p>首帧、尾帧、参考图和图片节点按上游节点语义保留;提交到后端时由 <code>useApi.js</code> 转成 <code>first_image</code><code>last_image</code> 或图片编辑参考。</p></div>
<div class="step"><div class="num">05</div><h3>工作流执行</h3><p>自动执行会根据提示词创建文生图、图转视频、故事板、多角度分镜或绘本等节点组;手动模式下用户可自行连接节点。</p></div>
<div class="step"><div class="num">06</div><h3>生成图片 / 视频</h3><p><code>generateImage</code><code>generateStoryboardVideo</code> 继续走 SKG 后端 <code>/api</code>;视频提交后先写入 <code>queued</code> 占位,再由后端队列按并发上限启动。</p></div>
<div class="step"><div class="num">07</div><h3>结果沉淀</h3><p>生成图、视频 URL、任务状态和下载入口回填到画布节点完整任务结果仍可进入 <code>/detail/?job=</code> 查看。</p></div>
<div class="step"><div class="num">07</div><h3>结果沉淀</h3><p>生成图、视频 URL、任务状态和下载入口回填到画布节点画布项目和个人工作流结构保存到 Postgres完整任务结果仍可进入 <code>/detail/?job=</code> 查看。</p></div>
<div class="step"><div class="num">08</div><h3>详情页</h3><p><code>/detail/?job=&lt;id&gt;</code> 展示参考图、全量生成图、视频候选、提示词和营销图文,并支持继续生成。</p></div>
<div class="step"><div class="num">09</div><h3>高级复刻</h3><p><code>AdRecreationBoard</code><code>/agent/</code> 作为高级入口保留,不再是默认路径。</p></div>
</div>
@@ -606,10 +652,14 @@
<tr><td><code>web/next.config.mjs</code></td><td>Next.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator并移除 Next 16 已不支持的 <code>eslint</code> 顶层配置,避免本地 dev 出现配置 Issue 提示。</td></tr>
<tr><td><code>web/app/globals.css</code></td><td>全局主题变量、登录页视觉样式、信息流工作台玻璃拟态 token、ReactFlow 样式引用,以及本地开发态 <code>nextjs-portal</code> 遮挡隐藏规则。工作台在 <code>skg-board-theme</code> 内按 Figma 本地 MCP 参考改成黑灰玻璃系统:深灰背景、<code>#383838</code> 胶囊侧栏、<code>rgba(255,255,255,.1)</code> 玻璃面、<code>backdrop-filter: blur(5px)</code><code>20px</code> 圆角、<code>10px 10px 10px rgba(0,0,0,.3)</code> 阴影和绿黄状态色;新增 <code>skg-board-shell</code><code>skg-board-rail</code><code>skg-glass-card</code><code>skg-glass-card--flat</code><code>skg-status-orb</code> 等样式。侧栏改为跟随视口拉满工作台可用高度的悬停胶囊,桌面最小 600px展开时在同一侧栏内承载素材输入抽屉。明暗主题已分开维护 shell、panel、glass、stat、action 和音频波形 token暗色压低灰雾和面板底色明亮模式改为暖白工作台避免指标卡、按钮和波形继续残留黑底/白线;顶部指标卡增加紫、黄绿、琥珀、青绿、绿色光斑变量,接近原版多色玻璃卡效果。主/次按钮、指标卡和空状态继续走统一类,避免各板块散写不同玻璃效果。</td></tr>
<tr><td><code>web/app/page.tsx</code></td><td>旧 React 单对话框生成台源码仍保留,便于以后回滚或抽能力;当前生产根域名已经由 <code>web/canvas-app/</code> 画布产物覆盖,不再把这个 React 首页作为默认首屏。该页面里的模式也已收敛为文生图、文生视频、图生视频;图生视频只显示“上传图片”,不把“首帧/首尾帧”作为用户入口。旧 TK 复刻工作台组件仍保留在 <code>web/components/ad-recreation-board.tsx</code>,但不再作为默认首页渲染。</td></tr>
<tr><td><code>web/canvas-app/</code></td><td>SKG 内部画布应用:从 <code>chatfire-AI/huobao-canvas</code> 交互逻辑改造而来。当前策略是“保留成熟画布能力,替换品牌/路由/API”Vue Flow 节点画布、项目列表、推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载都保留;可见品牌收敛为 SKG logo不展示上游注册链接或外部品牌。生产路径固定为根域名 <code>/</code>,内部路由用 <code>/p/:id?</code>;来源说明保存在 <code>THIRD_PARTY_NOTICES.md</code>,不展示给终端用户。</td></tr>
<tr><td><code>web/canvas-app/src/views/Canvas.vue</code></td><td>画布主交互:恢复上游底部 prompt composer、<code>AI 润色</code><code>自动执行</code>、推荐词、节点菜单、工作流面板、API/模型设置入口和批量下载入口。自动执行会调用 <code>useWorkflowOrchestrator</code> 分析提示词,创建文生图、图转视频、故事板、多角度分镜或绘本节点组;手动模式只创建文本节点,用户自行连接节点</td></tr>
<tr><td><code>web/canvas-app/src/config/models.js</code></td><td>画布媒体模型和规格的前端白名单:图片只内置 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>,尺寸只内置 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>;视频只内置 <code>seedance</code> / <code>Seedance 2.0 Fast</code>,画幅和时长对齐后端 <code>/health</code> 能力边界。<code>useModelConfig.js</code> 和 Pinia 模型 store 会忽略浏览器本地自定义图片/视频模型,防止旧缓存把不可用模型带回生成下拉</td></tr>
<tr><td><code>web/canvas-app/src/hooks/useApi.js</code></td><td>画布到本项目后端的适配层:不再读取浏览器 API Key而是使用当前登录会话 Cookie 调用 <code>/api</code>。文生图 / 图生图先创建轻量 creative job再调用 <code>/frames/0/generate</code>;文生视频 / 图生视频调用 <code>/storyboard/video</code> 并轮询 <code>/jobs/{id}</code>,完成后把图片或 mp4 URL 写回画布节点</td></tr>
<tr><td><code>web/canvas-app/</code></td><td>SKG 内部画布应用:从 <code>chatfire-AI/huobao-canvas</code> 交互逻辑改造而来。当前策略是“保留成熟画布能力,替换品牌/路由/API”Vue Flow 节点画布、项目列表、推荐词、AI 润色、自动执行、公共工作流、我的工作流、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载都保留;可见品牌收敛为 SKG logo不展示上游注册链接或外部品牌。生产路径固定为根域名 <code>/</code>,内部路由用 <code>/p/:id?</code>项目列表、画布 JSON 和个人工作流模板优先同步到服务端 Postgres浏览器本地存储只是缓存/导入来源;来源说明保存在 <code>THIRD_PARTY_NOTICES.md</code>,不展示给终端用户。</td></tr>
<tr><td><code>web/canvas-app/src/stores/projects.js</code></td><td>画布项目 Pinia store启动时先读本地 <code>localStorage["ai-canvas-projects"]</code> 作为缓存,再调用 <code>GET /canvas-projects</code> 拉服务端项目;如果发现本地旧项目,会调用 <code>POST /canvas-projects/import</code> 导入到当前登录用户。新建、重命名、画布节点变更、复制和删除会同步到 <code>/canvas-projects</code>,本地缓存只用于快速打开和网络异常兜底。远程保存用内容签名跳过重复 payload并以短防抖合并连续节点更新避免大画布频繁写入影响交互速度</td></tr>
<tr><td><code>web/canvas-app/src/stores/workflows.js</code></td><td>我的工作流 store调用 <code>GET/POST/DELETE /canvas-workflows</code> 读取、保存和删除当前登录用户自己的云端工作流模板。保存前会清理节点里的 <code>base64</code>、生成 URL、任务进度、错误、视频结果和 LLM 输出等运行态字段,只保留可复用的节点结构、连线、配置和提示词</td></tr>
<tr><td><code>web/canvas-app/src/views/Canvas.vue</code></td><td>画布主交互:恢复上游底部 prompt composer、<code>AI 润色</code><code>自动执行</code>、推荐词、节点菜单、工作流面板、API/模型设置入口和批量下载入口。自动执行会调用 <code>useWorkflowOrchestrator</code> 分析提示词,创建文生图、图转视频、故事板、多角度分镜或绘本节点组;手动模式只创建文本节点,用户自行连接节点。工作流面板支持公共模板和我的工作流:公共模板走本地 <code>createNodes()</code>,我的工作流从云端 <code>workflow_data</code> 插回当前画布,并重新生成节点 ID、按视口中心重排、按映射重连边。Vue Flow 开启可见节点渲染,大画布不再把所有节点同时挂载到 DOM节点数超过 120 时隐藏 MiniMap减少点击后的同步重绘压力。底部推荐词来自共享短词池4 个一组单行展示,刷新按钮在 30 组内轮换,不改变输入面板高度</td></tr>
<tr><td><code>web/canvas-app/src/config/suggestions.js</code></td><td>首页和画布共用的推荐词配置:维护 <code>QUICK_SUGGESTION_GROUPS</code>,当前为 30 组 / 120 个短词,每组 4 个,控制刷新按钮的轮换范围;词条保持短小,避免推荐栏换行或顶起 composer。</td></tr>
<tr><td><code>web/canvas-app/src/config/models.js</code></td><td>画布媒体模型和规格的前端白名单:图片只内置 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>,尺寸只内置 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>;视频内置 <code>seedance</code> / <code>Seedance 2.0 Fast</code> 和默认不可用的 <code>xai</code> / <code>Grok Imagine Video</code>,后者只有后端 <code>/health</code> 回传 <code>available=true</code> 时才进入生成下拉。画幅和时长对齐后端 <code>/health</code> 能力边界。<code>useModelConfig.js</code> 和 Pinia 模型 store 会忽略浏览器本地自定义图片/视频模型,防止旧缓存把不可用模型带回生成下拉。</td></tr>
<tr><td><code>web/canvas-app/src/hooks/useCachedMediaUrl.js</code></td><td>画布媒体本地缓存 Hook只缓存同源、登录保护下的 <code>/api/jobs/...</code><code>/api/agent-runs/...</code> 图片 / 视频 / 音频。图片节点和视频节点先用原始 URL 保证首屏可见,再后台写入浏览器 Cache Storage下次打开同一素材时返回本机 <code>blob:</code> URL减少反复从 VPS 下载。</td></tr>
<tr><td><code>web/canvas-app/src/hooks/useApi.js</code></td><td>画布到本项目后端的适配层:不再读取浏览器 API Key而是使用当前登录会话 Cookie 调用 <code>/api</code>。文生图 / 图生图先创建轻量 creative job再调用 <code>/frames/0/generate</code>;本地上传到图片节点的参考图也会先通过 <code>/creative/jobs/image</code> 写成后端资产,再把 <code>/api/jobs/...</code> URL 保存到节点,避免刷新后丢失。文生视频 / 图生视频调用 <code>/storyboard/video</code> 并轮询 <code>/jobs/{id}</code>,完成后把图片或 mp4 URL 写回画布节点。<code>useChat</code> 已从 SKG 广告文案接口切到 <code>/prompt/polish</code>AI 润色显式使用 image/video prompt 模式LLM 节点使用通用 chat 模式,避免自动注入用户没有提到的 SKG、产品、平台或营销语境后端会清理旧润色模板尾巴、判断人物/无人/物体/场景意图,并在输出后检查“有人却禁止人物、无人却新增人物、未写 SKG 却出现 SKG”等冲突。图生视频实际提交到后端后后端会对参考图追加 AI 虚拟角色条件说明,不要求前端判断图片里是否有人脸。</td></tr>
<tr><td><code>web/scripts/sync-canvas-root.mjs</code></td><td>构建桥接脚本:在 <code>next build</code> 静态导出完成后,把 Vite 画布产物 <code>web/canvas-app/dist</code> 覆盖到 <code>web/out</code> 根目录,使 <code>https://marketing.skg.com</code> 登录后直接进入画布;旧 <code>web/scripts/sync-canvas-dist.mjs</code> 保留但不再由生产构建调用。</td></tr>
<tr><td><code>web/app/detail/page.tsx</code></td><td>任务详情页:静态导出路由 <code>/detail/?job=&lt;id&gt;</code>,通过 query 读取 job id调用 <code>getJob</code> 恢复同一任务。页面展示参考图、全部生成图、视频候选、营销图文方案和历史提示词,可继续调用 <code>generateImage</code><code>generateStoryboardVideo</code><code>generateCreativeCopy</code>,并支持删除图片/视频。该页继续依赖后端 owner 过滤,用户不能通过切换 URL 读取别人的任务。</td></tr>
<tr><td><code>web/app/agent/page.tsx</code></td><td>新增一键出片终端页:只保留 TikTok 链接、产品图上传、实时 <code>Agent Terminal</code> 和最终成片播放器;通过 <code>POST /agent-runs</code> 创建受限后台状态机任务,通过 <code>GET /agent-runs/{id}</code> 轮询日志、进度、审片图和最终 mp4。该页不替代旧工作台深度编辑能力只承接“用户只看成品”的快速出片主路径。</td></tr>
@@ -620,7 +670,7 @@
<tr><td><code>AdRecreationBoard</code> 主题切换</td><td>左侧中段 65px 胶囊工具条上方图标组里有 <code>Sun</code> / <code>Moon</code> 图标按钮,切换 <code>skg-board-theme--light</code> 类名,并把选择写入 <code>localStorage["skg-board-theme"]</code>。暗色仍是默认模式;明亮模式只改变工作台外观,不改变任务、素材、分镜、模型调用或接口数据。</td></tr>
<tr><td><code>SourceReferenceBuildPanel</code></td><td>旧的“相似主体 / 主体模板”大面板代码仍保留在文件里,方便以后恢复模板库复用、入库命名和自定义视图选择;但当前源视频工作区主路径已经由 <code>SourceSubjectPipeline</code> 承接,不再在页面下方渲染这块,避免和“参考帧池 → 转换层 → 主体元素”重复。</td></tr>
<tr><td><code>web/components/media-asset-tile.tsx</code></td><td>项目内媒体素材缩略图基底组件:图片、视频、抽帧、产品图、相似主体图、首尾帧和视频候选默认从这里获得统一交互。组件负责缩略图显示、顶层固定浮层 hover 放大、删除按钮、下载/重新生成等操作按钮、忙碌遮罩、图片/视频共用预览,以及按场景启用原生视频播放 controls避免每个新板块重复手写不同的媒体交互。hover 预览支持 <code>previewPlacement</code><code>previewMaxWidth</code>,视频候选可让操作按钮常显,保证下载入口不是隐藏语义;参考帧池用左侧紧凑预览避免遮住转换层;画面胶片是例外:为了保持胶片原位浏览,不使用额外弹出预览,只让胶片缩略图自己在轨道内放大。</td></tr>
<tr><td><code>web/app/login/page.tsx</code></td><td>生产登录页:先读取 <code>/api/auth/config</code>,飞书 OAuth 配好时显示“飞书免登录”主按钮,账号密码表单保留为备用入口;如果 <code>feishu_enabled=true</code> 且浏览器 UA 命中飞书 / Lark 客户端,会在登录页自动跳转 <code>/api/auth/feishu/start</code>,让飞书客户端内打开应用时不需要再点一次按钮;登录成功后由后端设置 HttpOnly 会话 Cookie。当前只在原版 Digital Oasis 动态背景上叠加一个组合登录框,桌面端左侧是动态角色,右侧是图标化登录区;面板左上角只展示官网 SKG 字标,不再并列中文系统名。</td></tr>
<tr><td><code>web/app/login/page.tsx</code></td><td>生产登录页:先读取 <code>/api/auth/config</code>,飞书 OAuth 配好时显示“飞书免登录”主按钮;只有后端返回 <code>password_enabled=true</code> 时才展示账号密码表单。当前生产 <code>PASSWORD_AUTH_ENABLED=false</code>,所以普通浏览器也只显示飞书入口;如果 <code>feishu_enabled=true</code> 且浏览器 UA 命中飞书 / Lark 客户端,会在登录页自动跳转 <code>/api/auth/feishu/start</code>,让飞书客户端内打开应用时不需要再点一次按钮;登录成功后由后端设置 HttpOnly 会话 Cookie。当前只在原版 Digital Oasis 动态背景上叠加一个组合登录框,桌面端左侧是动态角色,右侧是图标化登录区;面板左上角只展示官网 SKG 字标,不再并列中文系统名。</td></tr>
<tr><td><code>web/app/login/layout.tsx</code></td><td>登录路由专属 layout覆盖全站默认网页标题和描述为空避免 <code>/login</code> 继承工作台 metadata 后在页面源码里继续出现登录界面文字以外的文案。</td></tr>
<tr><td><code>web/components/login/oasis-canvas.tsx</code></td><td>登录页全屏动态视觉层:用 iframe 直接承载下载包 <code>web/public/oasis-source/index.html</code> 的原 WebGPU / Three.js 草场源码;父级登录页只覆盖自己的文案和表单,并在捕获阶段把全局鼠标坐标同时用原生事件和 <code>postMessage</code> 转发给 iframe避免登录面板或输入框遮挡时草地失去鼠标响应。</td></tr>
<tr><td><code>web/public/oasis-source/index.html</code></td><td>从下载包 <code>remix-3d-website-the-digital-o</code> 复制来的原始视觉源码。嵌入登录页时会隐藏 demo 站自己的导航、文字和设置面板保留原多段滚动背景变化、WebGPU 草场、景深、风动和鼠标交互源码;末端阶段保留,只禁用原 footer 出现时把 canvas 上移的逻辑,避免底部露黑边。</td></tr>
@@ -640,8 +690,9 @@
<h3>后端核心</h3>
<table>
<tbody>
<tr><td><code>api/main.py</code></td><td>FastAPI 单文件后端登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回同时承载全局 <code>prompt_library</code><code>asset_library</code> 的磁盘索引、CRUD、删除保护和复制到 job API。轻量创作入口 <code>POST /creative/jobs/image</code> 把上传图片或空白底图写成一个只有 0 号关键帧的 <code>Job</code>,让首页直接复用生图/生视频接口;该接口兼容无 body / JSON 空对象 / 正常 multipart 上传,避免无首帧文生图或文生视频时空 multipart 被 FastAPI 在业务前置解析阶段拒绝;<code>/health</code> 返回 <code>image_options</code><code>image_size_options</code><code>video_options</code><code>video_size_options</code><code>video_duration_options</code><code>video_max_duration_seconds</code><code>/frames/{idx}/generate</code><code>model</code> 字段用于图片模型偏好,<code>size</code> 字段用于图片输出尺寸;<code>/storyboard/video</code> 继续使用 <code>model</code> 字段选择视频别名,并先校验画幅与时长能力边界,然后把 <code>GeneratedVideo</code> 写成 <code>queued</code> 占位并进入进程内视频队列。队列默认 <code>VIDEO_QUEUE_MAX_CONCURRENT=2</code><code>VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1</code>,同一用户连续提交不会占满全局并发;排队任务会回写 <code>queue_position</code><code>queue_size</code><code>queue_message</code>。旧 <code>AgentRun</code> 一键出片状态机、TK 复刻接口和 <code>POST /creative/copy</code> 继续保留。</td></tr>
<tr><td><code>video_model_options()</code></td><td>视频模型能力出口:如果 <code>seedance</code><code>kling</code><code>veo3</code><code>veo</code> 等业务别名实际都映射到同一个真实模型,会按真实模型去重,只给前端返回一个可用选项;当前生产真实模型为 <code>doubao-seedance-2-0-fast-260128</code>,前端显示为 <code>Seedance 2.0 Fast</code>。后续只有在服务器真的配置了不同可用视频模型时,才应把新的模型重新暴露给画布</td></tr>
<tr><td><code>api/main.py</code></td><td>FastAPI 单文件后端登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回同时承载全局 <code>prompt_library</code><code>asset_library</code> 的磁盘索引、CRUD、删除保护和复制到 job API。启动时会初始化 Postgres schema、扫描现有 <code>state.json</code> / 资源库并写入索引;<code>/canvas-projects</code> 系列接口把画布项目按当前登录用户持久化,<code>/canvas-workflows</code> 系列接口把我的工作流按当前登录用户持久化为可复用模板。轻量创作入口 <code>POST /creative/jobs/image</code> 把上传图片或空白底图写成一个只有 0 号关键帧的 <code>Job</code>,让首页直接复用生图/生视频接口;该接口兼容无 body / JSON 空对象 / 正常 multipart 上传,避免无首帧文生图或文生视频时空 multipart 被 FastAPI 在业务前置解析阶段拒绝;<code>POST /prompt/polish</code> 用于中性 AI 润色和通用 LLM 文本生成,只保留用户明确给出的主体、品牌、产品、地点、风格和意图,不默认加入 SKG、按摩产品、平台或短视频广告话术。润色链路会先用 <code>_strip_previous_polish_boilerplate</code> 去掉旧模板尾巴,再用 <code>_classify_prompt_intent</code> 判断人物、无人、物体、场景、动物或未知主体,最后用 <code>_repair_polished_prompt</code> 修掉有人/无人矛盾、未写人却新增人物、未写 SKG 却出现 SKG 等冲突;<code>_append_reference_image_person_guard</code> 会在视频任务最终入队前给参考图请求追加条件提示,声明参考图里若有人物则按 AI 生成的虚拟角色处理;<code>/health</code> 返回 <code>database</code><code>image_options</code><code>image_size_options</code><code>video_options</code><code>video_size_options</code><code>video_duration_options</code><code>video_max_duration_seconds</code> 和视频创建重试配置<code>/frames/{idx}/generate</code><code>model</code> 字段用于图片模型偏好,<code>size</code> 字段用于图片输出尺寸;<code>/storyboard/video</code> 继续使用 <code>model</code> 字段选择视频别名,并先校验画幅与时长能力边界,然后把 <code>GeneratedVideo</code> 写成 <code>queued</code> 占位并进入进程内视频队列。Grok/xAI 创建阶段遇到连接重置、超时、429 或 5xx 会按 <code>VIDEO_CREATE_RETRY_ATTEMPTS</code><code>VIDEO_CREATE_RETRY_BACKOFF_SECONDS</code> 自动退避重试400/403 等明确错误不重试。队列默认 <code>VIDEO_QUEUE_MAX_CONCURRENT=2</code><code>VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1</code>,同一用户连续提交不会占满全局并发;排队任务会回写 <code>queue_position</code><code>queue_size</code><code>queue_message</code>。旧 <code>AgentRun</code> 一键出片状态机、TK 复刻接口和 <code>POST /creative/copy</code> 作为明确的 SKG 营销文案接口继续保留。</td></tr>
<tr><td><code>api/db.py</code></td><td>Postgres 适配层:在 <code>DATABASE_URL</code> 存在且 <code>psycopg</code> 可用时启用;负责建表、健康检查、用户 upsert、审计日志、画布项目 CRUD、我的工作流 CRUD以及把 <code>Job</code><code>AgentRun</code>、提示词库和素材库写入索引表。数据库不可用时本地开发会降级为 disabled生产 <code>verify-prod-docker.sh</code> 会要求 <code>database.connected=true</code></td></tr>
<tr><td><code>video_model_options()</code></td><td>视频模型能力出口按当前视频网关过滤可真实路由的业务别名Doubao / Ark 网关只暴露 <code>doubao-seedance*</code> 真实模型Poe 网关才允许 Poe 的 Seedance / Kling / Veo 类模型;如果显式配置了 <code>VIDEO_API_BASE_URL</code><code>VIDEO_API_KEY</code> 为空,默认视频通道会标记不可用,不再回退通用 <code>LLM_API_KEY</code>。新增 <code>xai</code> / <code>grok-imagine-video</code> 独立走 <code>XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai</code><code>XAI_VIDEO_API_KEY</code><code>/v1/videos/generations</code><code>/v1/videos/{id}</code>,创建返回 <code>request_id</code>、轮询完成返回 <code>video.url</code>;未配置 xAI key 时 <code>/health</code> 会标记不可用,前端不显示。创建阶段的瞬时错误重试由 <code>VIDEO_CREATE_RETRY_ATTEMPTS</code> / <code>VIDEO_CREATE_RETRY_BACKOFF_SECONDS</code> 控制,并随 <code>/health</code> 暴露非敏感数值。</td></tr>
<tr><td><code>api/product_library/skg-products</code></td><td>内置 SKG 白底产品图库:<code>manifest.json</code> 记录从桌面产品图筛出的 gallery 白底图和桌面 4 张产品角度图,<code>images/</code> 存 45 张参考图。</td></tr>
<tr><td><code>api/character_library/skg-characters</code></td><td>内置相似主体形象库:从桌面 5 套策划形象导入,<code>manifest.json</code> 记录运动阳光男、都市型男、优雅白领女、运动辣妹、绅士大叔,每套含 7 张透明骨架参考图和一段 <code>prompt_brief</code>。相似主体生成时优先使用文字 brief 作为创意方向,避免把内置图作为强参考图复制。</td></tr>
<tr><td><code>asset_library/</code></td><td>全局素材库目录,和 <code>jobs/</code> 平级,不写入任何 job state。四类目录为 <code>subjects</code><code>products</code><code>scenes</code><code>videos</code>;每个素材自带 <code>manifest.json</code> 和图片/视频文件,<code>index.json</code> 只是启动扫描重建出来的缓存。库素材选用到 job 时必须复制文件到 <code>jobs/&lt;jobId&gt;/assets</code><code>storyboard-videos</code>,禁止直接保存 library 引用。</td></tr>
@@ -664,6 +715,8 @@
-> 生图generateImage(job.id, 0, { prompt, mode: text, model }) → jobs/&lt;jobId&gt;/gen
-> 生视频generateStoryboardVideo(job.id, 0, { prompt, model, first_image?, duration }) → jobs/&lt;jobId&gt;/storyboard_videos
-> 当前结果:图片 / 视频节点自动排列到画布
-> 画布项目web/canvas-app/src/stores/projects.js → GET/POST/PUT/DELETE /canvas-projects → Postgres canvas_projects
-> 我的工作流web/canvas-app/src/stores/workflows.js → GET/POST/PUT/DELETE /canvas-workflows → Postgres canvas_workflows
-> 任务详情页web/app/detail/page.tsx?job=&lt;id&gt; → getJob → 展示参考图、生成图、视频、提示词、图文方案 → 可继续生成 / 删除 / 复制
旧版 TK 复刻链路(最后版本保留):
@@ -681,6 +734,7 @@ web/app/page.tsx
后端主链路:
api/main.py
-> Auth session / Feishu OAuth / Job owner / AgentRun owner / KeyFrame / KeyElement / StoryboardScene / AudioScript
-> api/db.py / Postgres: app_users / canvas_projects / canvas_workflows / job_index / generated_assets / prompt_library_index / asset_library_index / agent_run_index / audit_events
-> 下载 / 上传 / 音频提取 / ASR / 翻译 / 声音背景音分析 / 抽帧 / Vision brief / GPT 图像生成 / 产品视角识别 / 分镜保存 / 首尾帧生成 / 后续 Azure OpenAI 配音预留
-> jobs/&lt;jobId&gt;/state.json + agent_runs/&lt;runId&gt;/state.json + 图片文件落盘API 层按登录用户过滤列表和详情</pre>
</section>
@@ -690,9 +744,14 @@ api/main.py
<div class="flow">
<div class="flow-row">
<div><strong>你看到的区域</strong><span>SKG 首页</span></div>
<div><strong>主要源码</strong><span><code>web/app/page.tsx</code>;前端 API client 在 <code>web/lib/api.ts</code>;轻量创作后端在 <code>api/main.py</code><code>/creative/jobs/image</code><code>/creative/copy</code>,实际图片和视频生成继续复用 <code>/jobs/{id}/frames/{idx}/generate</code><code>/jobs/{id}/frames/{idx}/storyboard/video</code></span></div>
<div><strong>主要源码</strong><span><code>web/app/page.tsx</code>;前端 API client 在 <code>web/lib/api.ts</code>;轻量创作后端在 <code>api/main.py</code><code>/creative/jobs/image</code><code>/creative/copy</code><code>/prompt/polish</code>,实际图片和视频生成继续复用 <code>/jobs/{id}/frames/{idx}/generate</code><code>/jobs/{id}/frames/{idx}/storyboard/video</code></span></div>
<div><strong>适合怎么描述</strong><span>“首页只有一个对话框,三个模式是文生图、文生视频、图生视频;图生视频上传图片后手写提示词生成”。</span></div>
</div>
<div class="flow-row">
<div><strong>你看到的区域</strong><span>个人生成画布 / 工作流面板</span></div>
<div><strong>主要源码</strong><span><code>web/canvas-app/src/views/Canvas.vue</code><code>web/canvas-app/src/components/WorkflowPanel.vue</code><code>web/canvas-app/src/stores/projects.js</code><code>web/canvas-app/src/stores/workflows.js</code>;后端项目接口在 <code>/canvas-projects</code>,我的工作流接口在 <code>/canvas-workflows</code></span></div>
<div><strong>适合怎么描述</strong><span>“画布当前节点怎么保存成我的工作流、我的工作流为什么空、插回画布时节点位置和连线怎么处理、同一飞书账号换电脑后为什么能看到同一批个人模板”。</span></div>
</div>
<div class="flow-row">
<div><strong>你看到的区域</strong><span>任务详情页</span></div>
<div><strong>主要源码</strong><span><code>web/app/detail/page.tsx</code>;通过 <code>/detail/?job=&lt;id&gt;</code> 读取任务;复用 <code>getJob</code><code>generateImage</code><code>generateStoryboardVideo</code><code>generateCreativeCopy</code><code>deleteGeneratedImage</code><code>deleteGeneratedVideo</code></span></div>
@@ -774,6 +833,45 @@ api/main.py
error,
created_at, updated_at
}</pre>
</div>
<div class="card">
<h3>CanvasProject</h3>
<p>根域名画布的项目持久化模型。前端仍保留本地缓存,但服务端 <code>canvas_projects</code> 是公司内部沉淀的主存储;默认可见性为 <code>private</code>,只有 owner 可改写,后续可扩展 team/company 可见项目。</p>
<pre>CanvasProject {
id,
owner_id, owner_name, owner_email, owner_provider, tenant_key,
name,
thumbnail,
visibility: private | team | company,
canvas_data: { nodes, edges, viewport, ... },
version,
created_at, updated_at, deleted_at
}</pre>
</div>
<div class="card">
<h3>CanvasWorkflow</h3>
<p>我的工作流的云端个人模板模型。它保存可复用的画布结构,不保存一次生成出来的媒体结果;前端写入前会清掉图片 URL、视频 URL、任务进度、错误、LLM 输出和生成结果列表,插回画布时重新生成节点 ID 并重连边。</p>
<pre>CanvasWorkflow {
id,
owner_id, owner_name, owner_email, owner_provider,
name, description, thumbnail,
workflow_data: { nodes, edges, viewport },
version,
created_at, updated_at, deleted_at
}</pre>
</div>
<div class="card">
<h3>Postgres 索引表</h3>
<p>Postgres 不替代大文件和完整 job state它负责跨用户、跨浏览器和后续后台管理需要的结构化索引。任务详情、媒体文件和资源库原始 manifest 仍保留在现有目录里。</p>
<pre>app_users
canvas_projects
canvas_workflows
job_index
generated_assets
prompt_library_index
asset_library_index
agent_run_index
audit_events</pre>
</div>
<div class="card">
<h3>KeyFrame</h3>
@@ -1047,11 +1145,14 @@ ProductRefStateItem {
<tr><th>功能</th><th>接口</th><th>前端调用</th><th>说明</th></tr>
</thead>
<tbody>
<tr><td>网页登录 / 飞书免登录</td><td><code>GET /auth/config</code><code>GET /auth/feishu/start</code><code>GET /auth/feishu/callback</code><code>POST /auth/login</code><code>GET /auth/check</code><code>GET /auth/me</code><code>POST /auth/logout</code></td><td><code>web/app/login/page.tsx</code>、Nginx <code>auth_request</code></td><td>登录页先读 <code>/api/auth/config</code> 判断是否显示飞书按钮;飞书客户端内且 <code>feishu_enabled=true</code> 时前端自动跳转授权入口,普通浏览器保留手动按钮和备用账号。飞书 OAuth 成功后后端用 open_id / union_id / email 生成多用户会话并设置 HttpOnly Cookie。账号密码登录保留为备用方式。生产 Nginx 对工作台和 <code>/api/</code><code>/auth/check</code> 做统一校验,未登录页面跳 <code>/login/?next=$request_uri</code>API 返回 JSON 401。</td></tr>
<tr><td>运行配置 / 模型标注</td><td><code>GET /health</code></td><td><code>getRuntimeHealth</code><code>ModelTrace</code></td><td>返回 <code>models</code>ASR、<code>asr_language</code>(默认 <code>auto</code>,表示中文/英文/多语言自动识别)、<code>asr_base_url</code><code>asr_remote_enabled</code><code>asr_local_fallback_enabled</code><code>asr_audio_fallback_enabled</code><code>faster_whisper</code>、本机 ASR、ASR fallback、翻译、GPT 改写、GPT 画面理解、产品视角识别 <code>product_view</code>、主图像模型 <code>gpt-image-2</code>、图片故障兜底 <code>image_fallbacks</code>、图片尺寸 <code>image_size_options</code>、短时熔断状态 <code>image_circuit</code>、主体 6 视图模型链路、Azure OpenAI TTS、视频别名、视频画幅 <code>video_size_options</code>、真实可用视频时长 <code>video_duration_options</code>、单条最大秒数 <code>video_max_duration_seconds</code> 和 Seedance 服务商。当前 <code>REWRITE_MODEL</code><code>AUDIO_REWRITE_MODEL</code><code>VISION_MODEL</code> 默认使用 <code>gpt-4o</code>;如果旧环境变量仍写 <code>gemini-*</code>,后端会归一化回 <code>GPT_TEXT_MODEL</code> / <code>REWRITE_MODEL</code>。语音只走 Azure OpenAI TTS<code>models.voice_tts_paths</code> 会回传当前尝试的语音路径,方便区分路径错误和语音服务不可用。前端所有当前主路径里会调用模型的按钮旁显示模型名,点击弹出小窗口查看模型链路和输入输出逻辑;不返回 API Key 或敏感凭证。</td></tr>
<tr><td>网页登录 / 飞书免登录</td><td><code>GET /auth/config</code><code>GET /auth/feishu/start</code><code>GET /auth/feishu/callback</code><code>POST /auth/login</code><code>GET /auth/check</code><code>GET /auth/me</code><code>POST /auth/logout</code></td><td><code>web/app/login/page.tsx</code>、Nginx <code>auth_request</code></td><td>登录页先读 <code>/api/auth/config</code> 判断是否显示飞书按钮和密码表单;飞书客户端内且 <code>feishu_enabled=true</code> 时前端自动跳转授权入口,普通浏览器保留手动飞书按钮。飞书 OAuth 成功后后端用 open_id / union_id / email 生成多用户会话并设置 HttpOnly Cookie。当前生产 <code>PASSWORD_AUTH_ENABLED=false</code>,因此 <code>password_enabled=false</code>,账号密码表单不展示,<code>POST /auth/login</code> 返回未配置。生产 Nginx 对工作台和 <code>/api/</code><code>/auth/check</code> 做统一校验,未登录页面跳 <code>/login/?next=$request_uri</code>API 返回 JSON 401。</td></tr>
<tr><td>运行配置 / 模型标注</td><td><code>GET /health</code></td><td><code>getRuntimeHealth</code><code>ModelTrace</code></td><td>返回 <code>database</code> 健康状态和 <code>models</code>ASR、<code>asr_language</code>(默认 <code>auto</code>,表示中文/英文/多语言自动识别)、<code>asr_base_url</code><code>asr_remote_enabled</code><code>asr_local_fallback_enabled</code><code>asr_audio_fallback_enabled</code><code>faster_whisper</code>、本机 ASR、ASR fallback、翻译、GPT 改写、GPT 画面理解、产品视角识别 <code>product_view</code>、主图像模型 <code>gpt-image-2</code>、图片故障兜底 <code>image_fallbacks</code>、图片尺寸 <code>image_size_options</code>、短时熔断状态 <code>image_circuit</code>、主体 6 视图模型链路、Azure OpenAI TTS、视频别名、视频画幅 <code>video_size_options</code>、真实可用视频时长 <code>video_duration_options</code>、单条最大秒数 <code>video_max_duration_seconds</code> 和 Seedance 服务商。当前 <code>REWRITE_MODEL</code><code>AUDIO_REWRITE_MODEL</code><code>VISION_MODEL</code> 默认使用 <code>gpt-4o</code>;如果旧环境变量仍写 <code>gemini-*</code>,后端会归一化回 <code>GPT_TEXT_MODEL</code> / <code>REWRITE_MODEL</code>。语音只走 Azure OpenAI TTS<code>models.voice_tts_paths</code> 会回传当前尝试的语音路径,方便区分路径错误和语音服务不可用。前端所有当前主路径里会调用模型的按钮旁显示模型名,点击弹出小窗口查看模型链路和输入输出逻辑;不返回 API Key、数据库密码或敏感凭证。</td></tr>
<tr><td>历史列表</td><td><code>GET /jobs</code></td><td><code>listJobs</code></td><td>当前登录用户可见 job 精简列表id/url/status/thumbnail/mtime/owner…按 state.json mtime 倒序。前端 URL 无 <code>?job=</code> 时拉它回填本人历史;带 <code>limit</code> 可截断。开启数据隔离时,飞书用户只看到自己的任务,历史无 owner 的旧任务只对备用账号可见。</td></tr>
<tr><td>创建任务</td><td><code>POST /jobs</code></td><td><code>createJob</code></td><td>提交 TK 链接,后台开始下载;后端会把当前登录用户写入 <code>Job.owner_*</code>,后续详情、素材文件、删除和生成接口都通过统一中间件校验归属。下载阶段默认不带 cookies生产环境必须显式保持 <code>YTDLP_COOKIES_FILE=</code><code>YTDLP_COOKIES_FROM_BROWSER=</code> 为空,避免容器内误读被打进镜像的开发 <code>api/.env</code></td></tr>
<tr><td>画布生成</td><td><code>POST /creative/jobs/image</code><br><code>POST /jobs/{id}/frames/upload</code><br><code>POST /jobs/{id}/frames/{idx}/generate</code><br><code>POST /jobs/{id}/frames/{idx}/storyboard/video</code><br><code>GET /jobs/{id}</code></td><td><code>web/canvas-app/src/hooks/useApi.js</code></td><td>根域名画布不单独保存后端画布表,画布项目当前在浏览器 localStorage一旦生成图片或视频就通过同一套 creative job / frame / storyboard video 接口写入当前登录用户自己的 job 目录。文生图会创建空白 creative job 后生成图片;图生视频会把上传图转成 frame 并作为视频参考图提交,提交视频后用 <code>skg:{jobId}:{videoId}</code> 作为画布侧任务 id 轮询 <code>/jobs/{id}</code>,直到视频状态完成或失败</td></tr>
<tr><td>画布项目</td><td><code>GET /canvas-projects</code><br><code>POST /canvas-projects</code><br><code>PUT /canvas-projects/{id}</code><br><code>GET /canvas-projects/{id}</code><br><code>DELETE /canvas-projects/{id}</code><br><code>POST /canvas-projects/import</code></td><td><code>web/canvas-app/src/stores/projects.js</code></td><td>根域名画布项目的服务端持久化接口。列表和详情按当前登录用户过滤;写入时保存画布 JSON、缩略图、可见性、版本和更新时间删除为软删除。首次上线后本地 <code>localStorage</code> 旧项目会通过 import 导入到当前用户,之后服务端 Postgres 是主存储</td></tr>
<tr><td>我的工作流</td><td><code>GET /canvas-workflows</code><br><code>POST /canvas-workflows</code><br><code>PUT /canvas-workflows/{id}</code><br><code>DELETE /canvas-workflows/{id}</code></td><td><code>web/canvas-app/src/stores/workflows.js</code><br><code>WorkflowPanel.vue</code></td><td>工作流面板“我的工作流”页的个人模板接口。列表、保存、更新和删除都按当前登录用户过滤;保存的是清理过运行态的 <code>workflow_data.nodes/edges/viewport</code>,用于跨设备复用画布结构。插回画布时前端会按当前视口中心重排节点、重建节点 ID并用旧 ID 到新 ID 的映射重连边,避免和现有画布节点冲突。</td></tr>
<tr><td>画布生成</td><td><code>POST /creative/jobs/image</code><br><code>POST /jobs/{id}/frames/upload</code><br><code>POST /jobs/{id}/frames/{idx}/generate</code><br><code>POST /jobs/{id}/frames/{idx}/storyboard/video</code><br><code>GET /jobs/{id}</code></td><td><code>web/canvas-app/src/hooks/useApi.js</code></td><td>画布项目结构保存在 <code>/canvas-projects</code>;一旦生成图片或视频,就通过同一套 creative job / frame / storyboard video 接口写入当前登录用户自己的 job 目录。文生图会创建空白 creative job 后生成图片;图生视频会把上传图转成 frame 并作为视频参考图提交,提交视频后用 <code>skg:{jobId}:{videoId}</code> 作为画布侧任务 id 轮询 <code>/jobs/{id}</code>,直到视频状态完成或失败。视频任务最终 prompt 会条件说明参考图人物是 AI 生成的虚拟角色,避免员工上传的 AI 人像素材被当成真实肖像处理。</td></tr>
<tr><td>AI 润色 / LLM 节点</td><td><code>POST /prompt/polish</code></td><td><code>web/canvas-app/src/hooks/useApi.js</code><br><code>web/canvas-app/src/api/chat.js</code></td><td>中性的提示词润色和通用文本生成接口。根画布和文本节点传 <code>mode=image</code>、默认输出英文提示词LLM 节点和自动执行意图分析传 <code>mode=chat</code>、保持输入语言。接口会清掉上一次润色遗留的模板尾巴,只保留用户明确写出的主体、品牌、产品、地点、平台、风格和意图;用户没写 <code>SKG</code> 时绝不主动加入 SKG也不主动补产品、平台、广告语境、slogan 或 hashtag。人物安全词按输入条件加入原文明确有人像、模特、角色、数字人等语义时才声明“虚构 AI 角色、非真人、非公众人物”;原文明确无人时才保留无人物约束;原文没写人时不主动造人,也不主动追加无人物禁令;输入提到参考图、首帧或尾帧时,提示词只条件保留已有可见人物,不凭空新增人物。</td></tr>
<tr><td>一键出片终端</td><td><code>POST /agent-runs</code><br><code>GET /agent-runs</code><br><code>GET /agent-runs/{id}</code><br><code>GET /agent-runs/{id}/final.mp4</code><br><code>GET /agent-runs/{id}/contact.jpg</code></td><td><code>web/app/agent/page.tsx</code></td><td>快速出片页的唯一主接口。前端提交 TikTok 链接和最多 6 张产品图;后端创建同 owner 的 <code>Job</code><code>AgentRun</code>后台执行下载、产品图归一化、透明骨架主体参考复制、12 段镜头计划、视频生成、失败镜头自动重跑一次、审片接触表和 ffmpeg 最终合成。列表、详情、最终 mp4 和接触表同样按 owner 隔离。</td></tr>
<tr><td>重试下载</td><td><code>POST /jobs/{id}/download/retry</code></td><td><code>retryJobDownload</code></td><td>用于 TK 链接下载失败且没有 <code>video_url</code> 的素材;清空错误、重新进入下载状态,并在后台再次执行 <code>pipeline_download</code>。上传视频不能重下载,需要重新上传文件。</td></tr>
<tr><td>上传视频</td><td><code>POST /jobs/upload</code></td><td><code>uploadJob</code></td><td>保存 source.mp4然后同样进入下载完成状态当前上传后也加入第一步队列下载完成后自动解析音频。</td></tr>
@@ -1090,7 +1191,7 @@ ProductRefStateItem {
<tr><td>分镜保存</td><td><code>PUT /frames/{idx}/storyboard</code></td><td><code>updateStoryboard</code></td><td>保存三字段中英镜像、选中视频 ID、4 图槽、时长、改造说明,以及高级抽屉里的镜头类型、人物描述、人物/产品开关、首帧规划、尾帧规划和产品出现方式。当前音频分镜行会额外写 <code>storyboard_row_idx</code>,避免多条分镜共用同一参考帧时互相覆盖。</td></tr>
<tr><td>三字段自动展开</td><td><code>POST /jobs/{job_id}/frames/{idx}/storyboard/quick-plan</code></td><td><code>quickPlanStoryboard</code></td><td>输入 <code>skg_copy_*</code><code>scene_one_line_*</code><code>action_one_line_*</code><code>subject_brief</code>,用 <code>REWRITE_MODEL</code> 展开为完整 <code>StoryboardScene</code>,只作为视频 prompt 来源,不直接持久化。</td></tr>
<tr><td>AI 改文案</td><td><code>POST /jobs/{job_id}/frames/{idx}/storyboard/refine</code></td><td><code>refineStoryboard</code></td><td>输入当前三字段和中文反馈,返回新的三字段中英镜像。前端必须先弹改前/改后预览,用户点应用后才写入行状态。</td></tr>
<tr><td>单条视频候选生成</td><td><code>POST /jobs/{job_id}/frames/{idx}/storyboard/video</code></td><td><code>generateStoryboardVideo</code></td><td>新增 <code>count</code><code>seed</code><code>storyboard_row_idx</code>,默认一次创建 4 个 <code>GeneratedVideo</code> 任务并立即返回 job每个候选独立排队、生成、失败或成功。前端提交 prompt 前用 quick-plan 展开,高级首尾帧存在时继续带上,不存在时后端用参考帧/主体图/产品图透明兜底。视频候选显示必须优先按 <code>storyboard_row_idx</code> 归属到音频分镜行,而不是只按 <code>frame_idx</code></td></tr>
<tr><td>单条视频候选生成</td><td><code>POST /jobs/{job_id}/frames/{idx}/storyboard/video</code></td><td><code>generateStoryboardVideo</code></td><td>新增 <code>count</code><code>seed</code><code>storyboard_row_idx</code>,默认一次创建 4 个 <code>GeneratedVideo</code> 任务并立即返回 job每个候选独立排队、生成、失败或成功。前端提交 prompt 前用 quick-plan 展开,高级首尾帧存在时继续带上,不存在时后端用参考帧/主体图/产品图透明兜底。最终提交给视频模型前,后端会为参考图追加 AI 虚拟角色条件提示:参考图若包含人物、脸、身体、手、头像或角色,就按虚构 AI 角色处理,不按真人或公众人物处理。视频候选显示必须优先按 <code>storyboard_row_idx</code> 归属到音频分镜行,而不是只按 <code>frame_idx</code></td></tr>
<tr><td>整片一键生成候选</td><td><code>POST /jobs/{job_id}/storyboard/batch-generate-all</code></td><td>当前主路径改为逐行调用 <code>generateStoryboardVideo</code></td><td>用户选择“每行 N 条”后,前端按音频分镜逐行提交,确保每个候选都带 <code>storyboard_row_idx</code>。后端批量接口保留为兼容能力,默认 <code>concurrency=1</code>,但当前 UI 不再用它做主路径。</td></tr>
<tr><td>生图</td><td><code>POST /frames/{idx}/generate</code></td><td><code>generateImage</code></td><td>基于关键帧或已选生成图做 image-to-image目前可用。</td></tr>
</tbody>
@@ -1118,9 +1219,9 @@ ProductRefStateItem {
</tr>
<tr>
<td><span class="tag blue">内容生产画布</span></td>
<td>承载个人自由排列的创作空间用户在画布上通过提示词、推荐词、AI 润色、自动执行、工作流模板或手动节点连接生成文本、图片和视频节点,结果按节点位置沉淀。画布项目先保存在浏览器本地,生成资产进入后端个人 job。</td>
<td>当前不做团队共享画布、管理员总览、多人协同编辑或跨浏览器同步;API 设置不能接回上游外部注册链接,生成调用必须继续走本项目后端 <code>/api</code> 和登录会话。</td>
<td><code>web/canvas-app/</code><code>deploy/nginx.conf</code><code>web/scripts/sync-canvas-root.mjs</code></td>
<td>承载个人自由排列的创作空间用户在画布上通过提示词、推荐词、AI 润色、自动执行、工作流模板或手动节点连接生成文本、图片和视频节点,结果按节点位置沉淀。画布项目服务端保存到 Postgres浏览器本地只缓存,生成资产进入后端个人 job。</td>
<td>当前不做实时多人协同编辑或管理员总览;默认项目仍是个人私有,可见性字段先预留 team/company。API 设置不能接回上游外部注册链接,生成调用必须继续走本项目后端 <code>/api</code> 和登录会话。</td>
<td><code>web/canvas-app/</code><code>web/canvas-app/src/stores/projects.js</code><code>api/main.py</code><code>api/db.py</code></td>
</tr>
<tr>
<td><span class="tag gray">音频条</span></td>
@@ -1156,7 +1257,7 @@ ProductRefStateItem {
<li>GPT Image 生图;当前 <code>IMAGE_MODEL</code> 和主体 6 视图链路默认使用 <code>gpt-image-2</code>,单次图片网关请求默认 60 秒超时主模型超时、429、5xx 或网络错误时允许 <code>gemini-3-pro-image-preview</code> 兜底,并有 2 次失败 / 600 秒短时熔断。</li>
<li>三字段分镜候选生成:默认行左侧露文案、场景一句话、人物+产品+动作,右侧直接展示横向视频轨;中文镜像失焦后会自动优化英文主值;支持 AI 改写预览、单条选择数量生成、追加生成、选中候选和整片按行排队提交。</li>
<li>全局资源中心:提示词库和素材库可从顶部“资源库”打开;提示词可复制并计数,素材应用到 job 时会复制成本 job 内普通 asset。</li>
<li>画布:<code>https://marketing.skg.com</code> 登录后直接进入个人生成画布,支持文生图、文生视频、图生视频三种节点化生成;生成资产继续写入当前登录用户自己的后端 job。</li>
<li>画布:<code>https://marketing.skg.com</code> 登录后直接进入个人生成画布,支持文生图、文生视频、图生视频三种节点化生成;画布项目服务端保存到 Postgres生成资产继续写入当前登录用户自己的后端 job。</li>
</ul>
</div>
<div class="card">
@@ -1165,7 +1266,7 @@ ProductRefStateItem {
<li>ASR优先走当前 OpenAI-compatible 音频转写入口;如果该网关没有 <code>/audio/transcriptions</code>,自动 fallback 到 <code>ASR_FALLBACK_MODEL</code>(默认 <code>gemini-2.5-flash</code>)的多模态音频识别。</li>
<li>Voice当前语音通道固定是 <code>VOICE_PROVIDER=azure_openai</code>,通过 <code>AZURE_OPENAI_BASE_URL=https://ai.skg.com/azure</code> 的 OpenAI 协议生成 TTS后端按 <code>AZURE_TTS_PATHS</code> 依次尝试路径。第一步暂不默认调用。</li>
<li>Audio Product Brief默认是通用 SKG 放松产品卖点;当前第一步只保留配置,后续分镜/新配音阶段再使用。</li>
<li>Video Gen当前视频通道固定优先 Seedance<code>VIDEO_API_BASE_URL=https://ai.skg.com/doubao</code> 走 content JSON 异步任务,提交后写入候选片段并轮询到完成</li>
<li>Video Gen当前视频通道默认 Seedance<code>VIDEO_API_BASE_URL=https://ai.skg.com/doubao</code> 走 content JSON 异步任务。新增 <code>xai</code> / <code>Grok Imagine Video</code> 时,后端按模型分流到 <code>XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai</code><code>/v1/videos/generations</code>,使用 <code>request_id</code> 轮询 <code>/v1/videos/{id}</code>,完成后下载 <code>video.url</code> 写入候选片段</li>
<li>Compose还没做本地 ffmpeg 字幕/TTS 合成。</li>
</ul>
</div>
@@ -1209,6 +1310,287 @@ ProductRefStateItem {
<h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog">
<article class="change">
<header>
<h3>2026-06-03 · 视频模型清单改为运行时真源</h3>
<span class="tag amber">API</span>
<span class="tag rose">UI</span>
<span class="tag blue">Docs</span>
</header>
<div class="body">
<p><strong>问题:</strong>本地接入 Grok Imagine Video 后,<code>/health</code> 仍把 Doubao 网关下的 <code>kling-omni</code><code>veo-3.1-fast</code> 作为可用视频模型返回,旧画布节点保存的 <code>Kling</code> 会继续提交到错误网关并触发 400。</p>
<p><strong>改动:</strong><code>api/main.py</code> 的视频模型清单按当前网关过滤Doubao / Ark 只暴露 <code>doubao-seedance*</code>Poe 才暴露 Poe 的 Seedance / Kling / Veo显式配置 <code>VIDEO_API_BASE_URL</code> 但未配置 <code>VIDEO_API_KEY</code> 时,默认视频通道不再借用 <code>LLM_API_KEY</code> 标记可用。视频提交入口新增可用模型校验,浏览器缓存硬塞的不可用模型会被本服务拦截。</p>
<p><strong>前端:</strong><code>web/canvas-app/src/stores/pinia/models.js</code> 在成功读取运行时模型后,以 <code>/health</code> 返回的可用视频模型替代静态视频清单;<code>VideoConfigNode.vue</code> 监听可用模型变化,旧节点若保存了不可用模型,会自动切回当前可用模型和对应清晰度。</p>
<p><strong>影响:</strong>本地只配置 <code>XAI_VIDEO_API_KEY</code> 时,画布视频下拉只显示 Grok Imagine Video同时配置有效 <code>VIDEO_API_KEY</code> 时才显示 Seedance。Kling / Veo 不会再因旧环境变量或旧缓存进入生成下拉。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-06-04 · Grok 视频创建阶段增加瞬时错误重试</h3>
<span class="tag blue">API</span>
<span class="tag orange">Bugfix</span>
</header>
<div class="body">
<p><strong>问题:</strong>生产排查刘凌的 Grok 视频失败时,后端状态显示模型已正确传为 <code>grok-imagine-video</code>,但 xAI 创建接口在返回 <code>request_id</code> 前出现 <code>500 Internal Server Error</code><code>Connection reset by peer</code>,旧逻辑会第一次失败就把候选视频标为失败。</p>
<p><strong>改动:</strong><code>api/main.py</code> 给 Grok/xAI 创建阶段增加 <code>VIDEO_CREATE_RETRY_ATTEMPTS</code><code>VIDEO_CREATE_RETRY_BACKOFF_SECONDS</code>默认遇到连接重置、超时、429 或 5xx 自动退避重试 3 次400/401/403/404 等明确参数或权限错误不重试。<code>/health</code> 暴露非敏感重试配置,错误提示把 5xx 归类为上游视频服务暂时异常。</p>
<p><strong>影响:</strong>Grok 通道不再因一次上游瞬时 500/断连直接失败;仍然保留日志中的每次重试状态,方便后续区分网关波动、权限问题和内容审核失败。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-06-03 · 接入 xAI Grok Imagine Video</h3>
<span class="tag blue">API</span>
<span class="tag violet">Model</span>
<span class="tag green">Canvas</span>
</header>
<div class="body">
<p><strong>问题:</strong>SKG xAI 网关 <code>https://ai.skg.com/ezlink/xai</code> 已确认可用 <code>grok-imagine-video</code> 文生视频,但项目只把 Seedance 暴露给画布,后端也按单一视频网关处理,无法同时保留 Seedance 并新增 xAI。</p>
<p><strong>改动:</strong><code>api/main.py</code> 新增 <code>xai</code> / <code>grok-imagine-video</code> 视频模型别名、<code>XAI_VIDEO_API_BASE_URL</code> / <code>XAI_VIDEO_API_KEY</code> / <code>XAI_VIDEO_CREATE_PATH</code> / <code>XAI_VIDEO_STATUS_PATH</code> 配置,按模型分流到 <code>/v1/videos/generations</code><code>/v1/videos/{id}</code>;创建时识别 xAI 的 <code>request_id</code>,轮询完成时读取 <code>video.url</code> 并下载 MP4。视频创建、轮询和 MP4 下载统一复用 <code>ai_http_client()</code>,可走 <code>AI_HTTP_PROXY</code>MP4 下载会跟随重定向并重试,避免 <code>vidgen.x.ai</code> TLS 握手偶发失败时直接丢结果。纯文生视频不会把系统空白帧误传为参考图;图生视频会把用户上传首帧作为 <code>image</code> 传入。</p>
<p><strong>前端 / 配置:</strong><code>web/canvas-app/src/config/models.js</code> 新增默认不可用的 <code>xai</code> 模型,<code>web/canvas-app/src/stores/pinia/models.js</code> 改为接受后端 <code>/health</code> 返回的可用视频模型,不再硬编码只保留 Seedance。<code>api/.env.example</code><code>deploy/.env.local.example</code><code>deploy/.env.production.example</code> 增加 xAI 私有 key 配置位,真实 key 只填本地或服务器私有 env。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-30 · 稳定性 / 安全加固子进程超时、SSRF、并发锁、上传持久化、轮询容错</h3>
<span class="tag blue">API</span>
<span class="tag violet">Canvas</span>
<span class="tag orange">Bugfix</span>
<span class="tag green">Security</span>
</header>
<div class="body">
<p><strong>问题:</strong>只读审查发现一批可复现隐患:<code>run()</code> 子进程yt-dlp/ffmpeg/ffprobe无超时会让 job 永挂 <code>downloading</code> 并泄漏进程;<code>create_job</code> 的源链接未校验,可被 <code>file://</code>/内网地址做 SSRF 与本地文件读取;视频队列多线程并发改同一 job 状态无锁互相覆盖;画布上传视频用 <code>blob:</code> URL重载后视频丢失首页/详情页轮询一次网络抖动就永久停。</p>
<p><strong>改动(后端 <code>api/main.py</code> / <code>api/db.py</code></strong><code>run()</code> 增加 timeout下载 <code>DOWNLOAD_TIMEOUT_SECONDS</code> 默认 600s其余 300s超时 kill 并标 <code>failed</code>;新增 <code>validate_source_url()</code>——只允许 http(s)、拒绝私有/环回/链路本地 IP、域名走 <code>SOURCE_URL_ALLOWED_HOSTS</code> 白名单(默认主流短视频平台);新增 per-job <code>RLock</code><code>save_state</code>/<code>update</code>/<code>update_generated_video</code> 及 retry 的 check-and-set 全部在锁内;<code>db.py</code> 改用 <code>psycopg_pool</code> 连接池、写失败由 <code>logging.error</code> 暴露;只读 GET 媒体路由改用不创建目录的 <code>job_path()</code>;多处 <code>Image.open()</code><code>with</code> 防 fd 泄漏。</p>
<p><strong>改动(前端画布 <code>web/canvas-app/src/</code></strong><code>VideoNode.vue</code> 上传改走后端 <code>/jobs/upload</code> 拿稳定 URL新增 <code>uploadCanvasVideo</code><code>cleanNodeForStorage</code> 同时剥离 <code>blob:</code><code>useCachedMediaUrl.js</code> 用真实 <code>blob.size</code> 统计缓存(修复 chunked 视频 size=0 让 LRU 失效、catch 路径补 token 竞态校验;<code>useApi.js</code> 读参考图补 <code>credentials</code>、移除与 Canvas 层重复的节点级视频轮询;<code>request.js</code> timeout 改 60s + <code>withCredentials</code>;删除 <code>api/video.js</code> 中忽略 taskId 的死代码。</p>
<p><strong>改动Next 首页 <code>web/app/</code></strong>首页/详情页视频轮询改为容错(连续失败 10 次才停);<code>agent</code> 页预览 ObjectURL 创建移入 <code>useEffect</code> 确保配对 revoke登录页 <code>pointermove</code> 用 rAF 节流并跳过 coarse 指针。飞书自动跳转行为按确认保留不动。</p>
<p><strong>影响 / 验证:</strong>新增后端依赖 <code>psycopg-pool</code>(已写入 <code>api/requirements.txt</code>,未装时自动回退按调用建连);新增可选 env<code>DOWNLOAD_TIMEOUT_SECONDS</code><code>SOURCE_URL_ALLOWED_HOSTS</code><code>DB_POOL_MAX_SIZE</code>。本地 <code>py_compile</code><code>pnpm build</code>canvas + next通过。描述需求时源链接受白名单约束新平台需加 <code>SOURCE_URL_ALLOWED_HOSTS</code>);画布上传视频现在持久化为后端 <code>/api/jobs/...</code> 地址。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-27 · 图片 API 改为运行时可配置并接入 Ark Seedream</h3>
<span class="tag blue">API</span>
<span class="tag violet">Model</span>
<span class="tag green">Canvas</span>
</header>
<div class="body">
<p><strong>问题:</strong>生图链路把主模型写死为 <code>gpt-image-2</code>,新增火山方舟 <code>doubao-seedream-4-5-251128</code> 时不能只靠 env 切换;同时 Seedream 4.5 的尺寸要求和图生图请求格式不同于现有 OpenAI-compatible <code>/images/edits</code> 路径。</p>
<p><strong>改动:</strong><code>api/main.py</code> 新增图片模型运行时注册:<code>IMAGE_MODEL</code> 决定主模型,<code>IMAGE_FALLBACK_MODELS</code> 支持多备用模型,<code>IMAGE_EXTRA_MODELS</code><code>IMAGE_MODEL_CONFIGS_JSON</code> 支持后续扩展Ark Seedream 使用 <code>ARK_IMAGE_BASE_URL</code><code>ARK_IMAGE_API_KEY</code><code>ARK_SEEDREAM_IMAGE_MODEL</code> 独立配置。普通模型继续走 <code>/images/generations</code> / <code>/images/edits</code>Seedream 图生图改走 <code>/images/generations</code> + <code>reference_images</code></p>
<p><strong>影响:</strong><code>web/canvas-app/src/config/models.js</code> 增加 <code>Seedream 4.5</code><code>2K/2048/1440x2560/2560x1440/4K</code> 尺寸;<code>/health</code> 返回每个图片模型的 provider、base URL、配置状态和尺寸能力。真实 Ark key 只应写入本地或 VPS 的 gitignored env 文件,不能提交到仓库。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-27 · 修复上传参考图刷新后丢失并降低保存频次</h3>
<span class="tag violet">Canvas</span>
<span class="tag blue">Data</span>
<span class="tag orange">Bugfix</span>
</header>
<div class="body">
<p><strong>问题:</strong>Dimi姚智恒反馈刷新后刚上传的参考图不见了。线上数据库显示其项目保存请求均为 200丢失节点是本地上传的 <code>参考图</code>,节点里只剩文件名和类型,没有 <code>url</code><code>base64</code></p>
<p><strong>根因:</strong><code>ImageNode.vue</code> 原来把上传文件转成 <code>data:</code> base64<code>cleanNodeForStorage</code> 为避免浏览器存储爆掉,会在保存到 localStorage 和 Postgres 前删除 <code>base64</code> 以及 <code>data:</code> URL所以刷新后无法恢复图片。这不是单纯网速问题。</p>
<p><strong>改动:</strong>图片节点上传时先调用 <code>/creative/jobs/image</code> 存成后端资产,再把 <code>/api/jobs/...</code> URL、<code>sourceJobId</code><code>sourceFrameIdx</code> 写入节点;项目远程保存增加内容签名去重,防抖从 800ms 调整到 2000ms减少连续重复 <code>PUT /canvas-projects</code></p>
<p><strong>影响:</strong><code>web/canvas-app/src/components/nodes/ImageNode.vue</code><code>web/canvas-app/src/hooks/useApi.js</code><code>web/canvas-app/src/stores/projects.js</code>。本地 Docker 中验证上传参考图后刷新,节点 URL 和页面图片都保留为同一个 <code>/api/jobs/...</code> 地址。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-27 · 固化最终产品和本地同构排查基线</h3>
<span class="tag violet">Product</span>
<span class="tag green">Ops</span>
</header>
<div class="body">
<p><strong>问题:</strong>项目经历过 React 单对话框、TK 复刻工作台、根域名画布、Postgres 持久化等多个版本,后续修复如果不先锁定主线,容易把旧入口或旧工作流误当成当前最终产品。</p>
<p><strong>改动:</strong>本页新增“最终产品基线”,明确后续升级默认针对 <code>https://marketing.skg.com</code> 登录后的个人生成画布;<code>AGENTS.md</code><code>RULES.md</code> 同步增加产品基线、本地 Docker 同构环境和线上用户 bug 只读排查 / 本地复现流程。</p>
<p><strong>影响:</strong>以后处理用户 bug 时先确认线上用户、时间线、job、画布项目和资产状态必要时把最小可复现数据拉到本地 Docker 修复。旧 React 首页、TK 复刻工作台、Agent Cut 和详情页只作为高级能力、兼容入口或源码参考。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-27 · 视频完成后自动回填画布节点</h3>
<span class="tag violet">Canvas</span>
<span class="tag amber">Video</span>
</header>
<div class="body">
<p><strong>问题:</strong>画布视频任务提交后,完成状态主要依赖单个 <code>VideoNode.vue</code> 组件内部长轮询。如果组件轮询中断、页面后台挂起或节点未稳定挂载,服务端已经生成好的视频不会立刻写回当前画布,用户需要刷新页面后才看到结果。</p>
<p><strong>改动:</strong><code>web/canvas-app/src/hooks/useApi.js</code> 新增 <code>readVideoTask</code>,用于按 <code>skg:&lt;jobId&gt;:&lt;videoId&gt;</code> 读取服务端当前视频状态;<code>web/canvas-app/src/views/Canvas.vue</code> 增加页面级待完成视频同步器,每 5 秒扫描带 <code>taskId</code> 且没有 <code>url</code> 的视频节点,完成后自动写入视频 URL、清掉 <code>taskId</code> 并保存画布;<code>VideoNode.vue</code> 只负责展示节点状态,不再承担任务同步。</p>
<p><strong>影响:</strong>视频排队、生成中和完成状态会在当前打开的画布里自动更新;刷新页面仍可恢复未完成任务,但不再是看到视频结果的必要步骤。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-27 · 修复刷新后画布记录被本地缓存覆盖</h3>
<span class="tag violet">Canvas</span>
<span class="tag blue">Backend</span>
<span class="tag green">Data</span>
</header>
<div class="body">
<p><strong>问题:</strong>画布项目从服务端拉取后,前端还会把浏览器 <code>localStorage</code> 里的项目通过 <code>POST /canvas-projects/import</code> 再导入一次;如果本地缓存是旧的、瘦身后的或缺少图片数据,可能覆盖同一个服务端项目,用户刷新后看到历史节点或生成记录变少。</p>
<p><strong>改动:</strong><code>web/canvas-app/src/stores/projects.js</code> 在画布节点变更时会调用 <code>scheduleRemoteSave(project)</code>,把当前项目正式同步到 <code>PUT /canvas-projects/{id}</code><code>api/main.py</code><code>/canvas-projects/import</code> 改为只迁移服务端缺失的本地旧项目,已存在项目直接返回服务端版本,不再由 localStorage 导入覆盖。</p>
<p><strong>影响:</strong>Postgres 里的 <code>canvas_projects</code> 重新成为主存储;刷新、换浏览器或本地缓存异常时,不应再把服务端项目缩小或清空。旧项目首次迁移仍可用,但迁移动作变为非破坏性。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-06-04 · AI 润色不再静默套模板</h3>
<span class="tag amber">API</span>
<span class="tag violet">Canvas</span>
<span class="tag cyan">Model</span>
</header>
<div class="body">
<p><strong>问题:</strong>当前网关分组对 <code>gpt-4o</code> 返回“无可用渠道”,而 <code>/prompt/polish</code> 捕获异常后直接返回本地 <code>fallback</code>,用户看到的是固定尾巴模板,不是真正的模型润色;同时前端 <code>useChat({ model: 'gpt-4o-mini' })</code> 没有把 <code>model</code> 发给后端,配置实际上未生效。</p>
<p><strong>改动:</strong><code>PromptPolishReq</code> 新增 <code>model</code> 字段,<code>web/canvas-app/src/hooks/useApi.js</code> 会把前端选择的模型传到 <code>/prompt/polish</code>;后端按“请求模型 → <code>REWRITE_MODEL</code><code>REWRITE_MODEL_FALLBACKS</code>”依次尝试,当前本地默认 <code>REWRITE_MODEL=gpt-4o-mini</code>、备用 <code>gemini-2.5-flash</code>。只有全部模型失败时才返回本地模板 <code>model=fallback</code>,并在日志里记录每个失败模型。</p>
<p><strong>影响:</strong>画布底部和文本节点的 AI 润色会优先走真实模型输出,不再把固定 “Clear main subject...” 或 “Cinematic motion...” 当作正常润色结果;如果未来网关主模型不可用,接口会自动降级到备用模型,而不是立刻套模板。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-26 · AI 润色改为意图分类和冲突校验</h3>
<span class="tag amber">API</span>
<span class="tag violet">Canvas</span>
</header>
<div class="body">
<p><strong>问题:</strong>旧润色会把人物/无人物安全词作为模板尾巴直接拼到可见 prompt。用户二次润色时这些尾巴会污染意图判断出现 <code>A person...</code> 后面又接 <code>do not introduce people</code> 这类自相矛盾结果;同时系统背景也不能替用户主动加入 SKG。</p>
<p><strong>改动:</strong><code>api/main.py</code><code>/prompt/polish</code> 改成“清理旧模板尾巴 → 分类人物/无人/物体/场景/动物/未知主体 → 按图片或视频结构改写 → 输出冲突校验/修复”。用户没写 <code>SKG</code> 时明确禁止主动加入 SKG用户没写人时不把未知主体润成 person也不追加“必须无人物”的兜底禁令用户明确有人时才把人物描述成虚构 AI 角色;用户明确无人时才保留无人物约束。</p>
<p><strong>影响:</strong>AI 润色继续保持中性专业但不会再把公司背景、SKG、产品、平台或人物安全尾巴硬塞给所有提示词。员工要 SKG 或具体产品时,需要自己写进输入;写了就保留并润色。视频生成的参考图链路会额外声明图中人物是 AI 生成的虚拟角色,以便员工继续使用 AI 人像素材做图生视频。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-26 · 我的工作流接入云端个人模板</h3>
<span class="tag violet">Canvas</span>
<span class="tag blue">Backend</span>
<span class="tag green">Data</span>
</header>
<div class="body">
<p><strong>问题:</strong>工作流面板只有公共模板,“我的工作流”为空;用户在当前画布里整理好的节点组合无法保存成自己的模板,也不能在另一台电脑用同一账号复用。</p>
<p><strong>改动:</strong>新增 Postgres 表 <code>canvas_workflows</code><code>GET/POST/PUT/DELETE /canvas-workflows</code> 接口,按当前登录用户保存个人工作流模板。前端新增 <code>web/canvas-app/src/stores/workflows.js</code><code>WorkflowPanel.vue</code> 的“我的工作流”页提供保存当前、刷新和删除;<code>Canvas.vue</code> 保存当前节点/连线,插入个人模板时重新生成节点 ID、按当前视口重排并按 ID 映射重连边。</p>
<p><strong>影响:</strong>同一飞书账号换电脑后应能看到自己的工作流模板;保存时会清掉生成图片、视频、进度、错误和 LLM 输出等运行态,只保留可复用结构和配置。当前仍是个人私有模板,不是公共模板库或公司共享模板库。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-26 · 推荐词扩展为 30 组共享短词池</h3>
<span class="tag violet">Canvas</span>
<span class="tag rose">UX</span>
</header>
<div class="body">
<p><strong>问题:</strong>推荐词刷新已经可用,但词池只有少数组,用户连续点击时会很快回到同一批,感觉“刷来刷去就这两组”。</p>
<p><strong>改动:</strong>新增 <code>web/canvas-app/src/config/suggestions.js</code>,把首页 <code>Home.vue</code> 和画布 <code>Canvas.vue</code> 的推荐词统一改为 <code>QUICK_SUGGESTION_GROUPS</code>,当前 30 组 / 120 个短词,每次仍显示 4 个并按组轮换。</p>
<p><strong>影响:</strong>用户可以连续刷新更多主题、场景、产品、镜头和风格灵感;推荐栏仍保持单行短词展示,不会顶起输入框。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-26 · 推荐词刷新改为短词轮换</h3>
<span class="tag violet">Canvas</span>
<span class="tag rose">UX</span>
</header>
<div class="body">
<p><strong>问题:</strong>画布和首页推荐词是固定数组,旁边刷新按钮没有绑定事件;推荐词如果过长或换行,会把底部输入区撑高。</p>
<p><strong>改动:</strong><code>web/canvas-app/src/views/Canvas.vue</code><code>Home.vue</code> 改为 4 个一组的短推荐词池点击刷新按钮时切换到下一组推荐栏固定单行高度chip 设置最大宽度和截断,按钮加 <code>title</code> / <code>aria-label</code></p>
<p><strong>影响:</strong>用户可连续点刷新切换推荐词,推荐区不会因为文案长度换行或顶起 composer。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-26 · AI 润色按人物意图加安全词</h3>
<span class="tag amber">API</span>
<span class="tag violet">Canvas</span>
</header>
<div class="body">
<p><strong>问题:</strong>为了降低 Seedance 人脸/肖像风控,不能把“虚构 AI 角色、非真人”无条件加到所有润色结果里;无人画面如果出现 <code>character</code><code>avatar</code> 等词,反而可能诱导模型新增人物。</p>
<p><strong>改动:</strong><code>api/main.py</code> 新增人物意图检测原始输入含人像、模特、角色、数字人、脸、portrait、model、character 等语义时润色才补“fully fictional synthetic AI character / virtual avatar / not based on any real person”原始输入没有人物语义或明确写无人时润色改为补“保持 object-only / scene-only / product-only composition不新增 people、faces、bodies、hands、avatars、characters、crowds”。</p>
<p><strong>影响:</strong>无人物产品图、场景图、食物图、街景图不会因为合规词被润成有人画面;有人物需求仍会尽量声明为虚构 AI 角色,减少被上游误判为真实肖像的概率。参考图里已有清晰人脸时,最终仍由上游图片安全检测决定,失败后继续显示可读的人脸风控提示。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-26 · AI 润色从 SKG 广告文案拆出</h3>
<span class="tag amber">API</span>
<span class="tag violet">Canvas</span>
</header>
<div class="body">
<p><strong>问题:</strong>画布 <code>AI 润色</code> 之前复用 <code>/creative/copy</code>,该接口本来是 SKG 营销短视频文案生成器,会默认加入 SKG、健康科技、按摩产品、TikTok/Reels 广告语境,导致普通图片或视频提示词也被带偏。</p>
<p><strong>改动:</strong><code>api/main.py</code> 新增中性 <code>POST /prompt/polish</code><code>web/canvas-app/src/hooks/useApi.js</code> 改为调用该接口并传入 <code>system_prompt</code><code>mode</code><code>target_language</code>。根画布和文本节点显式用 <code>mode=image</code> 输出英文生成提示词LLM 节点和自动执行意图分析用 <code>mode=chat</code> 保持输入语言。</p>
<p><strong>影响:</strong>AI 润色只优化用户原本写的主体、风格、镜头、光线、构图和细节,不再主动添加用户没提到的品牌、产品或营销话术;<code>/creative/copy</code> 继续保留给明确的 SKG 营销文案生成场景。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-26 · 视频生成失败改为员工可读提示</h3>
<span class="tag amber">API</span>
<span class="tag rose">UX</span>
</header>
<div class="body">
<p><strong>问题:</strong>Seedance / Doubao 视频上游返回 <code>InputImageSensitiveContentDetected.PrivacyInformation</code>、HTTP 400、429、timeout 等机器错误时,画布错误框原样展示会让员工误以为账号、模型或网关坏了,需要人工解释。</p>
<p><strong>改动:</strong><code>api/main.py</code> 新增视频错误归一化逻辑,提交失败、轮询失败和后台任务异常都会先转换成可读中文,再写入 <code>GeneratedVideo.error</code>。例如参考图被上游判成疑似真实人脸时,会提示系统已按 AI 虚拟角色提交但上游仍可能误判,并建议换低识别度首帧、裁掉或弱化脸部后重试。</p>
<p><strong>影响:</strong>前端现有视频失败卡、画布轮询错误框和详情里的 <code>video.error</code> 会自动显示中文解释;原始上游错误只写入 API 日志,方便管理员排查,不再要求用户把英文错误码发给开发者翻译。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-26 · 生产登录改为仅飞书</h3>
<span class="tag amber">Auth</span>
<span class="tag blue">Ops</span>
</header>
<div class="body">
<p><strong>问题:</strong>飞书接入后继续保留共享密码入口,后续成员容易误用旧密码账号,导致新内容继续落到 <code>password:skg</code>,和飞书个人身份沉淀混在一起。</p>
<p><strong>改动:</strong><code>api/main.py</code> 新增 <code>PASSWORD_AUTH_ENABLED</code> 总开关;生产设为 <code>false</code> 后,<code>/auth/config</code> 返回 <code>password_enabled=false</code>,登录页不再展示账号密码表单,<code>/auth/login</code> 不再可用,旧密码 Cookie 会在校验时失效。<code>WEB_AUTH_SESSION_SECRET</code> 仍保留用于飞书会话签名,旧备用账号配置只作为应急恢复材料,不作为当前入口。</p>
<p><strong>验证:</strong>生产 <code>/api/auth/config</code> 返回 <code>password_enabled=false</code><code>feishu_enabled=true</code><code>GET /api/auth/feishu/start?next=/</code> 返回 302 到飞书授权页;<code>POST /api/auth/login</code> 返回 503 <code>账号密码登录未配置</code></p>
<p><strong>影响:</strong>新成员只能通过飞书登录,后续新建任务和画布都会绑定真实飞书 owner。旧 <code>password:skg</code> 数据不会自动归属到任何飞书用户,后续应通过公司可见/认领/管理员迁移处理。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-26 · 旧密码账号内容迁到飞书账号</h3>
<span class="tag green">Data</span>
<span class="tag amber">Ops</span>
</header>
<div class="body">
<p><strong>问题:</strong>密码登录停用后,旧共享账号 <code>password:skg</code> 下的视频和画布不会自动出现在飞书账号里,用户会误以为之前生成的视频丢失。</p>
<p><strong>改动:</strong>在生产服务器先备份 Postgres 和 <code>data/jobs</code>,再把 <code>password:skg</code> 的 22 个 job、3 个画布项目和对应生成资产索引迁到飞书用户 <code>万康</code><code>feishu:ou_78276b4fd9dd818d8f70bc00d03ddbdf</code>。job 的 <code>state.json</code> 也同步改写 owner 字段,并重启 API 让内存态重新加载。</p>
<p><strong>影响:</strong>万康飞书登录后应能看到旧密码账号下的历史视频和画布;无 owner 的 4 个更早旧 job 保持不动,避免把来源不明确的内容误归属。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-26 · 接入 Postgres 做公司内部沉淀</h3>
<span class="tag blue">Backend</span>
<span class="tag amber">Deploy</span>
<span class="tag green">Data</span>
</header>
<div class="body">
<p><strong>问题:</strong>画布项目只存在浏览器 <code>localStorage</code> 时,换电脑、清缓存或多人长期使用都难以形成公司内部资产沉淀;任务和资源虽然已有文件持久化,但缺少结构化索引和审计。</p>
<p><strong>改动:</strong>新增 <code>api/db.py</code> 和 Postgres schema<code>app_users</code><code>canvas_projects</code><code>job_index</code><code>generated_assets</code><code>prompt_library_index</code><code>asset_library_index</code><code>agent_run_index</code><code>audit_events</code><code>api/main.py</code> 启动时建表并索引已有 job / AgentRun / 提示词 / 素材;新增 <code>/canvas-projects</code> CRUD 和 import 接口;登录、创建任务、资源库操作、画布保存都会写用户索引或审计。<code>web/canvas-app/src/stores/projects.js</code> 改为服务端项目优先,<code>localStorage</code> 只做缓存和旧项目导入。</p>
<p><strong>部署:</strong><code>docker-compose.prod.yml</code><code>docker-compose.standalone.yml</code> 增加 <code>postgres:16-alpine</code>;生产环境在 <code>deploy/.env.production</code> 写入 <code>DATABASE_URL</code> 和 Postgres 密码,数据目录为服务器 <code>./data/postgres</code><code>deploy-prod-safe.sh</code> 会在容器存在时导出 <code>pg_dump</code><code>verify-prod-docker.sh</code> 要求 <code>database.connected=true</code></p>
<p><strong>影响:</strong>画布项目开始具备跨浏览器、跨设备恢复的服务端主存储;默认仍按 owner 私有隔离,后续可在同一表上扩展 team/company 可见性。完整 job state 和媒体文件仍保留在原有文件目录,避免把大文件一次性搬进数据库。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-26 · 本地 Docker 先验收再上推</h3>
<span class="tag amber">Ops</span>
<span class="tag blue">Docs</span>
</header>
<div class="body">
<p><strong>问题:</strong>此前代码修改常直接走生产部署验证,容易把还在反复调整的模型、尺寸或界面配置带到线上,影响正在使用的团队成员。</p>
<p><strong>改动:</strong>新增 <code>docker-compose.local.yml</code><code>deploy/.env.local.example</code><code>scripts/start-local-docker.sh</code><code>scripts/verify-local-docker.sh</code><code>scripts/stop-local-docker.sh</code>。本地 Docker 默认暴露 <code>localhost:4390</code>,使用独立 <code>data-local/</code> 和本地 Postgres不读取生产 <code>deploy/.env.production</code></p>
<p><strong>影响:</strong>后续开发流程改为“本地 Docker 启动 → 本地验证 → 本地 commit → 用户确认后才推送/部署”。<code>AGENTS.md</code><code>RULES.md</code> 已同步该约束,避免后续接手会话自动推送或直接上生产。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-26 · 恢复最初生图配置</h3>
<span class="tag amber">API</span>
<span class="tag rose">UI</span>
<span class="tag blue">Docs</span>
</header>
<div class="body">
<p><strong>问题:</strong>连续加入真实像素、低/中/高画质、Gemini 官方 1K/2K/4K 尺寸和显式模型选择后,生图配置变复杂,员工容易选到不稳定或不符合预期的组合。</p>
<p><strong>改动:</strong>按用户要求恢复到最初简单版:<code>api/main.py</code> 只返回四个图片尺寸 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code><code>GenerateReq</code> 不再接收画质字段;<code>web/canvas-app/src/config/models.js</code> 恢复 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code> 三个图片模型和单一“标准”画质。</p>
<p><strong>影响:</strong><code>auto</code> 图片模型重新启用后端既有策略:优先 GPT Image 2遇到超时、限流或上游异常时可按熔断/兜底策略走 Gemini不再显示自定义尺寸输入也不再展示 Gemini 1K/2K/4K 长列表。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-25 · 媒体模型选择对齐真实后端能力</h3>
@@ -1325,7 +1707,7 @@ ProductRefStateItem {
<div class="body">
<p><strong>问题:</strong>默认首页适合“一次生成一个结果”,但内部多人使用时,用户还需要把多次生成的图片、视频、提示词和参考图放在一个自由空间里整理,避免结果都挤在同一条对话或详情页里。</p>
<p><strong>改动:</strong>新增 <code>web/canvas-app/</code>,将 huobao-canvas 的 Vue Flow 画布交互改造为 SKG 内部版可见品牌、GitHub 入口、API Key 设置和外部服务商配置都已移除,保留项目列表、生成画布、节点连接、四模式 prompt composer 和生成结果节点。构建链路新增 <code>pnpm build:canvas</code><code>web/scripts/sync-canvas-dist.mjs</code>,生产 Nginx 新增受登录保护的 <code>/canvas/</code> fallback 路由;首页顶部增加“生成画布”入口。</p>
<p><strong>影响:</strong>画布项目目前保存在浏览器 <code>localStorage</code>,不是团队共享,也不做跨设备同步;生成图片和视频仍调用本项目 <code>/api</code>,按当前登录用户写入个人 job。第三方来源说明只保存在 <code>THIRD_PARTY_NOTICES.md</code>,不进入终端用户 UI。</p>
<p><strong>影响:</strong>当时画布项目保存在浏览器 <code>localStorage</code>,不是团队共享,也不做跨设备同步;2026-05-26 已升级为服务端 Postgres 持久化。生成图片和视频仍调用本项目 <code>/api</code>,按当前登录用户写入个人 job。第三方来源说明只保存在 <code>THIRD_PARTY_NOTICES.md</code>,不进入终端用户 UI。</p>
</div>
</article>
<article class="change">

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
exit 1
}
if docker ps --format '{{.Names}}' | grep -qx skg-marketing-postgres; then
docker exec skg-marketing-postgres sh -lc 'pg_dump -U "\$POSTGRES_USER" "\$POSTGRES_DB"' \
| gzip > '$BACKUP_DIR/skg-marketing-postgres-'\$stamp'.sql.gz'
fi
find '$BACKUP_DIR' -name 'skg-marketing-preserve-*.tgz' -type f -printf '%T@ %p\n' | sort -nr | tail -n +8 | cut -d' ' -f2- | xargs -r rm -f
find '$BACKUP_DIR' -name 'skg-marketing-postgres-*.sql.gz' -type f -printf '%T@ %p\n' | sort -nr | tail -n +8 | cut -d' ' -f2- | xargs -r rm -f
echo backup:\$(ls -t '$BACKUP_DIR'/skg-marketing-preserve-*.tgz | head -1)
"
@@ -45,11 +50,20 @@ rsync -az --delete \
--filter='P /api/.env.production' \
--exclude='/.git/' \
--exclude='/.memory/' \
--exclude='/.backups/' \
--exclude='/.logs/' \
--exclude='/.pids/' \
--exclude='/.playwright-mcp/' \
--exclude='/.DS_Store' \
--exclude='*.log' \
--exclude='__pycache__/' \
--exclude='*.pyc' \
--exclude='/data/' \
--exclude='/data-local/' \
--exclude='/jobs/' \
--exclude='/output/' \
--exclude='/secrets/' \
--exclude='/api/.venv/' \
--exclude='/api/jobs/' \
--exclude='/api/.env' \
--exclude='/api/.env.local' \
@@ -58,6 +72,8 @@ rsync -az --delete \
--exclude='/web/node_modules/' \
--exclude='/web/.next/' \
--exclude='/web/out/' \
--exclude='/web/canvas-app/node_modules/' \
--exclude='/web/canvas-app/dist/' \
--exclude='/node_modules/' \
--exclude='内部分享-口播脚本.md' \
./ "$HOST:$APP_DIR/"

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
exit 1
fi
for p in / /login/ /_next/does-not-exist.js /api/health; do
code=\$(curl -sS -o /tmp/skg-smoke.out -w \"%{http_code}\" \"http://127.0.0.1\$p\")
case \"\$p:\$code\" in
/:302|/login/:200|/_next/does-not-exist.js:404|/api/health:401) echo \"web:\$p \$code\" ;;
*) echo \"ERROR: unexpected web route status \$p \$code\" >&2; head -c 200 /tmp/skg-smoke.out >&2; exit 1 ;;
esac
check_route() {
p=\"\$1\"
expected=\"\$2\"
attempts=\"\${3:-30}\"
i=1
while [ \"\$i\" -le \"\$attempts\" ]; do
code=\$(curl -sS -o /tmp/skg-smoke.out -w \"%{http_code}\" \"http://127.0.0.1\$p\" || echo 000)
if [ \"\$code\" = \"\$expected\" ]; then
echo \"web:\$p \$code\"
return 0
fi
sleep 1
i=\$((i + 1))
done
echo \"ERROR: unexpected web route status \$p \$code\" >&2
head -c 200 /tmp/skg-smoke.out >&2 || true
exit 1
}
for route in \"/ 302\" \"/login/ 200\" \"/_next/does-not-exist.js 404\" \"/api/health 401\"; do
set -- \$route
check_route \"\$1\" \"\$2\"
done
' && \
docker exec skg-marketing-api sh -lc '
set -e
test ! -f /app/.env || { echo \"ERROR: /app/.env leaked into API image\" >&2; exit 1; }
python -c \"import main; assert main.YTDLP_COOKIES_FROM_BROWSER == \\\"\\\", main.YTDLP_COOKIES_FROM_BROWSER; print(\\\"api:ytdlp_cookie_args\\\", main.ytdlp_cookie_args())\"
curl -sS http://127.0.0.1:4291/health | python -c \"import json,sys; d=json.load(sys.stdin); assert d[\\\"ok\\\"] is True; assert d[\\\"auth_configured\\\"] is True; print(\\\"api:health ok\\\")\"
curl -sS http://127.0.0.1:4291/health | python -c \"import json,sys; d=json.load(sys.stdin); assert d[\\\"ok\\\"] is True; assert d[\\\"auth_configured\\\"] is True; assert d.get(\\\"database\\\",{}).get(\\\"connected\\\") is True; print(\\\"api:health ok db connected\\\")\"
'"

View File

@@ -1,6 +1,6 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { useEffect, useRef, useState } from "react"
import {
ArrowDownToLine,
CheckCircle2,
@@ -73,8 +73,14 @@ export default function AgentPage() {
const [error, setError] = useState("")
const terminalRef = useRef<HTMLDivElement>(null)
const previews = useMemo(() => files.map((file) => ({ file, url: URL.createObjectURL(file) })), [files])
useEffect(() => () => previews.forEach((item) => URL.revokeObjectURL(item.url)), [previews])
// create object URLs inside the effect (not during render) so every URL has a
// matching revoke even under React strict-mode double-invocation
const [previews, setPreviews] = useState<{ file: File; url: string }[]>([])
useEffect(() => {
const next = files.map((file) => ({ file, url: URL.createObjectURL(file) }))
setPreviews(next)
return () => next.forEach((item) => URL.revokeObjectURL(item.url))
}, [files])
useEffect(() => {
fetch(`${API_BASE}/agent-runs?limit=8`, { cache: "no-store" })

View File

@@ -116,11 +116,16 @@ export default function DetailPage() {
useEffect(() => {
if (!job || !runningVideo) return
let failures = 0
const timer = window.setInterval(async () => {
try {
setJob(await getJob(job.id))
failures = 0
} catch {
window.clearInterval(timer)
// one transient 5xx / network blip must not freeze progress forever;
// only give up after sustained failures
failures += 1
if (failures >= 10) window.clearInterval(timer)
}
}, 2600)
return () => window.clearInterval(timer)

View File

@@ -32,12 +32,6 @@ function loginNextPath() {
return normalizeNextPath(new URLSearchParams(window.location.search).get("next"))
}
function isFeishuClient() {
if (typeof window === "undefined") return false
const ua = window.navigator.userAgent.toLowerCase()
return ua.includes("feishu") || ua.includes("lark")
}
export default function LoginPage() {
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null)
const [nextPath] = useState(loginNextPath)
@@ -67,15 +61,26 @@ export default function LoginPage() {
}, [])
useEffect(() => {
// skip touch / coarse pointers — the eye-follow effect is pointless there and
// would thrash state (and battery) on scroll-driven pointer events
if (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches) return
let frame = 0
const onPointerMove = (event: PointerEvent) => {
const centerX = window.innerWidth / 2
const centerY = window.innerHeight / 2
const nextX = Math.max(-1, Math.min(1, (event.clientX - centerX) / centerX))
const nextY = Math.max(-1, Math.min(1, (event.clientY - centerY) / centerY))
setEyeOffset({ x: nextX * 8, y: nextY * 5.5 })
if (frame) return // coalesce to at most one state update per animation frame
frame = window.requestAnimationFrame(() => {
frame = 0
const centerX = window.innerWidth / 2
const centerY = window.innerHeight / 2
const nextX = Math.max(-1, Math.min(1, (event.clientX - centerX) / centerX))
const nextY = Math.max(-1, Math.min(1, (event.clientY - centerY) / centerY))
setEyeOffset({ x: nextX * 8, y: nextY * 5.5 })
})
}
window.addEventListener("pointermove", onPointerMove)
return () => window.removeEventListener("pointermove", onPointerMove)
return () => {
window.removeEventListener("pointermove", onPointerMove)
if (frame) window.cancelAnimationFrame(frame)
}
}, [])
const disabled = status === "loading" || status === "success"
@@ -83,7 +88,7 @@ export default function LoginPage() {
const passwordEnabled = authConfig?.password_enabled ?? true
useEffect(() => {
if (!feishuEnabled || status !== "idle" || autoFeishuAttemptedRef.current || !isFeishuClient()) return
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")
@@ -155,7 +160,7 @@ export default function LoginPage() {
onClick={onFeishuLogin}
>
<Building2 className="h-4 w-4" />
<ArrowRight className="h-4 w-4" />
</button>
) : null}

View File

@@ -191,11 +191,16 @@ export default function Home() {
useEffect(() => {
if (!job || !runningVideo) return
let failures = 0
const timer = window.setInterval(async () => {
try {
setJob(await getJob(job.id))
failures = 0
} catch {
window.clearInterval(timer)
// one transient 5xx / network blip must not freeze progress forever;
// only give up after sustained failures
failures += 1
if (failures >= 10) window.clearInterval(timer)
}
}, 2600)
return () => window.clearInterval(timer)

View File

@@ -15,12 +15,18 @@ export const chatCompletions = (data) =>
// 流式对话补全
export const streamChatCompletions = async function* (data, signal, options = {}) {
const text = data?.messages?.at?.(-1)?.content || data?.goal || ''
const response = await fetch('/api/creative/copy', {
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({ goal: typeof text === 'string' ? text : JSON.stringify(text), seconds: 15 }),
body: JSON.stringify({
text: typeof text === 'string' ? text : JSON.stringify(text),
system_prompt: systemPrompt,
mode: 'chat',
target_language: 'keep'
}),
signal
})
@@ -30,6 +36,5 @@ export const streamChatCompletions = async function* (data, signal, options = {}
}
const json = await response.json()
const variant = json.variants?.[0]
yield variant?.image_prompt_en || variant?.video_prompt_en || ''
yield json.text || ''
}

View File

@@ -17,29 +17,6 @@ export const createVideoTask = (data, options = {}) => {
})
}
// 查询视频任务状态
export const getVideoTaskStatus = (taskId, options = {}) => {
const { endpoint = '/videos' } = options
return request({
url: `${endpoint}`,
method: 'get'
})
}
// 轮询视频任务直到完成
export const pollVideoTask = async (taskId, maxAttempts = 120, interval = 5000) => {
for (let i = 0; i < maxAttempts; i++) {
const result = await getVideoTaskStatus(taskId)
if (result.status === 'completed' || result.data) {
return result
}
if (result.status === 'failed') {
throw new Error(result.error?.message || '视频生成失败')
}
await new Promise(resolve => setTimeout(resolve, interval))
}
throw new Error('视频生成超时')
}
// 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.

View File

@@ -42,11 +42,55 @@
</div>
<!-- My workflows | 我的工作流 -->
<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 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>
@@ -69,15 +113,23 @@ import {
BookOutline,
PersonOutline,
CartOutline,
ChatbubbleOutline
ChatbubbleOutline,
BookmarkOutline,
RefreshOutline,
TrashOutline
} from '@vicons/ionicons5'
import { WORKFLOW_TEMPLATES } from '../config/workflows'
const props = defineProps({
show: Boolean
show: Boolean,
myWorkflows: {
type: Array,
default: () => []
},
loadingMyWorkflows: Boolean
})
const emit = defineEmits(['update:show', 'add-workflow'])
const emit = defineEmits(['update:show', 'add-workflow', 'save-current', 'delete-workflow', 'refresh-workflows'])
// Active tab | 当前标签
const activeTab = ref('public')
@@ -113,6 +165,11 @@ const handleAddWorkflow = (workflow) => {
visible.value = false
}
const formatWorkflowMeta = (workflow) => {
const count = workflow.workflowData?.nodes?.length || 0
return `${count} 个节点`
}
// Handle click outside | 点击外部关闭
const handleClickOutside = () => {
visible.value = false
@@ -216,6 +273,53 @@ const vClickOutside = {
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;
@@ -229,6 +333,10 @@ const vClickOutside = {
transition: transform 0.2s;
}
.my-workflow-card {
position: relative;
}
.workflow-card:hover {
transform: translateY(-2px);
}
@@ -269,6 +377,31 @@ const vClickOutside = {
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;

View File

@@ -41,7 +41,7 @@
<!-- Model selector | 模型选择 -->
<div class="flex items-center justify-between">
<span class="text-xs text-[var(--text-secondary)]">模型</span>
<n-dropdown :options="modelOptions" @select="handleModelSelect">
<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>
@@ -52,7 +52,7 @@
<!-- Quality selector | 画质选择 -->
<div v-if="hasQualityOptions" class="flex items-center justify-between">
<span class="text-xs text-[var(--text-secondary)]">画质</span>
<n-dropdown :options="qualityOptions" @select="handleQualitySelect">
<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>
@@ -64,7 +64,7 @@
<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 :options="sizeOptions" @select="handleSizeSelect">
<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 }}
@@ -97,7 +97,7 @@
<!-- Generate button | 生成按钮 -->
<div v-if="hasConnectedImageWithContent" class="flex gap-2">
<!-- Create new (primary) | 新建节点主按钮 -->
<button @click="handleGenerate('new')" :disabled="loading || !isConfigured"
<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>
@@ -106,7 +106,7 @@
</template>
</button>
<!-- Replace existing (secondary) | 替换现有次按钮 -->
<button @click="handleGenerate('replace')" :disabled="loading || !isConfigured"
<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>
@@ -115,7 +115,7 @@
</template>
</button>
</div>
<button v-else @click="handleGenerate('auto')" :disabled="loading || !isConfigured"
<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>
@@ -124,6 +124,9 @@
立即生成
</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">
@@ -182,6 +185,10 @@ 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()
@@ -232,6 +239,11 @@ const handleSelect = (item) => {
// 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)

View File

@@ -135,7 +135,7 @@
ref="imageContainerRef"
>
<img
:src="data.url"
:src="displayImageUrl"
:alt="data.label"
class="w-full h-auto object-cover"
:class="{ 'pointer-events-none': isInpaintMode }"
@@ -267,7 +267,7 @@
<!-- Image preview dialog | 图片预览弹窗 -->
<n-image-preview
v-model:show="showRef"
:src="props.data?.url"
:src="displayImageUrl"
/>
<!-- Replace image modal | 替换图片弹窗 -->
@@ -327,6 +327,8 @@ 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({
@@ -336,6 +338,7 @@ const props = defineProps({
// Vue Flow instance | Vue Flow 实例
const { updateNodeInternals } = useVueFlow()
const { cachedUrl: displayImageUrl, warmCache: warmImageCache } = useCachedMediaUrl(() => props.data?.url)
// Hover state | 悬浮状态
const showActions = ref(true)
@@ -665,27 +668,17 @@ const createInpaintWorkflow = () => {
window.$message?.success('已创建局部重绘工作流')
}
// Convert file to base64 | 将文件转换为 base64
const fileToBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
// Handle file upload | 处理文件上传
const handleFileUpload = async (event) => {
const file = event.target.files[0]
if (file) {
try {
// Convert to base64 | 转换为 base64
const base64 = await fileToBase64(file)
// Store both display URL and base64 | 同时存储显示 URL 和 base64
urlLoading.value = true
const uploaded = await uploadCanvasImage(file)
updateNode(props.id, {
url: base64, // Use base64 as display URL | 使用 base64 作为显示 URL
base64: base64, // Store base64 for API calls | 存储 base64 用于 API 调用
url: uploaded.url,
sourceJobId: uploaded.jobId,
sourceFrameIdx: uploaded.frameIdx,
fileName: file.name,
fileType: file.type,
label: '参考图',
@@ -694,6 +687,9 @@ const handleFileUpload = async (event) => {
} catch (err) {
console.error('File upload error:', err)
window.$message?.error('图片上传失败')
} finally {
urlLoading.value = false
event.target.value = ''
}
}
}
@@ -738,10 +734,12 @@ const handleReplaceFileUpload = async (event) => {
const file = event.target.files[0]
if (file) {
try {
const base64 = await fileToBase64(file)
urlLoading.value = true
const uploaded = await uploadCanvasImage(file)
updateNode(props.id, {
url: base64,
base64: base64,
url: uploaded.url,
sourceJobId: uploaded.jobId,
sourceFrameIdx: uploaded.frameIdx,
fileName: file.name,
fileType: file.type,
label: '参考图',
@@ -753,6 +751,9 @@ const handleReplaceFileUpload = async (event) => {
} catch (err) {
console.error('File upload error:', err)
window.$message?.error('图片上传失败')
} finally {
urlLoading.value = false
event.target.value = ''
}
}
}
@@ -891,6 +892,7 @@ const showRef = ref(false)
// Handle preview | 处理预览
const handlePreview = () => {
if (props.data.url) {
warmImageCache()
showRef.value = true
}
}
@@ -899,7 +901,7 @@ const handlePreview = () => {
const handleDownload = () => {
if (props.data.url) {
const link = document.createElement('a')
link.href = props.data.url
link.href = displayImageUrl.value || props.data.url
link.download = props.data.fileName || `image_${Date.now()}.png`
document.body.appendChild(link)
link.click()

View File

@@ -695,7 +695,9 @@ const formatOptions = [
const chatHook = computed(() => {
return useChat({
systemPrompt: systemPrompt.value,
model: model.value
model: model.value,
mode: 'chat',
targetLanguage: 'keep'
})
})
@@ -838,7 +840,9 @@ const handleGenerate = async () => {
const { send } = useChat({
systemPrompt: resolvedSystemPrompt,
model: model.value
model: model.value,
mode: 'chat',
targetLanguage: 'keep'
})
// 如果 user 消息为空,使用简单提示

View File

@@ -116,8 +116,10 @@ const isApiConfigured = computed(() => !!modelStore.currentApiKey)
// Chat hook for polish | 润色用的 Chat hook
const { send: sendChat } = useChat({
systemPrompt: '你是一个专业的AI绘画提示词专家。将用户输入的内容美化成高质量的生图提示词,包含风格、光线、構图、细节等要素。直接返回提示词,不要其他解释。',
model: 'gpt-4o-mini'
systemPrompt: '你是一个专业的 AI 绘画提示词编辑。只优化用户已经给出的主体、风格、光线、构图和细节,不添加用户没有提到的品牌、产品或营销话术。直接返回提示词,不要其他解释。',
model: 'gpt-4o-mini',
mode: 'image',
targetLanguage: 'en'
})
// Local content state | 本地内容状态

View File

@@ -22,12 +22,12 @@
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="复制节点">
<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 @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="删除节点">
<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>
@@ -40,8 +40,8 @@
<!-- Model selector | 模型选择 -->
<div class="flex items-center justify-between">
<span class="text-xs text-[var(--text-secondary)]">模型</span>
<n-dropdown :options="modelOptions" @select="handleModelSelect">
<button class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
<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>
@@ -51,8 +51,8 @@
<!-- Aspect ratio selector | 宽高比选择 -->
<div class="flex items-center justify-between">
<span class="text-xs text-[var(--text-secondary)]">比例</span>
<n-dropdown :options="ratioOptions" @select="handleRatioSelect">
<button class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
<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 />
@@ -64,8 +64,8 @@
<!-- Duration selector | 时长选择 -->
<div class="flex items-center justify-between">
<span class="text-xs text-[var(--text-secondary)]">时长</span>
<n-dropdown :options="durationOptions" @select="handleDurationSelect">
<button class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
<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 />
@@ -74,6 +74,19 @@
</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)]">
@@ -105,8 +118,8 @@
</div> -->
<!-- Generate button | 生成按钮 -->
<button @click="handleGenerate" :disabled="isGenerating || !isConfigured"
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">
<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">
@@ -115,6 +128,9 @@
生成视频
</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">
@@ -151,7 +167,7 @@ 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, getModelConfig, DEFAULT_VIDEO_MODEL } from '../../stores/models'
import { getModelRatioOptions, getModelDurationOptions, getModelResolutionOptions, getModelConfig, DEFAULT_VIDEO_MODEL } from '../../stores/models'
// 使用 Pinia store 获取模型选项(根据渠道过滤)
const modelStore = useModelStore()
@@ -164,18 +180,29 @@ const props = defineProps({
// Vue Flow instance | Vue Flow 实例
const { updateNodeInternals } = useVueFlow()
// API config state | API 配置状态
const isConfigured = computed(() => !!modelStore.currentApiKey)
// 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)
@@ -218,9 +245,12 @@ const imagesByRole = computed(() => {
// 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.allVideoModelOptions)
const modelOptions = computed(() => modelStore.videoModelOptions)
// Display model name | 显示模型名称
const displayModelName = computed(() => {
@@ -243,8 +273,14 @@ const durationOptions = computed(() => {
return getModelDurationOptions(localModel.value)
})
// Resolution options based on model | 基于模型的清晰度选项
const resolutionOptions = computed(() => {
return getModelResolutionOptions(localModel.value)
})
// Handle model selection | 处理模型选择
const handleModelSelect = (key) => {
const applyModelSelection = (key) => {
if (!key) return
localModel.value = key
// Update ratio and duration to model's default | 更新为模型默认比例和时长
const config = getModelConfig(key)
@@ -257,9 +293,34 @@ const handleModelSelect = (key) => {
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)
@@ -283,6 +344,12 @@ const handleDurationSelect = (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)
@@ -410,6 +477,11 @@ const handleGenerate = async () => {
params.dur = localDuration.value
}
// Add resolution | 添加清晰度
if (localResolution.value) {
params.resolution = localResolution.value
}
// 只创建任务,获取 taskId不在这里轮询
const { taskId: newTaskId, url } = await createVideoTaskOnly(params)
@@ -483,22 +555,27 @@ const handleDelete = () => {
// Initialize on mount | 挂载时初始化
onMounted(() => {
// 检查当前模型是否在可用模型列表中
const availableModels = modelStore.availableVideoModels
const isModelAvailable = availableModels.some(m => m.key === localModel.value)
if (!localModel.value || !isModelAvailable) {
// 使用 store 中的默认模型或第一个可用模型
const selected = availableModels.find(m => m.key === modelStore.selectedVideoModel)?.key
localModel.value = selected || availableModels[0]?.key || DEFAULT_VIDEO_MODEL
updateNode(props.id, { model: localModel.value })
}
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)
}
})

View File

@@ -79,8 +79,9 @@
class="aspect-video rounded-lg overflow-hidden bg-black"
>
<video
:src="data.url"
:src="displayVideoUrl"
controls
preload="metadata"
class="w-full h-full object-contain"
/>
</div>
@@ -140,12 +141,13 @@
* Video node component | 视频节点组件
* Displays and manages video content
*/
import { ref, nextTick, watch, onMounted } from 'vue'
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 { useVideoGeneration } from '../../hooks/useApi'
import { useCachedMediaUrl } from '../../hooks/useCachedMediaUrl'
import { uploadCanvasVideo } from '../../hooks/useApi'
import NodeHandleMenu from './NodeHandleMenu.vue'
const props = defineProps({
@@ -155,9 +157,8 @@ const props = defineProps({
// Vue Flow instance
const { updateNodeInternals } = useVueFlow()
// Get pollVideoTask from useVideoGeneration | 从 useVideoGeneration 获取轮询函数
const { pollVideoTask } = useVideoGeneration()
const { cachedUrl: displayVideoUrl, warmCache: warmVideoCache } = useCachedMediaUrl(() => props.data?.url)
const activeVideoUrl = computed(() => displayVideoUrl.value || props.data?.url || '')
// Hover state | 悬浮状态
const showActions = ref(false)
@@ -173,61 +174,6 @@ const operations = [
{ type: 'videoConfig', label: '生视频', icon: VideocamOutline }
]
// Polling state | 轮询状态
const isPolling = ref(false)
// Watch for taskId changes and start polling | 监听 taskId 变化并开始轮询
watch(() => props.data?.taskId, (taskId) => {
if (taskId && !props.data?.url && !isPolling.value) {
startPolling(taskId)
}
})
// 页面刷新后恢复轮询 | Resume polling after page refresh
onMounted(() => {
const { taskId, url } = props.data || {}
if (taskId && !url && !isPolling.value) {
startPolling(taskId)
}
})
// Start polling for video result | 开始轮询获取视频结果
const startPolling = async (taskId) => {
if (isPolling.value) return
isPolling.value = true
try {
const result = await pollVideoTask(taskId, (attempt, percentage) => {
// 更新进度
updateNode(props.id, {
progress: percentage,
attempt
})
})
// 轮询成功,更新视频节点
updateNode(props.id, {
url: result.url,
loading: false,
progress: 100,
label: '视频生成',
taskId: null // 清除 taskId
})
window.$message?.success('视频生成成功')
} catch (err) {
// 轮询失败
updateNode(props.id, {
loading: false,
error: err.message || '生成失败',
label: '生成失败',
taskId: null // 清除 taskId
})
window.$message?.error(err.message || '视频生成失败')
} finally {
isPolling.value = false
}
}
// Handle menu select | 处理菜单选择
const handleSelect = (item) => {
const currentNode = nodes.value.find(n => n.id === props.id)
@@ -250,14 +196,21 @@ const handleSelect = (item) => {
}
// Handle file upload | 处理文件上传
const handleFileUpload = (event) => {
const file = event.target.files[0]
if (file) {
const url = URL.createObjectURL(file)
updateNode(props.id, {
url,
updatedAt: Date.now()
})
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}`)
}
}
@@ -300,7 +253,8 @@ const handleDelete = () => {
// Handle preview | 处理预览
const handlePreview = () => {
if (props.data.url) {
window.open(props.data.url, '_blank')
warmVideoCache()
window.open(activeVideoUrl.value, '_blank')
}
}
@@ -308,7 +262,7 @@ const handlePreview = () => {
const handleDownload = () => {
if (props.data.url) {
const link = document.createElement('a')
link.href = props.data.url
link.href = activeVideoUrl.value
link.download = props.data.fileName || `video_${Date.now()}.mp4`
document.body.appendChild(link)
link.click()

View File

@@ -11,6 +11,16 @@ export const SEEDREAM_SIZE_OPTIONS = [
{ 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
@@ -67,6 +77,19 @@ export const IMAGE_MODELS = [
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'
}
},
]
@@ -100,10 +123,45 @@ export const VIDEO_MODELS = [
{ label: '12 秒', key: 12 },
{ label: '15 秒', key: 15 }
],
resolutions: ['720p'],
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 | 对话模型
@@ -121,7 +179,8 @@ export const IMAGE_SIZE_OPTIONS = [
{ label: '自动', key: 'auto' },
{ label: '竖图 2:3', key: '1024x1536' },
{ label: '方图 1:1', key: '1024x1024' },
{ label: '横图 3:2', key: '1536x1024' }
{ label: '横图 3:2', key: '1536x1024' },
...ARK_SEEDREAM_SIZE_OPTIONS
]
// Image quality options | 图片质量选项
@@ -149,10 +208,10 @@ export const VIDEO_DURATION_OPTIONS = [
]
// Default values | 默认值
export const DEFAULT_IMAGE_MODEL = 'auto'
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 = '1024x1536'
export const DEFAULT_IMAGE_SIZE = '2048x2048'
export const DEFAULT_VIDEO_RATIO = '720x1280'
export const DEFAULT_VIDEO_DURATION = 10

View File

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

View File

@@ -18,8 +18,12 @@ export {
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

@@ -49,7 +49,7 @@ const imageSourceToFile = async (source, filename = 'reference.jpg') => {
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)
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' })
@@ -70,6 +70,32 @@ const uploadReferenceFrame = async (jobId, 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]
@@ -79,6 +105,37 @@ 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 = {
@@ -127,7 +184,7 @@ export const useApiState = () => {
return { loading, error, status, reset, setLoading, setError, setSuccess }
}
export const useChat = () => {
export const useChat = (options = {}) => {
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
const messages = ref([])
const currentResponse = ref('')
@@ -137,12 +194,18 @@ export const useChat = () => {
setLoading(true)
stopped = false
try {
const response = await requestJson('/creative/copy', {
const mode = options.mode || 'chat'
const response = await requestJson('/prompt/polish', {
method: 'POST',
body: JSON.stringify({ goal: content, seconds: 15 })
body: JSON.stringify({
text: content,
system_prompt: options.systemPrompt || '',
model: options.model || '',
mode,
target_language: options.targetLanguage || (mode === 'chat' ? 'keep' : 'en')
})
})
const variant = response.variants?.[0]
const result = variant?.image_prompt_en || variant?.video_prompt_en || content
const result = response.text || content
if (!stopped) {
currentResponse.value = result
messages.value.push({ role: 'user', content })
@@ -234,7 +297,8 @@ export const useVideoGeneration = () => {
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)
size: normalizeVideoSize(params.ratio || params.size),
resolution: params.resolution || '720p'
})
})
const created = newestGeneratedVideo(updated)
@@ -250,45 +314,11 @@ export const useVideoGeneration = () => {
}
}
const pollVideoTask = async (pollTaskId, onProgress = () => {}) => {
const match = /^skg:([^:]+):([^:]+)$/.exec(String(pollTaskId || ''))
if (!match) throw new Error('未知视频任务类型')
const [, jobId, videoId] = match
const maxAttempts = 180
const interval = 5000
for (let i = 0; i < maxAttempts; i += 1) {
const job = await requestJson(`/jobs/${jobId}`, { method: 'GET' })
const item = (job.generated_videos || []).find(v => v.id === videoId)
if (!item) throw new Error('视频任务不存在')
const percentage = item.progress || Math.min(Math.round((i / maxAttempts) * 100), 98)
onProgress(i + 1, percentage)
progress.attempt = i + 1
progress.percentage = percentage
if (item.status === 'completed') {
const result = { ...item, url: toAssetUrl(item.url || `/jobs/${jobId}/storyboard-videos/${videoId}.mp4`) }
video.value = result
setSuccess()
return result
}
if (item.status === 'failed') {
throw new Error(item.error || '视频生成失败')
}
await new Promise(resolve => setTimeout(resolve, interval))
}
throw new Error('视频生成超时')
}
const generate = async (params) => {
const { taskId: newTaskId, url } = await createVideoTaskOnly(params)
if (url) {
video.value = { url }
return video.value
}
return pollVideoTask(newTaskId)
}
return { loading, error, status, video, taskId, progress, generate, reset, createVideoTaskOnly, pollVideoTask }
// 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 = () => {

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

@@ -36,6 +36,32 @@ 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
@@ -165,14 +191,16 @@ const checkSignificantChanges = (oldState, newState) => {
return false
}
// Add a new node | 添加新节点
export const addNode = (type, position = { x: 100, y: 100 }, data = {}) => {
const createNode = (type, position, data = {}, rootProps = {}, now = Date.now()) => {
const id = getNodeId()
const now = Date.now()
const newNode = {
const { dimensions, ...nodeRootProps } = rootProps
return {
id,
type,
position,
...nodeRootProps,
dimensions: normalizeDimensions(type, dimensions),
data: {
...getDefaultNodeData(type),
...data,
@@ -180,9 +208,14 @@ export const addNode = (type, position = { x: 100, y: 100 }, data = {}) => {
updatedAt: data.updatedAt || now
}
}
nodes.value = [...nodes.value, newNode]
}
// 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 id
return newNode.id
}
/**
@@ -200,33 +233,20 @@ export const addNodes = (nodeSpecs, autoBatch = true) => {
startBatchOperation()
}
const ids = []
const now = Date.now()
nodeSpecs.forEach(spec => {
const { type, position = { x: 100, y: 100 }, data = {} } = spec
const id = getNodeId()
const newNode = {
id,
type,
position,
data: {
...getDefaultNodeData(type),
...data,
createdAt: data.createdAt || now,
updatedAt: data.updatedAt || now
}
}
nodes.value = [...nodes.value, newNode]
ids.push(id)
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 ids
return newNodes.map(node => node.id)
}
// Get default data for node type | 获取节点类型的默认数据
@@ -318,7 +338,7 @@ export const duplicateNode = (id) => {
data: { ...sourceNode.data },
zIndex: maxZIndex + 1
}
nodes.value = [...nodes.value, newNode]
nodes.value.push(newNode)
saveToHistory() // Save after duplicating node | 复制节点后保存
return newId
}
@@ -329,7 +349,7 @@ export const addEdge = (params) => {
id: `edge_${params.source}_${params.target}`,
...params
}
edges.value = [...edges.value, newEdge]
edges.value.push(newEdge)
saveToHistory() // Save after adding edge | 添加连线后保存
}
@@ -348,23 +368,19 @@ export const addEdges = (edgeSpecs, autoBatch = true) => {
startBatchOperation()
}
const ids = []
edgeSpecs.forEach(params => {
const newEdge = {
const newEdges = edgeSpecs.map(params => ({
id: `edge_${params.source}_${params.target}`,
...params
}
edges.value = [...edges.value, newEdge]
ids.push(newEdge.id)
})
}))
edges.value.push(...newEdges)
// End batch operation if auto | 如果自动管理则结束批量操作并保存到历史
if (autoBatch) {
endBatchOperation()
}
return ids
return newEdges.map(edge => edge.id)
}
// Update edge data | 更新边数据
@@ -428,7 +444,7 @@ export const loadProject = (projectId) => {
if (canvasData) {
// Restore nodes | 恢复节点
nodes.value = canvasData.nodes || []
nodes.value = (canvasData.nodes || []).map(normalizeNodeForCanvas)
edges.value = canvasData.edges || []
canvasViewport.value = canvasData.viewport || { x: 100, y: 50, zoom: 0.8 }

View File

@@ -9,6 +9,7 @@ import {
VIDEO_MODELS,
CHAT_MODELS,
SEEDREAM_SIZE_OPTIONS,
ARK_SEEDREAM_SIZE_OPTIONS,
SEEDREAM_4K_SIZE_OPTIONS,
SEEDREAM_QUALITY_OPTIONS,
SEEDANCE_RESOLUTION_OPTIONS,
@@ -23,6 +24,7 @@ import {
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)
@@ -37,10 +39,23 @@ const getModelConfigHook = () => {
}
}
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]
@@ -52,6 +67,12 @@ export const loadAllModels = async () => {
* 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) ||
@@ -67,7 +88,11 @@ export const getModelConfig = (modelKey) => {
* Returns options based on model's sizes array and quality
*/
export const getModelSizeOptions = (modelKey, quality = 'standard') => {
const model = IMAGE_MODELS.find(m => m.key === modelKey)
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) {
@@ -88,7 +113,7 @@ export const getModelSizeOptions = (modelKey, quality = 'standard') => {
* Get quality options for image model | 获取图片模型画质选项
*/
export const getModelQualityOptions = (modelKey) => {
const model = IMAGE_MODELS.find(m => m.key === modelKey)
const model = getModelConfig(modelKey) || IMAGE_MODELS.find(m => m.key === modelKey)
return model?.qualities || []
}
@@ -97,7 +122,7 @@ export const getModelQualityOptions = (modelKey) => {
* Returns options based on model's ratios array
*/
export const getModelRatioOptions = (modelKey) => {
const model = VIDEO_MODELS.find(m => m.key === 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 数组为下拉选项
@@ -112,7 +137,7 @@ export const getModelRatioOptions = (modelKey) => {
* Returns options based on model's durs array
*/
export const getModelDurationOptions = (modelKey) => {
const model = VIDEO_MODELS.find(m => m.key === 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 } 格式
@@ -124,7 +149,10 @@ export const getModelDurationOptions = (modelKey) => {
* Returns options based on model's resolutions array
*/
export const getModelResolutionOptions = (modelKey) => {
const model = VIDEO_MODELS.find(m => m.key === 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 => {
@@ -208,6 +236,7 @@ export {
// 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

@@ -103,6 +103,106 @@ const isModelSupported = (model, provider) => {
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 ============
@@ -162,6 +262,9 @@ export const useModelStore = defineStore('model', () => {
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))
@@ -211,11 +314,13 @@ export const useModelStore = defineStore('model', () => {
])
const allImageModels = computed(() =>
IMAGE_MODELS.map(m => ({ ...m, isCustom: false }))
mergeModels(IMAGE_MODELS, runtimeImageModels.value)
)
const allVideoModels = computed(() =>
VIDEO_MODELS.map(m => ({ ...m, isCustom: false }))
runtimeVideoModelsLoaded.value
? runtimeVideoModels.value
: mergeModels(VIDEO_MODELS, runtimeVideoModels.value)
)
// ============ Computed: Available Models (filtered by provider) ============
@@ -226,11 +331,11 @@ export const useModelStore = defineStore('model', () => {
)
const availableImageModels = computed(() =>
allImageModels.value.filter(m => isModelSupported(m, currentProvider.value))
allImageModels.value.filter(m => isModelSupported(m, currentProvider.value) && m.available !== false)
)
const availableVideoModels = computed(() =>
allVideoModels.value.filter(m => isModelSupported(m, currentProvider.value))
allVideoModels.value.filter(m => isModelSupported(m, currentProvider.value) && m.available !== false)
)
// ============ Computed: Model Options for UI (all models, not filtered by provider) ============
@@ -239,14 +344,16 @@ export const useModelStore = defineStore('model', () => {
const allImageModelOptions = computed(() =>
allImageModels.value.map(m => ({
label: m.label,
key: m.key
key: m.key,
disabled: false
}))
)
const allVideoModelOptions = computed(() =>
allVideoModels.value.map(m => ({
label: m.label,
key: m.key
key: m.key,
disabled: false
}))
)
@@ -263,7 +370,8 @@ export const useModelStore = defineStore('model', () => {
const imageModelOptions = computed(() =>
availableImageModels.value.map(m => ({
label: m.label,
key: m.key
key: m.key,
disabled: m.available === false
}))
)
@@ -343,6 +451,32 @@ export const useModelStore = defineStore('model', () => {
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 ============
// 获取图片端点
@@ -383,10 +517,10 @@ export const useModelStore = defineStore('model', () => {
provider: [provider]
}))
]
const image = IMAGE_MODELS
const image = allImageModels.value
.filter(m => isModelSupported(m, provider))
.map(m => ({ ...m, isCustom: false }))
const video = VIDEO_MODELS
const video = allVideoModels.value
.filter(m => isModelSupported(m, provider))
.map(m => ({ ...m, isCustom: false }))
return { chat, image, video }
@@ -500,6 +634,9 @@ export const useModelStore = defineStore('model', () => {
allChatModels,
allImageModels,
allVideoModels,
runtimeImageModels,
runtimeVideoModels,
runtimeVideoModelsLoaded,
// Available models filtered by provider
availableChatModels,
@@ -551,6 +688,7 @@ export const useModelStore = defineStore('model', () => {
getChatModel,
getImageModel,
getVideoModel,
loadRuntimeModels,
// Get API endpoints
getImageEndpoint,

View File

@@ -16,11 +16,100 @@ 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 加载项目
*/
@@ -42,6 +131,71 @@ export const loadProjects = () => {
}
}
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 数据减小存储大小
@@ -61,7 +215,12 @@ const cleanNodeForStorage = (node) => {
// 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
@@ -89,7 +248,7 @@ const cleanProjectForStorage = (project) => {
* Save projects to localStorage | 保存项目到 localStorage
* Handles QuotaExceededError by compressing data | 通过压缩数据处理配额超限错误
*/
export const saveProjects = () => {
export const saveProjects = ({ remote = false } = {}) => {
// Always clean data before saving | 保存前始终清理数据
const cleanedProjects = projects.value.map(cleanProjectForStorage)
@@ -128,6 +287,10 @@ export const saveProjects = () => {
console.error('Failed to save projects:', err)
}
}
if (remote) {
for (const project of projects.value) scheduleRemoteSave(project)
}
}
/**
@@ -155,6 +318,7 @@ export const createProject = (name = '未命名项目') => {
projects.value = [newProject, ...projects.value]
saveProjects()
scheduleRemoteSave(newProject, 0)
return id
}
@@ -179,6 +343,7 @@ export const updateProject = (id, data) => {
projects.value = [updated, ...projects.value]
saveProjects()
scheduleRemoteSave(updated)
return true
}
@@ -219,6 +384,7 @@ export const updateProjectCanvas = (id, canvasData) => {
}
saveProjects()
scheduleRemoteSave(project)
return true
}
@@ -239,6 +405,10 @@ export const getProjectCanvas = (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))
}
}
/**
@@ -263,6 +433,7 @@ export const duplicateProject = (id) => {
projects.value = [newProject, ...projects.value]
saveProjects()
scheduleRemoteSave(newProject, 0)
return newId
}
@@ -320,51 +491,14 @@ export const getSortedProjects = (sortBy = 'updatedAt', order = 'desc') => {
/**
* Initialize projects store | 初始化项目存储
*/
export const initProjectsStore = () => {
export const initProjectsStore = async () => {
if (initPromise) return initPromise
initPromise = (async () => {
loadProjects()
// Create sample project if empty | 如果为空则创建示例项目
if (projects.value.length === 0) {
const id = createProject('示例项目')
const project = projects.value.find(p => p.id === id)
if (project) {
project.canvasData = {
nodes: [
{
id: 'node_0',
type: 'text',
position: { x: 150, y: 150 },
data: {
content: '一只金毛寻回犬在草地上奔跑,摇着尾巴,脸上带着快乐的表情。它的毛发在阳光下闪耀,眼神充满了对自由的渴望,全身散发着阳光、友善的气息。',
label: '文本输入'
}
},
{
id: 'node_1',
type: 'imageConfig',
position: { x: 500, y: 150 },
data: {
prompt: '',
model: 'auto',
size: '1024x1024',
label: '文生图'
}
}
],
edges: [
{
id: 'edge_node_0_node_1',
source: 'node_0',
target: 'node_1',
sourceHandle: 'right',
targetHandle: 'left'
}
],
viewport: { x: 100, y: 50, zoom: 0.8 }
}
saveProjects()
}
}
await loadRemoteProjects()
return projects.value
})()
return initPromise
}
// Export for debugging | 导出用于调试

View File

@@ -0,0 +1,160 @@
/**
* Personal workflow store | 个人工作流状态管理
*/
import { ref } from 'vue'
const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api'
const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`
export const myWorkflows = ref([])
export const workflowSyncStatus = ref('idle')
export const workflowSyncError = ref('')
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 secondsToDate = (value) => {
if (value instanceof Date) return value
const num = Number(value || 0)
return new Date(num > 100000000000 ? num : num * 1000)
}
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 workflowFromApi = (item) => ({
id: item.id,
name: item.name || '未命名工作流',
description: item.description || '',
thumbnail: item.thumbnail || '',
workflowData: item.workflow_data || {
nodes: [],
edges: [],
viewport: { x: 100, y: 50, zoom: 0.8 }
},
ownerId: item.owner_id || '',
ownerName: item.owner_name || '',
version: item.version || 1,
createdAt: secondsToDate(item.created_at),
updatedAt: secondsToDate(item.updated_at)
})
const runtimeKeys = [
'base64',
'maskData',
'loading',
'error',
'taskId',
'progress',
'status',
'thumbnail',
'outputContent',
'executed',
'revisedPrompt',
'generatedImages',
'generatedVideo'
]
const cleanNodeForWorkflow = (node) => {
const data = { ...(node.data || {}) }
for (const key of runtimeKeys) delete data[key]
if (node.type === 'image') {
data.url = ''
}
if (node.type === 'video') {
data.url = ''
data.duration = 0
}
if (node.type === 'llmConfig') {
data.outputContent = ''
}
delete data.createdAt
delete data.updatedAt
return {
id: node.id,
type: node.type,
position: {
x: Number(node.position?.x || 0),
y: Number(node.position?.y || 0)
},
data
}
}
const cleanEdgeForWorkflow = (edge) => ({
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle || 'right',
targetHandle: edge.targetHandle || 'left',
type: edge.type,
data: edge.data || {}
})
export const cleanCanvasForWorkflow = (canvasData) => ({
nodes: (canvasData?.nodes || []).map(cleanNodeForWorkflow),
edges: (canvasData?.edges || []).map(cleanEdgeForWorkflow),
viewport: canvasData?.viewport || { x: 100, y: 50, zoom: 0.8 }
})
export const loadMyWorkflows = async () => {
try {
workflowSyncStatus.value = 'syncing'
const response = await requestJson('/canvas-workflows')
myWorkflows.value = (response.items || []).map(workflowFromApi)
workflowSyncStatus.value = 'synced'
workflowSyncError.value = ''
return myWorkflows.value
} catch (err) {
workflowSyncStatus.value = 'error'
workflowSyncError.value = err.message || '工作流加载失败'
throw err
}
}
export const saveMyWorkflow = async ({ name, description = '', canvasData, sourceProjectId = '' }) => {
const now = new Date()
const payload = {
name: (name || '').trim() || '未命名工作流',
description,
thumbnail: '',
workflow_data: cleanCanvasForWorkflow(canvasData),
created_at: dateToSeconds(now),
updated_at: dateToSeconds(now),
source: 'canvas',
source_project_id: sourceProjectId
}
workflowSyncStatus.value = 'syncing'
const response = await requestJson('/canvas-workflows', {
method: 'POST',
body: JSON.stringify(payload)
})
const item = workflowFromApi(response.item)
myWorkflows.value = [item, ...myWorkflows.value.filter(workflow => workflow.id !== item.id)]
workflowSyncStatus.value = 'synced'
workflowSyncError.value = ''
return item
}
export const deleteMyWorkflow = async (id) => {
await requestJson(`/canvas-workflows/${encodeURIComponent(id)}`, { method: 'DELETE' })
myWorkflows.value = myWorkflows.value.filter(workflow => workflow.id !== id)
}

View File

@@ -9,7 +9,10 @@ import axios from 'axios'
// Create axios instance | 创建 axios 实例
const instance = axios.create({
baseURL: "/api",
timeout: 30000000
// 60s default (the old 30000000ms ≈ 8.3h was effectively no timeout, so a hung
// request would never abort). Send the auth cookie for cross-origin API bases.
timeout: 60000,
withCredentials: true
})
// Request interceptor | 请求拦截器

View File

@@ -52,6 +52,7 @@
:max-zoom="2"
:snap-to-grid="true"
:snap-grid="[20, 20]"
:only-render-visible-elements="true"
@connect="onConnect"
@node-click="onNodeClick"
@pane-click="onPaneClick"
@@ -61,7 +62,7 @@
>
<Background v-if="showGrid" :gap="20" :size="1" />
<MiniMap
v-if="!isMobile"
v-if="showMiniMap"
position="bottom-right"
:pannable="true"
:zoomable="true"
@@ -196,17 +197,23 @@
</div>
<!-- Quick suggestions | 快捷建议 -->
<div class="flex flex-wrap items-center justify-center gap-2 mt-2">
<span class="text-xs text-[var(--text-secondary)]">推荐</span>
<div class="flex flex-nowrap items-center justify-center gap-2 mt-2 h-7 overflow-hidden">
<span class="shrink-0 text-xs text-[var(--text-secondary)]">推荐</span>
<button
v-for="tag in suggestions"
:key="tag"
:key="`${suggestionPage}-${tag}`"
@click="chatInput = tag"
class="px-2 py-0.5 text-xs rounded-full bg-[var(--bg-secondary)]/80 border border-[var(--border-color)] hover:border-[var(--accent-color)] transition-colors"
:title="tag"
class="min-w-0 max-w-[118px] truncate px-2 py-0.5 text-xs rounded-full bg-[var(--bg-secondary)]/80 border border-[var(--border-color)] hover:border-[var(--accent-color)] transition-colors"
>
{{ tag }}
</button>
<button class="p-1 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
<button
@click="refreshSuggestions"
class="shrink-0 p-1 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
title="换一组推荐"
aria-label="换一组推荐"
>
<n-icon :size="14"><RefreshOutline /></n-icon>
</button>
</div>
@@ -234,11 +241,28 @@
</template>
</n-modal>
<!-- Save Workflow Modal | 保存工作流弹窗 -->
<n-modal v-model:show="showSaveWorkflowModal" preset="dialog" title="保存当前工作流">
<n-input v-model:value="workflowNameValue" placeholder="请输入工作流名称" />
<template #action>
<n-button @click="showSaveWorkflowModal = false">取消</n-button>
<n-button type="primary" :loading="isSavingWorkflow" @click="confirmSaveWorkflow">保存</n-button>
</template>
</n-modal>
<!-- Download Modal | 下载弹窗 -->
<DownloadModal v-model:show="showDownloadModal" />
<!-- Workflow Panel | 工作流面板 -->
<WorkflowPanel v-model:show="showWorkflowPanel" @add-workflow="handleAddWorkflow" />
<WorkflowPanel
v-model:show="showWorkflowPanel"
:my-workflows="myWorkflows"
:loading-my-workflows="loadingMyWorkflows"
@add-workflow="handleAddWorkflow"
@save-current="openSaveWorkflowModal"
@delete-workflow="handleDeleteWorkflow"
@refresh-workflows="refreshMyWorkflows"
/>
</div>
</template>
@@ -276,15 +300,17 @@ import {
} from '@vicons/ionicons5'
import { nodes, edges, addNode, addNodes, addEdge, addEdges, updateNode, initSampleData, loadProject, saveProject, clearCanvas, canvasViewport, updateViewport, undo, redo, canUndo, canRedo, manualSaveHistory, startBatchOperation, endBatchOperation } from '../stores/canvas'
import { loadAllModels } from '../stores/models'
import { useChat, useWorkflowOrchestrator } from '../hooks'
import { useChat, useWorkflowOrchestrator, readVideoTask } from '../hooks'
import { useModelStore } from '../stores/pinia'
import { projects, initProjectsStore, updateProject, renameProject, currentProject } from '../stores/projects'
import { myWorkflows, workflowSyncStatus, loadMyWorkflows, saveMyWorkflow, deleteMyWorkflow } from '../stores/workflows'
// API Settings component | API 设置组件
import ApiSettings from '../components/ApiSettings.vue'
import DownloadModal from '../components/DownloadModal.vue'
import WorkflowPanel from '../components/WorkflowPanel.vue'
import AppHeader from '../components/AppHeader.vue'
import { QUICK_SUGGESTION_GROUPS } from '../config/suggestions'
// API Config state | API 配置状态
const modelStore = useModelStore()
@@ -299,12 +325,12 @@ onMounted(() => {
const CHAT_TEMPLATES = {
imagePrompt: {
name: '生图提示词',
systemPrompt: '你是一个专业的AI绘画提示词专家。将用户输入的内容美化成高质量的生图提示词,包含风格、光线、構图、细节等要素。直接返回提示词,不要其他解释。',
systemPrompt: '你是一个专业的 AI 绘画提示词编辑。只优化用户已经给出的主体、风格、光线、构图和细节,不添加用户没有提到的品牌、产品或营销话术。直接返回提示词,不要其他解释。',
model: 'gpt-4o-mini'
},
videoPrompt: {
name: '视频提示词',
systemPrompt: '你是一个专业的AI视频提示词专家。将用户输入的内容美化成高质量的视频生成提示词,包含运动、场景、镜头等要素。直接返回提示词,不要其他解释。',
systemPrompt: '你是一个专业的 AI 视频提示词编辑。只优化用户已经给出的主体、动作、场景、镜头和节奏,不添加用户没有提到的品牌、产品或营销话术。直接返回提示词,不要其他解释。',
model: 'gpt-4o-mini'
}
}
@@ -320,7 +346,9 @@ const {
send: sendChat
} = useChat({
systemPrompt: CHAT_TEMPLATES.imagePrompt.systemPrompt,
model: CHAT_TEMPLATES.imagePrompt.model
model: CHAT_TEMPLATES.imagePrompt.model,
mode: 'image',
targetLanguage: 'en'
})
// Workflow orchestrator hook | 工作流编排 hook
@@ -388,7 +416,10 @@ const showRenameModal = ref(false)
const showDeleteModal = ref(false)
const showDownloadModal = ref(false)
const showWorkflowPanel = ref(false)
const showSaveWorkflowModal = ref(false)
const renameValue = ref('')
const workflowNameValue = ref('')
const isSavingWorkflow = ref(false)
// Check if has downloadable assets | 检查是否有可下载素材
const hasDownloadableAssets = computed(() => {
@@ -404,6 +435,12 @@ const projectName = computed(() => {
return project?.name || '未命名项目'
})
const loadingMyWorkflows = computed(() => workflowSyncStatus.value === 'syncing')
const videoTaskSyncing = new Set()
let videoTaskSyncTimer = null
let videoTaskSyncToastShown = false
// Project dropdown options | 项目下拉选项
const projectOptions = [
{ label: '重命名', key: 'rename' },
@@ -435,25 +472,28 @@ const nodeTypeOptions = [
const inputPlaceholder = '你可以试着说"帮我生成一个二次元的卡通角色"'
// Quick suggestions | 快捷建议
const suggestions = [
'像个魔法森林',
'三只不同的小猫',
'生成多角度分镜',
'夏日田野环绕漫步'
]
const suggestionPage = ref(0)
const suggestions = computed(() => QUICK_SUGGESTION_GROUPS[suggestionPage.value % QUICK_SUGGESTION_GROUPS.length])
const showMiniMap = computed(() => !isMobile.value && nodes.value.length <= 120)
const refreshSuggestions = () => {
suggestionPage.value = (suggestionPage.value + 1) % QUICK_SUGGESTION_GROUPS.length
}
// Add new node | 添加新节点
const addNewNode = async (type) => {
// Calculate viewport center position | 计算视口中心位置
const viewportCenterX = -viewport.value.x / viewport.value.zoom + (window.innerWidth / 2) / viewport.value.zoom
const viewportCenterY = -viewport.value.y / viewport.value.zoom + (window.innerHeight / 2) / viewport.value.zoom
const maxZIndex = Math.max(0, ...nodes.value.map(n => n.zIndex || 0))
// Add node at viewport center | 在视口中心添加节点
const nodeId = addNode(type, { x: viewportCenterX - 100, y: viewportCenterY - 100 })
// Set highest z-index | 设置最高层级
const maxZIndex = Math.max(0, ...nodes.value.map(n => n.zIndex || 0))
updateNode(nodeId, { zIndex: maxZIndex + 1 })
const nodeId = addNode(
type,
{ x: viewportCenterX - 100, y: viewportCenterY - 100 },
{},
{ zIndex: maxZIndex + 1 }
)
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
setTimeout(() => {
@@ -463,42 +503,55 @@ const addNewNode = async (type) => {
showNodeMenu.value = false
}
// Handle add workflow from panel | 处理从面板添加工作流
const handleAddWorkflow = ({ workflow, options }) => {
// Calculate viewport center position | 计算视口中心位置
const viewportCenterX = -viewport.value.x / viewport.value.zoom + (window.innerWidth / 2) / viewport.value.zoom
const viewportCenterY = -viewport.value.y / viewport.value.zoom + (window.innerHeight / 2) / viewport.value.zoom
const viewportCenterPosition = () => ({
x: -viewport.value.x / viewport.value.zoom + (window.innerWidth / 2) / viewport.value.zoom,
y: -viewport.value.y / viewport.value.zoom + (window.innerHeight / 2) / viewport.value.zoom
})
// Create nodes from workflow template | 从工作流模板创建节点
const startPosition = { x: viewportCenterX - 300, y: viewportCenterY - 200 }
const { nodes: newNodes, edges: newEdges } = workflow.createNodes(startPosition, options)
const insertWorkflowNodes = (workflowNodes, workflowEdges, workflowName, { reposition = false } = {}) => {
const sourceNodes = (workflowNodes || []).filter(node => node?.type)
if (!sourceNodes.length) {
window.$message?.warning('这个工作流没有可插入的节点')
return
}
// Calculate viewport center position | 计算视口中心位置
const center = viewportCenterPosition()
const startPosition = { x: center.x - 300, y: center.y - 200 }
const minX = Math.min(...sourceNodes.map(node => Number(node.position?.x || 0)))
const minY = Math.min(...sourceNodes.map(node => Number(node.position?.y || 0)))
// Start batch operation manually | 手动开始批量操作
startBatchOperation()
// Add nodes to canvas in batch | 批量将节点添加到画布
const nodeSpecs = newNodes.map(node => ({
const nodeSpecs = sourceNodes.map(node => ({
type: node.type,
position: node.position,
position: reposition
? {
x: startPosition.x + Number(node.position?.x || 0) - minX,
y: startPosition.y + Number(node.position?.y || 0) - minY
}
: node.position,
data: node.data
}))
const nodeIds = addNodes(nodeSpecs, false)
// Map old node IDs to new IDs | 映射旧节点ID到新ID
const idMap = {}
newNodes.forEach((node, index) => {
idMap[node.id] = nodeIds[index]
sourceNodes.forEach((node, index) => {
if (node.id) idMap[node.id] = nodeIds[index]
})
// Add edges to canvas in batch | 批量将边添加到画布
const edgeSpecs = newEdges.map(edge => ({
const edgeSpecs = (workflowEdges || []).map(edge => ({
source: idMap[edge.source] || edge.source,
target: idMap[edge.target] || edge.target,
sourceHandle: edge.sourceHandle || 'right',
targetHandle: edge.targetHandle || 'left',
type: edge.type,
data: edge.data
}))
})).filter(edge => edge.source && edge.target)
// Add edges (autoBatch=false to use manual batch) | 添加边autoBatch=false 以使用手动批量)
addEdges(edgeSpecs, false)
@@ -514,7 +567,186 @@ const handleAddWorkflow = ({ workflow, options }) => {
})
}, 100)
window.$message?.success(`已添加工作流: ${workflow.name}`)
window.$message?.success(`已添加工作流: ${workflowName}`)
}
// Handle add workflow from panel | 处理从面板添加工作流
const handleAddWorkflow = ({ workflow, options }) => {
if (typeof workflow?.createNodes === 'function') {
const center = viewportCenterPosition()
const startPosition = { x: center.x - 300, y: center.y - 200 }
const { nodes: newNodes, edges: newEdges } = workflow.createNodes(startPosition, options)
insertWorkflowNodes(newNodes, newEdges, workflow.name, { reposition: false })
return
}
const workflowData = workflow?.workflowData || workflow?.workflow_data
insertWorkflowNodes(workflowData?.nodes, workflowData?.edges, workflow?.name || '我的工作流', { reposition: true })
}
const refreshMyWorkflows = async () => {
try {
await loadMyWorkflows()
} catch (err) {
window.$message?.error(err.message || '工作流加载失败')
}
}
const openSaveWorkflowModal = () => {
if (!nodes.value.length) {
window.$message?.warning('当前画布没有可保存的节点')
return
}
workflowNameValue.value = `${projectName.value} 工作流`
showSaveWorkflowModal.value = true
}
const confirmSaveWorkflow = async () => {
const name = workflowNameValue.value.trim()
if (!name) {
window.$message?.warning('请填写工作流名称')
return
}
try {
isSavingWorkflow.value = true
await saveMyWorkflow({
name,
canvasData: {
nodes: nodes.value,
edges: edges.value,
viewport: viewport.value || canvasViewport.value
},
sourceProjectId: String(route.params.id || '')
})
showSaveWorkflowModal.value = false
window.$message?.success('已保存到我的工作流')
} catch (err) {
window.$message?.error(err.message || '工作流保存失败')
} finally {
isSavingWorkflow.value = false
}
}
const handleDeleteWorkflow = async (workflow) => {
if (!workflow?.id) return
if (!window.confirm(`确定删除工作流「${workflow.name}」吗?`)) return
try {
await deleteMyWorkflow(workflow.id)
window.$message?.success('已删除工作流')
} catch (err) {
window.$message?.error(err.message || '工作流删除失败')
}
}
const pendingVideoNodes = () => nodes.value.filter(node =>
node.type === 'video' && node.data?.taskId && !node.data?.url
)
const persistVideoSyncResult = () => {
// Terminal video status can arrive before loadProject enables autosave.
window.setTimeout(() => {
saveProject()
}, 0)
}
const syncPendingVideoNodes = async () => {
const pending = pendingVideoNodes()
if (!pending.length) return
for (const node of pending) {
const taskId = node.data?.taskId
const syncKey = `${node.id}:${taskId}`
if (!taskId || videoTaskSyncing.has(syncKey)) continue
videoTaskSyncing.add(syncKey)
readVideoTask(taskId)
.then((snapshot) => {
const item = snapshot.video
if (item.status === 'completed') {
updateNode(node.id, {
url: snapshot.url,
loading: false,
progress: 100,
label: '视频生成',
taskId: null,
error: '',
syncError: '',
updatedAt: Date.now()
})
persistVideoSyncResult()
window.$message?.success('视频生成成功,已自动回填到画布')
return
}
if (item.status === 'failed') {
updateNode(node.id, {
loading: false,
error: item.error || '视频生成失败',
label: '生成失败',
taskId: null,
queueMessage: '',
syncError: '',
updatedAt: Date.now()
})
persistVideoSyncResult()
window.$message?.error(item.error || '视频生成失败')
return
}
updateNode(node.id, {
loading: true,
progress: item.progress || node.data?.progress || 0,
queuePosition: item.queue_position || 0,
queueSize: item.queue_size || 0,
queueMessage: item.queue_message || '',
error: '',
syncError: '',
updatedAt: Date.now()
})
})
.catch((err) => {
if (err.terminal) {
updateNode(node.id, {
loading: false,
error: err.message || '视频任务不存在',
label: '生成失败',
taskId: null,
syncError: '',
updatedAt: Date.now()
})
persistVideoSyncResult()
return
}
updateNode(node.id, {
loading: true,
syncError: err.message || '视频状态同步暂时中断',
updatedAt: Date.now()
})
})
.finally(() => {
videoTaskSyncing.delete(syncKey)
})
}
if (!videoTaskSyncToastShown) {
videoTaskSyncToastShown = true
window.$message?.info('视频生成中,完成后会自动显示在画布里')
}
}
const startVideoTaskSync = () => {
if (videoTaskSyncTimer) return
syncPendingVideoNodes()
videoTaskSyncTimer = window.setInterval(syncPendingVideoNodes, 5000)
}
const stopVideoTaskSync = () => {
if (!videoTaskSyncTimer) return
window.clearInterval(videoTaskSyncTimer)
videoTaskSyncTimer = null
videoTaskSyncing.clear()
}
// Handle connection | 处理连接
@@ -832,16 +1064,34 @@ watch(
}
)
watch(showWorkflowPanel, (visible) => {
if (visible && workflowSyncStatus.value === 'idle') {
refreshMyWorkflows()
}
})
watch(
() => nodes.value
.filter(node => node.type === 'video')
.map(node => `${node.id}:${node.data?.taskId || ''}:${node.data?.url || ''}`)
.join('|'),
() => {
if (pendingVideoNodes().length) syncPendingVideoNodes()
}
)
// Initialize | 初始化
onMounted(() => {
onMounted(async () => {
checkMobile()
window.addEventListener('resize', checkMobile)
// Initialize projects store | 初始化项目存储
initProjectsStore()
await initProjectsStore()
// Load project data | 加载项目数据
loadProjectById(route.params.id)
refreshMyWorkflows()
startVideoTaskSync()
// Check for initial prompt from home page | 检查来自首页的初始提示词
const initialPrompt = sessionStorage.getItem('ai-canvas-initial-prompt')
@@ -858,6 +1108,7 @@ onMounted(() => {
// Cleanup on unmount | 卸载时清理
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
stopVideoTaskSync()
// Save project before leaving | 离开前保存项目
saveProject()
})

View File

@@ -54,17 +54,23 @@
</div>
<!-- Quick suggestions | 快捷建议 -->
<div class="flex flex-wrap items-center justify-center gap-2 mt-4">
<span class="text-sm text-[var(--text-secondary)]">推荐</span>
<div class="flex flex-nowrap items-center justify-center gap-2 mt-4 h-8 overflow-hidden">
<span class="shrink-0 text-sm text-[var(--text-secondary)]">推荐</span>
<button
v-for="tag in suggestions"
:key="tag"
:key="`${suggestionPage}-${tag}`"
@click="inputText = tag"
class="px-3 py-1.5 text-sm rounded-full bg-[var(--bg-secondary)] border border-[var(--border-color)] hover:border-[var(--accent-color)] transition-colors"
:title="tag"
class="min-w-0 max-w-[132px] truncate px-3 py-1.5 text-sm rounded-full bg-[var(--bg-secondary)] border border-[var(--border-color)] hover:border-[var(--accent-color)] transition-colors"
>
{{ tag }}
</button>
<button class="p-1.5 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
<button
@click="refreshSuggestions"
class="shrink-0 p-1.5 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
title="换一组推荐"
aria-label="换一组推荐"
>
<n-icon :size="16"><RefreshOutline /></n-icon>
</button>
</div>
@@ -226,6 +232,7 @@ import {
import { useModelStore } from '../stores/pinia'
import ApiSettings from '../components/ApiSettings.vue'
import AppHeader from '../components/AppHeader.vue'
import { QUICK_SUGGESTION_GROUPS } from '../config/suggestions'
const router = useRouter()
const dialog = useDialog()
@@ -278,12 +285,12 @@ const renameValue = ref('')
const renameTargetId = ref(null)
// Suggestions tags | 建议标签
const suggestions = [
'雨中魔法森林',
'日式街面美食摄影',
'瀑布水流飞溅',
'雨天富声旁边花语'
]
const suggestionPage = ref(0)
const suggestions = computed(() => QUICK_SUGGESTION_GROUPS[suggestionPage.value % QUICK_SUGGESTION_GROUPS.length])
const refreshSuggestions = () => {
suggestionPage.value = (suggestionPage.value + 1) % QUICK_SUGGESTION_GROUPS.length
}
// Format date | 格式化日期
const formatDate = (date) => {
@@ -415,7 +422,7 @@ const scrollToProjects = () => {
}
// Initialize projects store on mount | 挂载时初始化项目存储
onMounted(() => {
initProjectsStore()
onMounted(async () => {
await initProjectsStore()
})
</script>

View File

@@ -354,7 +354,7 @@ const createFusionShots = (): ProductFusionShot[] =>
scene_image: null,
action_text: fusionDescriptionForCharacter(DEFAULT_CHARACTER_ID, i),
duration: 5,
image_model: "gpt-image-2",
image_model: "auto",
video_model: "seedance",
guide_image: null,
}))

View File

@@ -172,7 +172,7 @@ export interface ProductFusionShot {
scene_image?: ImageRef | null
action_text?: string
duration?: number
image_model?: "gpt-image-2"
image_model?: string
video_model?: "seedance"
guide_image?: ImageRef | null
}
@@ -796,7 +796,7 @@ export type AssetSize = "source" | "1024" | "1536" | "2048"
export type SubjectKind = "object" | "living"
export type SubjectView = string
export type SubjectAssetStatus = "queued" | "in_progress" | "completed" | "failed"
export type SubjectImageModelPreference = "auto" | "gpt-image-2" | "gemini-3-pro-image-preview"
export type SubjectImageModelPreference = "auto" | "gpt-image-2" | "gemini-3-pro-image-preview" | "doubao-seedream-4-5-251128"
export type SubjectModelBundle = "gpt" | "gemini"
export type SceneMode = "remove_subject" | "similar" | "style"
export type SceneStyle = "source" | "premium_product" | "clean_studio" | "warm_lifestyle" | "cinematic"