23 Commits

Author SHA1 Message Date
c60cb47ee1 chore: record retry analysis worklog 2026-05-18 16:52:23 +08:00
061eb7d867 fix: allow retrying failed source analysis 2026-05-18 16:49:51 +08:00
07384c5e19 chore: record tiktok cookies worklog 2026-05-18 16:43:07 +08:00
4280624810 feat: support tiktok download cookies 2026-05-18 16:39:20 +08:00
028718df0b chore: record voice cleanup worklog 2026-05-18 15:53:26 +08:00
a6eddf1c14 chore: remove personal voice channel remnants 2026-05-18 15:52:44 +08:00
9e307e307c chore: record database backfill worklog 2026-05-18 15:41:25 +08:00
c2e9558f5b fix: backfill database on startup 2026-05-18 15:40:58 +08:00
c626ec51d6 chore: record database worklog 2026-05-18 15:35:53 +08:00
1ac9b1bde3 feat: add backend document database 2026-05-18 15:34:15 +08:00
1c451c6ab3 auto-save 2026-05-18 15:29 (+1, ~5) 2026-05-18 15:29:47 +08:00
408c5fca47 feat: use random subject frame extraction 2026-05-18 15:17:37 +08:00
2a1aa4c994 auto-save 2026-05-18 15:13 (~8) 2026-05-18 15:13:30 +08:00
ebac2e86b5 auto-save 2026-05-18 15:07 (~5) 2026-05-18 15:08:05 +08:00
47653ee319 chore: record voice worklog 2026-05-18 14:52:56 +08:00
4d2a4a0299 fix: force azure openai tts voice path 2026-05-18 14:49:53 +08:00
e6387cf7af auto-save 2026-05-18 14:46 (~7) 2026-05-18 14:46:24 +08:00
fde94f4698 chore: record worklog update 2026-05-18 14:42:13 +08:00
dddf410dcb chore: update development worklog 2026-05-18 14:39:23 +08:00
301ec4fc3b docs: refresh current project status 2026-05-18 14:38:02 +08:00
2cfd7de5d5 chore: force gpt routing for vision and rewrite 2026-05-18 14:34:36 +08:00
a2897ef2be chore: switch vision and rewrite models to gpt 2026-05-18 14:31:59 +08:00
e6a5ea46a6 auto-save 2026-05-18 14:30 (~5) 2026-05-18 14:30:08 +08:00
112 changed files with 6362 additions and 35435 deletions

View File

@@ -13,9 +13,6 @@ web/.next
web/out
api/.venv
api/.env
api/.env.local
api/.env.production
api/jobs
jobs
data

10
.gitignore vendored
View File

@@ -16,21 +16,11 @@ __pycache__/
.pids/
deploy/.env.production
deploy/.htpasswd
secrets/
.backups/
# api
api/.venv/
api/jobs/
asset_library/*
!asset_library/.gitkeep
prompt_library/*
!prompt_library/.gitkeep
_trash/
output/
# web
web/.next/
web/out/
web/public/canvas/
.pnpm-store/

View File

@@ -1,145 +1,85 @@
# 项目接力
- 生成时间May 25, 2026 at 23:14
- 项目SKG 营销内容生产平
- 生成时间May 16, 2026 at 16:38
- 项目SKG Marketing Studio / SKG 营销内容工作
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 状态active
- 主链接https://marketing.skg.com
## 最近助手会话概览
- Claudea9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
- Codex019e55d1-ccf1-7a90-805f-3bd4799a49cd · 时间未知
- Claudebe53a031-9311-4ee8-b822-d4cfb4f5e78b · 时间未知
- Codex019e2274-9db5-71c1-85e7-2bb0519c8ba9 · 时间未知
- Cursor未找到匹配当前项目的最近会话
## 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
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 分支:main
- Session IDbe53a031-9311-4ee8-b822-d4cfb4f5e78b
- Transcript/Users/kangwan/.claude/projects/-Users-kangwan-Projects-business-20260512-20260512-skg-tk-----/be53a031-9311-4ee8-b822-d4cfb4f5e78b.jsonl
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/api/jobs
- 分支:HEAD
- 敏感字段:已对 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视图因为很多时候需求方都不清楚自己要什么
- 音频处理的卡片太大了也不美观你看下怎么调整 里面也不填写东西,你别弄没用的展示
- 你也要稍微说一下这是干什么的,调用了什么模型
- 缩略图下方的滑动条需要明显一点且便于点击拖动,不然我总是点击不到
### Claude 最近回复
> 好问题。我先把转换层"到底是干嘛的"想清楚,再说怎么设计——因为之前它被反复改又清空,大概率是因为没锁定它的定位。
>
> ## 一、它的本质不是"聊天生图",是"定主体的确认台"
>
> 转换层夹在「参考帧池 → 转换层 → 主体元素」中间,真正的活儿是:**拿源视频里一个验证过的主体,二创成一个全新的、我们自己的主体**,输出是右侧"统一多角度套图"。
>
> 所以它要解决的核心矛盾就两个:
> 1. **意图收敛**:这张参考帧,用户是想原样复刻、换真人、卡通化,还是只借元素?产品怎么出现?
> 2. **花钱前的闸门**:`gpt-image-2` 单视图 60s、一套 6 视图很慢很贵,不能用户随口一句就直接烧一整套。所以"出 prompt → 确认 → 才生成"这个 gate 是刚需。
>
> 把它当"聊天机器人"做就会很难受(每次都得多轮对话);把它当"确认台"做就顺了。
>
> ## 二、关键设计判断:确认的不是"一张图的 prompt",是"主体设定(bible)"
>
> 你们后端已经有 `pack bible` / source brief 这套——一套图靠同一份 bible 保证多角度是**同一个主体**。
>
> 那 UI 上闸门就应该让用户**确认这份主体设定**,而不是确认某一张图。一旦确认:
> - **数量 = 从这份锁定设定里渲染几个角度**
> - 多角度统一 = 自动成立(因为大家共享同一 bible)
>
> 这一条想通了,"多角度风格统一"就不是额外功能,而是数据结构的自然结果。
>
> ## 三、流程:对话是"可选精修",不是"强制多轮"
>
> 你之前给 Codex 的描述(读图→问→答→弹 prompt→确认→生成)方向是对的,我只改一点:**别强制聊天**。常见情况下用户知道自己要什么,应该 3 下点完;需要讨论时聊天才出现。
>
> ```
> ┌─ 转换层 ───────────────────┐
> │ [参考帧缩略图] [+换/加帧] │ 顶部:当前参考
> │ AI读图:白色筋膜枪·握把有手 │ 一行机读,不啰嗦
> │ │
> │ 想怎…
> 加个粗紫色滚动条,覆盖节点内所有横滚浮条:
> 明白,这一句把设计的重心整个挪了。需求方不清楚自己要什么 → 那转换层就**不能问"你想怎么改"**(空白框对说不出需求的人是负担),它得**先替他想好,给他东西去反应**。
> 滚动条加粗到 14px + 紫色加亮:
>
> 这其实就是你那套讲解稿里的"快速到 60 分,不从零起步"——机器先给一个站在验证过爆款上的初稿,人在 60 分上挑、上改,而不是对着空白发愁。**同一个原则,直接套到转换层。**
> | | 浮条 1 (InputNode) | 浮条 2 (VisualLab) |
> |---|---|---|
> | 可视宽 | 1255 | 620 |
> | 内容总宽 | 2498 | 988 |
> | 需要横滚 | ✅ | ✅ |
>
> ## 修正后的定位:Agent 主动提案,需求方只做"选/推"
> CSS 改动:所有 `.react-flow__node .overflow-x-auto` 的 webkit scrollbar 高度 14px、轨道半透明白边框、拖动滑块紫色hover 加深、active 粉紫),最小宽度 48px 保证拖手够大。Firefox 自动用 native scrollbar-color。
>
> 我上一轮说的"别强制聊天、给个 chip + 自由描述框"——对**知道自己要什么**的人对,对**说不清的人是错的**。删掉。改成:
>
> ```
> ┌─ 转换层 ────────────────────┐
> │ [参考帧] AI读图:白色筋膜枪·手持 │
> │ │
> │ 我建议这 3 个方向(选一个): │ ← agent 主动出,不问
> │ ┌─────────────────────────┐ │
> │ │ A 原样复刻 · 稳 缩略│ │
> │ │ B 换暖木纹高端款 · 提质感 缩略│ │ ← 每个都已是可生成的成品设定
> │ │ C 卡通吉祥物版 · 抓眼球 缩略│ │
> │ └─────────────────────────┘ │
> │ [就用 A 生成 6 视图 →] │ ← 默认动作,零思考一键走
> │ 不对?跟我说一句…(可选) │ ← 想拨一下才打字
> └───────────────────────────────┘
>
> 右侧主体元素:6 视图 queued → 逐视角回填
> ```
>
> 核心三点:
> - **进帧即出提案**。参考帧一进来,agent 自动读图 + 直接抛 2~3 个**已经成型、点了就能生成**的方向(每个背后都是一份完整主体设定 bible),而不是一个等用户填的空框。
> - **最小动作 = 一次点击认可**。说不清没关系,默认有个推荐项,点"就用这个"直接出 6 视图。想微调才说话("再高端点…
> 去刷新看。如果还不够明显(被画布 zoom 缩太小),可以再加大到 18-20px告诉我。
## 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 ID019e2274-9db5-71c1-85e7-2bb0519c8ba9
- Transcript/Users/kangwan/.codex/sessions/2026/05/14/rollout-2026-05-14T01-48-48-019e2274-9db5-71c1-85e7-2bb0519c8ba9.jsonl
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 分支main
- 敏感字段:已对 token / key / password / secret 做脱敏
### 最近用户要求
- api没关系 其他的麻烦你直接给我恢复 别削弱
- ok 你现在看下api接入情况 有哪些模型可以直接接入好
- OK 接入吧
- <image name=[Image #1]> </image> [Image #1] logo 和那个营销内容工作台 你把营销内容工作台和logo水平对齐
- logo怎么没有在服务器上显现出来呢奇怪
- 账号密码创建了么
### Codex 最近回复
> 线上验收结果已经明确:`/health` 现在只返回 3 个图片模型和 1 个视频模型,前端包里也没有旧模型名。我把这次部署证据写回 `RULES.md` 和源码解析页,然后再做一个文档提交
> 原因找到了:不是 logo 文件没上去,而是生产 Nginx 把 `/skg-logo-black.svg` 当成受保护资源了,未登录访问会 `302` 到 `/login/`,所以登录页里的图片加载不到
>
> 已修:
> - `deploy/nginx.conf` 加了 `/skg-logo-black.svg` 公开静态资源规则
> - 同步更新了 `RULES.md` 和 `docs/source-analysis.html`
> - 已重新构建并重启生产 web 容器
>
> 验证结果:
> - `https://marketing.skg.com/skg-logo-black.svg` 现在是 `200 image/svg+xml`
> - `https://marketing.skg.com/login/` 是 `200 OK`
> - 我截了线上图logo 已经显示,和“营销内容工作台”也水平对齐了
>
> 你浏览器那边如果还看不到,强刷一下页面缓存即可。
> 已接入并上线。
> 创建好了,生产环境现在可用:
>
> 现在生产画布里媒体模型已经收口为真实可用项:
> ```text
> 登录地址https://marketing.skg.com/login/
> 账号skg
> 密码c413cdc5bbbf2ca042
> ```
>
> 图片:
> `auto`
> `GPT Image 2`
> `Gemini 图片`
>
> 视频:
> `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`。
> 密码备份位置按项目规则放在服务器:`/root/skg-marketing-studio-login.txt`。前面已验证登录接口返回成功。
## Cursor 最近会话
@@ -147,9 +87,9 @@
## 当前仓库状态
- 当前分支:main
- 未提交变更:1
- 最近提交:docs: record model option deployment
- 当前分支:HEAD
- 未提交变更:2
- 最近提交:auto-save 2026-05-16 10:20 (~1)
- 变更文件:
- M .memory/worklog.json

View File

@@ -1,127 +1,87 @@
# SKG TK 二创验证 — 当前状态2026-05-18
## 一句话
SKG 信息流广告快速复刻工作台TK 链接/上传 → 下载源视频并行音频解析与 12 张动作/节奏参考帧 → 相似主体 / 产品素材池 → 分镜口播改写 → 首尾帧审核 → 视频候选待开放。当前主流程不直接批量提交视频模型
当前产品方向已收窄为“信息流广告快速复刻TK 链接 / 上传视频后,先下载源视频,再并行音频文案路和视频视觉路;视频视觉路自动抽 6 张人物定向随机参考帧;产品素材独立成池,自动识别视角并补缺角度;分镜工作台按逐句时间轴写新口播、人物/产品需求和首尾帧规划。当前暂停直接提交视频模型,先逐条生成并审核首帧 / 尾帧
## 路径 / 端口
- 路径:`~/Projects/business/20260512-20260512-skg-tk-二创验证/`
- web dev`cd web && pnpm dev`(端口 **4290**
- api dev`cd api && source .venv/bin/activate && uvicorn main:app --port 4291 --reload`
- 测试 job`?job=c6767f3a166b`chrisorb 71s 竖屏 TK
- 当前工作树:`/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证-backend/`
- 主项目路径:`/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/`
- 后台启动:`./scripts/start-dev-background.sh`(前端 4290后端 4291launchd 托管)
- 后台停止:`./scripts/stop-dev-background.sh`
- web dev`cd web && npm run dev`
- api dev`cd api && uvicorn main:app --host 127.0.0.1 --port 4291`
- 注意:后端不要带 `--reload` 跑下载、抽帧、音频和生图等长任务。
## SKG 网关能力(实测 · 关键!)
`base_url: https://ai.skg.com/ezlink/v1`
key 写在 `api/.env``LLM_API_KEY`
## 当前模型分工
`LLM_BASE_URL` 默认走 `https://ai.skg.com/ezlink/v1`,图片同样默认走 `IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1`,语音默认走 `https://ai.skg.com/azure`,生产视频默认走 `https://ai.skg.com/doubao`
| 端点 / 字段 | 状态 | 用途 |
| 任务 | 当前模型 / 通道 | 备注 |
|---|---|---|
| 远端 ASR | `ASR_MODEL=whisper-1` | 第一优先级音频转写;失败后进本机 ASR。 |
| 本机 ASR | `LOCAL_ASR_MODEL=mlx-community/whisper-tiny` | 二级兜底,优先产出真实逐句时间轴。 |
| ASR 兜底 / 音频分析 | `ASR_FALLBACK_MODEL=gemini-2.5-flash` | 远端和本机都失败后才做多模态 ASR音频画像会读取 `audio.wav` + 转写时间轴,失败则本地估算。后端会拒绝假字幕、重复文本和覆盖率过低结果。 |
| 字幕翻译 | `TRANSLATE_MODEL=gemini-2.5-flash` | 按 ASR 段落补中文;失败时保留英文时间轴,中文可为空。 |
| 画面理解 / brief | `VISION_MODEL=gpt-4o` | 关键帧 Vision 和相似主体非身份化 brief 已切 GPT旧环境若写 `gemini-*` 会自动归一化到 `GPT_TEXT_MODEL`。 |
| TK 下载 | `yt-dlp` + 可选 cookies | 公开视频裸下载;受限视频可配 `YTDLP_COOKIES_FILE``YTDLP_COOKIES_FROM_BROWSER`,也可直接上传 MP4。 |
| 远端 ASR | `ASR_MODEL=whisper-1` | 失败后进本机 ASR再进多模态兜底。 |
| 本机 ASR | `LOCAL_ASR_MODEL=mlx-community/whisper-tiny` | 默认二级兜底,优先产出真实逐句时间轴。 |
| ASR 兜底 / 音频分析 | `ASR_FALLBACK_MODEL=gemini-2.5-flash` | 多模态音频兜底;后端会拒绝假字幕、重复文本和覆盖率过低结果。 |
| 字幕翻译 | `TRANSLATE_MODEL=gemini-2.5-flash` | 保留 Gemini。 |
| 画面理解 | `VISION_MODEL=gpt-4o` | 关键帧 Vision 已切 GPT旧环境若写 `gemini-*` 会自动归一化到 `GPT_TEXT_MODEL`。 |
| 通用改写 / 分镜描述 | `REWRITE_MODEL=gpt-4o` | 已切 GPT旧 Gemini 覆盖值会自动归一化。 |
| 新口播改写 | `AUDIO_REWRITE_MODEL=gpt-4o` | 默认跟随 `REWRITE_MODEL`失败后依次尝试 `ASR_FALLBACK_MODEL``TRANSLATE_MODEL`,再用本地模板兜底。 |
| 产品视角识别 | `PRODUCT_VIEW_MODEL=gpt-image-2` | 图批量识别;失败后单图重试,再写本地默认视角和风险备注。 |
| 所有生图 / 修图 | `gpt-image-2` | 服务端硬锁,无其他图片模型 fallback覆盖关键帧生图、水印清理、元素提取、主体资产包、产品补角度、首尾帧。 |
| 新口播改写 | `AUDIO_REWRITE_MODEL=gpt-4o` | 默认跟随 `REWRITE_MODEL`旧 Gemini 覆盖值会自动归一化。 |
| 产品视角识别 | `PRODUCT_VIEW_MODEL=gpt-image-2` | 产品图批量识别视角、左右 / 上下 / 内外侧、用途和风险。 |
| 所有生图 / 修图 | `gpt-image-2` | 服务端硬锁,无图片模型 fallback覆盖关键帧生图、水印清理、元素提取、主体资产包、产品补角度、首尾帧。 |
| 配音 | `VOICE_PROVIDER=azure_openai` + `AZURE_TTS_MODEL=gpt-4o-mini-tts` | 语音固定 Azure OpenAI TTS。后端会按 `AZURE_TTS_PATHS` 依次尝试路径,便于区分路径错误和整条语音服务不可用。 |
| 视频 | `VIDEO_MODEL=seedance`,别名支持 `kling-omni``veo-3.1-fast` | 当前主流程暂停直接提交;真实 ID 由 `VIDEO_MODEL_SEEDANCE` / `VIDEO_MODEL_KLING` / `VIDEO_MODEL_VEO3` 配置,入口按 `VIDEO_CREATE_PATHS`。 |
| 视频 | `VIDEO_MODEL=seedance` | 当前主流程暂停直接提交;生产通道默认 `ai.skg.com/doubao`Seedance 真实 ID 由 `VIDEO_MODEL_SEEDANCE` 配置。 |
**网关后端 = one-hub 多渠道代理**。当前 key 分组叫「纯OpenAI+AWSClaude+Gemini官方」缺 audio 渠道(`gpt-4o-audio-preview` 503 "无可用渠道")和 video 渠道。
## 模型选型(运行时默认 / 归一化后)
```
ASR_MODEL=whisper-1
LOCAL_ASR_MODEL=mlx-community/whisper-tiny
ASR_FALLBACK_MODEL=gemini-2.5-flash
TRANSLATE_MODEL=gemini-2.5-flash
GPT_TEXT_MODEL=gpt-4o
VISION_MODEL=gpt-4o
REWRITE_MODEL=gpt-4o
AUDIO_REWRITE_MODEL=gpt-4o
IMAGE_MODEL=gpt-image-2
PRODUCT_VIEW_MODEL=gpt-image-2
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
VOICE_PROVIDER=azure_openai
AZURE_TTS_MODEL=gpt-4o-mini-tts
VIDEO_MODEL=seedance
VIDEO_MODEL_KLING=kling-omni
VIDEO_MODEL_VEO3=veo-3.1-fast
```
## Pipeline 状态9 步工作台版)
当前主入口是信息流复刻工作表,不再是旧 ReactFlow 八节点主画布。
| 步 | 节点 | 状态 | 备注 |
## 当前主流程
| 步 | 模块 | 状态 | 备注 |
|---|---|---|---|
| 1 | **素材输入** | | TK 链接 / 上传视频;失败素材可重新下载。 |
| 2 | **源视频下载** | | yt-dlp + cookies 配置;上传视频直接进入 downloaded。 |
| 3 | **音频文案** | | `audio.wav`ASR、翻译、讲话人 / 节奏 / 背景音画像。 |
| 4 | **抽帧参考** | | 下载完成后自动抽 12动作/节奏参考帧;支持当前播放点手动补帧。 |
| 5 | **相似主体** | | GPT 视觉 brief + `gpt-image-2` 文字生图,生成类似但不复刻的人物/透明骨架主体。 |
| 6 | **产品素材池** | | 不限量上传;`gpt-image-2` 识别视角 / 用途 / 风险,缺角度可补图。 |
| 7 | **分镜文案** | | 按逐句时间轴生成行,可单段或整片 GPT 改写;保存后写入 storyboard action。 |
| 8 | **画面首尾帧** | ✅ | 用相似主体视图 + 产品素材池生成首帧/尾帧审核后保存规划。 |
| 9 | **视频候选** | ⏸️ | 历史候选可看;主流程当前暂停直接提交视频模型。 |
## UI 架构(重要)
- 主入口:`web/components/ad-recreation-board.tsx`,左侧素材输入列 + 右侧信息流复刻工作表。
- 工作流条01 素材输入 → 02 源视频下载 → 03 音频文案 → 04 抽帧参考 → 05 相似主体 → 06 产品素材池 → 07 分镜文案 → 08 画面首尾帧 → 09 视频候选。
- 源视频工作区:左侧 9:16 原视频播放器,内置当前点抽帧;右侧音频波形 + 逐句时间轴 + 参考帧池。
- 相似主体:模板库 / 内置形象 / 源视频相似方向;生成结果统一用媒体素材卡,支持 hover 放大、删除、单张重生。
- 分镜工作台:产品素材池、逐句口播、画面规划、首尾帧和历史视频候选在同一纵向工作表里处理。
- 旧 ReactFlow 节点、旧 lightbox、旧 storyboard workbench 底层保留,但当前不作为主入口。
## 数据模型(关键 typescript / pydantic
```typescript
KeyFrame {
index: number // 稳定 ID不连续frames 数组按 timestamp 排序)
timestamp: number
url: string
description?: {
scene, objects: [{name, position, color, extract_prompt}],
style, suggested_prompt
}
generated_images?: [{ id, prompt, model, mode, url, selected, created_at }]
}
Job { frames: KeyFrame[] ... }
```
**前端取帧必须用 `frames.find(x => x.index === activeIndex)` 不能用数组下标**(之前的 bug
| 1 | 输入 / 下载 | 已通 | TK 链接上传视频创建 job下载完成后进入分析队列。 |
| 2 | 音频文案路 | 已通 | `audio.wav`ASR、翻译、讲话人 / 节奏 / 背景音分析;结果默认折叠展示。 |
| 3 | 视频视觉路 | 已通 | 自动抽 6 张人物定向随机参考帧;当前工作区按 9:16 原视频播放秒数手动补帧。 |
| 4 | 相似主体资产 | 已通 | 用关键帧和可选内置角色生成同一主体的 10白底视图。 |
| 5 | 产品资产池 | 已通 | 上传 / 内置产品图统一入池,自动识别视角、结构点、用途、风险,缺角度可补图。 |
| 6 | 分镜工作台 | 已通 | 按逐句时间轴编辑新口播、镜头类型、人物 / 产品开关、首帧 / 尾帧规划。 |
| 7 | 首尾帧闸门 | 已通 | 每条分镜先用相似主体视图和产品素材生成首帧 / 尾帧,审核后保存。 |
| 8 | 视频候选 | 暂停直提 | 历史候选保留展示;当前不再一键打 Seedance等首尾帧审核后再开放单条提交。 |
## 关键文件
- `web/app/page.tsx` — 多 job state 管理jobs[] + activeJobId开始后并行触发音频解析和 12 张视觉抽帧
- `web/components/ad-recreation-board.tsx` — 当前主工作台:素材输入、音频结果、参考帧池、相似主体、产品素材池、分镜规划和首尾帧
- `web/components/media-asset-tile.tsx` — 图片 / 视频 / 抽帧 / 产品图 / 主体图 / 首尾帧 / 视频候选统一媒体卡
- `web/components/dashboard.tsx` — 旧 ReactFlow / Kanban 面板,底层保留但当前不作为主入口
- `web/components/lightbox.tsx` — 旧深度素材面板,底层保留
- `web/components/video-lightbox.tsx` — Input 节点点视频缩略图弹的播放器
- `web/components/nodes/index.tsx` — ReactFlow 8 节点定义
- `web/lib/api.ts`API client
- `api/main.py` — FastAPI 所有端点Job/KeyFrame/AudioScript/ProductRef/SubjectAsset/SceneAsset/GeneratedVideo 模型
- `api/main.py` — FastAPI 后端、模型路由、任务状态、ASR/翻译/音频分析、生图、产品识别、首尾帧和视频接口。
- `api/database.py` — 后端数据库层;当前用 SQLite 保存 document / job / media asset 元数据,媒体文件仍在 `jobs/<jobId>/`
- `api/.env.example` — 本地模型和网关模板;已包含 `GPT_TEXT_MODEL=gpt-4o`
- `deploy/.env.production.example` — 生产环境模板;视频默认 SKG Doubao / Seedance 网关。
- `RULES.md` — 启动、部署事实、模型环境变量和项目规则。
- `docs/source-analysis.html` — 源码解析页;任何影响产品理解、接口、模型分工或操作路径的改动都要同步这里。
- `web/components/ad-recreation-board.tsx` — 当前信息流复刻主工作台。
- `web/components/media-asset-tile.tsx`统一媒体素材缩略图、hover 放大、删除和状态遮罩组件。
- `web/lib/api.ts` — 前端 API client 和运行模型标注类型。
## 已通的 API 端点
## 主要 API
```
POST /jobs 创建 job链接
POST /jobs/{id}/download/retry TK 链接下载失败后重新下载
POST /jobs/upload 上传视频
GET /jobs/{id} job 状态
POST /jobs/{id}/transcribe 音频提取 + ASR + 翻译 + 讲话人/节奏/背景音分析
POST /jobs/{id}/analyze?frames=12 动作/节奏参考帧抽取
POST /jobs/{id}/frames?t=<sec> 手动按时间戳加帧
POST /jobs/{id}/frames/{idx}/describe ✅ Vision 识别3 次重试 + reasoning_content 兜底)
POST /jobs/{id}/frames/{idx}/generate ✅ 生图i2i / text-only, 含 negative_prompt
GET /jobs/{id}/frames/{idx}/gen/{gen_id}.jpg 生成图二进制
POST /jobs/{id}/frames/{idx}/gen/{gen_id}/select 选用某 gen 给下游
POST /jobs/{id}/assets/product-views/analyze 产品视角 / 用途 / 风险识别
POST /jobs/{id}/assets/product-angle 缺产品角度补图
POST /jobs/{id}/script/rewrite 单段 / 整片新口播改写
POST /jobs/{id}/frames/{idx}/scene-asset 首帧 / 尾帧 / 场景资产生成
GET /jobs/{id}/video.mp4 原视频
GET /jobs/{id}/audio.wav 原音频 wav
GET /jobs/{id}/frames/{idx}.jpg 关键帧 jpg
GET /health
GET /documents
POST /jobs
POST /jobs/{id}/download/retry
POST /jobs/upload
GET /jobs
GET /jobs/{id}
DELETE /jobs/{id}
POST /jobs/{id}/analyze
POST /jobs/{id}/transcribe
POST /jobs/{id}/frames?t=<sec>
DELETE /jobs/{id}/frames/{idx}
POST /jobs/{id}/frames/{idx}/describe
POST /jobs/{id}/frames/{idx}/cleanup
POST /jobs/{id}/frames/{idx}/cleanup/apply
POST /jobs/{id}/frames/{idx}/generate
POST /jobs/{id}/frames/{idx}/scene-asset
POST /jobs/{id}/frames/{idx}/elements
POST /jobs/{id}/frames/{idx}/elements/{element_id}/cutout
POST /jobs/{id}/frames/{idx}/elements/{element_id}/subject-assets
POST /jobs/{id}/assets
PUT /jobs/{id}/product-refs
POST /jobs/{id}/assets/product-views/analyze
POST /jobs/{id}/assets/product-angle
POST /jobs/{id}/script/rewrite
PUT /jobs/{id}/frames/{idx}/storyboard
POST /jobs/{id}/frames/{idx}/storyboard/video
```
## 当前约束 / 不要踩
@@ -130,16 +90,21 @@ GET /health
3. 画面理解和文案改写默认归 GPT`VISION_MODEL``REWRITE_MODEL``AUDIO_REWRITE_MODEL` 会拦截旧 `gemini-*` 覆盖值。
4. Gemini 仍保留在 ASR fallback / 音频分析 / 翻译链路,不要误删。
5. 语音只走 Azure OpenAI TTS不要新增或依赖其他配音通道配置。
6. TikTok 受限下载遇到 `Log in for access` 不是后端没接到任务;需要 `YTDLP_COOKIES_FILE``YTDLP_COOKIES_FROM_BROWSER`,配置后可点“重新下载”。
7. 当前主流程不直接批量提交视频;先走“分镜规划 → 首尾帧 → 人工审核”
8. 后端长任务不要用 `--reload`
9. 关键帧 `index` 是稳定 ID不等于数组下标前端取帧用 `frames.find(x => x.index === idx)`
6. 当前主流程不直接批量提交视频;先走“分镜规划 → 首尾帧 → 人工审核”。
7. 产品素材池默认是“同一产品”,不做不同产品身份判断;视角识别必须按佩戴者左 / 右、上 / 下、内 / 外侧描述
8. 自动抽帧默认是 `frames=6` + `target=random_subject` + `quality=accurate` + `mode=replace`;如果需要特定动作或表情,用“当前点抽帧”手动补
9. 文档是顶层业务归类:每个 TK 链接或上传视频默认一个 `document``job` 归属到 `document_id`DB 存元数据和文件索引,视频 / 图片 / 音频文件不进 DB
10. 后端长任务不要用 `--reload`
11. 关键帧 `index` 是稳定 ID不等于数组下标前端取帧用 `frames.find(x => x.index === idx)`
12. TikTok cookies 属于账号登录态,只能放本机 / 服务器私有环境;不要提交 cookies 文件或账号密码。
## 最近变更
- 2026-05-18前端模型链路弹窗、`.project.json``api/README.md` 和本状态文档已按真实后端链路重写:音频三级 ASR、翻译失败行为、音频画像兜底、产品识别重试、相似主体 GPT brief + gpt-image-2 文字生图、脚本改写本地模板兜底、视频主入口暂停
- 2026-05-18TK 链接下载新增 `YTDLP_COOKIES_FILE` / `YTDLP_COOKIES_FROM_BROWSER` 支持;受限视频失败时前端提示上传 MP4 或配置后端 cookies 登录态
- 2026-05-18素材输入端失败任务支持重新下载 / 重新解析;选中失败且无 `video_url` 的 TK 素材时调用后端重试接口,已有视频的失败任务会清掉自动触发标记并重新跑音频/视觉路。
- 2026-05-18清理个人语音通道残留`/health`、前端类型、环境模板和文档不再暴露相关字段或配置。
- 2026-05-18新增后端数据库层SQLite 默认落在 `APP_DB_URL` / `DATABASE_URL``JOBS_DIR/app.db``/documents` 返回文档归类列表,`/health.database` 返回 DB 状态。
- 2026-05-18`VISION_MODEL``REWRITE_MODEL``AUDIO_REWRITE_MODEL` 切到 GPT 默认模型 `gpt-4o`,并加旧 Gemini 环境变量归一化保护。
- 2026-05-18语音通道固定 Azure OpenAI TTS并按 `AZURE_TTS_PATHS` 尝试语音路径。
- 2026-05-18TikTok 受限链接支持 cookies 配置和失败素材“重新下载”
- 2026-05-18当前主路径暂停直接提交视频,改为逐条首尾帧闸门
- 2026-05-18媒体素材交互统一收口到 `MediaAssetTile`
- 2026-05-18产品图视角识别和产品缺角度补图收敛到 `gpt-image-2`

File diff suppressed because it is too large Load Diff

View File

@@ -1,113 +1,96 @@
{
"company" : "SKG",
"created" : "2026-05-12",
"credentials" : [
"company": "SKG",
"created": "2026-05-12",
"credentials": [
{
"description" : "SKG AI 网关 API Key生产只放服务器 deploy\/.env.production 的 LLM_API_KEY本地开发放 api\/.env不入库",
"name" : "LLM_API_KEY",
"storage" : "api\/.env \/ deploy\/.env.production",
"type" : "api_key"
"description": "SKG AI 网关 API Key生产只放服务器 deploy/.env.production 的 LLM_API_KEY本地开发放 api/.env不入库",
"name": "LLM_API_KEY",
"storage": "api/.env / deploy/.env.production",
"type": "api_key"
},
{
"description" : "OpenAI Audio Transcriptions 兼容 ASR Key未单独配置 ASR_API_KEY 时复用 LLM_API_KEY,本地开发只放 api\/.env不入库",
"name" : "ASR_API_KEY",
"storage" : "api\/.env \/ deploy\/.env.production",
"type" : "api_key"
"description": "MiniMax T2A 配音 API Key,本地开发只放 api/.env 的 MINIMAX_API_KEY,不入库",
"name": "MINIMAX_API_KEY",
"storage": "api/.env",
"type": "api_key"
},
{
"description" : "OpenAI-compatible GPT 图片模型 Key未单独配置 IMAGE_API_KEY 时复用 LLM_API_KEY本地开发只放 api\/.env不入库",
"name" : "IMAGE_API_KEY",
"storage" : "api\/.env \/ deploy\/.env.production",
"type" : "api_key"
"description": "OpenAI-compatible GPT 图片模型 Key未单独配置 IMAGE_API_KEY 时复用 LLM_API_KEY本地开发只放 api/.env不入库",
"name": "IMAGE_API_KEY",
"storage": "api/.env / deploy/.env.production",
"type": "api_key"
},
{
"description" : "Azure OpenAI 协议语音\/配音 Key未单独配置 AZURE_OPENAI_API_KEY 时复用 LLM_API_KEY本地开发只放 api\/.env不入库",
"name" : "AZURE_OPENAI_API_KEY",
"storage" : "api\/.env \/ deploy\/.env.production",
"type" : "api_key"
"description": "Azure OpenAI 协议语音/配音 Key未单独配置 AZURE_OPENAI_API_KEY 时复用 LLM_API_KEY本地开发只放 api/.env不入库",
"name": "AZURE_OPENAI_API_KEY",
"storage": "api/.env / deploy/.env.production",
"type": "api_key"
},
{
"description" : "SKG 豆包 \/ Seedance 视频生成 API Key生产只放服务器 deploy\/.env.production 的 VIDEO_API_KEY本地开发放 api\/.env不入库",
"name" : "VIDEO_API_KEY",
"storage" : "api\/.env \/ deploy\/.env.production",
"type" : "api_key"
"description": "SKG 豆包 / Seedance 视频生成 API Key生产只放服务器 deploy/.env.production 的 VIDEO_API_KEY本地开发放 api/.env不入库",
"name": "VIDEO_API_KEY",
"storage": "api/.env / 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"
},
{
"description" : "飞书免登录 OAuth 应用配置App ID 和 App Secret 只放服务器 deploy\/.env.production本地开发放 api\/.env不入库回调地址为 https:\/\/marketing.skg.com\/api\/auth\/feishu\/callback",
"name" : "FEISHU_OAUTH",
"storage" : "api\/.env \/ deploy\/.env.production \/ 飞书开放平台",
"type" : "oauth_app"
},
{
"description" : "Postgres 服务端持久化配置,用于画布项目、用户索引、任务索引、资源索引和审计日志;生产密码只放服务器 deploy\/.env.production 的 POSTGRES_PASSWORD\/DATABASE_URL不入库",
"name" : "POSTGRES_DATABASE",
"storage" : "deploy\/.env.production \/ docker-compose.prod.yml \/ 服务器 data\/postgres",
"type" : "database"
"description": "生产网页登录;用户名写 RULES.md密码只放服务器 /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"
}
],
"description" : "SKG 营销内容生产平台:根域名 https:\/\/marketing.skg.com 登录后直接进入个人生成画布,终端可见品牌位只保留 SKG logo。主路径为文生图、文生视频、图生视频每个登录用户只看到自己的任务和结果。画布项目已接入服务端 Postgres 持久化,浏览器 localStorage 只作为缓存和首次导入来源;图片\/视频资产继续写入当前用户自己的后端 job旧 TK 复刻\/一键出片能力保留为高级入口。",
"kind" : "app",
"name" : "SKG 营销内容生产平台",
"ownership" : "company",
"pin_order" : 1778664997,
"pinned" : true,
"ports" : [
"description": "SKG 信息流广告快速复刻第一步:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后优先解析原音频,提取原文案/字幕,分析讲话人、语速节奏、背景音乐/环境声/音效。抽帧、分镜、元素生成和视频合成暂保留为后续能力,不作为当前开始流程的默认动作。",
"kind": "app",
"name": "SKG Marketing Studio / SKG 营销内容工作台",
"ownership": "company",
"pin_order": 1778664997,
"pinned": true,
"ports": [
{
"fixed" : true,
"label" : "web-dev",
"port" : 4290
"fixed": true,
"label": "web-dev",
"port": 4290
},
{
"fixed" : true,
"label" : "api-dev",
"port" : 4291
"fixed": true,
"label": "api-dev",
"port": 4291
}
],
"quick_login" : {
"label" : "SKG 营销内容生产平台",
"password" : "",
"url" : "https:\/\/marketing.skg.com\/login\/",
"username" : "仅飞书免登录;密码登录已停用"
"quick_login": {
"label": "SKG Marketing Studio / SKG 营销内容工作台",
"password": "c413cdc5bbbf2ca042",
"url": "https://marketing.skg.com",
"username": "skg"
},
"stack" : [
"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"
"stack": [
"Next.js + Python(yt-dlp/ffmpeg) + OpenAI-compatible LLM + GPT Image + Azure OpenAI TTS + Seedance"
],
"status" : "active",
"urls" : [
"status": "active",
"urls": [
{
"label" : "production",
"type" : "app",
"url" : "https:\/\/marketing.skg.com"
"label": "production",
"type": "app",
"url": "https://marketing.skg.com"
},
{
"label" : "production-api",
"type" : "backend",
"url" : "https:\/\/marketing.skg.com\/api"
"label": "production-api",
"type": "backend",
"url": "https://marketing.skg.com/api"
},
{
"label" : "agent-cut-preview",
"type" : "app",
"url" : "http:\/\/2.24.28.41:4290\/agent\/"
"label": "source-analysis",
"type": "docs",
"url": "docs/source-analysis.html"
},
{
"label" : "git",
"type" : "repo",
"url" : "https:\/\/git.kang-kang.com\/kangwan\/20260512-skg-tk"
},
{
"label" : "git",
"type" : "repo",
"url" : "https:\/\/git.kang-kang.com"
"type": "repo",
"label": "git",
"url": "https://git.kang-kang.com/kangwan/20260512-skg-tk"
}
],
"worklog" : {
"auto" : true,
"path" : "\/Users\/kangwan\/Projects\/business\/20260512-20260512-skg-tk-二创验证\/.memory\/worklog.json"
"worklog": {
"auto": true,
"path": "/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证/.memory/worklog.json"
}
}

View File

@@ -1,4 +1,4 @@
# SKG 营销内容工作台 Agent Rules
# SKG AI 素材管线 - TK 二创验证 Agent Rules
## Must Read First

View File

@@ -9,7 +9,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates curl libgomp1 \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
COPY api/requirements.txt /app/requirements.txt

View File

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

118
RULES.md
View File

@@ -1,10 +1,9 @@
# SKG 营销内容生产平台
# SKG AI 素材管线 - TK 二创验证
## 启动
- 后台启动(不弹 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 端口占用但新请求卡住。
@@ -12,88 +11,27 @@
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`
- 第一冲刺:步骤 1-4下载 / 拆轨 / 关键帧 / ASR+翻译)
- 当前产品方向2026-05-26 Postgres 持久化版):默认入口是多人通用的 SKG 营销内容生产平台,`https://marketing.skg.com` 登录后直接进入个人生成画布,`/canvas/` 只作为旧链接兼容跳转到根域名。终端可见品牌位只放 SKG logo不在主界面展示“生图生视频”“SKG 生成画布”或长系统名。画布本体尽量恢复 `chatfire-AI/huobao-canvas` 的成熟交互不再削成三模式单输入框保留首页推荐词、画布底部推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载等上游能力;多角度分镜、故事板、图转视频、绘本等工作流按上游结构创建节点。API 接入是例外:生成调用继续走本项目后端 `/api` 和当前登录 Cookie不要求员工在浏览器配置个人 API Key图片/视频模型选择只显示后端已经接通的媒体模型不能让浏览器本地自定义或旧缓存模型进入生成下拉。API 设置弹窗只保留模型/端点配置外观,不能出现上游注册链接或外部品牌。用户登录后仍只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离;画布项目以服务端 Postgres 为主持久化,浏览器 `localStorage` 只作为缓存和首次导入来源,图片/视频资产按登录用户写入后端 job。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架
- 当前产品方向2026-05-18 再确认):先解决信息流广告快速复刻的第一步,不再沿用“开始后线性完成抽帧、分镜、元素生成、合成”的旧做法。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取 6 张人物定向随机参考帧,供人工选择可用主体并生成相似主体白底视图。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度分镜工作台按逐句时间轴规划新口播、镜头类型、首帧/尾帧、人物需求和产品出现方式;当前暂停直接调视频模型,先逐条用“相似主体视图 + 产品素材池 + 首尾帧文字规划”生成并审核首帧/尾帧,保存规划后再决定哪些分镜进入单条视频候选
## 部署事实
- 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik
- Agent Cut 独立预览服务器:`2.24.28.41`Ubuntu 24.04 / Docker Compose / 裸端口 `4290`),部署目录 `/opt/skg-marketing-studio`Compose 入口 `docker-compose.standalone.yml`,访问地址 `http://2.24.28.41:4290/agent/`。该入口用于“一分钟二创出片终端”预览:用户只提交 TikTok 链接和产品图,后端 `AgentRun` 状态机负责下载、抽帧、规划、生成、自动重跑、审片和合成。
- Agent Cut 独立预览验证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-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-25`cce9779` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,恢复 `chatfire-AI/huobao-canvas` 上游画布能力但保留 SKG 后端 `/api` 接入。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525102857.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。补验:外部访问 `https://marketing.skg.com/` 未登录返回 302 到 `/login/?next=/``https://marketing.skg.com/canvas/` 返回 308 到 `/``https://marketing.skg.com/p/test` 未登录返回 302 到 `/login/?next=/p/test`;容器内静态 bundle 命中 `AI 润色 / 自动执行 / 推荐: / 首帧 / 尾帧 / 多角度分镜 / 儿童绘本 / 工作流模板 / 批量下载素材`未命中上游注册链接、火宝欢迎文案、GitHub 入口或 `/huobao-canvas`
- 最近部署验证2026-05-25`e767d2b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,生产根域名改为直接进入个人生成画布,`/canvas/` 仅作为旧链接 308 跳转到 `/`。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525095839.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。补验:容器内 `/usr/share/nginx/html/index.html` 为 Vue 画布产物,引用 `/assets/index-CioZwOvT.js` 且 title 为 `SKG`;静态 bundle 命中 `文生图 / 文生视频 / 图生视频`,未命中 `首帧生视频 / 首尾帧生视频 / 上传首帧 / 上传尾帧 / 推荐:`;外部访问 `https://marketing.skg.com/` 未登录返回 302 到 `/login/?next=/``https://marketing.skg.com/canvas/` 返回 308 到 `/``/p/test` 未登录返回 302 到 `/login/?next=/p/test`
- 最近部署验证2026-05-25`2a1ceee` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,可见品牌位从文字命名收敛为 logo-only首页、登录页和画布首页只显示 SKG logo网页 title 和画布 title 为 `SKG`,首页入口按钮文案为“画布”。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525092749.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。容器内静态产物复验:`index.html` 包含 `<title>SKG</title>``/skg-logo-black.svg`,首页入口包含“画布”,登录页只保留 logo当前 `_next``/canvas` 产物未再命中 `SKG 生图生视频``SKG 生成画布``营销内容生产平台``内容生产画布` 等旧可见文案。
- 发布状态已部署并验证2026-05-20主体元素按套图文件夹分组展示主体生成接口提交后立即返回 queued 占位并后台逐视角生成、逐张回填;工作台外层取消 1800x1000 固定画布和应用层 `zoom` 缩放,改为正常流式桌面容器,最低操作宽度 1280px源视频工作区主体链路为上方竖向参考帧池 + 宽幅对话式转换层、下方主体元素结果栏;转换层通过参考帧 `+` 加入、参考图分析、生图对话,英文 prompt 就绪后由发送区主按钮切换为确认生成,点击后才触发主体套图生成;转换层不再固定 640px 长高,按内容自然高度显示,仅以 560px 最大高度兜底内部滚动;下方主体元素结果栏的套图输出、轮询、文件夹分组、单张重生、删除和 hover 预览逻辑保持不变;胶片双击/拖拽加入参考帧池 + 胶片缓存复用 + 音频解析失败可重试,参考帧缩略图保持小尺寸 9:16 比例 + hover 左侧紧凑预览,旧主体模板区移出主路径 + 逐句时间轴移到原版视频下方并支持双行文案 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片上下错落 + body 顶层原位大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401认证后首页 200容器内 `/health` 返回 `ok:true`
- 最近部署验证2026-05-25`2192f15` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,可见命名从“营销内容工作台 / 无限画布”改为“SKG 生图生视频 / 生成画布”。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525091127.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。容器内静态产物复验:首页标题和 header 包含 `SKG 生图生视频`,首页按钮包含 `生成画布``/canvas/index.html` 标题为 `SKG 生成画布`,当前 `_next``/canvas` 产物未再命中旧可见命名。
- 最近部署验证2026-05-25`2d19560` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,新增登录保护下的 SKG 内部生成画布入口 `https://marketing.skg.com/canvas/`,并把首页“生成画布”按钮接到该路径。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525085342.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。补验:未登录访问 `/canvas` 返回 308 到 `/canvas/`,未登录访问 `/canvas/` 返回 302 到 `/login/?next=/canvas/`;容器内确认 `/usr/share/nginx/html/canvas/index.html``canvas/assets` 已存在。
- 最近部署验证2026-05-25`779e9b3` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,视频生成改为个人公平队列:全局默认同时 2 个视频、单用户同时 1 个视频,同一用户连续提交会显示排队且不会占满所有生成通道。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525075706.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。复验静态 bundle 已包含 `queue_message` 和“排队中”文案API 容器确认 `VIDEO_QUEUE_MAX_CONCURRENT=2``VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1`
- 最近部署验证2026-05-25`b2d84dc` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,修复首页生成视频完成后结果卡点击无反馈的问题:`MediaAssetTile` 新增可选原生视频 controls首页仅在视频 `completed` 后开启播放控件,失败状态展示错误说明。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525071823.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。复验静态 bundle 已包含 `videoControls``controls:`;生产容器内最近完成视频文件存在:`/data/jobs/16b984e804f5/storyboard_videos/ac96d8eba342/video.mp4`,大小 3687229 bytes。
- 最近部署验证2026-05-25`486a682` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,登录页新增飞书客户端 UA 自动发起 `/api/auth/feishu/start`Nginx 未登录跳转改为 `/login/?next=$request_uri` 以保留回跳页面。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525070905.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。复验静态 bundle 已包含 `skg-feishu-auto-login``auth/feishu/start?next`,未登录访问 `/detail/?job=test` 返回 `Location: /login/?next=/detail/?job=test`。该部署当时生产 `auth_config()` 仍显示 `feishu_enabled=false`2026-05-25 23:49 CST 已在服务器环境补齐飞书 OAuth 配置并重建 API当前 `feishu_enabled=true`
- 最近部署验证2026-05-25`a02c5eb` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,修复无首帧文生图 / 文生视频创建空白创作任务时的 `createCreativeImageJob 400 There was an error parsing the body`。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525064659.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。云端容器确认图片 / 视频密钥均已配置:`image_configured=True``video_configured=True``image_base_url=https://ai.skg.com/ezlink/v1``video_base_url=https://ai.skg.com/doubao`;同一个缺 boundary 的空 multipart 探针已从旧版 400 变为认证层 401说明请求体解析问题已消除。
- 最近部署验证2026-05-25`e77e77f` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,生产入口新增图片尺寸、视频画幅和按真实能力返回的视频时长选择。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525062614.tgz`;脚本内首次验证在容器刚启动 3 秒时遇到 `/` 500随后复跑 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。容器内能力复验:`image_sizes=auto,1024x1536,1024x1024,1536x1024``video_sizes=720x1280,1280x720,1024x1024,960x1280``video_durations=5,8,10,12,15``video_max=15`;当前 Doubao / Seedance 单条不暴露 30 秒。
- 最近部署验证2026-05-25`dcc8abc` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,生产入口为单对话框四模式生成页,并接入图片 / 视频模型选择。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525030237.tgz`;首次脚本校验在容器刚启动时遇到 `/` 500经日志确认是 Nginx auth 子请求早于 API 就绪导致的临时连接拒绝,随后复跑 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。容器内模型选项复验:`image_options=auto,gpt-image-2,gemini-3-pro-image-preview``video_options=seedance,kling,veo3,veo``video_configured=True`
- 最近部署验证2026-05-24`828b86d` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,生产入口切换为多人通用营销内容创作平台首页,并保留 `/agent/` 作为高级复刻入口、`/detail/?job=<id>` 作为任务详情页。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260523175306.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。
- 最近部署验证2026-05-22`6427935` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260522012756.tgz`,生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`)。部署后已把生产私有 `deploy/.env.production` 明确固定为多语言本地 ASR 路径并重启 API`ASR_LANGUAGE=auto``FASTER_WHISPER_MODEL=base``ASR_REMOTE_ENABLED=false``ASR_LOCAL_FALLBACK_ENABLED=true``ASR_AUDIO_FALLBACK_ENABLED=false`;复验 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过,容器内 `/health` 确认 `asr_language=auto``faster_whisper=base`
- 最近部署验证2026-05-21`8458dac` 已按“先本地 Docker、再上传部署”流程上线。上线前在本机 Docker 构建 `skg-marketing-studio-web:latest` / `skg-marketing-studio-api:latest`,并用本地 Compose 容器验证通过:`web:/ 302``web:/login/ 200``web:/_next/does-not-exist.js 404``web:/api/health 401``api:health ok``api:ytdlp_cookie_args []`、静态 bundle 包含 `未来健康 · 营销内容工作台``信息流广告复刻生产`,且未发现本地 API/dev URL 泄漏。随后通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260521070327.tgz`,生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。
- 最近部署验证2026-05-20`6597db3` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520151033.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/c48f07b9aef1cd29.js` 已包含 `min-w-[1280px]``max-w-[1920px]`,未再命中旧的 `h-[1000px]``w-[1800px]``BOARD_SCALE_PRESETS``boardScale`;对应工作台取消固定画布缩放,按浏览器正常流式布局渲染。
- 最近部署验证2026-05-20`2b842fd` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520145223.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/743b82648dfa9db9.js` 已包含 `h-32``maxHeight:560``提示词就绪``确认生成`,且未再命中旧的 `height:640` / `h-40`;对应转换层取消固定长高,生成要求输入区回到 128px底部仍由发送区主按钮确认生成。
- 最近部署验证2026-05-20`ab31a98` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520144227.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/5bbecb6cf31316cb.js` 已包含 `h-40``提示词就绪``确认生成`,对应生成要求输入框加高到 160px出图提示词生成后不再自动弹窗底部主按钮直接切换为确认生成。
- 最近部署验证2026-05-20`215987a` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520142849.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/54e1ee55c5019be8.js` 已包含 `height:640`,对应转换层固定高度从 560px 扩到 640px。
- 最近部署验证2026-05-20`e1e9bf8` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520142145.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后 Playwright 以 2048x1060 复测生成要求 composer文本输入区实际高约 119px张数控件和发送按钮实际高约 42px页面无客户端异常验证截图 `/tmp/skg-generation-composer-expanded.png`
- 最近部署验证2026-05-20`45b25d0` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520140706.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后 Playwright 以 2048x1060 复测转换层:生成要求区不再渲染“当前要求”、“提示词已生成”和“对话记录已收起”摘要,保留元素副本也已移除;该区只保留文本输入、张数控件和发送按钮,页面无客户端异常,验证截图 `/tmp/skg-generation-composer-simplified.png`
- 最近部署验证2026-05-20`54f159b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520135509.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后 Playwright 以 2048x1060 复测转换层:转换层和参考帧池共用高度从 500px 拉到 560px转换层内部改为 gap 堆叠并让主要板块 `shrink-0`,超出由转换层自身滚动承接;页面无客户端异常,验证截图 `/tmp/skg-conversion-stretched.png`
- 最近部署验证2026-05-20`d1e2b17` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520134529.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后 Playwright 以 2048x1060 复测转换层:内嵌“待确认提示词”卡和黑色“确认并生成 N 张”按钮均不再渲染,页面无客户端异常;有待确认 prompt 时只在“生成要求”标题右侧显示小型“待确认 · N 张”入口,验证截图 `/tmp/skg-conversion-no-inline-confirm.png`
- 最近部署验证2026-05-20`caa7b73` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520132820.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后 Playwright 以 2048x1060 复测:页面无客户端异常,源视频工作区已撤销“布局调节”按钮和 `localStorage["skg-source-workspace-layout:v1"]` 布局读写,固定为左侧原视频列 380px、9:16 视频高 500px、逐句时间轴最大高 270px、参考帧池 140px、转换层 500px 内部滚动、主体空态 78px验证截图 `/tmp/skg-layout-fixed-no-tuning.png`
- 最近部署验证2026-05-20`0db265f` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520131649.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后 Playwright 打开历史任务并展开“布局调节”:面板显示左列宽、视频高、时间轴高、参考池宽、转换层高、主体空态 6 个滑杆,调参值写入 `localStorage["skg-source-workspace-layout:v1"]`,供用户先在线试比例再固化默认值。
- 最近部署验证2026-05-20`5bffd63` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520123949.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后 Playwright 复测 1440x900 与 2048x1060历史任务加载后转换层占据主操作宽度主体元素下移为转换层下方的紧凑结果栏未再出现右侧三栏挤压滚动到主体元素位置后仍能看到下方分镜工作台承接。
- 最近部署验证2026-05-20`f0f567b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520120958.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 截图复测 1440x900、1728x1117、2048x1060、2560x1440缩放后的工作台在 1440/1728/2560 这类高度有余量的窗口上下居中2048x1060 保持顶部对齐并承接纵向内容,未出现先前的底部黑边失衡。
- 最近部署验证2026-05-20`3e7c165` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520114759.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测 1366x768、1440x900、1728x1117、1920x1080、2048x1060、2200x1400、2560x1440缩放档位分别为 0.72、0.8、0.92、1.06、1.06、1.16、1.342048x1060 保留左右 70px 呼吸感且无横向溢出,浏览器 `pageerror` 为空。
- 最近部署验证2026-05-20`e33463e` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520113414.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测转换层:页面显示“生成要求”和“对话记录已收起”,不再显示旧标题“生图对话”,也不再渲染“我们将不再强制...”这类模型确认消息;最终英文 prompt 仍保留在“待确认提示词”区域,浏览器 `pageerror` 为空。
- 最近部署验证2026-05-20`f35bfe0` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520111824.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测 1440x900、2048x1060、2200x1400 三种窗口,工作台仍按可见宽度优先铺满且外层 wrapper 左右间隙均为 0内层画布已改用 CSS `zoom` 渲染,三个窗口分别为 `zoom=0.8/1.138/1.222``transform` 均为 `none`,避免整屏 transform 小数缩放造成文字发虚,浏览器 `pageerror` 为空。
- 最近部署验证2026-05-20`1d0a77b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520105846.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测 1440x900、2048x1060、2200x1400 三种窗口,工作台按可见宽度优先缩放,外层 wrapper 左右间隙均为 0三个窗口分别缩放到 0.8、1.138、1.2222048x1060 这类高度不足场景通过纵向滚动承接,不再为了完整高度留下左右空白,浏览器 `pageerror` 为空。
- 最近部署验证2026-05-20`54eaac0` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520104155.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测 1440x900、2048x1060、2200x1400 三种窗口,工作台以 1800x1000 为基准分别缩放到 0.8、1.06、1.222,主网格列宽、源视频区列宽和三栏主体管线列宽保持一致,浏览器 `pageerror` 为空。
- 最近部署验证2026-05-20`64fef5a` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520102354.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 以 1440x900 与 2200x1400 两种窗口复测,工作台内部画布固定为 1800x1000主网格列宽、源视频区列宽和三栏主体管线列宽一致浏览器 `pageerror` 为空。
- 最近部署验证2026-05-20`40f1f28` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520095941.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测首页正常渲染,浏览器 `pageerror` 为空。转换层不再显示固定快捷需求按钮,生图对话空态和输入框改为中性“保留识别元素 / 补充调整要求”,由识别结果 chip 和自然语言对话承接用户意图。
- 最近部署验证2026-05-20`2c0e8a0` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520094923.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 复测首页正常渲染到工作台,浏览器 `pageerror` 为空;本次修复 `selectedAgentTraitsDirty` 残留变量名导致的客户端 `ReferenceError`,恢复转换层页面首屏渲染。
- 最近部署验证2026-05-20`5bdde89` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520092721.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、无本地 API 地址泄漏)。线上转换层识别结果 chip 改为本地即时切换:点亮表示保留元素、再次点击取消、清空按钮取消全部;点击 chip 不再触发 `/subject-agent/message`,保留元素随下一条“发送消息”一次性提交,避免每点一个特征都等待模型导致卡顿。
- 最近部署验证2026-05-20`10d955c` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520090750.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、无本地 API 地址泄漏)。线上转换层已移除可见快捷需求 chip复刻/创新/卡通/人物占比作为对话默认意图写入输入提示;生成张数控件移到发送消息旁边,默认 6 张、当前支持 1-10参考输入空态和已选参考图缩略图压小并继续复用 `MediaAssetTile` hover 放大预览。
- 最近部署验证2026-05-20`b9c5511` 已推送并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520085513.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、无本地 API 地址泄漏)。线上转换层已更新为参考输入区支持左侧 `+`、参考帧拖拽、胶片拖拽和本地图片拖入,下方为生图对话消息 composer右侧主体元素套图输出逻辑保持不变。
- 最近部署验证2026-05-19`fd794e3` 已推送并部署到 `/opt/skg-marketing-studio`;生产 `/health` 显示 `image=gpt-image-2``subject_image=gpt-image-2``image_request_timeout_seconds=60``image_base_url=https://ai.skg.com/ezlink/v1`。容器内最小文字生图探针在 20 秒限制下返回 `ReadTimeout`,说明当前阻塞点是 `https://ai.skg.com/ezlink/v1``gpt-image-2` 上游通道超时,服务端不会更换图片模型。
- 最近部署验证2026-05-19`3756259` 已推送并部署到 `/opt/skg-marketing-studio`;生产 `/health` 显示 `image=gpt-image-2``image_fallbacks=['gemini-3-pro-image-preview']``subject_image_fallbacks=['gpt-image-2','gemini-3-pro-image-preview']`、短时熔断阈值 2 次 / 600 秒。线上真实探针确认 `gpt-image-2` 读超时后同次调用可自动兜底到 `gemini-3-pro-image-preview` 并返回图片;模拟探针确认连续 2 次主模型失败后第三次直接走 Gemini。
- 最近部署验证2026-05-20`c245bff` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py``web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过web 容器 Up、API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。容器内模型偏好探针确认转换层 `image_model_preference` 路由:`auto -> ['gpt-image-2','gemini-3-pro-image-preview']``gpt-image-2 -> ['gpt-image-2']``gemini-3-pro-image-preview -> ['gemini-3-pro-image-preview']`
- 最近部署验证2026-05-20`2366662` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py``web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过。容器内后处理探针确认白底小主体保存为 `1152x2048` 时有效主体高度占比从约 0.60 可放大到 `0.906`,主体 6 视图 prompt 已注入同一份 pack bible。
- 最近部署验证2026-05-20`7acbfd5` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py``web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层主体提示词记忆和生图模型偏好改为按 `job.id` 隔离;有参考帧的 `reconstruction_mode=similar` 会先生成 source brief再把参考帧作为 `/images/edits``image[]` 参考提交;自主描述空文本切到 `reconstruction_mode=same` 源形象锁定路径。
- 最近部署验证2026-05-20`e10b1a6` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py``web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层改为项目内生图对话智能体,新增 `Job.subject_agent``/subject-agent/analyze``/subject-agent/message`GPT / Gemini 改为成套控制分析、对话和生图模型数量与要求修改进入对话状态后再调用主体套图生成Pydantic `model_bundle` protected namespace warning 已消除。
- 最近部署验证2026-05-20`d82175f` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `python3 -m py_compile api/main.py``web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层去掉方向卡片、卡通风格下拉和独立数量按钮,保留单一参考区 + 生图对话;后端 `/subject-agent/message` 从对话中识别 `selected_mode``quantity` 后再驱动主体套图生成。
- 最近部署验证2026-05-20`f1c710e` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层中间栏先清空为待重构占位,不再接收拖拽或触发 subject-agent / subject-assets右侧主体元素输出逻辑保持不变。
- 最近部署验证2026-05-20`7e763cf` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层改为参考帧分析 + 对话生成提示词 + 弹窗确认后再生成主体套图;右侧主体元素输出逻辑保持不变。部署时发现服务器 `WEB_AUTH_*` 环境变量缺失导致 `/auth/check` 503已从 `/root/skg-marketing-studio-login.txt` 和新 session secret 恢复服务器 `deploy/.env.production` 后重启验证通过;后续同步生产代码必须继续排除服务器真实 `deploy/.env.production`
- 发布状态已部署并验证2026-05-15`https://marketing.skg.com` 已启用应用内登录页,认证后首页 200`/api/health` 返回 `ok:true`
- 主站 / 前端:`https://marketing.skg.com`
- 旧画布路径:`https://marketing.skg.com/canvas/`(仅兼容跳转到根域名)
- API / 后端:`https://marketing.skg.com/api`
- 代码仓库 / Gitea`https://git.kang-kang.com/kangwan/20260512-skg-tk`
- 文档 / 解析:`docs/source-analysis.html`(项目内独立文档,不公开挂主应用路由)
- 管理后台:待定
- 服务器目录:`/opt/skg-marketing-studio`
- 生产部署唯一入口:`./scripts/deploy-prod-safe.sh`(先在服务器备份 `deploy/.env.production``data/jobs`、资源库和 `secrets`,如 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`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` 作为上线依据。
- 当前音频解析:`https://ai.skg.com/azure/v1``gpt-4o-transcribe` 当前返回 `DeploymentNotFound`,且官方 Azure OpenAI transcription 路径探测也未返回可用部署;生产临时复制本地成功策略,直接使用容器内多语言 `faster-whisper` 真实转写,默认语种为 `auto`,支持中文、英文和其他多语言原文识别,关闭 Gemini 多模态音频兜底。拿到真实 Azure ASR deployment 名后再恢复 `ASR_REMOTE_ENABLED=true`,并保持 `ASR_LANGUAGE` 为空或 `auto`,除非明确只想强制单一语种。
- 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`;全局资源中心持久化在 `./data/asset_library``./data/prompt_library``./data/_trash`Postgres 数据目录为服务器 `./data/postgres`,部署脚本通过 `pg_dump` 产出 `/opt/skg-marketing-studio-backups/skg-marketing-postgres-*.sql.gz`
- TikTok 下载登录态:公开视频默认不带 cookies 直接下载,生产环境变量必须显式保持 `YTDLP_COOKIES_FILE=``YTDLP_COOKIES_FROM_BROWSER=` 为空,防止容器读取不存在的浏览器 cookies。只有 TikTok 明确要求登录态时,才使用服务器私有 cookies 文件 `./secrets/tiktok_cookies.txt` 挂载到 API 容器 `/run/secrets/tiktok_cookies.txt` 并配置 `YTDLP_COOKIES_FILE=/run/secrets/tiktok_cookies.txt``yt-dlp` 会在任务结束时回写 cookies因此不要把该挂载设为只读不要使用云端浏览器读取方案也不要把 cookies 入库。生产容器严禁使用 `YTDLP_COOKIES_FROM_BROWSER=chrome`
- 登录凭证:生产入口只允许飞书免登录;飞书 OAuth 的 `FEISHU_APP_ID` / `FEISHU_APP_SECRET` 只放服务器 `deploy/.env.production`,回调地址固定为 `https://marketing.skg.com/api/auth/feishu/callback` 并需要在飞书开放平台应用安全设置中登记。登录页读取 `/api/auth/config` 后,如果检测到飞书客户端并且 `feishu_enabled=true`,会自动跳转 `/api/auth/feishu/start`,普通浏览器显示“飞书免登录”按钮;生产 `PASSWORD_AUTH_ENABLED=false` 时账号密码表单不展示,`POST /auth/login` 不可用,旧密码 Cookie 会失效。原账号密码只作为紧急备用配置保留在服务器 `/root/skg-marketing-studio-login.txt``deploy/.env.production`,如需临时恢复必须显式改为 `PASSWORD_AUTH_ENABLED=true` 并重启 API。开启 `AUTH_DATA_ISOLATION_ENABLED=true` 后,新建任务、素材任务和一键出片记录按登录用户隔离;历史无 owner 的旧任务不再通过密码账号访问,后续应走迁移/认领。
- 禁止手动裸 `rsync --delete` 到服务器;必须使用 `./scripts/deploy-prod-safe.sh`。如遇极端情况必须手动同步,命令必须同时包含 protect/exclude`.git``.memory``.logs``.pids``data``jobs``secrets``api/jobs``api/.env``api/.env.local``api/.env.production``deploy/.env.production``web/node_modules``web/.next``web/out`。不要把本地 `api/.env``deploy/.env.production` 覆盖到 `/opt/skg-marketing-studio`,也不要删除服务器 `data/jobs`,否则会清空案例、登录和模型配置。
- 生产启动:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build`
- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出;`/login/``/_next/``/assets/``/skg-logo-black.svg``/oasis-source/` 等登录页必需静态资源公开访问;未登录访问工作台跳转 `/login/``/api/` 通过 Nginx `auth_request` 校验 FastAPI 会话 Cookie 后反代到 `skg-marketing-api:4291`Traefik 通过 `coolify` 外部网络接入 80/443
- 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`;默认后端数据库为 `APP_DB_URL=sqlite:////data/jobs/app.db`,只存文档 / job / 媒体资产元数据和文件索引,原视频、音频、抽帧、生图、视频候选仍放在 `/data/jobs/<jobId>/`
- 登录凭证:用户名写下方快捷登录;密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt`,生产环境变量 `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`
## 快捷登录
- 登录地址:`https://marketing.skg.com/login/`
- 主路径:飞书免登录
- 密码登录:生产已停用
- 备用用户名:`skg`
- 备用密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
- 说明:当前是生产入口应用内登录页;飞书 App Secret、数据库密码、API Key、服务器 root 密码不要写这里
- 用户名:`skg`
- 密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
- 说明当前是生产入口应用内登录页数据库密码、API Key、服务器 root 密码不要写这里
## 元数据回写清单
- 新增或变更公网地址后,必须同步更新 `.project.json.urls`
@@ -112,16 +50,10 @@
- 能联网和鉴权时必须 `git push origin main`;如果不能推送,最终回复必须写清楚当前分支、领先/落后数量、最新未推送 commit 和失败原因
## 环境变量
- `LLM_BASE_URL` / `LLM_API_KEY`OpenAI 兼容网关,用于翻译、文案改写、音频分析等文本/多模态理解模型调用
- `ASR_BASE_URL` / `ASR_API_KEY`OpenAI Audio Transcriptions 兼容网关,用于上传 `audio.wav` 做真实转写;未配置 `ASR_API_KEY` 时复用 `LLM_API_KEY`,生产默认指向 `https://ai.skg.com/azure/v1`
- `ASR_MODEL`OpenAI Audio Transcriptions 音频转写模型;微软通道使用 Azure OpenAI 部署名 `gpt-4o-transcribe`如果 Azure 侧实际部署名不同必须同步改这里
- `ASR_LANGUAGE`:远端和本地 ASR 的可选输入语言提示,默认空值/`auto`,由模型自动识别中文、英文和其他多语言;只有明确知道素材固定语种时才填写 ISO-639-1 代码强制识别。
- `ASR_REMOTE_ENABLED`:是否启用远端 OpenAI Audio Transcriptions微软 ASR 验收时必须为 `true`。当前生产因 `https://ai.skg.com/azure/v1``gpt-4o-transcribe` 返回 `DeploymentNotFound`,临时设为 `false`,直接走容器内 `faster-whisper`,等真实 Azure deployment 名补齐后再恢复。
- `ASR_LOCAL_FALLBACK_ENABLED`:是否允许远端 ASR 失败后落到本机 / 容器内 ASR当前生产为 `true`,复制本地成功路径的“本机真实转写”策略,云端用 CPU 版 `faster-whisper` 替代本机 Mac 的 `mlx_whisper`
- `ASR_AUDIO_FALLBACK_ENABLED`:是否允许远端和本机 ASR 失败后落到多模态音频兜底;生产微软 ASR 验收设为 `false`,避免静默使用 Gemini 音频
- `FASTER_WHISPER_MODEL` / `FASTER_WHISPER_DEVICE` / `FASTER_WHISPER_COMPUTE_TYPE`:容器内本地 ASR 兜底,仅在 `ASR_LOCAL_FALLBACK_ENABLED=true` 时启用;默认用多语言 `base`,不要改回 `*.en` 英文专用模型,否则中文和多语言识别会退化。
- `ASR_FALLBACK_MODEL`:多模态音频兜底模型,仅在 `ASR_AUDIO_FALLBACK_ENABLED=true` 时用于兜底或音频画像,默认 `gemini-2.5-flash`;如果模型不能真实听到音频或返回疑似逐秒假字幕,后端必须拒绝写入时间轴
- `ASR_TIMEOUT_SECONDS`:远端 ASR / 翻译 / 音频分析单次请求超时;当前生产本地转写模式设为 45 秒,微软 ASR 重新启用时可按素材长度提高。
- `LLM_BASE_URL` / `LLM_API_KEY`OpenAI 兼容网关,用于 ASR、翻译、文案改写、音频分析等文本/音频理解模型调用
- `ASR_MODEL`OpenAI Audio Transcriptions 音频转写模型,默认 `whisper-1`
- `ASR_FALLBACK_MODEL`:远端 ASR 和本机 ASR 都不可用时才尝试的多模态兜底,默认 `gemini-2.5-flash`如果模型不能真实听到音频或返回疑似逐秒假字幕,后端必须拒绝写入时间轴
- `ASR_TIMEOUT_SECONDS`:远端 ASR / 音频分析单次请求超时,默认 45 秒,避免第一步长时间停在转录中
- `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 覆盖值
@@ -130,28 +62,18 @@
- `AUDIO_REWRITE_MODEL`:后续音频口播改写模型,默认跟随 `REWRITE_MODEL`;如果旧环境仍写 `gemini-*`,后端会自动改用 `REWRITE_MODEL`
- `AUDIO_PRODUCT_BRIEF`:音频口播改写时注入的 SKG 产品卖点
- `PRODUCT_VIEW_MODEL`:同一产品素材池的视角标注/自动识别模型;当前按项目要求强制使用 `gpt-image-2`
- `IMAGE_BASE_URL` / `IMAGE_API_KEY` / `IMAGE_MODEL`OpenAI 兼容生图网关;当前所有生图入口主模型仍为 `gpt-image-2`
- `IMAGE_REQUEST_TIMEOUT_SECONDS`:单次图片网关请求超时,默认 60 秒;超时会直接把该视图标失败并继续下一张,避免主体 6 视图整包长时间无反馈
- `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` 会让主体套图只走所选模型
- `IMAGE_BASE_URL` / `IMAGE_API_KEY` / `IMAGE_MODEL`OpenAI 兼容生图网关;当前所有生图入口一律强制使用 `gpt-image-2`,不做其他图片模型 fallback
- `GPT_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODELS`:保留兼容旧环境变量名,但服务端会强制主体 6 视图和所有其他生图入口都只使用 `gpt-image-2`
- `AI_HTTP_PROXY` / `IMAGE_HTTP_PROXY`:可选的 AI 网关出站代理;本地 launchd 后台进程不一定继承 shell 的 `http_proxy/https_proxy`,如生图报 DNS / ConnectError可在本地 `api/.env` 配置后重启后端。`/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` 会被忽略
- `YTDLP_COOKIES_FILE` / `YTDLP_COOKIES_FROM_BROWSER`:可选 TikTok 下载登录态;优先使用 cookies 文件,其次读取本机浏览器 cookies。cookies 文件属于敏感登录态,只能放本机或服务器私有路径,不允许入库。
- `VOICE_PROVIDER`:配音通道,服务端固定使用 `azure_openai`
- `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只能放本地环境变量
- `PASSWORD_AUTH_ENABLED`:生产密码登录总开关;当前固定为 `false`,只允许飞书免登录。若应急恢复密码入口,必须显式改成 `true` 并重启 API
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产备用网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库。当前密码入口被 `PASSWORD_AUTH_ENABLED=false` 禁用;即使只开飞书免登录,也必须配置 `WEB_AUTH_SESSION_SECRET` 用于签名会话 Cookie。
- `FEISHU_APP_ID` / `FEISHU_APP_SECRET`:飞书免登录 OAuth 应用凭证;只放服务器 `deploy/.env.production` 或本地 `api/.env`,不入库。
- `FEISHU_REDIRECT_URI`:飞书 OAuth 回调地址,生产固定为 `https://marketing.skg.com/api/auth/feishu/callback`
- `FEISHU_OAUTH_SCOPE`:飞书 OAuth 授权范围;默认空值,按飞书应用后台已开权限执行。
- `FEISHU_ALLOWED_EMAIL_DOMAINS` / `FEISHU_ALLOWED_EMAILS` / `FEISHU_ALLOWED_TENANT_KEYS`:可选飞书账号白名单;留空时由飞书应用可见范围控制。
- `AUTH_DATA_ISOLATION_ENABLED`:多用户数据隔离开关,生产保持 `true`;新建 `Job` / `AgentRun` 会写入当前登录用户 owner列表和详情访问只返回本人数据。
- `VIDEO_QUEUE_MAX_CONCURRENT` / `VIDEO_QUEUE_MAX_CONCURRENT_PER_USER`:视频生成进程内队列并发上限,生产默认全局同时 2 个、单用户同时 1 个;同一用户连续提交会排队,其他用户仍可获得执行机会。当前队列不依赖 RedisAPI 容器重启会把未完成视频标记为失败并提示重新生成。
- `APP_DB_URL` / `DATABASE_URL`:后端元数据数据库;当前内置实现支持 `sqlite:///`,生产默认 `sqlite:////data/jobs/app.db`。文档归类以 `documents` 为顶层,一条 TK 链接或一次上传默认一个 document`jobs``media_assets` 归属到 `document_id`
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库
- `FFMPEG_BIN` / `FFPROBE_BIN`:可选本地媒体二进制路径;本机 Homebrew ffmpeg 动态库损坏时,后端会自动跳过不可用的 PATH 版本并尝试本机静态 ffmpeg 备选,生产仍建议使用系统 ffmpeg/ffprobe
- 生产环境变量:服务器只使用 `deploy/.env.production`,模板为 `deploy/.env.production.example`;真实 Key 不入库
- 同步生产代码时必须排除服务器真实 `deploy/.env.production`,只同步 `deploy/.env.production.example`网页登录密码、session secret、ASR/API Key 只保留在服务器环境文件和 `/root/skg-marketing-studio-login.txt`
## 规则
- 不允许编造不存在的部署域名、账号、密码

View File

@@ -1,12 +0,0 @@
# Third Party Notices
## huobao-canvas
Portions of the internal SKG canvas module are adapted from `chatfire-AI/huobao-canvas`.
- Source: https://github.com/chatfire-AI/huobao-canvas
- License note: the upstream README declares MIT licensing and links to a `LICENSE` file, but the cloned snapshot used for this integration did not include that file.
- Local integration path: `web/canvas-app/`
- SKG changes: branding, visible product text, routing, auth behavior, and API calls were changed for SKG internal use; visible upstream registration links and external provider branding are removed from the product UI.
This notice is kept in the repository for engineering traceability and is not shown in the product UI.

View File

@@ -8,18 +8,6 @@ WEB_AUTH_PASSWORD=
WEB_AUTH_SESSION_SECRET=
WEB_AUTH_COOKIE_NAME=skg_marketing_session
WEB_AUTH_COOKIE_SECURE=false
AUTH_DATA_ISOLATION_ENABLED=true
VIDEO_QUEUE_MAX_CONCURRENT=2
VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1
# 飞书免登录OAuth。生产回调地址需同步配置到飞书开放平台应用安全设置。
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_REDIRECT_URI=https://marketing.skg.com/api/auth/feishu/callback
FEISHU_OAUTH_SCOPE=
FEISHU_ALLOWED_EMAIL_DOMAINS=
FEISHU_ALLOWED_EMAILS=
FEISHU_ALLOWED_TENANT_KEYS=
# 模型分工
ASR_MODEL=whisper-1
@@ -36,14 +24,9 @@ PRODUCT_VIEW_MODEL=gpt-image-2
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_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
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2
# 可选:本地网络需要代理访问 ai.skg.com 时配置launchd 不一定继承 shell 代理变量。
AI_HTTP_PROXY=
YTDLP_COOKIES_FILE=
@@ -54,7 +37,7 @@ VIDEO_MODEL_KLING=kling-omni
VIDEO_MODEL_VEO3=veo-3.1-fast
# 音频文案改写 + Azure OpenAI 配音
AUDIO_REWRITE_MODEL=gemini-2.5-pro
AUDIO_REWRITE_MODEL=gpt-4o
AUDIO_PRODUCT_BRIEF="SKG 智能按摩产品,主打日常肩颈、腰背、眼部、膝盖或足部放松;广告表达要高级、干净、可信,不做医疗疗效承诺。"
# 语音通道服务端固定为 Azure OpenAI。
VOICE_PROVIDER=azure_openai
@@ -96,7 +79,8 @@ VIDEO_DURATION_FIELD=seconds
VIDEO_POLL_TIMEOUT_SECONDS=900
# 工作目录
KEYFRAME_COUNT=12
APP_DB_URL=sqlite:///./jobs/app.db
KEYFRAME_COUNT=6
JOBS_DIR=./jobs
# CORS

View File

@@ -1,6 +1,6 @@
# SKG TK 二创 API
FastAPI 后端,跑 yt-dlp + ffmpeg + ASR/翻译/音频画像、抽帧、GPT 图像生成/修图、Azure OpenAI TTS 预留和视频候选预留管线。
FastAPI 后端,跑 yt-dlp + ffmpeg + ASR/翻译/英文 SKG 产品介绍文案 + Azure OpenAI 英文配音管线。
## 启动
@@ -18,23 +18,23 @@ uvicorn main:app --host 127.0.0.1 --port 4291
## 路由
- `GET /health` — 健康检查 + 配置状态
- `POST /jobs` `{url}` — 创建 job后台下载源视频前端“开始分析”会在视频就绪后自动启动音频解析和视觉抽帧
- `POST /jobs/{id}/download/retry` — TK 链接下载失败后重试下载;上传视频任务不能重下载
- `GET /documents` — 后端数据库里的文档归类列表;一条 TK 链接或一次上传视频默认一个 document
- `POST /jobs` `{url}` — 创建 job后台下载源视频视频就绪后可手动解析或提取音频
- `GET /jobs/{id}` — 当前状态 + 产物;若原始音轨已拆出,会返回 `source_audio_url`
- `POST /jobs/{id}/transcribe` — 触发音频提取 + ASR + 中文翻译 + 讲话人 / 节奏 / 背景音分析;当前第一步不默认生成 SKG 新口播或 TTS 配音
- `POST /jobs/{id}/transcribe` — 触发音频提取 + ASR + 翻译 + SKG 英文产品介绍文案;文案长度按原音频时长估算,配置 Azure OpenAI TTS 后从 Azure 音色池生成配音。前端 Audio 节点提供“提取音频 / 重新提取音频”按钮,可与抽帧并行,不自动触发
- `GET /jobs/{id}/video.mp4` — 原视频
- `GET /jobs/{id}/audio.wav` — 拆轨后的原始音频,供前端音频波形和多模态音频分析使用
- `GET /jobs/{id}/audio-script.mp3`后续新配音阶段保留的 Azure OpenAI TTS 文件
- `GET /jobs/{id}/frames/{i}.jpg` — 第 i 张参考帧;当前主流程自动抽 12 张动作 / 节奏参考帧,也支持手动按当前播放点补帧
- `GET /jobs/{id}/audio.wav` — 拆轨后的原始音频,供前端底部音频条生成波形
- `GET /jobs/{id}/audio-script.mp3`英文改写文案的 Azure OpenAI TTS 配音
- `GET /jobs/{id}/frames/{i}.jpg` — 第 i 张关键帧0-9
## Mock 模式
未设 `LLM_API_KEY` 时,转录走本地 mock便于 UI 联调;未设 `AZURE_OPENAI_API_KEY` 时,后续 TTS 文件不会生成,但不影响当前第一步音频解析
未设 `LLM_API_KEY` 时,转录走本地 mock便于 UI 联调;未设 `AZURE_OPENAI_API_KEY` 且无法复用 `LLM_API_KEY` 时只生成改写文案,不生成配音文件
## 依赖
- `ffmpeg` 系统二进制(拆轨 / 抽帧)
- `yt-dlp` 系统二进制(也可走 Python 包)
- OpenAI 兼容 LLM 网关ASR / 翻译 / 文案改写 / 视觉 briefASR 默认自动识别中文、英文和其他多语言,远端失败后先走容器内多语言 `faster-whisper` / 本机 `mlx_whisper`,再按开关用 `ASR_FALLBACK_MODEL` 走多模态音频识别,后端会拒绝疑似假字幕或覆盖率过低的时间轴
- GPT 图片网关(当前所有生图 / 修图 / 产品视角识别 / 主体资产 / 首尾帧都强制使用 `gpt-image-2`,不做其他图片模型 fallback
- Azure OpenAI TTS后续新配音阶段使用 `AZURE_OPENAI_API_KEY`;默认模型 `gpt-4o-mini-tts`,按 `AZURE_TTS_PATHS` 依次尝试语音路径
- SQLite 元数据数据库(默认 `APP_DB_URL=sqlite:///./jobs/app.db`);只存 document / job / media asset 元数据,原视频、音频、抽帧和生成文件继续放 `jobs/<jobId>/`
- OpenAI 兼容 LLM 网关ASR / 翻译 / 文案改写);如果 `/audio/transcriptions` 不可用,会用 `ASR_FALLBACK_MODEL` 走 Gemini 多模态音频识别
- Azure OpenAI TTS英文产品介绍文案配音,使用 `AZURE_OPENAI_API_KEY` 或回退复用 `LLM_API_KEY`;默认音色池 `alloy,verse,shimmer`

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -8,7 +8,6 @@
"name": "运动阳光男",
"folder": "01_运动阳光男",
"description": "运动阳光男透明骨架人角色含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
"prompt_brief": "Athletic sunny male transparent wellness character, young adult energy, lean fit proportions, open and upbeat posture, clean translucent skin shell with visible white skeleton. The character should feel friendly, active, outdoor-sport inspired, bright, healthy, and suitable for premium SKG neck-and-shoulder wearable device ads. Keep neck, collarbone, shoulders, upper back, and cervical spine readable without bulky clothing or props.",
"primary_image": "character-01-front",
"images": [
{
@@ -81,7 +80,6 @@
"name": "都市型男",
"folder": "02_都市型男",
"description": "都市型男透明骨架人角色含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
"prompt_brief": "Urban stylish male transparent wellness character, adult metropolitan feel, clean confident posture, refined proportions, translucent body shell with visible white skeleton. The commercial mood is premium city lifestyle, composed, sharp, and modern, suitable for office or commute-oriented SKG neck-and-shoulder massage ads. Keep shoulder line, side neck, collarbone, and upper back clear for wearable device placement.",
"primary_image": "character-02-front",
"images": [
{
@@ -154,7 +152,6 @@
"name": "优雅白领女",
"folder": "03_优雅白领女",
"description": "优雅白领女透明骨架人角色含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
"prompt_brief": "Elegant professional female transparent wellness character, young adult to adult office-worker mood, slim balanced proportions, calm poised posture, translucent outer body with a clean visible white skeleton. The style should feel premium, gentle, trustworthy, and workplace-friendly for SKG neck-and-shoulder wearable device ads. Keep hair, collars, and accessories from hiding the neck, shoulders, collarbone, upper back, and cervical spine.",
"primary_image": "character-03-front",
"images": [
{
@@ -227,7 +224,6 @@
"name": "运动辣妹",
"folder": "04_运动辣妹",
"description": "运动辣妹透明骨架人角色含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
"prompt_brief": "Sporty confident female transparent wellness character, energetic young adult fitness mood, toned proportions, expressive posture, translucent skin shell with visible white skeleton. The character should feel active, fashionable, bright, and creator-ad friendly while remaining premium and non-horror. Keep the neck, side neck, shoulders, collarbone, upper trapezius, and upper back open and readable for SKG wearable massage device scenes.",
"primary_image": "character-04-front",
"images": [
{
@@ -300,7 +296,6 @@
"name": "绅士大叔",
"folder": "05_绅士大叔",
"description": "绅士大叔透明骨架人角色含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
"prompt_brief": "Mature gentleman transparent wellness character, adult to middle-aged presence without exact age, steady confident posture, slightly stronger build, translucent body shell with a clean visible white skeleton. The commercial mood is calm, trustworthy, premium, and lifestyle-oriented for SKG neck-and-shoulder wearable device ads. Keep collars and styling minimal so the neck, shoulders, upper back, cervical spine, and shoulder blades remain visible.",
"primary_image": "character-05-front",
"images": [
{
@@ -369,4 +364,4 @@
]
}
]
}
}

536
api/database.py Normal file
View File

@@ -0,0 +1,536 @@
from __future__ import annotations
import json
import os
import sqlite3
import time
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 1
def default_database_url(jobs_dir: Path) -> str:
return os.getenv("APP_DB_URL") or os.getenv("DATABASE_URL") or f"sqlite:///{jobs_dir / 'app.db'}"
def redact_database_url(url: str) -> str:
if "://" not in url or "@" not in url:
return url
scheme, rest = url.split("://", 1)
_, host = rest.rsplit("@", 1)
return f"{scheme}://***@{host}"
def infer_source_kind(url: str) -> str:
if url.startswith("upload://"):
return "upload"
if url.startswith("http://") or url.startswith("https://"):
return "tiktok_link"
return "unknown"
def default_workflow_mode(source_kind: str) -> str:
if source_kind == "upload":
return "uploaded_reference"
return "feed_recreation"
def document_title(url: str, source_kind: str, fallback: str) -> str:
if source_kind == "upload":
return url.replace("upload://", "", 1).strip() or fallback
if url:
return url.strip()[:120]
return fallback
def storage_prefix(document_id: str, source_kind: str, workflow_mode: str) -> str:
source = source_kind or "unknown"
mode = workflow_mode or default_workflow_mode(source)
return f"{mode}/{source}/{document_id}"
class AppDatabase:
def __init__(self, url: str, jobs_dir: Path):
self.url = url
self.jobs_dir = jobs_dir
self.path = self._sqlite_path(url)
self.enabled = True
self.error = ""
@staticmethod
def _sqlite_path(url: str) -> Path:
if url == ":memory:":
return Path(":memory:")
if not url.startswith("sqlite:///"):
raise RuntimeError("当前内置数据库层只支持 sqlite:/// URLPostgres 迁移会复用同一张表语义。")
raw = url[len("sqlite:///"):]
return Path(raw).expanduser().resolve()
def connect(self) -> sqlite3.Connection:
if str(self.path) != ":memory:":
self.path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(self.path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init(self) -> None:
with self.connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
source_kind TEXT NOT NULL,
workflow_mode TEXT NOT NULL,
source_url TEXT NOT NULL DEFAULT '',
primary_job_id TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'created',
storage_prefix TEXT NOT NULL,
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
source_kind TEXT NOT NULL,
workflow_mode TEXT NOT NULL,
source_url TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
progress INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '',
storage_path TEXT NOT NULL,
state_path TEXT NOT NULL,
video_url TEXT NOT NULL DEFAULT '',
duration REAL NOT NULL DEFAULT 0,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
frame_count INTEGER NOT NULL DEFAULT 0,
video_count INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS media_assets (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
job_id TEXT NOT NULL,
kind TEXT NOT NULL,
role TEXT NOT NULL,
path TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
frame_index INTEGER,
timestamp REAL,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
duration REAL NOT NULL DEFAULT 0,
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE,
FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_documents_updated_at ON documents(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_documents_source_kind ON documents(source_kind);
CREATE INDEX IF NOT EXISTS idx_documents_workflow_mode ON documents(workflow_mode);
CREATE INDEX IF NOT EXISTS idx_jobs_document_id ON jobs(document_id);
CREATE INDEX IF NOT EXISTS idx_jobs_updated_at ON jobs(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_assets_document_id ON media_assets(document_id);
CREATE INDEX IF NOT EXISTS idx_assets_job_id ON media_assets(job_id);
CREATE INDEX IF NOT EXISTS idx_assets_role ON media_assets(role);
"""
)
conn.execute(
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES('schema_version', ?)",
(str(SCHEMA_VERSION),),
)
def normalize_job_document(self, job: dict[str, Any]) -> dict[str, Any]:
job_id = str(job.get("id") or "")
source_url = str(job.get("url") or "")
source_kind = str(job.get("source_kind") or "") or infer_source_kind(source_url)
workflow_mode = str(job.get("workflow_mode") or "") or default_workflow_mode(source_kind)
document_id = str(job.get("document_id") or "") or job_id
prefix = str(job.get("storage_prefix") or "") or storage_prefix(document_id, source_kind, workflow_mode)
return {
"document_id": document_id,
"source_kind": source_kind,
"workflow_mode": workflow_mode,
"storage_prefix": prefix,
"title": document_title(source_url, source_kind, document_id),
}
def sync_job(self, job: dict[str, Any], job_path: Path) -> None:
if not self.enabled:
return
now = time.time()
job_id = str(job.get("id") or "")
if not job_id:
return
doc = self.normalize_job_document(job)
state_path = job_path / "state.json"
frames = list(job.get("frames") or [])
generated_videos = list(job.get("generated_videos") or [])
metadata = {
"audio_segment_count": len(job.get("transcript") or []),
"product_ref_count": len(job.get("product_refs") or []),
"storyboard_image_count": len(job.get("storyboard_images") or []),
}
with self.connect() as conn:
existing = conn.execute(
"SELECT created_at FROM documents WHERE id = ?",
(doc["document_id"],),
).fetchone()
created_at = float(existing["created_at"]) if existing else now
conn.execute(
"""
INSERT INTO documents(
id, title, source_kind, workflow_mode, source_url, primary_job_id,
status, storage_prefix, metadata_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
source_kind = excluded.source_kind,
workflow_mode = excluded.workflow_mode,
source_url = excluded.source_url,
primary_job_id = excluded.primary_job_id,
status = excluded.status,
storage_prefix = excluded.storage_prefix,
metadata_json = excluded.metadata_json,
updated_at = excluded.updated_at
""",
(
doc["document_id"],
doc["title"],
doc["source_kind"],
doc["workflow_mode"],
str(job.get("url") or ""),
job_id,
str(job.get("status") or "created"),
doc["storage_prefix"],
json.dumps(metadata, ensure_ascii=False),
created_at,
now,
),
)
existing_job = conn.execute("SELECT created_at FROM jobs WHERE id = ?", (job_id,)).fetchone()
job_created_at = float(existing_job["created_at"]) if existing_job else now
conn.execute(
"""
INSERT INTO jobs(
id, document_id, source_kind, workflow_mode, source_url, status,
progress, message, storage_path, state_path, video_url, duration,
width, height, frame_count, video_count, error, metadata_json,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
document_id = excluded.document_id,
source_kind = excluded.source_kind,
workflow_mode = excluded.workflow_mode,
source_url = excluded.source_url,
status = excluded.status,
progress = excluded.progress,
message = excluded.message,
storage_path = excluded.storage_path,
state_path = excluded.state_path,
video_url = excluded.video_url,
duration = excluded.duration,
width = excluded.width,
height = excluded.height,
frame_count = excluded.frame_count,
video_count = excluded.video_count,
error = excluded.error,
metadata_json = excluded.metadata_json,
updated_at = excluded.updated_at
""",
(
job_id,
doc["document_id"],
doc["source_kind"],
doc["workflow_mode"],
str(job.get("url") or ""),
str(job.get("status") or "created"),
int(job.get("progress") or 0),
str(job.get("message") or ""),
str(job_path),
str(state_path),
str(job.get("video_url") or ""),
float(job.get("duration") or 0),
int(job.get("width") or 0),
int(job.get("height") or 0),
len(frames),
len(generated_videos),
str(job.get("error") or ""),
json.dumps(metadata, ensure_ascii=False),
job_created_at,
now,
),
)
conn.execute("DELETE FROM media_assets WHERE job_id = ?", (job_id,))
for asset in self._job_assets(job, job_path, doc["document_id"]):
conn.execute(
"""
INSERT INTO media_assets(
id, document_id, job_id, kind, role, path, url, frame_index,
timestamp, width, height, duration, metadata_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
asset["id"],
asset["document_id"],
asset["job_id"],
asset["kind"],
asset["role"],
asset.get("path", ""),
asset.get("url", ""),
asset.get("frame_index"),
asset.get("timestamp"),
int(asset.get("width") or 0),
int(asset.get("height") or 0),
float(asset.get("duration") or 0),
json.dumps(asset.get("metadata") or {}, ensure_ascii=False),
now,
now,
),
)
def _job_assets(self, job: dict[str, Any], job_path: Path, document_id: str) -> list[dict[str, Any]]:
job_id = str(job.get("id") or "")
items: list[dict[str, Any]] = []
def add(
asset_id: str,
kind: str,
role: str,
path: Path | str = "",
url: str = "",
frame_index: int | None = None,
timestamp: float | None = None,
width: int = 0,
height: int = 0,
duration: float = 0.0,
metadata: dict[str, Any] | None = None,
) -> None:
items.append({
"id": asset_id,
"document_id": document_id,
"job_id": job_id,
"kind": kind,
"role": role,
"path": str(path) if path else "",
"url": url,
"frame_index": frame_index,
"timestamp": timestamp,
"width": width,
"height": height,
"duration": duration,
"metadata": metadata or {},
})
if (job_path / "source.mp4").exists() or job.get("video_url"):
add(
f"{job_id}:source_video",
"video",
"source_video",
job_path / "source.mp4",
str(job.get("video_url") or f"/jobs/{job_id}/video.mp4"),
duration=float(job.get("duration") or 0),
width=int(job.get("width") or 0),
height=int(job.get("height") or 0),
)
if (job_path / "audio.wav").exists() or job.get("source_audio_url"):
add(
f"{job_id}:source_audio",
"audio",
"source_audio",
job_path / "audio.wav",
str(job.get("source_audio_url") or f"/jobs/{job_id}/audio.wav"),
duration=float(job.get("duration") or 0),
)
for frame in job.get("frames") or []:
idx = int(frame.get("index") or 0)
add(
f"{job_id}:frame:{idx}",
"image",
"keyframe",
job_path / "frames" / f"{idx:03d}.jpg",
str(frame.get("url") or f"/jobs/{job_id}/frames/{idx}.jpg"),
frame_index=idx,
timestamp=float(frame.get("timestamp") or 0),
metadata={"quality_report": frame.get("quality_report")},
)
if frame.get("cleaned_url"):
add(
f"{job_id}:frame:{idx}:cleaned",
"image",
"cleaned_keyframe",
job_path / "cleaned" / f"{idx:03d}.jpg",
str(frame.get("cleaned_url")),
frame_index=idx,
timestamp=float(frame.get("timestamp") or 0),
)
for generated in frame.get("generated_images") or []:
gen_id = str(generated.get("id") or "")
if gen_id:
add(
f"{job_id}:generated_image:{idx}:{gen_id}",
"image",
"generated_image",
job_path / "gen" / f"{idx:03d}_{gen_id}.jpg",
str(generated.get("url") or ""),
frame_index=idx,
metadata={"model": generated.get("model"), "mode": generated.get("mode")},
)
for scene_asset in frame.get("scene_assets") or []:
asset_id = str(scene_asset.get("id") or "")
if asset_id:
add(
f"{job_id}:scene_asset:{asset_id}",
"image",
str(scene_asset.get("asset_role") or "scene_asset"),
job_path / "assets" / f"{asset_id}.jpg",
str(scene_asset.get("url") or ""),
frame_index=idx,
width=int(scene_asset.get("width") or 0),
height=int(scene_asset.get("height") or 0),
metadata={"label": scene_asset.get("label"), "scene_mode": scene_asset.get("scene_mode")},
)
for element in frame.get("elements") or []:
element_id = str(element.get("id") or "")
cutout_ids = list(element.get("cutouts") or [])
legacy_cutout = element.get("cutout_id")
if legacy_cutout and legacy_cutout not in cutout_ids:
cutout_ids.append(legacy_cutout)
for cutout_id in cutout_ids:
add(
f"{job_id}:cutout:{idx}:{element_id}:{cutout_id}",
"image",
"element_cutout",
job_path / "elements" / f"{idx:03d}_{element_id}_{cutout_id}.jpg",
f"/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutouts/{cutout_id}.jpg",
frame_index=idx,
metadata={"element_id": element_id, "name_zh": element.get("name_zh")},
)
for subject_asset in element.get("subject_assets") or []:
asset_id = str(subject_asset.get("id") or "")
if asset_id:
add(
f"{job_id}:subject_asset:{asset_id}",
"image",
"subject_asset",
job_path / "assets" / f"{asset_id}.jpg",
str(subject_asset.get("url") or ""),
frame_index=idx,
width=int(subject_asset.get("width") or 0),
height=int(subject_asset.get("height") or 0),
metadata={"view": subject_asset.get("view"), "label": subject_asset.get("label")},
)
for ref in job.get("product_refs") or []:
asset_id = str(ref.get("id") or ref.get("asset_id") or ref.get("url") or "")
if asset_id:
add(
f"{job_id}:product_ref:{asset_id}",
"image",
"product_ref",
self._path_from_job_url(job_path, job_id, str(ref.get("url") or "")),
str(ref.get("url") or ""),
metadata=ref,
)
for video in job.get("generated_videos") or []:
video_id = str(video.get("id") or "")
if video_id:
add(
f"{job_id}:generated_video:{video_id}",
"video",
"generated_video",
job_path / "videos" / f"{video_id}.mp4",
str(video.get("url") or ""),
frame_index=video.get("frame_idx"),
duration=float(video.get("duration") or 0),
metadata={"status": video.get("status"), "model": video.get("model"), "error": video.get("error")},
)
return items
def _path_from_job_url(self, job_path: Path, job_id: str, url: str) -> str:
prefix = f"/jobs/{job_id}/"
if not url.startswith(prefix):
return ""
tail = url[len(prefix):]
if tail == "video.mp4":
return str(job_path / "source.mp4")
return str(job_path / tail)
def delete_job(self, job_id: str) -> None:
if not self.enabled:
return
with self.connect() as conn:
row = conn.execute("SELECT document_id FROM jobs WHERE id = ?", (job_id,)).fetchone()
conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
if row:
remaining = conn.execute(
"SELECT COUNT(*) AS c FROM jobs WHERE document_id = ?",
(row["document_id"],),
).fetchone()
if int(remaining["c"] or 0) == 0:
conn.execute("DELETE FROM documents WHERE id = ?", (row["document_id"],))
def list_documents(self, limit: int | None = None) -> list[dict[str, Any]]:
sql = """
SELECT
d.*,
COUNT(DISTINCT j.id) AS job_count,
COUNT(DISTINCT a.id) AS asset_count
FROM documents d
LEFT JOIN jobs j ON j.document_id = d.id
LEFT JOIN media_assets a ON a.document_id = d.id
GROUP BY d.id
ORDER BY d.updated_at DESC
"""
params: tuple[Any, ...] = ()
if limit is not None and limit > 0:
sql += " LIMIT ?"
params = (limit,)
with self.connect() as conn:
rows = conn.execute(sql, params).fetchall()
return [dict(row) for row in rows]
def health(self) -> dict[str, Any]:
if not self.enabled:
return {"enabled": False, "url": redact_database_url(self.url), "error": self.error}
try:
with self.connect() as conn:
docs = conn.execute("SELECT COUNT(*) AS c FROM documents").fetchone()["c"]
jobs = conn.execute("SELECT COUNT(*) AS c FROM jobs").fetchone()["c"]
assets = conn.execute("SELECT COUNT(*) AS c FROM media_assets").fetchone()["c"]
return {
"enabled": True,
"url": redact_database_url(self.url),
"schema_version": SCHEMA_VERSION,
"documents": int(docs or 0),
"jobs": int(jobs or 0),
"assets": int(assets or 0),
}
except Exception as e:
return {"enabled": False, "url": redact_database_url(self.url), "error": str(e)}
def create_database(url: str, jobs_dir: Path) -> AppDatabase:
db = AppDatabase(url, jobs_dir)
db.init()
return db

769
api/db.py
View File

@@ -1,769 +0,0 @@
from __future__ import annotations
import os
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
DATABASE_URL = os.getenv("DATABASE_URL", "").strip()
DB_ENABLED = bool(DATABASE_URL and psycopg is not None)
def enabled() -> bool:
return DB_ENABLED
def _connect():
if not DB_ENABLED:
raise RuntimeError("database disabled")
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):
if not DB_ENABLED:
return None
try:
return fn()
except Exception as exc:
print(f"[db] {label} failed: {exc}", flush=True)
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 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_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
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 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

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -6,9 +6,6 @@ python-dotenv==1.0.1
yt-dlp==2026.3.17
openai==1.55.3
httpx==0.27.2
requests==2.32.5
psycopg[binary]==3.2.3
imagehash==4.3.1
Pillow>=11.0
numpy>=2.0
faster-whisper==1.1.1

View File

View File

@@ -3,57 +3,25 @@
# Runtime
JOBS_DIR=/data/jobs
ASSET_LIBRARY_DIR=/data/asset_library
PROMPT_LIBRARY_DIR=/data/prompt_library
KEYFRAME_COUNT=12
APP_DB_URL=sqlite:////data/jobs/app.db
KEYFRAME_COUNT=6
CORS_ORIGINS=https://marketing.skg.com
API_PORT=4291
# Company persistence database. Real password and DATABASE_URL live only on server.
POSTGRES_DB=skg_marketing
POSTGRES_USER=skg_marketing
POSTGRES_PASSWORD=
DATABASE_URL=postgresql://skg_marketing:CHANGE_ME@postgres:5432/skg_marketing
# Web login. Keep real password and session secret only on the server.
PASSWORD_AUTH_ENABLED=false
WEB_AUTH_USERNAME=skg
WEB_AUTH_PASSWORD=
WEB_AUTH_SESSION_SECRET=
WEB_AUTH_COOKIE_NAME=skg_marketing_session
WEB_AUTH_COOKIE_SECURE=true
AUTH_DATA_ISOLATION_ENABLED=true
VIDEO_QUEUE_MAX_CONCURRENT=2
VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1
# Feishu OAuth login. Register this callback in the Feishu developer console:
# https://marketing.skg.com/api/auth/feishu/callback
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_REDIRECT_URI=https://marketing.skg.com/api/auth/feishu/callback
FEISHU_OAUTH_SCOPE=
FEISHU_ALLOWED_EMAIL_DOMAINS=
FEISHU_ALLOWED_EMAILS=
FEISHU_ALLOWED_TENANT_KEYS=
# SKG AI gateway, OpenAI-compatible
LLM_BASE_URL=https://ai.skg.com/ezlink/v1
LLM_API_KEY=
# Model routing
# Azure ASR can be re-enabled once the real deployment name exists.
ASR_BASE_URL=https://ai.skg.com/azure/v1
ASR_API_KEY=
ASR_MODEL=gpt-4o-transcribe
ASR_LANGUAGE=en
ASR_REMOTE_ENABLED=false
ASR_LOCAL_FALLBACK_ENABLED=true
ASR_AUDIO_FALLBACK_ENABLED=false
ASR_MODEL=whisper-1
ASR_FALLBACK_MODEL=gemini-2.5-flash
ASR_TIMEOUT_SECONDS=45
FASTER_WHISPER_MODEL=tiny.en
FASTER_WHISPER_DEVICE=cpu
FASTER_WHISPER_COMPUTE_TYPE=int8
TRANSLATE_MODEL=gemini-2.5-flash
GPT_TEXT_MODEL=gpt-4o
REWRITE_MODEL=gpt-4o
@@ -62,24 +30,18 @@ PRODUCT_VIEW_MODEL=gpt-image-2
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_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
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2
# Optional outbound proxy for AI gateway calls. Leave blank on normal VPS networking.
AI_HTTP_PROXY=
# Optional TikTok download login state for yt-dlp. Keep cookies files private.
# Leave blank for public TikTok videos. Set to /run/secrets/tiktok_cookies.txt only when a link explicitly requires login cookies.
YTDLP_COOKIES_FILE=
YTDLP_COOKIES_FROM_BROWSER=
# Audio rewrite and Azure OpenAI TTS
AUDIO_REWRITE_MODEL=gemini-2.5-pro
AUDIO_REWRITE_MODEL=gpt-4o
AUDIO_PRODUCT_BRIEF="SKG smart massage products for daily neck, shoulder, back, eye, knee, and foot relaxation. Keep claims premium, clean, credible, and non-medical."
# Voice is fixed to Azure OpenAI in the backend.
VOICE_PROVIDER=azure_openai

View File

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

View File

@@ -1,24 +1,6 @@
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: .
@@ -28,22 +10,10 @@ services:
- ./deploy/.env.production
environment:
JOBS_DIR: /data/jobs
AGENT_RUNS_DIR: /data/agent_runs
ASSET_LIBRARY_DIR: /data/asset_library
PROMPT_LIBRARY_DIR: /data/prompt_library
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
CORS_ORIGINS: https://marketing.skg.com
volumes:
- ./data/jobs:/data/jobs
- ./data/agent_runs:/data/agent_runs
- ./data/asset_library:/data/asset_library
- ./data/prompt_library:/data/prompt_library
- ./data/_trash:/data/_trash
- ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
networks:
- skg-marketing-internal

View File

@@ -1,69 +0,0 @@
name: skg-agent-cut
services:
postgres:
image: postgres:16-alpine
container_name: skg-agent-postgres
environment:
POSTGRES_DB: ${POSTGRES_DB:-skg_marketing}
POSTGRES_USER: ${POSTGRES_USER:-skg_marketing}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
volumes:
- ./data/postgres:/var/lib/postgresql/data
restart: unless-stopped
networks:
- skg-agent-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skg_marketing} -d ${POSTGRES_DB:-skg_marketing}"]
interval: 10s
timeout: 5s
retries: 10
api:
build:
context: .
dockerfile: Dockerfile.api
container_name: skg-agent-api
env_file:
- ./deploy/.env.production
environment:
JOBS_DIR: /data/jobs
AGENT_RUNS_DIR: /data/agent_runs
ASSET_LIBRARY_DIR: /data/asset_library
PROMPT_LIBRARY_DIR: /data/prompt_library
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
CORS_ORIGINS: http://2.24.28.41:4290,http://localhost:4290
volumes:
- ./data/jobs:/data/jobs
- ./data/agent_runs:/data/agent_runs
- ./data/asset_library:/data/asset_library
- ./data/prompt_library:/data/prompt_library
- ./data/_trash:/data/_trash
- ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
networks:
skg-agent-internal:
aliases:
- skg-marketing-api
web:
build:
context: .
dockerfile: Dockerfile.web
args:
NEXT_PUBLIC_API_BASE: /api
container_name: skg-agent-web
depends_on:
- api
ports:
- "0.0.0.0:4290:80"
restart: unless-stopped
networks:
- skg-agent-internal
networks:
skg-agent-internal:
name: skg-agent-internal

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -1,76 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HOST="${HOST:-root@76.13.31.179}"
APP_DIR="${APP_DIR:-/opt/skg-marketing-studio}"
BACKUP_DIR="${BACKUP_DIR:-/opt/skg-marketing-studio-backups}"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
if [[ "${1:-}" == "--no-build" ]]; then
BUILD_FLAG=""
else
BUILD_FLAG="--build"
fi
echo "==> Preflight: creating remote data/env backup"
ssh "$HOST" "set -euo pipefail
cd '$APP_DIR'
mkdir -p '$BACKUP_DIR'
stamp=\$(date +%Y%m%d%H%M%S)
tar -czf '$BACKUP_DIR/skg-marketing-preserve-'\$stamp'.tgz' \
deploy/.env.production \
data/jobs \
data/asset_library \
data/prompt_library \
data/_trash \
secrets 2>/tmp/skg-backup-warnings.log || {
cat /tmp/skg-backup-warnings.log >&2 || true
exit 1
}
if docker ps --format '{{.Names}}' | grep -qx skg-marketing-postgres; then
docker exec skg-marketing-postgres sh -lc 'pg_dump -U "\$POSTGRES_USER" "\$POSTGRES_DB"' \
| gzip > '$BACKUP_DIR/skg-marketing-postgres-'\$stamp'.sql.gz'
fi
find '$BACKUP_DIR' -name 'skg-marketing-preserve-*.tgz' -type f -printf '%T@ %p\n' | sort -nr | tail -n +8 | cut -d' ' -f2- | xargs -r rm -f
find '$BACKUP_DIR' -name 'skg-marketing-postgres-*.sql.gz' -type f -printf '%T@ %p\n' | sort -nr | tail -n +8 | cut -d' ' -f2- | xargs -r rm -f
echo backup:\$(ls -t '$BACKUP_DIR'/skg-marketing-preserve-*.tgz | head -1)
"
echo "==> Syncing code with production data protected"
rsync -az --delete \
--filter='P /data/***' \
--filter='P /jobs/***' \
--filter='P /secrets/***' \
--filter='P /deploy/.env.production' \
--filter='P /api/jobs/***' \
--filter='P /api/.env' \
--filter='P /api/.env.local' \
--filter='P /api/.env.production' \
--exclude='/.git/' \
--exclude='/.memory/' \
--exclude='/.logs/' \
--exclude='/.pids/' \
--exclude='/data/' \
--exclude='/jobs/' \
--exclude='/secrets/' \
--exclude='/api/jobs/' \
--exclude='/api/.env' \
--exclude='/api/.env.local' \
--exclude='/api/.env.production' \
--exclude='/deploy/.env.production' \
--exclude='/web/node_modules/' \
--exclude='/web/.next/' \
--exclude='/web/out/' \
--exclude='/node_modules/' \
--exclude='内部分享-口播脚本.md' \
./ "$HOST:$APP_DIR/"
echo "==> Rebuilding production containers"
ssh "$HOST" "cd '$APP_DIR' && docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d $BUILD_FLAG"
echo "==> Verifying production"
"$ROOT_DIR/scripts/verify-prod-docker.sh" "$HOST"
echo "==> Done"

View File

@@ -1,29 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HOST="${1:-root@76.13.31.179}"
APP_DIR="${APP_DIR:-/opt/skg-marketing-studio}"
ssh "$HOST" "cd '$APP_DIR' && \
docker ps --filter name=skg-marketing --format '{{.Names}} {{.Status}}' && \
docker exec skg-marketing-web sh -lc '
set -e
echo web:no_local_api_refs
if grep -Rao \"http://localhost:4291\\|http://127.0.0.1:4291\\|localhost:4290\\|127.0.0.1:4290\" /usr/share/nginx/html/_next/static 2>/dev/null | head -1 | grep -q .; then
echo \"ERROR: local API/dev URL leaked into web static bundle\" >&2
exit 1
fi
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
done
' && \
docker exec skg-marketing-api sh -lc '
set -e
test ! -f /app/.env || { echo \"ERROR: /app/.env leaked into API image\" >&2; exit 1; }
python -c \"import main; assert main.YTDLP_COOKIES_FROM_BROWSER == \\\"\\\", main.YTDLP_COOKIES_FROM_BROWSER; print(\\\"api:ytdlp_cookie_args\\\", main.ytdlp_cookie_args())\"
curl -sS http://127.0.0.1:4291/health | python -c \"import json,sys; d=json.load(sys.stdin); assert d[\\\"ok\\\"] is True; assert d[\\\"auth_configured\\\"] is True; assert d.get(\\\"database\\\",{}).get(\\\"connected\\\") is True; print(\\\"api:health ok db connected\\\")\"
'"

View File

@@ -1,332 +0,0 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import {
ArrowDownToLine,
CheckCircle2,
CircleAlert,
Film,
ImagePlus,
Link2,
Loader2,
Play,
RotateCcw,
TerminalSquare,
Upload,
} from "lucide-react"
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:4291"
type AgentRunLog = {
ts: number
level: "info" | "warn" | "error"
message: string
}
type AgentRun = {
id: string
job_id: string
status: "draft" | "queued" | "executing" | "reviewing" | "completed" | "failed"
stage: string
progress: number
logs: AgentRunLog[]
video_ids: string[]
final_video_url: string
contact_sheet_url: string
error: string
created_at: number
updated_at: number
}
const STAGES = [
{ key: "download", label: "下载" },
{ key: "assets", label: "素材" },
{ key: "analyze", label: "拆解" },
{ key: "plan", label: "规划" },
{ key: "execute", label: "生成" },
{ key: "review", label: "审片" },
{ key: "compose", label: "合成" },
{ key: "final", label: "成片" },
]
function formatClock(ts: number) {
if (!ts) return "--:--:--"
return new Date(ts * 1000).toLocaleTimeString("zh-CN", { hour12: false })
}
function runVideoUrl(run: AgentRun | null) {
if (!run?.final_video_url) return ""
return `${API_BASE}${run.final_video_url}`
}
function runContactUrl(run: AgentRun | null) {
if (!run?.contact_sheet_url) return ""
return `${API_BASE}${run.contact_sheet_url}`
}
export default function AgentPage() {
const [url, setUrl] = useState("")
const [files, setFiles] = useState<File[]>([])
const [run, setRun] = useState<AgentRun | null>(null)
const [recent, setRecent] = useState<AgentRun[]>([])
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState("")
const terminalRef = useRef<HTMLDivElement>(null)
const previews = useMemo(() => files.map((file) => ({ file, url: URL.createObjectURL(file) })), [files])
useEffect(() => () => previews.forEach((item) => URL.revokeObjectURL(item.url)), [previews])
useEffect(() => {
fetch(`${API_BASE}/agent-runs?limit=8`, { cache: "no-store" })
.then((res) => (res.ok ? res.json() : []))
.then((items: AgentRun[]) => {
setRecent(items)
const latest = items.find((item) => item.status === "executing" || item.status === "reviewing" || item.status === "completed")
if (latest) setRun(latest)
})
.catch(() => undefined)
}, [])
useEffect(() => {
if (!run || run.status === "completed" || run.status === "failed") return
const timer = window.setInterval(async () => {
try {
const res = await fetch(`${API_BASE}/agent-runs/${run.id}`, { cache: "no-store" })
if (!res.ok) return
const next = await res.json()
setRun(next)
} catch {
/* keep current state */
}
}, 2000)
return () => window.clearInterval(timer)
}, [run?.id, run?.status])
useEffect(() => {
const el = terminalRef.current
if (el) el.scrollTop = el.scrollHeight
}, [run?.logs.length])
async function submit() {
setError("")
if (!url.trim()) {
setError("需要 TikTok 链接")
return
}
setSubmitting(true)
try {
const form = new FormData()
form.append("tk_url", url.trim())
files.slice(0, 6).forEach((file) => form.append("product_files", file))
const res = await fetch(`${API_BASE}/agent-runs`, { method: "POST", body: form })
if (!res.ok) {
const text = await res.text().catch(() => "")
throw new Error(text.slice(0, 260) || `HTTP ${res.status}`)
}
const created = await res.json()
setRun(created)
setRecent((prev) => [created, ...prev.filter((item) => item.id !== created.id)].slice(0, 8))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSubmitting(false)
}
}
const activeStageIndex = run ? Math.max(0, STAGES.findIndex((item) => item.key === run.stage)) : -1
const canStart = !!url.trim() && !submitting
const videoSrc = runVideoUrl(run)
const contactSrc = runContactUrl(run)
return (
<main className="min-h-screen bg-[#f3f4f7] text-[#111318]">
<div className="mx-auto flex min-h-screen w-full max-w-[1720px] flex-col gap-5 px-5 py-5">
<header className="flex items-center justify-between rounded-[28px] border border-black/5 bg-white/80 px-5 py-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
<div>
<div className="text-[12px] font-semibold uppercase tracking-[0.18em] text-[#7b8190]">SKG Agent Cut</div>
<h1 className="mt-1 text-[26px] font-semibold tracking-normal text-[#111318]"></h1>
</div>
<div className="hidden items-center gap-2 rounded-full bg-[#111318] px-3 py-2 text-[12px] font-medium text-white md:flex">
<TerminalSquare className="h-4 w-4 text-[#81d4ff]" />
{run ? `${run.status} · ${run.progress}%` : "standby"}
</div>
</header>
<section className="grid min-h-[calc(100vh-128px)] grid-cols-1 gap-5 xl:grid-cols-[390px_minmax(520px,1fr)_420px]">
<aside className="flex flex-col gap-4 rounded-[30px] border border-black/5 bg-white/85 p-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
<div className="rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-4">
<label className="mb-2 flex items-center gap-2 text-[13px] font-semibold text-[#2b3038]">
<Link2 className="h-4 w-4 text-[#0a84ff]" />
TikTok
</label>
<textarea
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://www.tiktok.com/@..."
className="h-28 w-full resize-none rounded-[18px] border border-[#d9dee8] bg-white px-4 py-3 text-[14px] leading-relaxed text-[#111318] outline-none transition focus:border-[#0a84ff] focus:ring-4 focus:ring-[#0a84ff]/10"
/>
</div>
<div className="rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-4">
<label className="mb-3 flex items-center gap-2 text-[13px] font-semibold text-[#2b3038]">
<ImagePlus className="h-4 w-4 text-[#34c759]" />
</label>
<label className="flex h-32 cursor-pointer flex-col items-center justify-center rounded-[20px] border border-dashed border-[#c7ceda] bg-white text-center transition hover:border-[#0a84ff] hover:bg-[#f7fbff]">
<Upload className="mb-2 h-6 w-6 text-[#7b8190]" />
<span className="text-[13px] font-medium text-[#2b3038]"></span>
<span className="mt-1 text-[12px] text-[#7b8190]"> 6 </span>
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
const next = Array.from(e.target.files ?? []).slice(0, 6)
setFiles(next)
}}
/>
</label>
{previews.length > 0 && (
<div className="mt-3 grid grid-cols-3 gap-2">
{previews.map((item) => (
<div key={`${item.file.name}-${item.file.size}`} className="aspect-square overflow-hidden rounded-[14px] border border-black/5 bg-white">
<img src={item.url} alt={item.file.name} className="h-full w-full object-contain" />
</div>
))}
</div>
)}
</div>
{error && (
<div className="rounded-[18px] border border-[#ff453a]/20 bg-[#ff453a]/10 px-4 py-3 text-[13px] text-[#9f1d17]">
{error}
</div>
)}
<button
type="button"
disabled={!canStart}
onClick={submit}
className="flex h-14 items-center justify-center gap-2 rounded-[20px] bg-[#111318] text-[15px] font-semibold text-white shadow-[0_16px_40px_rgba(17,19,24,0.18)] transition hover:bg-black disabled:cursor-not-allowed disabled:bg-[#b8bec8]"
>
{submitting ? <Loader2 className="h-5 w-5 animate-spin" /> : <Play className="h-5 w-5" />}
</button>
<div className="mt-auto rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-3">
<div className="mb-2 text-[12px] font-semibold text-[#7b8190]"></div>
<div className="space-y-2">
{recent.slice(0, 4).map((item) => (
<button
key={item.id}
type="button"
onClick={() => setRun(item)}
className="flex w-full items-center justify-between rounded-[16px] bg-white px-3 py-2 text-left text-[12px] text-[#2b3038] transition hover:bg-[#f1f5fb]"
>
<span className="font-medium">{item.id}</span>
<span className="text-[#7b8190]">{item.status}</span>
</button>
))}
</div>
</div>
</aside>
<section className="flex min-h-[680px] flex-col rounded-[30px] border border-black/5 bg-[#111318] p-4 shadow-[0_24px_80px_rgba(20,25,38,0.16)]">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[16px] bg-white/8">
<TerminalSquare className="h-5 w-5 text-[#81d4ff]" />
</div>
<div>
<h2 className="text-[16px] font-semibold text-white">Agent Terminal</h2>
<p className="text-[12px] text-white/45">{run ? `run ${run.id} · job ${run.job_id}` : "waiting for input"}</p>
</div>
</div>
{run?.status === "failed" ? (
<CircleAlert className="h-5 w-5 text-[#ff453a]" />
) : run?.status === "completed" ? (
<CheckCircle2 className="h-5 w-5 text-[#34c759]" />
) : (
<Loader2 className={`h-5 w-5 text-[#81d4ff] ${run ? "animate-spin" : ""}`} />
)}
</div>
<div className="mb-4 grid grid-cols-4 gap-2 lg:grid-cols-8">
{STAGES.map((stage, index) => {
const active = index <= activeStageIndex || run?.status === "completed"
return (
<div key={stage.key} className={`rounded-[14px] px-3 py-2 text-[12px] ${active ? "bg-white text-[#111318]" : "bg-white/6 text-white/40"}`}>
{stage.label}
</div>
)
})}
</div>
<div className="mb-4 h-2 overflow-hidden rounded-full bg-white/8">
<div className="h-full rounded-full bg-[#34c759] transition-all duration-700" style={{ width: `${run?.progress ?? 0}%` }} />
</div>
<div ref={terminalRef} className="min-h-0 flex-1 overflow-auto rounded-[22px] border border-white/8 bg-black px-4 py-4 font-mono text-[12px] leading-relaxed text-[#d8f3dc]">
{!run && <div className="text-white/35">$ idle</div>}
{run?.logs.map((log, index) => (
<div key={`${log.ts}-${index}`} className={log.level === "error" ? "text-[#ff8a80]" : log.level === "warn" ? "text-[#ffd166]" : "text-[#d8f3dc]"}>
<span className="text-white/30">[{formatClock(log.ts)}]</span> {log.message}
</div>
))}
</div>
</section>
<aside className="flex flex-col gap-4 rounded-[30px] border border-black/5 bg-white/85 p-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
<div className="flex items-center justify-between">
<div>
<div className="text-[12px] font-semibold uppercase tracking-[0.16em] text-[#7b8190]">Final</div>
<h2 className="mt-1 text-[18px] font-semibold text-[#111318]"></h2>
</div>
<Film className="h-5 w-5 text-[#ff9f0a]" />
</div>
<div className="aspect-[9/16] overflow-hidden rounded-[26px] border border-black/8 bg-[#111318]">
{videoSrc ? (
<video key={videoSrc} src={videoSrc} controls playsInline className="h-full w-full bg-black object-contain" />
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#7b8190]">
<Film className="h-8 w-8" />
<span className="text-[13px]"></span>
</div>
)}
</div>
{contactSrc && (
<div className="overflow-hidden rounded-[18px] border border-black/8 bg-white">
<img src={contactSrc} alt="final contact sheet" className="w-full object-cover" />
</div>
)}
<div className="grid grid-cols-2 gap-2">
<a
href={videoSrc || undefined}
download
className={`flex h-11 items-center justify-center gap-2 rounded-[16px] text-[13px] font-semibold ${videoSrc ? "bg-[#0a84ff] text-white" : "pointer-events-none bg-[#dfe3ea] text-[#8d94a1]"}`}
>
<ArrowDownToLine className="h-4 w-4" />
</a>
<button
type="button"
onClick={() => {
setRun(null)
setError("")
}}
className="flex h-11 items-center justify-center gap-2 rounded-[16px] bg-[#eef1f6] text-[13px] font-semibold text-[#2b3038] transition hover:bg-[#e3e7ef]"
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
</aside>
</section>
</div>
</main>
)
}

View File

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

View File

@@ -464,688 +464,6 @@ nextjs-portal {
min-width: 0;
transform: translateY(44px);
}
/* ============================================================
信息流工作台 · 登录页同源质感
============================================================ */
.skg-board-theme {
--skg-gold-1: #c8cd19;
--skg-gold-2: #a2c638;
--skg-cream: #f6f6ee;
--skg-bg-1: #1b1b1b;
--skg-bg-2: #242424;
--skg-bg-3: rgba(255, 255, 255, 0.1);
--skg-border: rgba(255, 255, 255, 0.14);
--skg-text-1: #ffffff;
--skg-text-2: rgba(255, 255, 255, 0.56);
--skg-text-3: rgba(255, 255, 255, 0.36);
--skg-success: #a2c638;
--skg-warn: #c8cd19;
--skg-danger: #fb7185;
--skg-info: #a6d533;
--skg-radius-sm: 6px;
--skg-radius-md: 8px;
--skg-radius-lg: 20px;
--skg-shadow-button: 10px 10px 10px rgba(0, 0, 0, 0.3);
--skg-shadow-card: 10px 10px 10px rgba(0, 0, 0, 0.3);
--skg-glass-bg: rgba(255, 255, 255, 0.1);
--skg-glass-bg-soft: rgba(255, 255, 255, 0.055);
--skg-rail: #383838;
--skg-wave-bg: rgba(0, 0, 0, 0.35);
--skg-wave-fill: rgba(209, 213, 219, 0.74);
--skg-wave-stroke-1: rgba(229, 231, 235, 0.7);
--skg-wave-stroke-2: rgba(229, 231, 235, 0.52);
--skg-wave-grid: rgba(255, 255, 255, 0.14);
--skg-wave-marker: rgba(255, 255, 255, 0.12);
--skg-wave-hover: rgba(207, 250, 254, 0.7);
--skg-wave-playhead: #a7f3d0;
--skg-wave-playhead-shadow: rgba(110, 231, 183, 0.85);
color: var(--skg-text-1);
background:
radial-gradient(circle at 20% 18%, rgba(162, 198, 56, 0.09), transparent 28%),
radial-gradient(circle at 86% 78%, rgba(200, 205, 25, 0.1), transparent 28%),
linear-gradient(120deg, #171717 0%, #202020 48%, #101010 100%);
}
.skg-board-theme::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.018) 1px, transparent 1px),
linear-gradient(180deg, rgba(255, 255, 255, 0.016) 1px, transparent 1px);
background-size: 56px 56px;
opacity: 0.34;
}
.skg-board-theme::after {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background:
linear-gradient(180deg, rgba(0, 0, 0, 0.2), transparent 45%, rgba(0, 0, 0, 0.42)),
linear-gradient(90deg, rgba(0, 0, 0, 0.38), transparent 36%, rgba(0, 0, 0, 0.22));
}
.skg-board-ambient {
background:
radial-gradient(circle at 72% 12%, rgba(162, 198, 56, 0.13), transparent 28%),
radial-gradient(circle at 18% 92%, rgba(200, 205, 25, 0.12), transparent 32%);
}
.skg-board-topbar,
.skg-board-panel {
border-color: var(--skg-border) !important;
background:
radial-gradient(circle at 88% 22%, rgba(162, 198, 56, 0.06), transparent 38%),
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.032)),
rgba(30, 30, 30, 0.78) !important;
box-shadow: var(--skg-shadow-card);
backdrop-filter: blur(5px);
}
.skg-board-topbar {
background:
linear-gradient(100deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.038) 58%, rgba(162, 198, 56, 0.07)),
rgba(28, 28, 28, 0.84) !important;
}
.skg-board-theme input:focus,
.skg-board-theme textarea:focus,
.skg-board-theme select:focus {
border-color: rgba(214, 179, 106, 0.58) !important;
box-shadow: 0 0 0 2px rgba(162, 198, 56, 0.18);
}
.skg-board-theme input[type="checkbox"] {
accent-color: #d6b36a;
}
.skg-board-theme ::selection {
background: rgba(214, 179, 106, 0.28);
color: #fff;
}
.skg-board-shell {
min-height: calc(100vh - 32px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.026), rgba(255, 255, 255, 0.01)),
rgba(18, 18, 18, 0.72);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.38);
backdrop-filter: blur(8px);
}
.skg-board-rail {
width: 65px;
height: calc(100vh - 64px);
min-height: 600px;
max-height: calc(100vh - 32px);
align-self: flex-start;
top: 16px;
z-index: 30;
overflow: visible;
transition: width 220ms ease;
}
.skg-board-rail.is-open {
width: 397px;
}
.skg-board-rail__strip {
width: 65px;
height: 100%;
min-height: 600px;
border: 1px solid #383838;
border-radius: 0 70px 70px 0;
background:
radial-gradient(circle at 86% 18%, rgba(162, 198, 56, 0.1), transparent 28%),
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
#383838;
box-shadow: 10px 10px 10px rgba(0, 0, 0, 0.3);
overflow: hidden;
transition: width 220ms ease, border-radius 220ms ease;
}
.skg-board-rail.is-open .skg-board-rail__strip {
width: 397px;
border-radius: 0 34px 34px 0;
}
.skg-board-rail__iconbar {
width: 65px;
height: 100%;
}
.skg-board-rail__logo {
border: 2px solid rgba(255, 255, 255, 0.2);
background:
radial-gradient(circle at 68% 38%, #a2c638 0 34%, transparent 36%),
radial-gradient(circle at 50% 50%, #c8cd19 0 47%, transparent 49%),
#ffffff;
color: #ffffff;
}
.skg-board-rail__button {
color: rgba(255, 255, 255, 0.52);
transition: color 180ms ease, background 180ms ease, transform 180ms ease;
}
.skg-board-rail__button:hover,
.skg-board-rail__button:focus-visible,
.skg-board-rail__button.is-active {
color: #ffffff;
background: rgba(255, 255, 255, 0.08);
transform: translateX(2px);
}
.skg-board-rail__drawer {
height: 100%;
animation: skgRailDrawerIn 220ms ease both;
}
.skg-board-rail__drawer .skg-board-panel {
height: 100%;
min-height: 0 !important;
border-radius: 0 30px 30px 0;
}
@keyframes skgRailDrawerIn {
from {
opacity: 0;
transform: translateX(-12px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.skg-glass-card {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 20px;
background:
radial-gradient(circle at 80% 86%, rgba(162, 198, 56, 0.13), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.034)),
rgba(38, 38, 38, 0.76);
box-shadow: var(--skg-shadow-card);
backdrop-filter: blur(5px);
}
.skg-glass-card--flat {
border-radius: 16px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.022)),
rgba(18, 18, 18, 0.62);
}
.skg-audio-waveform {
border-color: rgba(255, 255, 255, 0.1) !important;
background: var(--skg-wave-bg) !important;
}
.skg-audio-waveform__center {
background: var(--skg-wave-grid);
}
.skg-audio-waveform__segment {
background: var(--skg-wave-marker);
}
.skg-audio-waveform__hover {
background: var(--skg-wave-hover);
}
.skg-audio-waveform__playhead {
background: var(--skg-wave-playhead);
box-shadow: 0 0 16px var(--skg-wave-playhead-shadow);
}
.skg-status-orb {
display: inline-flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
border: 5px solid rgba(255, 255, 255, 0.2);
border-radius: 999px;
background:
radial-gradient(circle at 78% 32%, #a2c638 0 12%, transparent 13%),
conic-gradient(from 40deg, #a2c638 0 74%, rgba(255, 255, 255, 0.22) 75% 100%);
color: #ffffff;
}
.skg-board-theme--light {
--skg-bg-1: #f4f1e8;
--skg-bg-2: #fbfaf5;
--skg-bg-3: rgba(0, 0, 0, 0.03);
--skg-border: rgba(72, 78, 56, 0.14);
--skg-text-1: #20241b;
--skg-text-2: rgba(32, 36, 27, 0.68);
--skg-text-3: rgba(32, 36, 27, 0.42);
--skg-success: #059669;
--skg-warn: #b7791f;
--skg-danger: #e11d48;
--skg-info: #0891b2;
--skg-wave-bg: rgba(255, 255, 255, 0.74);
--skg-wave-fill: rgba(80, 90, 70, 0.42);
--skg-wave-stroke-1: rgba(47, 57, 44, 0.46);
--skg-wave-stroke-2: rgba(47, 57, 44, 0.3);
--skg-wave-grid: rgba(72, 78, 56, 0.16);
--skg-wave-marker: rgba(72, 78, 56, 0.14);
--skg-wave-hover: rgba(23, 96, 111, 0.52);
--skg-wave-playhead: #10b981;
--skg-wave-playhead-shadow: rgba(16, 185, 129, 0.36);
color: var(--skg-text-1);
background:
radial-gradient(circle at 50% 0%, rgba(232, 212, 168, 0.16), transparent 31%),
radial-gradient(circle at 4% 100%, rgba(143, 176, 113, 0.1), transparent 28%),
linear-gradient(126deg, #f5f2e9 0%, #ece7dc 48%, #fbfaf5 100%);
}
.skg-board-theme--light::before {
background:
linear-gradient(90deg, rgba(42, 50, 36, 0.05) 1px, transparent 1px),
linear-gradient(180deg, rgba(42, 50, 36, 0.045) 1px, transparent 1px);
opacity: 0.72;
}
.skg-board-theme--light::after {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.36), transparent 46%, rgba(214, 179, 106, 0.08)),
linear-gradient(90deg, rgba(255, 255, 255, 0.3), transparent 42%, rgba(255, 255, 255, 0.24));
}
.skg-board-theme--light .skg-board-ambient {
background:
radial-gradient(circle at 20% 18%, rgba(214, 179, 106, 0.2), transparent 28%),
radial-gradient(circle at 70% 6%, rgba(143, 176, 113, 0.16), transparent 30%),
radial-gradient(circle at 52% 100%, rgba(214, 179, 106, 0.12), transparent 38%);
}
.skg-board-theme--light .skg-board-topbar,
.skg-board-theme--light .skg-board-panel {
border-color: rgba(82, 93, 62, 0.16) !important;
background:
radial-gradient(circle at 88% 18%, rgba(143, 176, 113, 0.12), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(249, 247, 239, 0.64)),
rgba(246, 243, 234, 0.84) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.78),
0 18px 48px rgba(65, 55, 30, 0.12);
}
.skg-board-theme--light .skg-board-topbar {
background:
linear-gradient(100deg, rgba(214, 179, 106, 0.1), rgba(143, 176, 113, 0.07) 42%, rgba(255, 255, 255, 0.72)),
rgba(250, 248, 241, 0.9) !important;
}
.skg-board-theme--light .skg-board-shell {
border-color: rgba(72, 78, 56, 0.14);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(246, 243, 235, 0.62)),
rgba(246, 243, 235, 0.74);
box-shadow: 0 24px 70px rgba(74, 68, 44, 0.13);
}
.skg-board-theme--light .skg-board-rail__strip {
border-color: #3f4239;
background:
radial-gradient(circle at 86% 18%, rgba(162, 198, 56, 0.12), transparent 28%),
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
#3f4239;
box-shadow: 10px 10px 26px rgba(74, 68, 44, 0.18);
}
.skg-board-theme--light .skg-glass-card {
border-color: rgba(72, 78, 56, 0.16);
background:
radial-gradient(circle at 80% 86%, rgba(143, 176, 113, 0.12), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(246, 243, 235, 0.64)),
rgba(250, 248, 241, 0.82);
box-shadow: 0 16px 42px rgba(74, 68, 44, 0.12);
}
.skg-board-theme--light .skg-glass-card--flat {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(246, 243, 235, 0.5)),
rgba(255, 255, 255, 0.54);
}
.skg-board-theme--light .skg-board-theme-toggle {
border-color: rgba(82, 93, 62, 0.16) !important;
background: rgba(255, 255, 255, 0.54) !important;
color: rgba(36, 40, 30, 0.72) !important;
}
.skg-board-theme--light .text-white,
.skg-board-theme--light [class*="text-white/"] {
color: rgba(32, 36, 28, 0.78) !important;
}
.skg-board-theme--light [class*="bg-black/"],
.skg-board-theme--light [class*="bg-white/"] {
background-color: rgba(255, 255, 250, 0.52) !important;
}
.skg-board-theme--light [class*="border-white/"] {
border-color: rgba(70, 78, 54, 0.14) !important;
}
.skg-board-theme--light [class*="text-[#d7efbc]"] {
color: #43662d !important;
}
.skg-board-theme--light [class*="text-[#e8c77a]"],
.skg-board-theme--light [class*="text-[#f2d58a]"],
.skg-board-theme--light [class*="text-[#f5d98e]"] {
color: #856015 !important;
}
.skg-board-theme--light [class*="text-emerald-"] {
color: #2f6d3d !important;
}
.skg-board-theme--light [class*="text-cyan-"],
.skg-board-theme--light [class*="text-sky-"],
.skg-board-theme--light [class*="text-teal-"] {
color: #17606f !important;
}
.skg-board-theme--light [class*="text-amber-"],
.skg-board-theme--light [class*="text-yellow-"] {
color: #8a5c00 !important;
}
.skg-board-theme--light [class*="text-rose-"],
.skg-board-theme--light [class*="text-red-"] {
color: #9f1239 !important;
}
.skg-board-theme--light [class*="text-violet-"],
.skg-board-theme--light [class*="text-purple-"] {
color: #62438a !important;
}
.skg-board-theme--light [class*="border-[#8fb071]"] {
border-color: rgba(67, 102, 45, 0.28) !important;
}
.skg-board-theme--light [class*="border-[#d6b36a]"] {
border-color: rgba(133, 96, 21, 0.26) !important;
}
.skg-board-theme--light [class*="bg-[#8fb071]"],
.skg-board-theme--light [class*="bg-[#d6b36a]"] {
background-color: rgba(214, 179, 106, 0.14) !important;
}
.skg-board-theme--light input,
.skg-board-theme--light textarea,
.skg-board-theme--light select {
color: #22261f !important;
}
.skg-board-theme--light input::placeholder,
.skg-board-theme--light textarea::placeholder {
color: rgba(34, 38, 31, 0.36) !important;
}
.skg-board-theme--light ::selection {
background: rgba(214, 179, 106, 0.32);
color: #171a14;
}
.skg-board-brand {
display: flex;
min-width: 0;
align-items: center;
gap: 14px;
}
.skg-board-brand__logo-chip {
display: inline-flex;
height: 42px;
width: 132px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 1px solid rgba(214, 179, 106, 0.24);
border-radius: var(--skg-radius-md);
background: #f5efe3;
box-shadow: var(--skg-shadow-button);
}
.skg-board-brand__logo {
width: 96px;
height: auto;
display: block;
}
.skg-board-brand__system {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
color: var(--skg-gold-1);
}
.skg-board-brand__title {
margin-top: 3px;
color: var(--skg-text-1);
font-size: 20px;
font-weight: 650;
line-height: 1.1;
letter-spacing: 0;
}
.skg-board-brand__subtitle {
margin-top: 4px;
color: var(--skg-text-3);
font-size: 12px;
line-height: 1.3;
}
.skg-stat-card {
--skg-stat-glow:
radial-gradient(circle at 92% 86%, rgba(162, 198, 56, 0.22), transparent 48%);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
position: relative;
isolation: isolate;
overflow: hidden;
background:
var(--skg-stat-glow),
linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.045)),
rgba(0, 0, 0, 0.16);
color: #ffffff;
box-shadow: 8px 8px 10px rgba(0, 0, 0, 0.22);
backdrop-filter: blur(5px);
}
.skg-stat-card--violet {
--skg-stat-glow:
radial-gradient(circle at 94% 82%, rgba(126, 87, 194, 0.74), transparent 48%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
}
.skg-stat-card--lime {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(200, 205, 25, 0.72), transparent 48%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
}
.skg-stat-card--gold {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(214, 179, 106, 0.7), transparent 48%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
}
.skg-stat-card--teal {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(68, 162, 150, 0.7), transparent 48%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
}
.skg-stat-card--green {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(84, 190, 104, 0.72), transparent 48%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
}
.skg-stat-card__label {
color: rgba(255, 255, 255, 0.48);
}
.skg-stat-card__value {
color: #ffffff;
}
.skg-primary-action {
border-radius: var(--skg-radius-md);
background: linear-gradient(135deg, #c8cd19, #a2c638);
color: #101010;
box-shadow: var(--skg-shadow-button);
}
.skg-primary-action:hover {
background: linear-gradient(135deg, #d6db25, #b0d83d);
}
.skg-secondary-action {
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: var(--skg-radius-md);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.76);
}
.skg-secondary-action:hover {
border-color: rgba(162, 198, 56, 0.44);
background: rgba(162, 198, 56, 0.11);
color: #ffffff;
}
.skg-empty-state {
border: 1px dashed rgba(214, 179, 106, 0.22);
border-radius: var(--skg-radius-lg);
background:
radial-gradient(circle at 50% 0%, rgba(214, 179, 106, 0.1), transparent 38%),
rgba(255, 255, 255, 0.028);
color: var(--skg-text-3);
}
.skg-empty-character {
width: min(230px, 82%);
margin: 0 auto 12px;
pointer-events: none;
}
.skg-empty-character .login-character-stage {
min-height: 112px;
border-color: rgba(214, 179, 106, 0.16);
background:
radial-gradient(circle at 78% 18%, rgba(214, 179, 106, 0.16), transparent 28%),
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.026));
box-shadow: none;
}
.skg-empty-character .login-character-stage::after,
.skg-empty-character .login-stage-grid {
display: none;
}
.skg-empty-character .login-characters-container {
bottom: -6px;
transform: translateX(-50%) scale(0.22);
}
.skg-board-theme--light .skg-board-brand__logo-chip {
background: #ffffff;
}
.skg-board-theme--light .skg-stat-card {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(143, 176, 113, 0.2), transparent 50%);
border-color: rgba(72, 78, 56, 0.14);
background:
var(--skg-stat-glow),
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(246, 243, 235, 0.58)),
rgba(255, 255, 255, 0.56);
color: #20241b;
box-shadow: 0 12px 28px rgba(74, 68, 44, 0.1);
}
.skg-board-theme--light .skg-stat-card--violet {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(126, 87, 194, 0.22), transparent 50%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
}
.skg-board-theme--light .skg-stat-card--lime {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(200, 205, 25, 0.28), transparent 50%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
}
.skg-board-theme--light .skg-stat-card--gold {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(214, 179, 106, 0.26), transparent 50%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
}
.skg-board-theme--light .skg-stat-card--teal {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(68, 162, 150, 0.24), transparent 50%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
}
.skg-board-theme--light .skg-stat-card--green {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(84, 190, 104, 0.24), transparent 50%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
}
.skg-board-theme--light .skg-stat-card__label {
color: rgba(32, 36, 27, 0.48);
}
.skg-board-theme--light .skg-stat-card__value {
color: #20241b;
}
.skg-board-theme--light .skg-primary-action {
background: linear-gradient(135deg, #c8cd19, #a2c638);
color: #10140d;
box-shadow: 0 12px 28px rgba(128, 144, 37, 0.2);
}
.skg-board-theme--light .skg-primary-action:hover {
background: linear-gradient(135deg, #d6db25, #b0d83d);
}
.skg-board-theme--light .skg-secondary-action {
border-color: rgba(72, 78, 56, 0.16);
background: rgba(255, 255, 255, 0.5);
color: rgba(32, 36, 27, 0.72);
}
.skg-board-theme--light .skg-secondary-action:hover {
border-color: rgba(143, 176, 113, 0.38);
background: rgba(143, 176, 113, 0.12);
color: #20241b;
}
.skg-board-theme--light .skg-empty-state {
background:
radial-gradient(circle at 50% 0%, rgba(232, 212, 168, 0.28), transparent 38%),
rgba(255, 255, 255, 0.66);
}
.login-hero {
isolation: isolate;
color: #282828;

View File

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

View File

@@ -1,10 +1,9 @@
"use client"
import type { FormEvent } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import {
ArrowRight,
Building2,
CheckCircle2,
Eye,
EyeOff,
@@ -15,32 +14,8 @@ import { AnimatedLoginCharacters, type LoginCharacterMood } from "@/components/l
import { OasisCanvas } from "@/components/login/oasis-canvas"
type LoginStatus = "idle" | "loading" | "success"
type AuthConfig = {
auth_configured?: boolean
password_enabled?: boolean
feishu_enabled?: boolean
}
function normalizeNextPath(value: string | null | undefined) {
const next = (value || "/").trim() || "/"
if (!next.startsWith("/") || next.startsWith("//")) return "/"
return next
}
function loginNextPath() {
if (typeof window === "undefined") return "/"
return normalizeNextPath(new URLSearchParams(window.location.search).get("next"))
}
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)
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [remember, setRemember] = useState(true)
@@ -49,22 +24,6 @@ export default function LoginPage() {
const [hasError, setHasError] = useState(false)
const [status, setStatus] = useState<LoginStatus>("idle")
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 })
const autoFeishuAttemptedRef = useRef(false)
useEffect(() => {
let cancelled = false
fetch("/api/auth/config", { cache: "no-store", credentials: "include" })
.then((res) => res.ok ? res.json() : null)
.then((data) => {
if (!cancelled && data) setAuthConfig(data)
})
.catch(() => {
if (!cancelled) setAuthConfig(null)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const onPointerMove = (event: PointerEvent) => {
@@ -79,18 +38,6 @@ export default function LoginPage() {
}, [])
const disabled = status === "loading" || status === "success"
const feishuEnabled = Boolean(authConfig?.feishu_enabled)
const passwordEnabled = authConfig?.password_enabled ?? true
useEffect(() => {
if (!feishuEnabled || status !== "idle" || autoFeishuAttemptedRef.current || !isFeishuClient()) return
const attemptKey = `skg-feishu-auto-login:${nextPath}`
if (window.sessionStorage.getItem(attemptKey) === "1") return
window.sessionStorage.setItem(attemptKey, "1")
autoFeishuAttemptedRef.current = true
setStatus("loading")
window.location.href = `/api/auth/feishu/start?next=${encodeURIComponent(nextPath)}`
}, [feishuEnabled, nextPath, status])
const mood: LoginCharacterMood = useMemo(() => {
if (status === "success") return "success"
@@ -103,7 +50,6 @@ export default function LoginPage() {
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setHasError(false)
if (!passwordEnabled) return
if (!username.trim() || !password) {
setHasError(true)
return
@@ -121,7 +67,7 @@ export default function LoginPage() {
}
setStatus("success")
window.setTimeout(() => {
window.location.href = nextPath
window.location.href = "/"
}, 420)
} catch {
setStatus("idle")
@@ -129,11 +75,6 @@ export default function LoginPage() {
}
}
function onFeishuLogin() {
setStatus("loading")
window.location.href = `/api/auth/feishu/start?next=${encodeURIComponent(nextPath)}`
}
return (
<main className="login-page login-page--oasis login-page--source relative min-h-screen overflow-hidden text-white">
<OasisCanvas />
@@ -142,34 +83,13 @@ export default function LoginPage() {
<section className="login-auth-panel login-source-auth-panel login-source-combo-panel rounded-[8px]">
<div className="login-top-brand" aria-hidden="true">
<img className="login-top-brand__logo" src="/skg-logo-black.svg" alt="" />
<span className="login-top-brand__system"></span>
</div>
<div className="login-source-character-strip" aria-hidden="true">
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
</div>
<form className="login-source-form-pane w-full" onSubmit={onSubmit}>
{feishuEnabled ? (
<button
className="mb-3 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
type="button"
disabled={disabled}
onClick={onFeishuLogin}
>
<Building2 className="h-4 w-4" />
<ArrowRight className="h-4 w-4" />
</button>
) : null}
{feishuEnabled && passwordEnabled ? (
<div className="mb-3 flex items-center gap-3 text-xs text-white/35">
<span className="h-px flex-1 bg-white/10" />
<span></span>
<span className="h-px flex-1 bg-white/10" />
</div>
) : null}
{passwordEnabled ? (
<div className="space-y-3">
<div className="space-y-3">
<label className="block">
<span className="flex h-11 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#d6b36a] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#d6b36a]/25">
<UserRound className="h-4 w-4 text-white/45" />
@@ -215,11 +135,9 @@ export default function LoginPage() {
</button>
</span>
</label>
</div>
) : null}
</div>
{passwordEnabled ? (
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
<input
className="h-4 w-4 rounded border-white/20 bg-black/30 accent-[#c89b3c]"
type="checkbox"
@@ -228,8 +146,7 @@ export default function LoginPage() {
onChange={(event) => setRemember(event.target.checked)}
/>
<span></span>
</label>
) : null}
</label>
{status === "success" ? (
<div className="mt-3">
@@ -239,15 +156,13 @@ export default function LoginPage() {
</div>
) : null}
{passwordEnabled ? (
<button
<button
className="mt-4 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
type="submit"
disabled={disabled}
>
<ArrowRight className="h-4 w-4" />
</button>
) : null}
</button>
</form>
</section>
</div>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,20 +0,0 @@
# SKG 画布
这是 SKG 营销内容生产平台的内部画布模块,部署在主站 `/canvas/` 路径下。
## 内部使用方式
- `/canvas/`:项目入口和本机项目列表。
- `/canvas/p/new`:直接进入一个新画布。
- 画布里的生图、生视频请求统一走主后端 `/api`,员工不需要填写模型密钥。
- 生成的图片和视频仍由主后端保存到当前登录用户可访问的任务数据里,画布状态保存在当前浏览器本地。
## 开发
```bash
cd web/canvas-app
pnpm install
pnpm dev
```
主站构建会自动执行 `web/package.json` 里的 `build:canvas`,把 Vite 输出同步到 `web/public/canvas/` 后再执行 Next 静态导出。

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/skg-logo-black.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SKG</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -1,30 +0,0 @@
{
"name": "skg-internal-canvas",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vicons/ionicons5": "^0.13.0",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.1",
"@vue-flow/minimap": "^1.5.4",
"axios": "^1.13.2",
"naive-ui": "^2.43.2",
"pinia": "^3.0.4",
"vue": "^3.5.24",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.0",
"vite": "^5.2.0"
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,17 +0,0 @@
<svg id="组_464" data-name="组 464" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="126.523" height="20.579" viewBox="0 0 126.523 20.579">
<defs>
<clipPath id="clip-path">
<rect id="矩形_97" data-name="矩形 97" width="126.523" height="20.579" fill="#252525"/>
</clipPath>
</defs>
<g id="组_37" data-name="组 37" clip-path="url(#clip-path)">
<path id="路径_171" data-name="路径 171" d="M382.888,44.125a.471.471,0,0,1,.526.539.465.465,0,0,1-.526.526h-4.477v1.942h5.164a.526.526,0,1,1,0,1.052H378.52a14.282,14.282,0,0,0,2.279,2.171,23.357,23.357,0,0,0,3.141,2.05.6.6,0,0,1,.364.634.513.513,0,0,1-.58.58,2.425,2.425,0,0,1-.526-.162Q381.89,52.62,380.7,51.7a20.51,20.51,0,0,1-2.292-2.171v4.706q0,.539-.58.539a.471.471,0,0,1-.526-.539v-4.6a14.979,14.979,0,0,1-1.564,1.618,18.642,18.642,0,0,1-3.2,2.292,1.39,1.39,0,0,1-.35.121q-.58,0-.58-.688a.569.569,0,0,1,.418-.58,18.559,18.559,0,0,0,3.344-2.252,10.037,10.037,0,0,0,1.794-1.969H372.4a.526.526,0,1,1,0-1.052h4.908V45.191h-4.477a.465.465,0,0,1-.526-.526.471.471,0,0,1,.526-.539h4.477V42.979a.471.471,0,0,1,.526-.539q.579,0,.58.539v1.146Z" transform="translate(-299.773 -34.235)" fill="#252525"/>
<path id="路径_172" data-name="路径 172" d="M455.771,43.632q.448.014.475.529a.453.453,0,0,1-.475.475H451.16v3.445h5.181a.485.485,0,0,1,.489.529.46.46,0,0,1-.489.475h-4.68a17.982,17.982,0,0,0,1.967,2.008,17.664,17.664,0,0,0,2.713,1.763.788.788,0,0,1,.638.692q-.013.461-.529.475a.421.421,0,0,1-.258-.108,12.929,12.929,0,0,1-2.876-1.75A18.024,18.024,0,0,1,451.16,50.1v4.015a.528.528,0,0,1-.529.542.506.506,0,0,1-.529-.542V50.211a11.854,11.854,0,0,1-1.614,1.533,17.92,17.92,0,0,1-3.432,2.17.4.4,0,0,1-.2.108.623.623,0,0,1-.529-.38q0-.556.42-.678a20.3,20.3,0,0,0,3.188-2.129,12.461,12.461,0,0,0,1.777-1.75H444.96a.459.459,0,0,1-.488-.475.484.484,0,0,1,.488-.529H450.1V44.636h-4.463a.453.453,0,0,1-.475-.475q.027-.516.475-.529H450.1v-.841a.5.5,0,0,1,.529-.543.517.517,0,0,1,.529.543v.841Zm-8.587,1.221a.584.584,0,0,1,.421.217,10.848,10.848,0,0,1,1,1.953v.366q-.054.38-.529.434a.41.41,0,0,1-.366-.271,8.655,8.655,0,0,0-.909-1.858,1.136,1.136,0,0,1-.095-.366.463.463,0,0,1,.474-.475m7.311-.109q.353.028.366.529a1.033,1.033,0,0,1-.027.149,9.743,9.743,0,0,1-1.085,2.238.814.814,0,0,1-.474.163.524.524,0,0,1-.475-.529,1.127,1.127,0,0,1,.149-.326,9.623,9.623,0,0,0,.909-1.858.628.628,0,0,1,.637-.366" transform="translate(-358.424 -34.081)" fill="#252525"/>
<path id="路径_173" data-name="路径 173" d="M519.865,43.241q.524,0,.524.406a.854.854,0,0,1-.052.249,18.483,18.483,0,0,1-.616,2.5v8.4q0,.472-.511.472a.411.411,0,0,1-.459-.472V48.783q-.157.315-.314.59a.682.682,0,0,1-.406.157q-.511,0-.511-.458a.9.9,0,0,1,.1-.315,14.351,14.351,0,0,0,1-2.241,18.69,18.69,0,0,0,.786-3.027q.066-.249.459-.249m1.035.459h1.48q.564,0,.563.459a2.922,2.922,0,0,1-.328.982l-1.14,2.332a.708.708,0,0,0-.066.262h1.166q.524,0,.524.721a10.8,10.8,0,0,1-.17,1.572,11.214,11.214,0,0,1-.629,2.555c-.026.07-.052.136-.079.2a3.086,3.086,0,0,0,.943.721,4.25,4.25,0,0,0,1.756.432h4.625q.407,0,.406.511a.452.452,0,0,1-.511.511h-4.271a6.355,6.355,0,0,1-1.952-.327,4.037,4.037,0,0,1-1.467-.878,5.2,5.2,0,0,1-1.061,1.258.437.437,0,0,1-.773-.3.6.6,0,0,1,.209-.419,5.262,5.262,0,0,0,1.035-1.258,5.843,5.843,0,0,1-.629-1.48,11.654,11.654,0,0,1-.354-1.808q0-.511.459-.511a.415.415,0,0,1,.406.3,10.438,10.438,0,0,0,.616,2.332,9.233,9.233,0,0,0,.34-1.349,9.41,9.41,0,0,0,.171-1.6.22.22,0,0,0-.249-.249h-1.074q-.616,0-.616-.472a2,2,0,0,1,.223-.773l1.219-2.45a.717.717,0,0,0,.092-.3c0-.035-.07-.052-.21-.052H520.9a.459.459,0,1,1,0-.917m2.857.459h1.69V43.7a.405.405,0,0,1,.459-.459q.51,0,.511.459v.459h1.8q.917,0,.917.812v.93h.367a.458.458,0,1,1,0,.917h-.367v.917q0,.878-.917.878h-1.8v.97h2.109a.406.406,0,0,1,.459.459q0,.511-.459.511h-2.109v.878h2.516a.405.405,0,0,1,.458.459q0,.511-.458.511h-2.516v.97q0,.459-.511.459a.405.405,0,0,1-.459-.459V52.4H523.4q-.459,0-.459-.511a.406.406,0,0,1,.459-.459h2.044v-.878h-1.69q-.459,0-.459-.511a.406.406,0,0,1,.459-.459h1.69v-.97h-1.638q-.511,0-.511-.459,0-.419.511-.419h1.638v-.917h-2.306a.458.458,0,1,1,0-.917h2.306v-.878h-1.69q-.511,0-.511-.459,0-.406.511-.406m2.659.865V45.9h1.743V45.39q0-.3-.406-.367Zm0,1.795v.917h1.441q.3,0,.3-.354v-.564Z" transform="translate(-417.47 -34.881)" fill="#252525"/>
<path id="路径_174" data-name="路径 174" d="M601.82,43.86q.509,0,.509.47a.45.45,0,0,1-.509.509h-4.488a.547.547,0,0,1,.156.4V45.6h2.792q1.03,0,1.031.965v.718h.757a.457.457,0,0,1,0,.913h-.757v.77q0,.966-1.031.965h-2.61a4.034,4.034,0,0,0,.5.992,4.9,4.9,0,0,0,1.057,1.161,8.22,8.22,0,0,0,1.279.887,6.927,6.927,0,0,0,1.331.587,1.1,1.1,0,0,1,.639.352.586.586,0,0,1,.1.353.5.5,0,0,1-.561.561,3.513,3.513,0,0,1-.992-.339,9.027,9.027,0,0,1-1.552-.874,11.617,11.617,0,0,1-1.149-.939,6.1,6.1,0,0,1-.835-1.1v2.44q0,1.174-1.435,1.174a4.988,4.988,0,0,1-1.226-.209.507.507,0,0,1-.4-.509q.026-.483.457-.509a.892.892,0,0,1,.183.026,4.822,4.822,0,0,0,.887.17.449.449,0,0,0,.508-.509V49.927h-3.405q-.417,0-.417-.456t.417-.457h3.405v-.822h-3.77a.457.457,0,1,1,0-.913h3.77v-.77H593q-.4,0-.4-.457t.4-.457h3.457v-.352a.474.474,0,0,1,.157-.4h-4.279a.265.265,0,0,0-.3.3v3.875a20.713,20.713,0,0,1-.274,3.614,11.665,11.665,0,0,1-.548,1.944.543.543,0,0,1-.509.352q-.535-.026-.561-.509a25.775,25.775,0,0,0,.626-2.518A16.351,16.351,0,0,0,591,49.014V44.578q0-.717.77-.718h4.11a.483.483,0,0,1-.078-.248.45.45,0,0,1,.509-.509.9.9,0,0,1,.353.1q.143.117.274.235a3.914,3.914,0,0,1,.339.365.22.22,0,0,1,.013.052ZM595.7,51.454q.509,0,.509.561,0,.2-.457.574a13.053,13.053,0,0,1-1.344,1.018,10.525,10.525,0,0,1-1.279.77,1.305,1.305,0,0,1-.444.091.45.45,0,0,1-.509-.509q0-.274.352-.431a8.458,8.458,0,0,0,1.448-.835,11.143,11.143,0,0,0,1.409-1.083.452.452,0,0,1,.313-.156m-2.6-1.214a1,1,0,0,1,.352.091q.26.209.5.391.248.222.627.639a.813.813,0,0,1,.052.3q0,.4-.509.4a.639.639,0,0,1-.365-.1q-.34-.339-.574-.535-.2-.157-.444-.326a.577.577,0,0,1-.143-.352.45.45,0,0,1,.509-.509m4.384-3.731v.77h2.792v-.4q0-.3-.352-.365Zm0,1.683v.822h2.544c.165,0,.248-.1.248-.313v-.509Zm4.071,1.892a.4.4,0,0,1,.456.457,1.041,1.041,0,0,1-.2.561,8.481,8.481,0,0,1-1.435,1.018.663.663,0,0,1-.352.1q-.561,0-.561-.509a.662.662,0,0,1,.1-.352,8.327,8.327,0,0,0,1.579-1.07.463.463,0,0,1,.4-.209" transform="translate(-476.054 -34.771)" fill="#252525"/>
<path id="路径_175" data-name="路径 175" d="M3.625,6.084a2.037,2.037,0,0,1,.06-2.413c.986-1.363,2.875-1.626,4.4-1.613a9.6,9.6,0,0,1,2.885.4,4.809,4.809,0,0,1,1.848,1.147,7.853,7.853,0,0,0,3.371,2.012,9.119,9.119,0,0,0,4.164,2.059c.582.044.564-.29.381-.476-.729-.575-1.884-1.305-2.122-2.367.366-.547.593-2.216-1.376-3.292A10.944,10.944,0,0,0,12.912.374,24.56,24.56,0,0,0,9.1,0,13.054,13.054,0,0,0,3.212,1.243C.958,2.415.032,4.451.547,6.275c.828,2.931,4.67,3.774,7.412,4.359,2.162.461,5.015.942,6.828,2.19,1.126.775,1.782,1.71,1.475,2.706a2.527,2.527,0,0,1-1.627,1.48,15.021,15.021,0,0,1-7.277.352,15.757,15.757,0,0,1-5.4-1.831,1.215,1.215,0,0,0-1.626.222,1.78,1.78,0,0,0-.32,1.257,2.518,2.518,0,0,0,1.751,2.077,23.2,23.2,0,0,0,8.788,1.483,16.031,16.031,0,0,0,7.266-1.656c3.485-2.024,3.417-5.992-.093-8.02C15.108,9.383,11.18,8.558,8.326,8c-1.648-.319-3.746-.476-4.7-1.92" transform="translate(0 0)" fill="#252525" fill-rule="evenodd"/>
<path id="路径_176" data-name="路径 176" d="M143.656,11.318c6.6-3.007,10-4.054,11.119-4.5a1.976,1.976,0,0,0,1.146-2.545,1.014,1.014,0,0,0-1.253-.571,88.026,88.026,0,0,0-13.116,5.438c-1.588.782-2.06,2.329-2.06,4.216,0,1.964.489,3.383,2.046,4.2A83.552,83.552,0,0,0,154.687,23a1.006,1.006,0,0,0,1.222-.578,1.992,1.992,0,0,0-1.128-2.59c-1.2-.4-4.44-1.552-11.126-4.45-1.335-.579-2.068-.96-2.068-2.025s.722-1.424,2.069-2.037" transform="translate(-112.525 -2.945)" fill="#252525" fill-rule="evenodd"/>
<path id="路径_177" data-name="路径 177" d="M123.463,19.464c0,1.649,2.673,1.329,2.673,0V1.088c0-1.649-2.673-1.219-2.673,0Z" transform="translate(-99.595 -0.005)" fill="#252525" fill-rule="evenodd"/>
<path id="路径_178" data-name="路径 178" d="M249.654,8.73h-8.53a2.09,2.09,0,0,0-2.276,2.259.922.922,0,0,0,.9.886c.62,0,8.683.005,8.683,0-.363,3.1-3.24,5.7-8.28,5.7-5.692,0-9.631-3.56-9.631-7.3,0-3.634,3.793-7.253,9.627-7.253a10.64,10.64,0,0,1,8.034,3.255c1.031,1.159,1.751-1.744.823-2.686A12.433,12.433,0,0,0,240.147,0c-6.834,0-11.667,4.992-11.667,10.285,0,5.431,4.946,10.293,11.667,10.293a11.854,11.854,0,0,0,2.5-.28,9.954,9.954,0,0,0,8.048-10c0-.811-.254-1.572-1.04-1.572" transform="translate(-184.309 -0.002)" fill="#252525" fill-rule="evenodd"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 8.3 KiB

View File

@@ -1,57 +0,0 @@
<script setup>
/**
* Root App component | 根组件
* Provides naive-ui config and router view
*/
import { computed } from 'vue'
import { NConfigProvider, NMessageProvider, NDialogProvider, darkTheme } from 'naive-ui'
import { isDark } from './stores/theme'
// Naive UI theme based on dark mode | 基于深色模式的 Naive UI 主题
const theme = computed(() => isDark.value ? darkTheme : null)
// Global theme overrides | 全局主题覆盖
const themeOverrides = {
common: {
borderRadius: '12px',
borderRadiusSmall: '8px'
},
Dialog: {
borderRadius: '16px',
padding: '24px'
},
Modal: {
borderRadius: '16px',
padding: '24px'
},
Card: {
borderRadius: '16px',
padding: '24px'
},
Button: {
borderRadiusMedium: '10px',
borderRadiusSmall: '8px',
borderRadiusLarge: '12px',
heightMedium: '36px',
paddingMedium: '0 16px'
},
Input: {
borderRadius: '10px',
heightMedium: '36px'
}
}
</script>
<template>
<n-config-provider :theme="theme" :theme-overrides="themeOverrides">
<n-message-provider>
<n-dialog-provider>
<router-view />
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
<style>
/* Global app styles handled in style.css */
</style>

View File

@@ -1,35 +0,0 @@
/**
* Chat API | 对话 API
*/
import { request } from '@/utils'
// 对话补全
export const chatCompletions = (data) =>
request({
url: `/chat/completions`,
method: 'post',
data
})
// 流式对话补全
export const streamChatCompletions = async function* (data, signal, options = {}) {
const text = data?.messages?.at?.(-1)?.content || data?.goal || ''
const response = await fetch('/api/creative/copy', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ goal: typeof text === 'string' ? text : JSON.stringify(text), seconds: 15 }),
signal
})
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(error?.detail || error?.message || '提示词助手请求失败')
}
const json = await response.json()
const variant = json.variants?.[0]
yield variant?.image_prompt_en || variant?.video_prompt_en || ''
}

View File

@@ -1,17 +0,0 @@
/**
* Image API | 图片生成 API
*/
import { request } from '@/utils'
// 生成图片
export const generateImage = (data, options = {}) => {
const { requestType = 'json', endpoint = '/images/generations' } = options
return request({
url: endpoint,
method: 'post',
data,
headers: requestType === 'formdata' ? { 'Content-Type': 'multipart/form-data' } : {}
})
}

View File

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

View File

@@ -1,34 +0,0 @@
/**
* Model API | 模型 API
*/
import { request } from '@/utils'
// 分页查询模型列表
export const getModelPage = (params) =>
request({
url: `/model/page`,
method: 'get',
params: { enable: true, size: 1000, current: 1, ...params }
})
// 根据类型获取模型列表
export const getModelsByType = async (type) => {
const rsp = await getModelPage({ type, enable: true, size: 1000, current: 1 })
return rsp?.data?.records || []
}
// 根据全称获取模型详情
export const getModelByFullName = (fullName) =>
request({
url: `/model/fullName`,
method: 'get',
params: { fullName }
})
// 获取所有模型类型
export const getModelTypes = () =>
request({
url: `/model/types`,
method: 'get'
})

View File

@@ -1,45 +0,0 @@
/**
* Video API | 视频生成 API
*/
import { request } from '@/utils'
// 创建视频任务
export const createVideoTask = (data, options = {}) => {
const { endpoint = '/videos', requestType = 'json' } = options
return request({
url: endpoint,
method: 'post',
data,
headers: requestType === 'formdata'
? { 'Content-Type': 'multipart/form-data' }
: { 'Content-Type': 'application/json' }
})
}
// 查询视频任务状态
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('视频生成超时')
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 898 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 936 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 902 KiB

View File

@@ -1,358 +0,0 @@
<template>
<!-- API Settings Modal | API 设置弹窗 -->
<n-modal v-model:show="showModal" preset="card" title="API 设置" style="width: 560px;">
<n-tabs type="line" animated>
<!-- API 配置标签 -->
<n-tab-pane name="api" tab="API 配置">
<n-form ref="formRef" :model="formData" label-placement="left" label-width="80">
<n-form-item label="渠道" path="provider">
<n-select
v-model:value="formData.provider"
:options="providerOptions"
placeholder="选择 API 渠道"
/>
</n-form-item>
<n-form-item label="Base URL" path="baseUrl">
<n-input
v-model:value="formData.baseUrl"
placeholder="/api"
/>
</n-form-item>
<n-form-item label="API Key" path="apiKey">
<n-input
v-model:value="formData.apiKey"
type="password"
show-password-on="click"
placeholder="内部接口无需填写"
/>
</n-form-item>
<n-divider title-placement="left" class="!my-3">
<span class="text-xs text-[var(--text-secondary)]">端点路径</span>
</n-divider>
<div class="endpoint-list">
<div class="endpoint-item">
<span class="endpoint-label">问答</span>
<n-tag size="small" type="info" class="endpoint-tag">{{ currentEndpoints.chat }}</n-tag>
</div>
<div class="endpoint-item">
<span class="endpoint-label">生图</span>
<n-tag size="small" type="success" class="endpoint-tag">{{ currentEndpoints.image }}</n-tag>
</div>
<div class="endpoint-item">
<span class="endpoint-label">视频生成</span>
<n-tag size="small" type="warning" class="endpoint-tag">{{ currentEndpoints.video }}</n-tag>
</div>
<div class="endpoint-item">
<span class="endpoint-label">视频查询</span>
<n-tag size="small" type="warning" class="endpoint-tag">{{ currentEndpoints.videoQuery }}</n-tag>
</div>
</div>
<n-alert v-if="!isConfigured" type="warning" title="未配置" class="mb-4">
<div class="flex flex-col gap-2">
<p>当前使用 SKG 内部登录会话调用生成接口</p>
</div>
</n-alert>
<n-alert v-else type="success" title="已配置" class="mb-4">
API 已就绪可以使用 AI 功能
</n-alert>
</n-form>
</n-tab-pane>
<!-- 模型配置标签 -->
<n-tab-pane name="models" tab="模型配置">
<div class="model-config-section">
<!-- 问答模型 -->
<div class="model-group">
<div class="model-group-header">
<span class="model-group-title">问答模型</span>
<n-tag size="tiny" type="info">{{ allChatModels.length }} </n-tag>
</div>
<div class="model-input-row">
<n-input
v-model:value="newChatModel"
placeholder="输入模型名称,如 gpt-4o"
size="small"
@keyup.enter="handleAddChatModel"
/>
<n-button size="small" type="primary" @click="handleAddChatModel" :disabled="!newChatModel">
添加
</n-button>
</div>
<div class="model-tags">
<n-tag
v-for="model in allChatModels"
:key="model.key"
size="small"
:closable="model.isCustom"
:type="model.isCustom ? 'info' : 'default'"
@close="handleRemoveChatModel(model.key)"
>
{{ model.label }}
</n-tag>
</div>
</div>
<!-- 图片模型 -->
<div class="model-group">
<div class="model-group-header">
<span class="model-group-title">图片模型</span>
<n-tag size="tiny" type="success">{{ allImageModels.length }} </n-tag>
</div>
<div class="model-tags">
<n-tag
v-for="model in allImageModels"
:key="model.key"
size="small"
:closable="model.isCustom"
:type="model.isCustom ? 'success' : 'default'"
@close="handleRemoveImageModel(model.key)"
>
{{ model.label }}
</n-tag>
</div>
</div>
<!-- 视频模型 -->
<div class="model-group">
<div class="model-group-header">
<span class="model-group-title">视频模型</span>
<n-tag size="tiny" type="warning">{{ allVideoModels.length }} </n-tag>
</div>
<div class="model-tags">
<n-tag
v-for="model in allVideoModels"
:key="model.key"
size="small"
:closable="model.isCustom"
:type="model.isCustom ? 'warning' : 'default'"
@close="handleRemoveVideoModel(model.key)"
>
{{ model.label }}
</n-tag>
</div>
</div>
</div>
</n-tab-pane>
</n-tabs>
<template #footer>
<div class="flex justify-between items-center">
<span class="text-xs text-[var(--text-secondary)]">生成调用走当前登录会话无需个人 API Key</span>
<div class="flex gap-2">
<n-button @click="handleClear" tertiary>清除配置</n-button>
<n-button @click="showModal = false">取消</n-button>
<n-button type="primary" @click="handleSave">保存</n-button>
</div>
</div>
</template>
</n-modal>
</template>
<script setup>
/**
* API Settings Component | API 设置组件
* Modal for configuring API key, base URL, and custom models
*/
import { ref, reactive, watch, computed } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, NAlert, NDivider, NTag, NTabs, NTabPane, NSelect } from 'naive-ui'
import { useModelStore } from '../stores/pinia'
import { getProviderConfig } from '../config/providers'
// Props | 属性
const props = defineProps({
show: {
type: Boolean,
default: false
}
})
// Emits | 事件
const emit = defineEmits(['update:show', 'saved'])
// API Config 状态
const isConfigured = computed(() => !!modelStore.currentApiKey)
// Model Store (Pinia) | 模型配置 Store
const modelStore = useModelStore()
// Provider options for select | 渠道下拉选项
const providerOptions = modelStore.providerList.map(p => ({
label: p.label,
value: p.key
}))
// 当前渠道的端点路径
const currentEndpoints = computed(() => {
const config = getProviderConfig(formData.provider)
return config.endpoints || {
chat: '/chat/completions',
image: '/v1/images/generations',
video: '/v1/videos',
videoQuery: '/v1/videos/{taskId}'
}
})
// 全局模型列表(不区分渠道)
const allChatModels = computed(() => modelStore.allChatModels)
const allImageModels = computed(() => modelStore.allImageModels)
const allVideoModels = computed(() => modelStore.allVideoModels)
// Modal visibility | 弹窗可见性
const showModal = ref(props.show)
// Form data | 表单数据
const formData = reactive({
provider: modelStore.currentProvider,
apiKey: '',
baseUrl: ''
})
// New model inputs | 新模型输入
const newChatModel = ref('')
// 初始化或切换渠道时,更新 API 配置
const updateFormApiConfig = () => {
const provider = formData.provider
const config = getProviderConfig(provider)
formData.apiKey = modelStore.apiKeysByProvider[provider] || ''
formData.baseUrl = modelStore.baseUrlsByProvider[provider] || config.defaultBaseUrl || ''
}
// Watch prop changes | 监听属性变化
watch(() => props.show, (val) => {
showModal.value = val
if (val) {
formData.provider = modelStore.currentProvider
updateFormApiConfig()
}
})
// 监听渠道变化,更新表单中的 API 配置
watch(() => formData.provider, () => {
updateFormApiConfig()
})
// Watch modal changes | 监听弹窗变化
watch(showModal, (val) => {
emit('update:show', val)
})
// Handle add models | 处理添加模型
const handleAddChatModel = () => {
if (newChatModel.value.trim()) {
modelStore.addCustomChatModel(newChatModel.value.trim())
newChatModel.value = ''
}
}
// Handle remove models | 处理删除模型
const handleRemoveChatModel = (modelKey) => {
modelStore.removeCustomChatModel(modelKey)
}
const handleRemoveImageModel = (modelKey) => {
modelStore.removeCustomImageModel(modelKey)
}
const handleRemoveVideoModel = (modelKey) => {
modelStore.removeCustomVideoModel(modelKey)
}
// Handle save | 处理保存
const handleSave = () => {
if (formData.provider) {
modelStore.setProvider(formData.provider)
}
if (formData.apiKey) {
modelStore.setApiKeyByProvider(formData.provider, formData.apiKey)
}
if (formData.baseUrl) {
modelStore.setBaseUrlByProvider(formData.provider, formData.baseUrl)
}
showModal.value = false
emit('saved')
}
// Handle clear | 处理清除
const handleClear = () => {
modelStore.clearApiConfigByProvider(formData.provider)
modelStore.clearCustomModels()
formData.apiKey = ''
formData.baseUrl = ''
}
</script>
<style scoped>
.endpoint-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
padding: 12px;
background: var(--bg-secondary, #f5f5f5);
border-radius: 6px;
}
.endpoint-item {
display: flex;
align-items: center;
justify-content: space-between;
}
.endpoint-label {
font-size: 13px;
color: var(--text-secondary, #666);
min-width: 70px;
}
.endpoint-tag {
font-family: monospace;
font-size: 12px;
}
.model-config-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.model-group {
padding: 12px;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
}
.model-group-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.model-group-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary, #333);
}
.model-input-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.model-input-row .n-input {
flex: 1;
}
.model-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
</style>

View File

@@ -1,44 +0,0 @@
<template>
<!-- App Header | 应用头部 -->
<header class="flex items-center justify-between px-4 md:px-8 py-4 border-b border-[var(--border-color)]">
<!-- Left slot | 左侧插槽 -->
<div class="flex items-center gap-2">
<slot name="left">
<!-- Default: empty or logo -->
</slot>
</div>
<!-- Right section | 右侧区域 -->
<div class="flex items-center gap-4">
<!-- Center slot | 中间插槽 -->
<slot name="center"></slot>
<!-- Theme toggle | 主题切换 -->
<button
@click="toggleTheme"
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
>
<n-icon :size="20">
<SunnyOutline v-if="isDark" />
<MoonOutline v-else />
</n-icon>
</button>
<!-- Right slot | 右侧插槽 -->
<slot name="right"></slot>
</div>
</header>
</template>
<script setup>
/**
* App Header component | 应用头部组件
* Reusable header with slots for customization
*/
import { NIcon } from 'naive-ui'
import {
SunnyOutline,
MoonOutline
} from '@vicons/ionicons5'
import { isDark, toggleTheme } from '../stores/theme'
</script>

View File

@@ -1,120 +0,0 @@
<template>
<!-- Download Modal | 下载弹窗 -->
<n-modal v-model:show="visible" preset="card" title="素材下载" style="width: 600px; max-width: 90vw;">
<div class="space-y-4">
<!-- Stats | 统计 -->
<div class="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
<span>图片: {{ imageAssets.length }} </span>
<span>视频: {{ videoAssets.length }} </span>
</div>
<!-- Image assets | 图片素材 -->
<div v-if="imageAssets.length > 0">
<h4 class="text-sm font-medium mb-2">图片素材</h4>
<div class="grid grid-cols-4 gap-2 max-h-[200px] overflow-y-auto">
<div
v-for="(asset, idx) in imageAssets"
:key="idx"
class="relative aspect-square rounded-lg overflow-hidden bg-[var(--bg-tertiary)] cursor-pointer group"
@click="downloadAsset(asset)"
>
<img :src="asset.url" class="w-full h-full object-cover" />
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<n-icon :size="24" color="white"><DownloadOutline /></n-icon>
</div>
</div>
</div>
</div>
<!-- Video assets | 视频素材 -->
<div v-if="videoAssets.length > 0">
<h4 class="text-sm font-medium mb-2">视频素材</h4>
<div class="space-y-2 max-h-[200px] overflow-y-auto">
<div
v-for="(asset, idx) in videoAssets"
:key="idx"
class="flex items-center gap-3 p-2 rounded-lg bg-[var(--bg-tertiary)] hover:bg-[var(--bg-secondary)] cursor-pointer transition-colors"
@click="downloadAsset(asset)"
>
<div class="w-16 h-10 rounded bg-[var(--bg-primary)] flex items-center justify-center">
<n-icon :size="20"><VideocamOutline /></n-icon>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm truncate">{{ asset.label || '视频' }}</div>
<div class="text-xs text-[var(--text-secondary)]">{{ asset.duration ? asset.duration + 's' : '' }}</div>
</div>
<n-icon :size="20" class="text-[var(--text-secondary)]"><DownloadOutline /></n-icon>
</div>
</div>
</div>
<!-- Empty state | 空状态 -->
<div v-if="imageAssets.length === 0 && videoAssets.length === 0" class="text-center py-8 text-[var(--text-secondary)]">
暂无可下载的素材
</div>
</div>
<template #footer>
<div class="flex items-center justify-end">
<n-button @click="visible = false">关闭</n-button>
</div>
</template>
</n-modal>
</template>
<script setup>
/**
* Download Modal Component | 下载弹窗组件
* Display and download image/video assets from canvas nodes
*/
import { computed } from 'vue'
import { NModal, NButton, NIcon } from 'naive-ui'
import { DownloadOutline, VideocamOutline } from '@vicons/ionicons5'
import { nodes } from '../stores/canvas'
// Props | 属性
const props = defineProps({
show: {
type: Boolean,
default: false
}
})
// Emits | 事件
const emit = defineEmits(['update:show'])
// Visible state with v-model support | 支持 v-model 的显示状态
const visible = computed({
get: () => props.show,
set: (val) => emit('update:show', val)
})
// Get downloadable image assets | 获取可下载的图片素材
const imageAssets = computed(() => {
return nodes.value
.filter(n => n.type === 'image' && n.data?.url)
.map(n => ({
url: n.data.url,
label: n.data.label || '图片',
nodeId: n.id
}))
})
// Get downloadable video assets | 获取可下载的视频素材
const videoAssets = computed(() => {
return nodes.value
.filter(n => n.type === 'video' && n.data?.url)
.map(n => ({
url: n.data.url,
label: n.data.label || '视频',
duration: n.data.duration,
nodeId: n.id
}))
})
// Download single asset | 下载单个素材
const downloadAsset = (asset) => {
window.open(asset.url, '_blank')
window.$message?.success('已在新标签页打开')
}
</script>

View File

@@ -1,350 +0,0 @@
<template>
<n-popover
:show="isShow"
trigger="manual"
placement="bottom-start"
:x="position.x"
:y="position.y"
:style="{ padding: 0 }"
raw
:show-arrow="false"
@update:show="handleShowChange"
>
<div class="mentions-picker">
<div class="mentions-search" v-if="showSearch">
<n-input
v-model:value="searchQuery"
placeholder="搜索节点..."
size="small"
:autofocus="true"
@keydown="handleKeydown"
/>
</div>
<div class="mentions-list" v-if="filteredNodes.length > 0">
<div
v-for="(node, index) in filteredNodes"
:key="node.id"
class="mentions-item"
:class="{ active: index === selectedIndex }"
@click="selectNode(node)"
@mouseenter="selectedIndex = index"
>
<!-- ImageNode 显示图片预览 -->
<div v-if="node.type === 'image'" class="mentions-item-image">
<img v-if="node.data?.url" :src="node.data.url" :alt="node.data.publicProps?.name" />
<div v-else class="mentions-item-image-placeholder">
<n-icon :size="20"><ImageOutline /></n-icon>
</div>
</div>
<!-- ImageNode 显示图标 -->
<div v-else class="mentions-item-icon">
<n-icon :component="getNodeIcon(node.type)" />
</div>
<div class="mentions-item-content">
<div class="mentions-item-label">
<!-- ImageNode 优先显示 publicProps.name -->
{{ node.type === 'image' ? (node.data?.publicProps?.name || node.data?.label || '未命名') : (node.data?.label || node.id) }}
</div>
<div class="mentions-item-id">{{ node.id }}</div>
</div>
</div>
</div>
<div class="mentions-empty" v-else>
<span>没有可引用的节点</span>
</div>
</div>
</n-popover>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { NPopover, NInput, NIcon } from 'naive-ui'
import { ImageOutline } from '@vicons/ionicons5'
import { nodes } from '@/stores/canvas'
const props = defineProps({
// 可见性
visible: {
type: Boolean,
default: false
},
// 位置
position: {
type: Object,
default: () => ({ x: 0, y: 0 })
},
// 上下文类型:'text' | 'llmConfig'
context: {
type: String,
default: 'text'
},
// 是否显示搜索框
showSearch: {
type: Boolean,
default: true
},
// 限制只显示已连接的节点 ID 列表(可选)
connectedNodeIds: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:visible', 'select'])
const searchQuery = ref('')
const selectedIndex = ref(0)
const isShow = ref(false)
// Sync with prop | 与 prop 同步
watch(() => props.visible, (newVal) => {
isShow.value = newVal
}, { immediate: true })
// Handle show change | 处理显示变化
const handleShowChange = (val) => {
isShow.value = val
if (!val) {
emit('update:visible', false)
}
}
// 根据上下文获取可引用的节点类型
const targetTypes = computed(() => {
if (props.context === 'llmConfig') {
return ['text']
}
return ['image']
})
// 检查节点是否公开(仅 ImageNode 需要检查 publicProps.name
const isNodePublic = (node) => {
if (node.type === 'image') {
// ImageNode 需要有 publicProps.name 才算公开
return node.data?.publicProps?.name && node.data.publicProps.name !== ''
}
// 其他节点类型默认公开
return true
}
// 可引用的节点列表
const availableNodes = computed(() => {
return nodes.value.filter(node => {
// 先检查类型
if (!targetTypes.value.includes(node.type)) return false
// 再检查是否公开
if (!isNodePublic(node)) return false
// 如果指定了 connectedNodeIds则只显示已连接的节点
if (props.connectedNodeIds.length > 0) {
return props.connectedNodeIds.includes(node.id)
}
return true
})
})
// 过滤后的节点列表
const filteredNodes = computed(() => {
if (!searchQuery.value) {
return availableNodes.value
}
const query = searchQuery.value.toLowerCase()
return availableNodes.value.filter(node => {
const label = node.data?.label?.toLowerCase() || ''
const name = node.data?.publicProps?.name?.toLowerCase() || ''
const id = node.id.toLowerCase()
return label.includes(query) || name.includes(query) || id.includes(query)
})
})
// 监听搜索变化,重置选中索引
watch(searchQuery, () => {
selectedIndex.value = 0
})
// 监听可见性变化,重置搜索
watch(() => props.visible, (newVal) => {
if (newVal) {
searchQuery.value = ''
selectedIndex.value = 0
// 添加全局键盘事件监听
document.addEventListener('keydown', handleGlobalKeydown)
} else {
// 移除全局键盘事件监听
document.removeEventListener('keydown', handleGlobalKeydown)
}
})
// 全局键盘事件处理(用于在选择器显示时处理 Enter/Escape
function handleGlobalKeydown(event) {
if (!isShow.value) return
if (event.key === 'Enter') {
event.preventDefault()
if (filteredNodes.value[selectedIndex.value]) {
selectNode(filteredNodes.value[selectedIndex.value])
}
} else if (event.key === 'Escape') {
event.preventDefault()
isShow.value = false
emit('update:visible', false)
} else if (event.key === 'ArrowDown') {
event.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, filteredNodes.value.length - 1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
}
}
// 获取节点图标
function getNodeIcon(type) {
const icons = {
image: '📷',
text: '📝',
llmConfig: '🤖',
imageConfig: '🎨',
video: '🎬',
videoConfig: '🎥'
}
return icons[type] || '📄'
}
// 选择节点
function selectNode(node) {
// ImageNode 优先使用 publicProps.name其他节点使用 label
const displayName = node.type === 'image'
? (node.data?.publicProps?.name || node.data?.label || node.id)
: (node.data?.label || node.id)
emit('select', {
nodeId: node.id,
label: displayName,
type: node.type
})
isShow.value = false
emit('update:visible', false)
}
// 键盘导航
function handleKeydown(event) {
const { key } = event
if (key === 'ArrowDown') {
event.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, filteredNodes.value.length - 1)
} else if (key === 'ArrowUp') {
event.preventDefault()
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
} else if (key === 'Enter') {
event.preventDefault()
if (filteredNodes.value[selectedIndex.value]) {
selectNode(filteredNodes.value[selectedIndex.value])
}
} else if (key === 'Escape') {
event.preventDefault()
isShow.value = false
emit('update:visible', false)
}
}
</script>
<style scoped>
.mentions-picker {
width: 240px;
max-height: 300px;
background: var(--card-bg, #fff);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
overflow: hidden;
}
.mentions-search {
padding: 8px;
border-bottom: 1px solid var(--border-color, #eee);
}
.mentions-list {
max-height: 240px;
overflow-y: auto;
}
.mentions-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s;
}
.mentions-item:hover,
.mentions-item.active {
background: var(--hover-bg, #f5f5f5);
}
.mentions-item-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
background: var(--bg-color, #f0f0f0);
border-radius: 6px;
margin-right: 10px;
}
.mentions-item-image {
width: 32px;
height: 32px;
border-radius: 6px;
overflow: hidden;
margin-right: 10px;
flex-shrink: 0;
}
.mentions-item-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.mentions-item-image-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-color, #f0f0f0);
color: var(--text-secondary, #999);
}
.mentions-item-content {
flex: 1;
min-width: 0;
}
.mentions-item-label {
font-size: 13px;
font-weight: 500;
color: var(--text-color, #333);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mentions-item-id {
font-size: 11px;
color: var(--text-secondary, #999);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mentions-empty {
padding: 20px;
text-align: center;
color: var(--text-secondary, #999);
font-size: 13px;
}
</style>

View File

@@ -1,312 +0,0 @@
<template>
<!-- Workflow panel | 工作流浮动面板 -->
<Transition name="panel-slide">
<div v-if="visible" class="workflow-panel" v-click-outside="handleClickOutside">
<!-- Header | 头部 -->
<div class="panel-header">
<div class="panel-tabs">
<span
class="tab-item"
:class="{ active: activeTab === 'public' }"
@click="activeTab = 'public'"
>公共工作流</span>
<span
class="tab-item"
:class="{ active: activeTab === 'my' }"
@click="activeTab = 'my'"
>我的工作流</span>
</div>
<button class="expand-btn" @click="visible = false">
<n-icon :size="16"><CloseOutline /></n-icon>
</button>
</div>
<!-- Content | 内容 -->
<div class="panel-content">
<!-- Public workflows | 公共工作流 -->
<div v-if="activeTab === 'public'" class="workflow-grid">
<div
v-for="workflow in publicWorkflows"
:key="workflow.id"
class="workflow-card"
@click="handleAddWorkflow(workflow)"
>
<div class="card-cover">
<img v-if="workflow.cover" :src="workflow.cover" :alt="workflow.name" class="cover-img" />
<n-icon v-else :size="36" class="cover-icon">
<component :is="getIcon(workflow.icon)" />
</n-icon>
</div>
<div class="card-title">{{ workflow.name }}</div>
</div>
</div>
<!-- My workflows | 我的工作流 -->
<div v-else class="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>
</Transition>
</template>
<script setup>
/**
* Workflow Panel Component | 工作流面板组件
* 显示工作流模板列表,支持一键添加到画布
*/
import { computed, ref } from 'vue'
import { NIcon } from 'naive-ui'
import {
CloseOutline,
GridOutline,
ImageOutline,
VideocamOutline,
FolderOpenOutline,
BookOutline,
PersonOutline,
CartOutline,
ChatbubbleOutline
} from '@vicons/ionicons5'
import { WORKFLOW_TEMPLATES } from '../config/workflows'
const props = defineProps({
show: Boolean
})
const emit = defineEmits(['update:show', 'add-workflow'])
// Active tab | 当前标签
const activeTab = ref('public')
// Visible state | 显示状态
const visible = computed({
get: () => props.show,
set: (val) => emit('update:show', val)
})
// Public workflows | 公共工作流
const publicWorkflows = computed(() => WORKFLOW_TEMPLATES)
// Icon mapping | 图标映射
const iconMap = {
GridOutline,
ImageOutline,
VideocamOutline,
BookOutline,
PersonOutline,
ShoppingOutline: CartOutline,
ChatbubbleOutline
}
const getIcon = (iconName) => {
return iconMap[iconName] || GridOutline
}
// Handle add workflow | 处理添加工作流
const handleAddWorkflow = (workflow) => {
// 直接添加工作流,节点内容由用户自己填写
emit('add-workflow', { workflow, options: {} })
visible.value = false
}
// Handle click outside | 点击外部关闭
const handleClickOutside = () => {
visible.value = false
}
// Custom directive | 自定义指令
const vClickOutside = {
mounted(el, binding) {
el._clickOutside = (e) => {
if (!el.contains(e.target)) {
binding.value()
}
}
setTimeout(() => {
document.addEventListener('click', el._clickOutside)
}, 0)
},
unmounted(el) {
document.removeEventListener('click', el._clickOutside)
}
}
</script>
<style scoped>
/* Panel container | 面板容器 */
.workflow-panel {
position: fixed;
left: 72px;
top: 100px;
width: 520px;
max-height: 70vh;
background: var(--bg-secondary);
backdrop-filter: blur(12px);
border-radius: 16px;
border: 1px solid var(--border-color);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
z-index: 100;
overflow: hidden;
display: flex;
flex-direction: column;
}
:global(.dark) .workflow-panel {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
/* Header | 头部 */
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border-color);
}
.panel-tabs {
display: flex;
gap: 24px;
}
.tab-item {
font-size: 15px;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s;
padding-bottom: 4px;
}
.tab-item:hover {
color: var(--text-primary);
}
.tab-item.active {
color: var(--text-primary);
font-weight: 500;
}
.expand-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
border: none;
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.expand-btn:hover {
background: var(--border-color);
color: var(--text-primary);
}
/* Content | 内容区 */
.panel-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
/* Workflow grid | 工作流网格 */
.workflow-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
/* Workflow card | 工作流卡片 */
.workflow-card {
cursor: pointer;
transition: transform 0.2s;
}
.workflow-card:hover {
transform: translateY(-2px);
}
.workflow-card:hover .card-cover {
border-color: var(--accent-color);
}
.card-cover {
aspect-ratio: 1;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
transition: border-color 0.2s;
overflow: hidden;
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-icon {
color: var(--text-secondary);
}
.card-title {
margin-top: 10px;
font-size: 13px;
color: var(--text-primary);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Empty state | 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
text-align: center;
color: var(--text-secondary);
}
/* Transition | 过渡动画 */
.panel-slide-enter-active,
.panel-slide-leave-active {
transition: all 0.25s ease;
}
.panel-slide-enter-from,
.panel-slide-leave-to {
opacity: 0;
transform: translateX(-12px);
}
/* Scrollbar | 滚动条 */
.panel-content::-webkit-scrollbar {
width: 6px;
}
.panel-content::-webkit-scrollbar-track {
background: transparent;
}
.panel-content::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.panel-content::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
</style>

View File

@@ -1,150 +0,0 @@
<template>
<!-- Custom edge with image order selector | 带图片顺序选择器的自定义边 -->
<BaseEdge :path="path" :style="edgeStyle" />
<!-- Edge label with order selector | 带顺序选择器的边标签 -->
<EdgeLabelRenderer>
<div
:style="{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all'
}"
class="nodrag nopan"
>
<n-dropdown
:options="orderOptions"
@select="handleOrderSelect"
size="small"
>
<button
class="flex items-center justify-center w-6 h-6 text-xs font-bold rounded-full bg-blue-500 text-white border-2 border-white shadow-md hover:scale-110 transition-transform"
>
{{ currentOrder }}
</button>
</n-dropdown>
</div>
</EdgeLabelRenderer>
</template>
<script setup>
import { computed } from 'vue'
import { BaseEdge, EdgeLabelRenderer, getBezierPath, useVueFlow } from '@vue-flow/core'
import { NDropdown } from 'naive-ui'
import { edges, nodes } from '../../stores/canvas'
// Get VueFlow instance | 获取 VueFlow 实例
const { updateEdgeData } = useVueFlow()
const props = defineProps({
id: String,
source: String,
target: String,
sourceX: Number,
sourceY: Number,
targetX: Number,
targetY: Number,
sourcePosition: String,
targetPosition: String,
data: Object,
markerEnd: String,
style: Object
})
// Order labels | 顺序标签
const orderLabels = [
{ label: '① 第一张', key: 1 },
{ label: '② 第二张', key: 2 },
{ label: '③ 第三张', key: 3 },
{ label: '④ 第四张', key: 4 },
{ label: '⑤ 第五张', key: 5 }
]
// Dynamic order options based on connected edges count + @ mentioned images | 基于连接边数量和@提及图片的动态顺序选项
const orderOptions = computed(() => {
// Get all imageOrder edges connected to the same target | 获取连接到同一目标的图片边
const sameTargetImageEdges = edges.value.filter(edge =>
edge.target === props.target &&
edge.type === 'imageOrder'
)
const edgeCount = sameTargetImageEdges.length || 1
// Get @ mentioned image count from connected TextNodes | 获取已连接 TextNode 中 @ 提及的图片数量
let mentionedImageCount = 0
const connectedTextEdges = edges.value.filter(e => e.target === props.target)
for (const edge of connectedTextEdges) {
const sourceNode = nodes.value.find(n => n.id === edge.source)
if (sourceNode?.type === 'text') {
const content = sourceNode.data?.content || ''
// Count @ mentions of image nodes | 统计图片节点的 @ 提及
const mentionRegex = /@\[([^\]|]+)(?:\|([^\]]+))?\]/g
let match
while ((match = mentionRegex.exec(content)) !== null) {
const mentionedNode = nodes.value.find(n => n.id === match[1])
if (mentionedNode?.type === 'image') {
mentionedImageCount++
}
}
}
}
// Minimum order is mentionedImageCount + 1 | 最小顺序是 @ 提及图片数量 + 1
const minOrder = mentionedImageCount + 1
// Total count = edge count + mentioned image count | 总数量 = 边数量 + @ 提及图片数量
const totalCount = edgeCount + mentionedImageCount
const maxOrder = Math.min(totalCount, 5)
// Return options from minOrder to maxOrder | 返回从 minOrder 到 maxOrder 的选项
return orderLabels.filter(label => label.key >= minOrder && label.key <= maxOrder)
})
// Current order from edge data | 从边数据获取当前顺序
const currentOrder = computed(() => props.data?.imageOrder || 1)
// Calculate bezier path | 计算贝塞尔路径
const path = computed(() => {
const [edgePath] = getBezierPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
targetX: props.targetX,
targetY: props.targetY,
sourcePosition: props.sourcePosition,
targetPosition: props.targetPosition
})
return edgePath
})
// Label position (center of edge) | 标签位置(边的中心)
const labelX = computed(() => (props.sourceX + props.targetX) / 2)
const labelY = computed(() => (props.sourceY + props.targetY) / 2)
// Edge style | 边样式
const edgeStyle = computed(() => ({
stroke: '#3b82f6',
strokeWidth: 2,
...props.style
}))
// Handle order selection | 处理顺序选择
const handleOrderSelect = (newOrder) => {
// Get all image edges connected to the same target | 获取连接到同一目标的所有图片边
const sameTargetImageEdges = edges.value.filter(edge =>
edge.target === props.target &&
edge.type === 'imageOrder'
)
// Find edge currently using this order | 查找当前使用此顺序的边
const edgeWithSameOrder = sameTargetImageEdges.find(edge =>
edge.id !== props.id &&
edge.data?.imageOrder === newOrder
)
// If another edge has this order, swap with current | 如果另一条边有此顺序,则交换
if (edgeWithSameOrder) {
updateEdgeData(edgeWithSameOrder.id, { imageOrder: currentOrder.value })
}
// Update current edge order | 更新当前边顺序
updateEdgeData(props.id, { imageOrder: newOrder })
}
</script>

View File

@@ -1,117 +0,0 @@
<template>
<!-- Custom edge with image role selector | 带图片角色选择器的自定义边 -->
<BaseEdge :path="path" :style="edgeStyle" />
<!-- Edge label with role dropdown | 带角色下拉的边标签 -->
<EdgeLabelRenderer>
<div
:style="{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all'
}"
class="nodrag nopan"
>
<n-dropdown
:options="imageRoleOptions"
@select="handleRoleSelect"
size="small"
>
<button
class="flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 shadow-sm hover:shadow transition-shadow"
>
{{ currentRoleLabel }}
<n-icon :size="10"><ChevronDownOutline /></n-icon>
</button>
</n-dropdown>
</div>
</EdgeLabelRenderer>
</template>
<script setup>
import { computed } from 'vue'
import { BaseEdge, EdgeLabelRenderer, getBezierPath, useVueFlow } from '@vue-flow/core'
import { NDropdown, NIcon } from 'naive-ui'
import { ChevronDownOutline } from '@vicons/ionicons5'
import { edges } from '../../stores/canvas'
// Get VueFlow instance | 获取 VueFlow 实例
const { updateEdgeData } = useVueFlow()
const props = defineProps({
id: String,
source: String,
target: String,
sourceX: Number,
sourceY: Number,
targetX: Number,
targetY: Number,
sourcePosition: String,
targetPosition: String,
data: Object,
markerEnd: String,
style: Object
})
// Image role options | 图片角色选项
const imageRoleOptions = [
{ label: '首帧', key: 'first_frame_image' },
{ label: '尾帧', key: 'last_frame_image' },
{ label: '参考图', key: 'input_reference' }
]
// Current role from edge data | 从边数据获取当前角色
const currentRole = computed(() => props.data?.imageRole || 'first_frame_image')
// Current role label | 当前角色标签
const currentRoleLabel = computed(() => {
const option = imageRoleOptions.find(o => o.key === currentRole.value)
return option?.label || '首帧'
})
// Calculate bezier path | 计算贝塞尔路径
const path = computed(() => {
const [edgePath] = getBezierPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
targetX: props.targetX,
targetY: props.targetY,
sourcePosition: props.sourcePosition,
targetPosition: props.targetPosition
})
return edgePath
})
// Label position (center of edge) | 标签位置(边的中心)
const labelX = computed(() => (props.sourceX + props.targetX) / 2)
const labelY = computed(() => (props.sourceY + props.targetY) / 2)
// Edge style | 边样式
const edgeStyle = computed(() => ({
stroke: '#6366f1',
strokeWidth: 2,
...props.style
}))
// Handle role selection | 处理角色选择
const handleRoleSelect = (role) => {
// If selecting first_frame or last_frame, ensure uniqueness | 如果选择首帧或尾帧,确保唯一性
if (role === 'first_frame_image' || role === 'last_frame_image') {
// Find other edges connected to the same target with the same role | 查找连接到同一目标且具有相同角色的其他边
const sameTargetEdges = edges.value.filter(edge =>
edge.target === props.target &&
edge.id !== props.id &&
edge.data?.imageRole === role
)
// Auto-switch the other edge to the opposite role | 自动切换其他边到相反角色
sameTargetEdges.forEach(edge => {
const oppositeRole = role === 'first_frame_image' ? 'last_frame_image' : 'first_frame_image'
updateEdgeData(edge.id, { imageRole: oppositeRole })
})
}
// Update current edge role | 更新当前边角色
updateEdgeData(props.id, { imageRole: role })
}
</script>

View File

@@ -1,123 +0,0 @@
<template>
<!-- Custom edge with prompt order selector | 带提示词顺序选择器的自定义边 -->
<BaseEdge :path="path" :style="edgeStyle" />
<!-- Edge label with order selector | 带顺序选择器的边标签 -->
<EdgeLabelRenderer>
<div
:style="{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all'
}"
class="nodrag nopan"
>
<n-dropdown
:options="orderOptions"
@select="handleOrderSelect"
size="small"
>
<button
class="flex items-center justify-center w-6 h-6 text-xs font-bold rounded-full bg-[var(--accent-color)] text-white border-2 border-white shadow-md hover:scale-110 transition-transform"
>
{{ currentOrder }}
</button>
</n-dropdown>
</div>
</EdgeLabelRenderer>
</template>
<script setup>
import { computed } from 'vue'
import { BaseEdge, EdgeLabelRenderer, getBezierPath, useVueFlow } from '@vue-flow/core'
import { NDropdown } from 'naive-ui'
import { edges } from '../../stores/canvas'
// Get VueFlow instance | 获取 VueFlow 实例
const { updateEdgeData } = useVueFlow()
const props = defineProps({
id: String,
source: String,
target: String,
sourceX: Number,
sourceY: Number,
targetX: Number,
targetY: Number,
sourcePosition: String,
targetPosition: String,
data: Object,
markerEnd: String,
style: Object
})
// Order labels | 顺序标签
const orderLabels = [
{ label: '① 第一个', key: 1 },
{ label: '② 第二个', key: 2 },
{ label: '③ 第三个', key: 3 },
{ label: '④ 第四个', key: 4 },
{ label: '⑤ 第五个', key: 5 }
]
// Dynamic order options based on connected edges count | 基于连接边数量的动态顺序选项
const orderOptions = computed(() => {
// Get all promptOrder edges connected to the same target | 获取连接到同一目标的所有文本边
const sameTargetTextEdges = edges.value.filter(edge =>
edge.target === props.target &&
edge.type === 'promptOrder'
)
const count = sameTargetTextEdges.length || 1
return orderLabels.slice(0, count)
})
// Current order from edge data | 从边数据获取当前顺序
const currentOrder = computed(() => props.data?.promptOrder || 1)
// Calculate bezier path | 计算贝塞尔路径
const path = computed(() => {
const [edgePath] = getBezierPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
targetX: props.targetX,
targetY: props.targetY,
sourcePosition: props.sourcePosition,
targetPosition: props.targetPosition
})
return edgePath
})
// Label position (center of edge) | 标签位置(边的中心)
const labelX = computed(() => (props.sourceX + props.targetX) / 2)
const labelY = computed(() => (props.sourceY + props.targetY) / 2)
// Edge style | 边样式
const edgeStyle = computed(() => ({
stroke: '#10b981',
strokeWidth: 2,
...props.style
}))
// Handle order selection | 处理顺序选择
const handleOrderSelect = (newOrder) => {
// Get all text edges connected to the same target | 获取连接到同一目标的所有文本边
const sameTargetTextEdges = edges.value.filter(edge =>
edge.target === props.target &&
edge.type === 'promptOrder'
)
// Find edge currently using this order | 查找当前使用此顺序的边
const edgeWithSameOrder = sameTargetTextEdges.find(edge =>
edge.id !== props.id &&
edge.data?.promptOrder === newOrder
)
// If another edge has this order, swap with current | 如果另一条边有此顺序,则交换
if (edgeWithSameOrder) {
updateEdgeData(edgeWithSameOrder.id, { promptOrder: currentOrder.value })
}
// Update current edge order | 更新当前边顺序
updateEdgeData(props.id, { promptOrder: newOrder })
}
</script>

View File

@@ -1,783 +0,0 @@
<template>
<!-- Image config node wrapper | 文生图配置节点包裹层 -->
<div class="image-config-node-wrapper" @mouseenter="showHandleMenu = true" @mouseleave="showHandleMenu = false">
<!-- Image config node | 文生图配置节点 -->
<div
class="image-config-node bg-[var(--bg-secondary)] rounded-xl border min-w-[300px] transition-all duration-200"
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'">
<!-- Header | 头部 -->
<div class="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)]">
<span
v-if="!isEditingLabel"
@dblclick="startEditLabel"
class="text-sm font-medium text-[var(--text-secondary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
title="双击编辑名称"
>{{ data.label }}</span>
<input
v-else
ref="labelInputRef"
v-model="editingLabelValue"
@blur="finishEditLabel"
@keydown.enter="finishEditLabel"
@keydown.escape="cancelEditLabel"
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] px-1 rounded outline-none border border-blue-500"
/>
<div class="flex items-center gap-1">
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="复制节点">
<n-icon :size="14">
<CopyOutline />
</n-icon>
</button>
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="删除节点">
<n-icon :size="14">
<TrashOutline />
</n-icon>
</button>
</div>
</div>
<!-- Config options | 配置选项 -->
<div class="p-3 space-y-3">
<!-- Model selector | 模型选择 -->
<div class="flex items-center justify-between">
<span class="text-xs text-[var(--text-secondary)]">模型</span>
<n-dropdown :options="modelOptions" @select="handleModelSelect">
<button class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
{{ displayModelName }}
<n-icon :size="12"><ChevronDownOutline /></n-icon>
</button>
</n-dropdown>
</div>
<!-- Quality selector | 画质选择 -->
<div v-if="hasQualityOptions" class="flex items-center justify-between">
<span class="text-xs text-[var(--text-secondary)]">画质</span>
<n-dropdown :options="qualityOptions" @select="handleQualitySelect">
<button class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
{{ displayQuality }}
<n-icon :size="12"><ChevronForwardOutline /></n-icon>
</button>
</n-dropdown>
</div>
<!-- Size selector | 尺寸选择 -->
<div v-if="hasSizeOptions" class="flex items-center justify-between">
<span class="text-xs text-[var(--text-secondary)]">尺寸</span>
<div class="flex items-center gap-2">
<n-dropdown :options="sizeOptions" @select="handleSizeSelect">
<button
class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
{{ displaySize }}
<n-icon :size="12">
<ChevronForwardOutline />
</n-icon>
</button>
</n-dropdown>
</div>
</div>
<!-- Model tips | 模型提示 -->
<div v-if="currentModelConfig?.tips" class="text-xs text-[var(--text-tertiary)] bg-[var(--bg-tertiary)] rounded px-2 py-1">
💡 {{ currentModelConfig.tips }}
</div>
<!-- Connected inputs indicator | 连接输入指示 -->
<div
class="flex items-center gap-2 text-xs text-[var(--text-secondary)] py-1 border-t border-[var(--border-color)]">
<span class="px-2 py-0.5 rounded-full"
:class="connectedPrompts.length > 0 ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
提示词 {{ connectedPrompts.length > 0 ? `${connectedPrompts.length}` : '○' }}
</span>
<span class="px-2 py-0.5 rounded-full"
:class="connectedRefImages.length > 0 ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
参考图 {{ connectedRefImages.length > 0 ? `${connectedRefImages.length}` : '○' }}
</span>
</div>
<!-- Generate button | 生成按钮 -->
<div v-if="hasConnectedImageWithContent" class="flex gap-2">
<!-- Create new (primary) | 新建节点主按钮 -->
<button @click="handleGenerate('new')" :disabled="loading || !isConfigured"
class="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<n-spin v-if="loading" :size="14" />
<template v-else>
<n-icon :size="14"><AddOutline /></n-icon>
新建生成
</template>
</button>
<!-- Replace existing (secondary) | 替换现有次按钮 -->
<button @click="handleGenerate('replace')" :disabled="loading || !isConfigured"
class="flex-shrink-0 flex items-center justify-center gap-1 py-2 px-2.5 rounded-lg border border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-color)] hover:text-[var(--accent-color)] text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<n-spin v-if="loading" :size="14" />
<template v-else>
<n-icon :size="14"><RefreshOutline /></n-icon>
替换
</template>
</button>
</div>
<button v-else @click="handleGenerate('auto')" :disabled="loading || !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">
<n-spin v-if="loading" :size="14" />
<template v-else>
<span
class="text-[var(--accent-color)] bg-white rounded-full w-4 h-4 flex items-center justify-center text-xs"></span>
立即生成
</template>
</button>
<!-- Error message | 错误信息 -->
<div v-if="error" class="text-xs text-red-500 mt-2">
{{ error.message || '生成失败' }}
</div>
<!-- Generated images preview | 生成图片预览 -->
<!-- <div v-if="generatedImages.length > 0" class="mt-3 space-y-2">
<div class="text-xs text-[var(--text-secondary)]">生成结果:</div>
<div class="grid grid-cols-2 gap-2 max-w-[240px]">
<div
v-for="(img, idx) in generatedImages"
:key="idx"
class="aspect-square rounded-lg overflow-hidden bg-[var(--bg-tertiary)] max-w-[110px]"
>
<img :src="img.url" class="w-full h-full object-cover" />
</div>
</div>
</div> -->
</div>
<!-- Handles | 连接点 -->
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
<NodeHandleMenu :nodeId="id" nodeType="imageConfig" :visible="showHandleMenu" :operations="operations" @select="handleSelect" />
</div>
</div>
</template>
<script setup>
/**
* Image config node component | 文生图配置节点组件
* Configuration panel for text-to-image generation with API integration
*/
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { Handle, Position, useVueFlow } from '@vue-flow/core'
import { NIcon, NDropdown, NSpin } from 'naive-ui'
import { ChevronDownOutline, ChevronForwardOutline, CopyOutline, TrashOutline, RefreshOutline, AddOutline, ImageOutline, CreateOutline } from '@vicons/ionicons5'
import { useImageGeneration } from '../../hooks'
import { updateNode, addNode, addEdge, nodes, edges, duplicateNode, removeNode } from '../../stores/canvas'
import NodeHandleMenu from './NodeHandleMenu.vue'
import { useModelStore } from '../../stores/pinia'
import { getModelSizeOptions, getModelQualityOptions, getModelConfig, DEFAULT_IMAGE_MODEL, DEFAULT_IMAGE_SIZE } from '../../stores/models'
import { parseMentions } from '../../hooks/useNodeRef'
// 使用 Pinia store 获取模型选项(根据渠道过滤)
const modelStore = useModelStore()
const props = defineProps({
id: String,
data: Object
})
// Vue Flow instance | Vue Flow 实例
const { updateNodeInternals } = useVueFlow()
// API config state | API 配置状态
const isConfigured = computed(() => !!modelStore.currentApiKey)
// Image generation hook | 图片生成 hook
const { loading, error, images: generatedImages, generate } = useImageGeneration()
// Local state | 本地状态
const showHandleMenu = ref(false)
const localModel = ref(props.data?.model || DEFAULT_IMAGE_MODEL)
const localSize = ref(props.data?.size || DEFAULT_IMAGE_SIZE)
const localQuality = ref(props.data?.quality || 'standard')
// Label editing state | Label 编辑状态
const isEditingLabel = ref(false)
const editingLabelValue = ref('')
const labelInputRef = ref(null)
// ImageConfig node menu operations | 图片配置节点菜单操作
const operations = [
// { type: 'imageConfig', label: '图生图', icon: ImageOutline, action: 'imageConfig_imageConfig' }
]
// Handle menu select | 处理菜单选择
const handleSelect = (item) => {
const action = item.action
if (action === 'imageConfig_imageConfig') {
// Image-to-image (create new image node for editing) | 图生图(创建新图片节点用于编辑)
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
// Create new image node for editing
const imageNodeId = addNode('image', { x: nodeX + 400, y: nodeY }, {
label: '图片编辑'
})
// Connect current config to new image node
addEdge({
source: props.id,
target: imageNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
setTimeout(() => updateNodeInternals(imageNodeId), 50)
window.$message?.success('已创建图片编辑节点')
}
}
// Get current model config | 获取当前模型配置
const currentModelConfig = computed(() => getModelConfig(localModel.value))
// Model options from Pinia store (filtered by provider) | 从 Pinia store 获取模型选项(根据渠道过滤)
const modelOptions = computed(() => modelStore.allImageModelOptions)
// Display model name | 显示模型名称
const displayModelName = computed(() => {
const model = modelOptions.value.find(m => m.key === localModel.value)
// 如果当前模型不在选项中,尝试从 allImageModels 找到
if (!model) {
const allModel = modelStore.allImageModels.find(m => m.key === localModel.value)
return allModel?.label || localModel.value || '选择模型'
}
return model?.label || localModel.value || '选择模型'
})
// Quality options based on model | 基于模型的画质选项
const qualityOptions = computed(() => {
return getModelQualityOptions(localModel.value)
})
// Check if model has quality options | 检查模型是否有画质选项
const hasQualityOptions = computed(() => {
return qualityOptions.value && qualityOptions.value.length > 0
})
// Display quality | 显示画质
const displayQuality = computed(() => {
const option = qualityOptions.value.find(o => o.key === localQuality.value)
return option?.label || '标准画质'
})
// Size options based on model and quality | 基于模型和画质的尺寸选项
const sizeOptions = computed(() => {
return getModelSizeOptions(localModel.value, localQuality.value)
})
// Check if model has size options | 检查模型是否有尺寸选项
const hasSizeOptions = computed(() => {
const config = getModelConfig(localModel.value)
return config?.sizes && config.sizes.length > 0
})
// Display size with label | 显示尺寸(带标签)
const displaySize = computed(() => {
const option = sizeOptions.value.find(o => o.key === localSize.value)
return option?.label || localSize.value
})
// Initialize on mount | 挂载时初始化
onMounted(() => {
// 检查当前模型是否在可用模型列表中
const availableModels = modelStore.availableImageModels
const isModelAvailable = availableModels.some(m => m.key === localModel.value)
if (!localModel.value || !isModelAvailable) {
// 使用 store 中的默认模型或第一个可用模型
const selected = availableModels.find(m => m.key === modelStore.selectedImageModel)?.key
localModel.value = selected || availableModels[0]?.key || DEFAULT_IMAGE_MODEL
updateNode(props.id, { model: localModel.value })
}
})
// 解析 textNode 内容中的 @ 引用,转换为简短引用(如 图 1并收集图片
const resolveTextMentionsForImage = (textNode) => {
const content = textNode.data?.content || ''
const mentions = parseMentions(content)
if (mentions.length === 0) {
return { resolvedContent: content, refImages: [] }
}
// 收集引用的图片节点
const imageMentions = []
for (const mention of mentions) {
const referencedNode = nodes.value.find(n => n.id === mention.nodeId)
if (referencedNode?.type === 'image') {
const imageData = referencedNode.data?.base64 || referencedNode.data?.url
if (imageData) {
imageMentions.push({
order: mention.order,
nodeId: mention.nodeId,
imageData
})
}
}
}
if (imageMentions.length === 0) {
return { resolvedContent: content, refImages: [] }
}
// 按出现顺序排序
imageMentions.sort((a, b) => a.order - b.order)
// 替换 @[nodeId] 为按顺序的 "图1"、"图2" 等
let resolvedContent = content
for (let i = 0; i < imageMentions.length; i++) {
const mention = imageMentions[i]
const placeholder = `@[${mention.nodeId}]`
// 按排序后的索引替换为 "图1"、"图2" 等
resolvedContent = resolvedContent.replace(placeholder, `${i + 1}`)
}
// 返回解析后的内容和图片数组(按引用顺序)
const refImages = imageMentions.map(m => m.imageData)
return { resolvedContent, refImages }
}
// Computed connected prompts (sorted by order) | 计算连接的提示词(按顺序排列)
const connectedPrompts = computed(() => {
return getConnectedInputs().prompts
})
// Computed connected reference images | 计算连接的参考图
const connectedRefImages = computed(() => {
return getConnectedInputs().refImages
})
// 已连接的文本节点 ID 列表(用于 @ 提及时过滤)
const connectedTextNodeIds = computed(() => {
const incomingEdges = edges.value.filter(e => e.target === props.id)
const connectedIds = []
for (const edge of incomingEdges) {
const sourceNode = nodes.value.find(n => n.id === edge.source)
if (sourceNode?.type === 'text') {
connectedIds.push(sourceNode.id)
}
}
return connectedIds
})
// Get connected nodes | 获取连接的节点
const getConnectedInputs = () => {
// 1. First check @ mentions | 首先检查 @ 引用
// Only check connected TextNodes | 只检查已连接的 TextNode
const textNodes = nodes.value.filter(n => n.type === 'text' && connectedTextNodeIds.value.includes(n.id))
const mentionsPrompts = []
const mentionsRefImages = []
for (const textNode of textNodes) {
const { resolvedContent, refImages: nodeRefImages } = resolveTextMentionsForImage(textNode)
// 如果有解析出图片引用
if (nodeRefImages.length > 0) {
// 添加解析后的提示词内容
mentionsPrompts.push({
order: mentionsPrompts.length,
content: resolvedContent,
nodeId: textNode.id
})
// 添加参考图
for (const imageData of nodeRefImages) {
mentionsRefImages.push({
order: mentionsRefImages.length,
imageData,
nodeId: textNode.id
})
}
}
}
// 2. Get edge-connected ImageNodes | 获取边连接的 ImageNode
const connectedEdges = edges.value.filter(e => e.target === props.id)
const edgeRefImages = [] // Array of { order, imageData, nodeId } | 参考图数组
for (const edge of connectedEdges) {
const sourceNode = nodes.value.find(n => n.id === edge.source)
if (!sourceNode) continue
if (sourceNode.type === 'image') {
// Prefer base64, fallback to url | 优先使用 base64回退到 url
const imageData = sourceNode.data?.base64 || sourceNode.data?.url
if (imageData) {
// Get order from edge data, default to 1 | 从边数据获取顺序默认为1
// Add offset of @ mentions count | 加上 @ 提及图片数量的偏移
const baseOrder = edge.data?.imageOrder || 1
const order = mentionsRefImages.length + baseOrder
edgeRefImages.push({ order, imageData, nodeId: sourceNode.id })
}
}
}
// 3. Merge and sort refImages | 合并并排序参考图
// Combine @ mentions refImages and edge-connected refImages | 合并 @ 提及和边连接的图片
const allRefImages = [...mentionsRefImages, ...edgeRefImages]
// Sort by order | 按顺序排序
allRefImages.sort((a, b) => a.order - b.order)
const sortedRefImages = allRefImages.map(r => r.imageData)
// 4. If there are @ mentions, use them | 如果有 @ 提及,使用它们
if (mentionsPrompts.length > 0) {
// Sort prompts by order | 按顺序排序提示词
mentionsPrompts.sort((a, b) => a.order - b.order)
const combinedPrompt = mentionsPrompts.map(p => p.content).join('\n\n')
return {
prompt: combinedPrompt,
prompts: mentionsPrompts,
refImages: sortedRefImages,
refImagesWithOrder: allRefImages,
fromMentions: true
}
}
// 5. Fallback to edge connections | 降级到边的连接
// (only prompts, no @ mentions) (只有提示词,没有 @ 提及)
const prompts = [] // Array of { order, content } | 提示词数组
for (const edge of connectedEdges) {
const sourceNode = nodes.value.find(n => n.id === edge.source)
if (!sourceNode) continue
if (sourceNode.type === 'text') {
const content = sourceNode.data?.content || ''
if (content) {
// Get order from edge data, default to 1 | 从边数据获取顺序默认为1
const order = edge.data?.promptOrder || 1
prompts.push({ order, content, nodeId: sourceNode.id })
}
} else if (sourceNode.type === 'llmConfig') {
// LLM node output as prompt | LLM 节点输出作为提示词
const content = sourceNode.data?.outputContent || ''
if (content) {
const order = edge.data?.promptOrder || 1
prompts.push({ order, content, nodeId: sourceNode.id })
}
}
// Note: ImageNode handling moved to step 2 above | 注意ImageNode 处理已移至步骤 2
}
// Sort prompts by order and concatenate | 按顺序排序并拼接
prompts.sort((a, b) => a.order - b.order)
const combinedPrompt = prompts.map(p => p.content).join('\n\n')
// Use edge-connected refImages (already sorted above) | 使用边连接的参考图(已在上面排序)
return { prompt: combinedPrompt, prompts, refImages: sortedRefImages, refImagesWithOrder: allRefImages, fromMentions: false }
}
// Handle model selection | 处理模型选择
const handleModelSelect = (key) => {
localModel.value = key
const config = getModelConfig(key)
// 同步 Quality 到模型默认值
if (config?.defaultParams?.quality) {
localQuality.value = config.defaultParams.quality
}
// 同步 Size 到模型默认值
const newSizeOptions = getModelSizeOptions(key, localQuality.value)
let defaultSize = config?.defaultParams?.size
if (!defaultSize && newSizeOptions.length > 0) {
defaultSize = newSizeOptions.find(o => o.key === DEFAULT_IMAGE_SIZE)?.key
|| newSizeOptions.find(o => o.key.includes('1024'))?.key
|| newSizeOptions[0].key
}
localSize.value = defaultSize
// 更新节点数据
updateNode(props.id, {
model: key,
quality: localQuality.value,
size: defaultSize
})
}
// Handle quality selection | 处理画质选择
const handleQualitySelect = (quality) => {
localQuality.value = quality
// Update size to first option of new quality | 更新尺寸为新画质的第一个选项
const newSizeOptions = getModelSizeOptions(localModel.value, quality)
if (newSizeOptions.length > 0) {
const defaultSize = newSizeOptions.find(o => o.key === DEFAULT_IMAGE_SIZE)?.key
localSize.value = defaultSize || newSizeOptions[0].key
updateNode(props.id, { quality, size: localSize.value })
} else {
updateNode(props.id, { quality })
}
}
// Handle size selection | 处理尺寸选择
const handleSizeSelect = (size) => {
localSize.value = size
updateNode(props.id, { size })
}
// Update size from manual input | 更新手动输入的尺寸
const updateSize = () => {
updateNode(props.id, { size: localSize.value })
}
// Created image node ID | 创建的图片节点 ID
const createdImageNodeId = ref(null)
// Find connected output image node | 查找已连接的输出图片节点
const findConnectedOutputImageNode = (onlyEmpty = true) => {
// Find edges where this node is the source | 查找以当前节点为源的边
const outputEdges = edges.value.filter(e => e.source === props.id)
for (const edge of outputEdges) {
const targetNode = nodes.value.find(n => n.id === edge.target)
if (targetNode?.type === 'image') {
if (onlyEmpty) {
// Check if target is an image node with empty or no url | 检查目标是否为空白图片节点
if (!targetNode.data?.url || targetNode.data?.url === '') {
return targetNode.id
}
} else {
// Return any connected image node | 返回任意连接的图片节点
return targetNode.id
}
}
}
return null
}
// Check if there's a connected image node with content | 检查是否有已连接且有内容的图片节点
const hasConnectedImageWithContent = computed(() => {
const outputEdges = edges.value.filter(e => e.source === props.id)
for (const edge of outputEdges) {
const targetNode = nodes.value.find(n => n.id === edge.target)
if (targetNode?.type === 'image' && targetNode.data?.url && targetNode.data.url !== '') {
return true
}
}
return false
})
// Handle generate action | 处理生成操作
// mode: 'auto' = 自动判断, 'replace' = 替换现有, 'new' = 新建节点
const handleGenerate = async (mode = 'auto') => {
const { prompt, prompts, refImages, refImagesWithOrder } = getConnectedInputs()
if (!prompt && refImages.length === 0) {
window.$message?.warning('请连接文本节点(提示词)或图片节点(参考图)')
return
}
// Log prompt order for debugging | 记录提示词顺序用于调试
if (prompts.length > 1) {
console.log('[ImageConfigNode] 拼接提示词顺序:', prompts.map(p => `${p.order}: ${p.content.substring(0, 20)}...`))
}
// Log image order for debugging | 记录图片顺序用于调试
if (refImagesWithOrder && refImagesWithOrder.length > 1) {
console.log('[ImageConfigNode] 参考图顺序:', refImagesWithOrder.map(r => `${r.order}: ${r.nodeId}`))
}
if (!isConfigured.value) {
window.$message?.warning('生成接口未就绪,请稍后重试')
return
}
let imageNodeId = null
if (mode === 'replace') {
// Replace mode: find any connected image node | 替换模式:查找任意连接的图片节点
imageNodeId = findConnectedOutputImageNode(false)
if (imageNodeId) {
updateNode(imageNodeId, { loading: true, url: '' })
}
} else if (mode === 'new') {
// New mode: always create new node | 新建模式:始终创建新节点
imageNodeId = null
} else {
// Auto mode: check for empty connected node first | 自动模式:先检查空白连接节点
imageNodeId = findConnectedOutputImageNode(true)
if (imageNodeId) {
updateNode(imageNodeId, { loading: true })
}
}
if (!imageNodeId) {
// Get current node position | 获取当前节点位置
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
// Calculate Y offset if creating new node alongside existing | 如果是新建节点计算Y偏移
let yOffset = 0
if (mode === 'new') {
const outputEdges = edges.value.filter(e => e.source === props.id)
yOffset = outputEdges.length * 280 // Stack below existing outputs | 在现有输出下方堆叠
}
// Create image node with loading state | 创建带加载状态的图片节点
imageNodeId = addNode('image', { x: nodeX + 400, y: nodeY + yOffset }, {
url: '',
loading: true,
label: '图像生成结果'
})
// Auto-connect imageConfig → image | 自动连接 生图配置 → 图片
addEdge({
source: props.id,
target: imageNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
}
createdImageNodeId.value = imageNodeId
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
setTimeout(() => {
updateNodeInternals(imageNodeId)
}, 50)
try {
// Build request params | 构建请求参数
const params = {
model: localModel.value,
prompt: prompt,
size: localSize.value,
quality: localQuality.value,
n: 1
}
// Add reference image if provided | 如果有参考图则添加
if (refImages.length > 0) {
params.image = refImages
}
const result = await generate(params)
// Update image node with generated URL | 更新图片节点 URL
if (result && result.length > 0) {
updateNode(imageNodeId, {
url: result[0].url,
loading: false,
label: '文生图',
model: localModel.value,
updatedAt: Date.now()
})
// Mark this config node as executed | 标记配置节点已执行
updateNode(props.id, { executed: true, outputNodeId: imageNodeId })
}
window.$message?.success('图片生成成功')
} catch (err) {
// Update node to show error | 更新节点显示错误
updateNode(imageNodeId, {
loading: false,
error: err.message || '生成失败',
updatedAt: Date.now()
})
window.$message?.error(err.message || '图片生成失败')
}
}
// Handle duplicate | 处理复制
const handleDuplicate = () => {
const newNodeId = duplicateNode(props.id)
window.$message?.success('节点已复制')
if (newNodeId) {
setTimeout(() => {
updateNodeInternals(newNodeId)
}, 50)
}
}
// Start editing label | 开始编辑 label
const startEditLabel = () => {
editingLabelValue.value = props.data?.label || ''
isEditingLabel.value = true
nextTick(() => {
labelInputRef.value?.focus()
labelInputRef.value?.select()
})
}
// Finish editing label | 完成编辑 label
const finishEditLabel = () => {
const newLabel = editingLabelValue.value.trim()
if (newLabel && newLabel !== props.data?.label) {
updateNode(props.id, { label: newLabel })
}
isEditingLabel.value = false
}
// Cancel editing label | 取消编辑 label
const cancelEditLabel = () => {
isEditingLabel.value = false
}
// Handle delete | 处理删除
const handleDelete = () => {
removeNode(props.id)
window.$message?.success('节点已删除')
}
// 监听模型变化,同步 Quality 和 Size
watch(() => props.data?.model, (newModel) => {
if (newModel && newModel !== localModel.value) {
localModel.value = newModel
const config = getModelConfig(newModel)
// 同步 Quality
if (config?.defaultParams?.quality) {
localQuality.value = config.defaultParams.quality
}
// 同步 Size
if (config?.defaultParams?.size) {
localSize.value = config.defaultParams.size
}
}
})
// 修复 Vue Flow visibility: hidden 问题
watch(() => props.data, () => {
nextTick(() => {
updateNodeInternals(props.id)
})
}, { deep: true })
// Watch for auto-execute flag | 监听自动执行标志
watch(
() => props.data?.autoExecute,
(shouldExecute) => {
if (shouldExecute && !loading.value) {
// Clear the flag first to prevent re-triggering | 先清除标志防止重复触发
updateNode(props.id, { autoExecute: false })
// Delay to ensure node connections are established | 延迟确保节点连接已建立
setTimeout(() => {
handleGenerate()
}, 100)
}
},
{ immediate: true }
)
</script>
<style scoped>
.image-config-node-wrapper {
position: relative;
padding-top: 20px;
}
.image-config-node {
cursor: default;
position: relative;
}
</style>

View File

@@ -1,992 +0,0 @@
<template>
<!-- Image node wrapper for hover area | 图片节点包裹层扩展悬浮区域 -->
<div class="image-node-wrapper" @mouseenter="showActions = true; showHandleMenu = true" @mouseleave="showActions = false; showHandleMenu = false">
<!-- Image node | 图片节点 -->
<div
class="image-node bg-[var(--bg-secondary)] rounded-xl border min-w-[200px] max-w-[280px] relative transition-all duration-200"
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'">
<!-- Header | 头部 -->
<div class="px-3 py-2 border-b border-[var(--border-color)]">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span
v-if="!isEditingLabel"
@dblclick="startEditLabel"
class="text-sm font-medium text-[var(--text-primary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
title="双击编辑名称"
>{{ data.label || '图像生成结果' }}</span>
<input
v-else
ref="labelInputRef"
v-model="editingLabelValue"
@blur="finishEditLabel"
@keydown.enter="finishEditLabel"
@keydown.escape="cancelEditLabel"
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-primary)] px-1 rounded outline-none border border-blue-500"
/>
<!-- Public switch | 公开开关 -->
<n-tooltip trigger="hover">
<template #trigger>
<button
class="flex items-center"
title="设置公开(可被 @ 引用)"
>
<n-switch
:value="isPublic"
@update:value="handleTogglePublic"
size="small"
/>
</button>
</template>
{{ isPublic ? '已公开: ' + (data.label || '图片') : '点击公开(可被 @ 引用)' }}
</n-tooltip>
</div>
<div class="flex items-center gap-1">
<!-- Replace button | 替换按钮 -->
<n-tooltip trigger="hover">
<template #trigger>
<button @click="showReplaceModal = true" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
<n-icon :size="14">
<SwapHorizontalOutline />
</n-icon>
</button>
</template>
替换图片
</n-tooltip>
<n-tooltip v-if="data.url" trigger="hover">
<template #trigger>
<button @click="handlePreview" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
<n-icon :size="14">
<EyeOutline />
</n-icon>
</button>
</template>
预览
</n-tooltip>
<n-tooltip v-if="data.url" trigger="hover">
<template #trigger>
<button @click="handleDownload" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
<n-icon :size="14">
<DownloadOutline />
</n-icon>
</button>
</template>
下载
</n-tooltip>
<n-tooltip trigger="hover">
<template #trigger>
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
<n-icon :size="14">
<CopyOutline />
</n-icon>
</button>
</template>
复制节点
</n-tooltip>
<n-tooltip trigger="hover">
<template #trigger>
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
<n-icon :size="14">
<TrashOutline />
</n-icon>
</button>
</template>
删除节点
</n-tooltip>
</div>
</div>
<!-- Model name | 模型名称 -->
<div v-if="data.model" class="mt-1 text-xs text-[var(--text-secondary)] truncate">
{{ data.model }}
</div>
</div>
<!-- Image preview area | 图片预览区域 -->
<div class="p-3">
<!-- Loading state | 加载状态 -->
<div v-if="data.loading"
class="aspect-square rounded-xl bg-gradient-to-br from-cyan-400 via-blue-300 to-amber-200 flex flex-col items-center justify-center gap-3 relative overflow-hidden">
<!-- Animated gradient overlay | 动画渐变遮罩 -->
<div
class="absolute inset-0 bg-gradient-to-br from-cyan-500/20 via-blue-400/20 to-amber-300/20 animate-pulse">
</div>
<!-- Loading image | 加载图片 -->
<div class="relative z-10">
<img src="../../assets/loading.webp" alt="Loading" class="w-14 h-12" />
</div>
<span class="text-sm text-white font-medium relative z-10">创作中</span>
</div>
<!-- Error state | 错误状态 -->
<div v-else-if="data.error"
class="aspect-square rounded-xl bg-red-50 dark:bg-red-900/20 flex flex-col items-center justify-center gap-2 border border-red-200 dark:border-red-800">
<n-icon :size="32" class="text-red-500">
<CloseCircleOutline />
</n-icon>
<span class="text-sm text-red-600 dark:text-red-400 text-center px-2">{{ data.error }}</span>
</div>
<!-- Image display | 图片显示 -->
<div
v-else-if="data.url"
class="rounded-xl overflow-hidden relative"
ref="imageContainerRef"
>
<img
:src="data.url"
:alt="data.label"
class="w-full h-auto object-cover"
:class="{ 'pointer-events-none': isInpaintMode }"
/>
<!-- Inpaint canvas with events | 涂抹画布带事件 -->
<canvas
v-if="isInpaintMode"
ref="canvasRef"
class="absolute inset-0 w-full h-full cursor-none z-10"
@mousedown.stop.prevent="onCanvasPaint"
@mousemove.stop="onCanvasMove"
@mouseup.stop="onPaintEnd"
@mouseleave="onPaintEnd"
/>
<!-- Brush cursor | 画笔光标 -->
<div
v-show="brushCursor.visible && isInpaintMode"
class="absolute pointer-events-none border-2 border-purple-500 rounded-full bg-purple-400/30 transition-none"
:style="{
width: brushSize * 2 + 'px',
height: brushSize * 2 + 'px',
left: brushCursor.x - brushSize + 'px',
top: brushCursor.y - brushSize + 'px'
}"
/>
<!-- Inpaint toolbar | 涂抹工具栏 -->
<div
v-show="isInpaintMode"
class="absolute top-1.5 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-2 py-1 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm rounded-full shadow-md border border-gray-200/80 dark:border-gray-700 z-[9999]"
@mousedown.stop
@click.stop
>
<!-- Mode indicator | 模式指示 -->
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 pr-1.5 border-r border-gray-200 dark:border-gray-600">
<n-icon :size="12"><BrushOutline /></n-icon>
<span>擦除</span>
</div>
<!-- Size slider | 大小滑块 -->
<div class="flex items-center gap-1 w-16">
<div class="w-1.5 h-1.5 rounded-full bg-purple-400"></div>
<input
type="range"
v-model="brushSize"
min="10"
max="80"
class="w-full h-0.5 bg-gray-200 rounded-lg appearance-none cursor-pointer slider-purple"
/>
<div class="w-2.5 h-2.5 rounded-full bg-purple-400"></div>
</div>
<!-- Reset button | 重置按钮 -->
<button
@click="clearMask"
class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title="清除"
>
<n-icon :size="12" class="text-gray-400"><RefreshOutline /></n-icon>
</button>
<!-- Apply button | 应用按钮 -->
<button
@click="applyInpaint"
class="px-2 py-0.5 bg-purple-500 hover:bg-purple-600 text-white text-xs rounded transition-colors"
>
应用
</button>
</div>
</div>
<!-- URL Loading state | URL 加载状态 -->
<div v-else-if="urlLoading"
class="aspect-square rounded-xl bg-gradient-to-br from-cyan-400 via-blue-300 to-amber-200 flex flex-col items-center justify-center gap-3 relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-cyan-500/20 via-blue-400/20 to-amber-300/20 animate-pulse"></div>
<div class="relative z-10">
<img src="../../assets/loading.webp" alt="Loading" class="w-14 h-12" />
</div>
<span class="text-sm text-white font-medium relative z-10">加载中...</span>
</div>
<!-- Upload placeholder | 上传占位 -->
<div v-else class="rounded-xl bg-[var(--bg-tertiary)] border-2 border-dashed border-[var(--border-color)] p-3">
<!-- Upload area | 上传区域 -->
<div class="aspect-video flex flex-col items-center justify-center gap-2 relative cursor-pointer hover:bg-[var(--bg-secondary)] rounded-lg transition-colors">
<n-icon :size="32" class="text-[var(--text-secondary)]">
<ImageOutline />
</n-icon>
<span class="text-sm text-[var(--text-secondary)] text-center">拖放图片或点击上传</span>
<input type="file" accept="image/*" class="absolute inset-0 opacity-0 cursor-pointer"
@change="handleFileUpload" />
</div>
<!-- Divider | 分割线 -->
<div class="flex items-center gap-2 my-3">
<div class="flex-1 h-px bg-[var(--border-color)]"></div>
<span class="text-xs text-[var(--text-secondary)]"></span>
<div class="flex-1 h-px bg-[var(--border-color)]"></div>
</div>
<!-- URL input | URL 输入 -->
<div class="flex gap-2">
<input
v-model="urlInput"
type="text"
placeholder="输入图片地址..."
class="flex-1 px-2 py-1 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg outline-none focus:border-[var(--accent-color)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]"
@keydown.enter="handleUrlSubmit"
/>
<button
@click="handleUrlSubmit"
:disabled="!urlInput.trim()"
class="px-3 py-2 text-xs bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
预览
</button>
</div>
</div>
</div>
<!-- Handles | 连接点 -->
<NodeHandleMenu :nodeId="id" nodeType="image" :visible="showHandleMenu" :operations="operations" @select="handleSelect" />
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
</div>
</div>
<!-- Image preview dialog | 图片预览弹窗 -->
<n-image-preview
v-model:show="showRef"
:src="props.data?.url"
/>
<!-- Replace image modal | 替换图片弹窗 -->
<n-modal v-model:show="showReplaceModal" preset="card" title="替换图片" class="w-[400px]" :mask-closable="true">
<div class="space-y-4">
<!-- Upload area | 上传区域 -->
<div
class="border-2 border-dashed border-[var(--border-color)] rounded-xl p-4 cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
@click="replaceFileInputRef?.click()"
>
<div class="flex flex-col items-center gap-2">
<n-icon :size="32" class="text-[var(--text-secondary)]">
<ImageOutline />
</n-icon>
<span class="text-sm text-[var(--text-secondary)]">点击上传图片</span>
<input
ref="replaceFileInputRef"
type="file"
accept="image/*"
class="hidden"
@change="handleReplaceFileUpload"
/>
</div>
</div>
<!-- Divider | 分割线 -->
<div class="flex items-center gap-2">
<div class="flex-1 h-px bg-[var(--border-color)]"></div>
<span class="text-xs text-[var(--text-secondary)]"></span>
<div class="flex-1 h-px bg-[var(--border-color)]"></div>
</div>
<!-- URL input | URL 输入 -->
<div class="flex gap-2">
<input
v-model="replaceUrlInput"
type="text"
placeholder="输入图片地址..."
class="flex-1 px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg outline-none focus:border-[var(--accent-color)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]"
@keydown.enter="handleReplaceUrlSubmit"
/>
<n-button type="primary" size="small" :disabled="!replaceUrlInput.trim()" @click="handleReplaceUrlSubmit">
确认
</n-button>
</div>
</div>
</n-modal>
</template>
<script setup>
/**
* Image node component | 图片节点组件
* Displays and manages image content with loading state
*/
import { ref, nextTick, computed } from 'vue'
import { Handle, Position, useVueFlow } from '@vue-flow/core'
import { NIcon, NTooltip, NSwitch, NImagePreview, NModal, NButton } from 'naive-ui'
import { TrashOutline, ExpandOutline, ImageOutline, CloseCircleOutline, CopyOutline, VideocamOutline, DownloadOutline, EyeOutline, BrushOutline, RefreshOutline, ColorWandOutline, SwapHorizontalOutline } from '@vicons/ionicons5'
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
import NodeHandleMenu from './NodeHandleMenu.vue'
const props = defineProps({
id: String,
data: Object
})
// Vue Flow instance | Vue Flow 实例
const { updateNodeInternals } = useVueFlow()
// Hover state | 悬浮状态
const showActions = ref(true)
const showHandleMenu = ref(false)
// Label editing state | Label 编辑状态
const isEditingLabel = ref(false)
const editingLabelValue = ref('')
const labelInputRef = ref(null)
// URL input state | URL 输入状态
const urlInput = ref('')
const urlLoading = ref(false)
// Replace modal state | 替换弹窗状态
const showReplaceModal = ref(false)
const replaceUrlInput = ref('')
const replaceFileInputRef = ref(null)
// Inpainting state | 涂抹重绘状态
const isInpaintMode = ref(false)
const brushSize = ref(40)
const isDrawing = ref(false)
const canvasRef = ref(null)
const imageContainerRef = ref(null)
const interactionLayerRef = ref(null)
const brushCursor = ref({ x: 0, y: 0, visible: false })
const maskData = ref(null)
// Computed public props status | 计算是否公开
const isPublic = computed(() => {
return props.data?.publicProps?.name != null && props.data?.publicProps?.name !== ''
})
// Handle toggle public | 处理切换公开状态
const handleTogglePublic = (value) => {
if (value) {
// 公开:使用节点名称
const name = props.data?.label || '图片'
updateNode(props.id, {
publicProps: { name }
})
} else {
// 取消公开
updateNode(props.id, {
publicProps: {}
})
}
}
// Image node menu operations | 图片节点菜单操作
const operations = [
{ type: 'imageConfig', label: '图生图', icon: ImageOutline, action: 'image_imageConfig' },
{ type: 'videoConfig', label: '生视频', icon: VideocamOutline, action: 'image_videoConfig' }
]
// Handle menu select | 处理菜单选择
const handleSelect = (item) => {
const action = item.action
if (action === 'image_imageConfig') {
// Image-to-image workflow | 图生图工作流
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
const sourceUrl = currentNode?.data?.url
if (!sourceUrl) {
window.$message?.warning('当前图片节点没有图片')
return
}
// Create text node for prompt
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
content: '',
label: '提示词'
})
// Create imageConfig node
const configNodeId = addNode('imageConfig', { x: nodeX + 900, y: nodeY }, {
model: 'auto',
size: '1024x1536',
label: '生图配置'
})
// Connect edges
addEdge({ source: props.id, target: configNodeId, sourceHandle: 'right', targetHandle: 'left' })
addEdge({ source: textNodeId, target: configNodeId, sourceHandle: 'right', targetHandle: 'left' })
setTimeout(() => updateNodeInternals([textNodeId, configNodeId]), 50)
window.$message?.success('已创建图生图工作流')
} else if (action === 'image_videoConfig') {
// Video generation workflow | 视频生成工作流
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
// Create text node for prompt
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
content: '',
label: '提示词'
})
// Create videoConfig node
const configNodeId = addNode('videoConfig', { x: nodeX + 600, y: nodeY }, {
label: '视频生成'
})
// Connect image to videoConfig
addEdge({
source: props.id,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left',
type: 'imageRole',
data: { imageRole: 'first_frame_image' }
})
// Connect text to videoConfig
addEdge({
source: textNodeId,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
setTimeout(() => updateNodeInternals([textNodeId, configNodeId]), 50)
window.$message?.success('已创建视频生成工作流')
}
}
// Toggle inpaint mode | 切换涂抹模式
const toggleInpaintMode = () => {
isInpaintMode.value = !isInpaintMode.value
if (isInpaintMode.value) {
nextTick(() => initCanvas())
} else {
clearMask()
}
}
// Initialize canvas | 初始化画布
const initCanvas = () => {
setTimeout(() => {
const canvas = canvasRef.value
if (!canvas) return
// Set canvas internal size to match its CSS rendered size | 设置画布内部尺寸匹配 CSS 渲染尺寸
// clientWidth/clientHeight give the CSS box size
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, canvas.width, canvas.height)
}, 100)
}
// Ensure canvas size matches display | 确保画布尺寸匹配显示
const syncCanvasSize = () => {
const canvas = canvasRef.value
if (!canvas) return
if (canvas.width !== canvas.clientWidth || canvas.height !== canvas.clientHeight) {
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
}
}
// Canvas paint handlers | 画布绘制处理器
const onCanvasPaint = (e) => {
syncCanvasSize()
isDrawing.value = true
paintAt(e.offsetX, e.offsetY)
brushCursor.value = { x: e.offsetX, y: e.offsetY, visible: true }
}
const onCanvasMove = (e) => {
brushCursor.value = { x: e.offsetX, y: e.offsetY, visible: true }
if (isDrawing.value) {
paintAt(e.offsetX, e.offsetY)
}
}
const onPaintEnd = () => {
isDrawing.value = false
brushCursor.value.visible = false
}
// Paint at coordinates | 在坐标绘制
const paintAt = (x, y) => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
ctx.beginPath()
ctx.arc(x, y, brushSize.value, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(139, 92, 246, 0.5)'
ctx.fill()
}
// Hide brush cursor | 隐藏画笔光标
const hideBrushCursor = () => {
brushCursor.value.visible = false
}
// Clear mask | 清除蒙版
const clearMask = () => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, canvas.width, canvas.height)
maskData.value = null
}
// Apply inpaint and create workflow | 应用重绘并创建工作流
const applyInpaint = () => {
const canvas = canvasRef.value
if (!canvas || canvas.width === 0 || canvas.height === 0) {
window.$message?.error('画布未初始化')
return
}
// Get the original image and resize mask to match | 获取原图并调整蒙版大小匹配
const container = imageContainerRef.value
const img = container?.querySelector('img')
if (!img) {
window.$message?.error('未找到图片')
return
}
// Create mask at original image resolution | 创建原图分辨率的蒙版
const maskCanvas = document.createElement('canvas')
const imgWidth = img.naturalWidth || img.width
const imgHeight = img.naturalHeight || img.height
maskCanvas.width = imgWidth
maskCanvas.height = imgHeight
const maskCtx = maskCanvas.getContext('2d')
// Fill black background | 填充黑色背景
maskCtx.fillStyle = '#000000'
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height)
// Scale factor from display to original | 从显示尺寸到原图的缩放因子
const scaleX = imgWidth / canvas.width
const scaleY = imgHeight / canvas.height
// Get painted areas and scale to original resolution | 获取绑制区域并缩放到原图分辨率
const originalData = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height)
// Draw scaled white areas on mask | 在蒙版上绘制缩放后的白色区域
maskCtx.fillStyle = '#FFFFFF'
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const i = (y * canvas.width + x) * 4
if (originalData.data[i + 3] > 0) {
// Scale and draw | 缩放并绘制
maskCtx.fillRect(
Math.floor(x * scaleX),
Math.floor(y * scaleY),
Math.ceil(scaleX),
Math.ceil(scaleY)
)
}
}
}
// Convert to base64 (remove data URL prefix for API) | 转换为 base64移除前缀用于 API
const dataUrl = maskCanvas.toDataURL('image/png')
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '')
maskData.value = base64Data
// Create inpaint workflow | 创建重绘工作流
createInpaintWorkflow()
}
// Create inpaint workflow | 创建重绘工作流
const createInpaintWorkflow = () => {
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
// Create text node for prompt | 创建文本节点用于提示词
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
content: '请输入重绘提示词...',
label: '重绘提示词'
})
// Create imageConfig node for inpainting | 创建图生图配置节点
const configNodeId = addNode('imageConfig', { x: nodeX + 600, y: nodeY }, {
model: 'auto',
size: '1024x1536',
label: '局部重绘',
inpaintMode: true
})
// Update current node with mask data | 更新当前节点的蒙版数据
updateNode(props.id, {
maskData: maskData.value,
hasInpaintMask: true
})
// Connect image node to config node | 连接图片节点到配置节点
addEdge({
source: props.id,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
// Connect text node to config node | 连接文本节点到配置节点
addEdge({
source: textNodeId,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
// Exit inpaint mode | 退出涂抹模式
isInpaintMode.value = false
// Force Vue Flow to recalculate | 强制重新计算
setTimeout(() => {
updateNodeInternals([textNodeId, configNodeId])
}, 50)
window.$message?.success('已创建局部重绘工作流')
}
// 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
updateNode(props.id, {
url: base64, // Use base64 as display URL | 使用 base64 作为显示 URL
base64: base64, // Store base64 for API calls | 存储 base64 用于 API 调用
fileName: file.name,
fileType: file.type,
label: '参考图',
updatedAt: Date.now()
})
} catch (err) {
console.error('File upload error:', err)
window.$message?.error('图片上传失败')
}
}
}
// Handle URL submit | 处理 URL 提交
const handleUrlSubmit = () => {
const url = urlInput.value.trim()
if (!url) return
// Validate URL format | 验证 URL 格式
if (!url.startsWith('http://') && !url.startsWith('https://')) {
window.$message?.warning('请输入有效的图片地址 (http:// 或 https://)')
return
}
// Show loading state | 显示加载状态
urlLoading.value = true
// Preload image to check validity | 预加载图片检查有效性
const img = new Image()
img.onload = () => {
// Update node with URL | 更新节点 URL
updateNode(props.id, {
url: url,
label: '网络图片',
updatedAt: Date.now()
})
urlInput.value = ''
urlLoading.value = false
}
img.onerror = () => {
window.$message?.error('图片加载失败,请检查地址是否正确')
urlLoading.value = false
}
img.src = url
}
// Handle replace file upload | 处理替换文件上传
const handleReplaceFileUpload = async (event) => {
const file = event.target.files[0]
if (file) {
try {
const base64 = await fileToBase64(file)
updateNode(props.id, {
url: base64,
base64: base64,
fileName: file.name,
fileType: file.type,
label: '参考图',
updatedAt: Date.now()
})
showReplaceModal.value = false
replaceUrlInput.value = ''
window.$message?.success('图片已替换')
} catch (err) {
console.error('File upload error:', err)
window.$message?.error('图片上传失败')
}
}
}
// Handle replace URL submit | 处理替换 URL 提交
const handleReplaceUrlSubmit = () => {
const url = replaceUrlInput.value.trim()
if (!url) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
window.$message?.warning('请输入有效的图片地址 (http:// 或 https://)')
return
}
const img = new Image()
img.onload = () => {
updateNode(props.id, {
url: url,
label: '网络图片',
updatedAt: Date.now()
})
showReplaceModal.value = false
replaceUrlInput.value = ''
window.$message?.success('图片已替换')
}
img.onerror = () => {
window.$message?.error('图片加载失败,请检查地址是否正确')
}
img.src = url
}
// Start editing label | 开始编辑 label
const startEditLabel = () => {
editingLabelValue.value = props.data?.label || '图像生成结果'
isEditingLabel.value = true
nextTick(() => {
labelInputRef.value?.focus()
labelInputRef.value?.select()
})
}
// Finish editing label | 完成编辑 label
const finishEditLabel = () => {
const newLabel = editingLabelValue.value.trim()
if (newLabel && newLabel !== props.data?.label) {
updateNode(props.id, { label: newLabel })
}
isEditingLabel.value = false
}
// Cancel editing label | 取消编辑 label
const cancelEditLabel = () => {
isEditingLabel.value = false
}
// Handle delete | 处理删除
const handleDelete = () => {
removeNode(props.id)
}
// Handle duplicate | 处理复制
const handleDuplicate = () => {
const newId = duplicateNode(props.id)
if (newId) {
// Clear selection and select the new node | 清除选中并选中新节点
updateNode(props.id, { selected: false })
updateNode(newId, { selected: true })
window.$message?.success('节点已复制')
setTimeout(() => {
updateNodeInternals(newId)
}, 50)
}
}
// Handle image generation | 处理图片生图(图生图)
const handleImageGen = () => {
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
// Create text node for prompt | 创建文本节点用于提示词
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
content: '',
label: '提示词'
})
// Create ImageNode for editing | 创建图片编辑节点
const imageNodeId = addNode('image', { x: nodeX + 600, y: nodeY }, {
url: props.data.url, // Pass the current image as input
label: '图生图',
refImage: props.data.url // Mark as reference image
})
// Create imageConfig node for generation | 创建生图配置节点
const configNodeId = addNode('imageConfig', { x: nodeX + 900, y: nodeY }, {
model: 'auto',
size: '1024x1536',
label: '生图配置'
})
// Connect image node to new image node | 连接当前图片节点到新图片节点
addEdge({
source: props.id,
target: imageNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
// Connect new image node to config node | 连接新图片节点到配置节点
addEdge({
source: imageNodeId,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
// Connect text node to config node | 连接文本节点到配置节点
addEdge({
source: textNodeId,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
setTimeout(() => {
updateNodeInternals([textNodeId, imageNodeId, configNodeId])
}, 50)
window.$message?.success('已创建图生图工作流')
}
// Preview state | 预览状态
const showRef = ref(false)
// Handle preview | 处理预览
const handlePreview = () => {
if (props.data.url) {
showRef.value = true
}
}
// Handle download | 处理下载
const handleDownload = () => {
if (props.data.url) {
const link = document.createElement('a')
link.href = props.data.url
link.download = props.data.fileName || `image_${Date.now()}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.$message?.success('图片下载中...')
}
}
// Handle video generation | 处理视频生成
const handleVideoGen = () => {
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
// Create text node for prompt | 创建文本节点用于提示词
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
content: '',
label: '提示词'
})
// Create videoConfig node | 创建视频配置节点
const configNodeId = addNode('videoConfig', { x: nodeX + 600, y: nodeY }, {
label: '视频生成'
})
// Connect image node to config node with role | 连接图片节点到配置节点并设置角色
addEdge({
source: props.id,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left',
type: 'imageRole',
data: { imageRole: 'first_frame_image' } // Default to first frame | 默认首帧
})
// Connect text node to config node | 连接文本节点到配置节点
addEdge({
source: textNodeId,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
setTimeout(() => {
updateNodeInternals([textNodeId, configNodeId])
}, 50)
}
</script>
<style scoped>
.image-node-wrapper {
position: relative;
padding-right: 50px;
padding-top: 20px;
}
.image-node {
cursor: default;
position: relative;
}
/* Slider styling | 滑块样式 */
.slider-purple::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #8b5cf6;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.slider-purple::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #8b5cf6;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
/* Inpaint mode cursor | 涂抹模式光标 */
.cursor-none {
cursor: none;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,232 +0,0 @@
<template>
<!-- Right handle with expandable menu | 右侧连接点带展开菜单 -->
<div class="handle-menu-anchor">
<!-- Vue Flow handle for edge connections - visible and draggable | 可见且可拖拽的 Vue Flow 连接点 -->
<Handle type="source" :position="Position.Right" id="right" style="width: 12px; height: 12px;" />
<!-- Hover zone with + icon | + 图标的悬浮区域 -->
<div v-if="true && showHandleHoverZone" class="handle-hover-zone"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave">
<n-icon :size="14" class="add-icon">
<AddOutline />
</n-icon>
<transition name="menu-fade">
<div v-if="showMenu" class="handle-menu"
@mouseenter="handleMenuMouseEnter"
@mouseleave="handleMenuMouseLeave"
@mousedown.stop>
<button v-for="item in menuItems" :key="item.type" @click.stop="handleCreate(item)" class="menu-item group">
<n-icon :size="14" class="text-gray-500 group-hover:text-white">
<component :is="item.icon" />
</n-icon>
<span class="menu-label">{{ item.label }}</span>
</button>
</div>
</transition>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Handle, Position } from '@vue-flow/core'
import { NIcon } from 'naive-ui'
import { AddOutline } from '@vicons/ionicons5'
const props = defineProps({
nodeId: { type: String, required: true },
nodeType: { type: String, required: true },
visible: { type: Boolean },
dotColor: { type: String, default: 'var(--accent-color)' },
operations: { type: Array, default: null } // 传空数组则不显示 handle-hover-zone
})
// Emit select event to parent component | 向父组件发送选择事件
const emit = defineEmits(['select'])
const showMenu = ref(false)
let hideTimeout = null
// Handle mouse enter with delay cancellation
const handleMouseEnter = () => {
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
showMenu.value = true
}
// Handle mouse leave with delay
const handleMouseLeave = () => {
hideTimeout = setTimeout(() => {
showMenu.value = false
}, 150)
}
// Handle menu mouse enter - cancel hide timeout
const handleMenuMouseEnter = () => {
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
showMenu.value = true
}
// Handle menu mouse leave with delay
const handleMenuMouseLeave = () => {
hideTimeout = setTimeout(() => {
showMenu.value = false
}, 150)
}
// Menu items from operations prop | 从 operations prop 获取菜单项
const menuItems = computed(() => {
return props.operations || []
})
// Whether to show handle-hover-zone | 是否显示 handle-hover-zone
const showHandleHoverZone = computed(() => {
return props.operations && props.operations.length > 0
})
// Emit select event to parent component | 向父组件发送选择事件
const handleCreate = (item) => {
emit('select', item)
showMenu.value = false
}
</script>
<style scoped>
/* Anchor sits at the right edge center of the parent node | 锚点在父节点右边缘中心 */
.handle-menu-anchor {
position: absolute;
right: 0;
top: 50%;
transform: translate(50%, -50%);
z-index: 100;
}
/* Hover zone - hidden by default, show on anchor hover | 默认隐藏,锚点 hover 时显示 */
.handle-hover-zone {
position: absolute;
left: 50%;
top: -30px;
transform: translate(-50%, -50%);
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 6px;
background: var(--bg-tertiary, #2a2a3e);
border: 1px solid var(--border-color, #444);
opacity: 1;
transition: all 0.2s ease;
}
/* Show hover zone when anchor is hovered | 锚点 hover 时显示悬浮区域 */
.handle-menu-anchor:hover .handle-hover-zone {
opacity: 1;
pointer-events: auto;
}
.handle-hover-zone:hover {
background: var(--accent-color, #8b5cf6);
border-color: var(--accent-color, #8b5cf6);
transform: translate(-50%, -50%) scale(1.1);
}
/* Add icon | 添加图标 */
.add-icon {
color: var(--text-secondary, #999);
font-size: 14px;
font-weight: 500;
line-height: 1;
transition: color 0.2s ease;
}
.handle-hover-zone:hover .add-icon {
color: white;
}
/* Visible dot | 可见圆点 */
.handle-dot {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.6);
transition: all 0.2s ease;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
.handle-dot.is-active {
width: 14px;
height: 14px;
box-shadow: 0 0 8px rgba(139, 92, 246, 0.5);
}
/* Menu floats to the right of the dot | 菜单浮在圆点右侧 */
.handle-menu {
position: absolute;
left: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px;
background: var(--bg-secondary, #1e1e2e);
border: 1px solid var(--border-color, #333);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
white-space: nowrap;
}
.menu-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
color: var(--text-secondary, #999);
transition: all 0.15s ease;
cursor: pointer;
border: none;
background: none;
}
.menu-item:hover {
background: var(--accent-color, #8b5cf6);
color: white;
}
.menu-label {
font-size: 11px;
}
/* Menu divider | 菜单分隔线 */
.menu-divider {
height: 1px;
background: var(--border-color, #333);
margin: 4px 0;
}
/* Animation | 动画 */
.menu-fade-enter-active,
.menu-fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.menu-fade-enter-from,
.menu-fade-leave-to {
opacity: 0;
transform: translateX(-4px);
}
</style>

View File

@@ -1,856 +0,0 @@
<template>
<!-- Text node wrapper | 文本节点包裹层 -->
<div class="text-node-wrapper" @mouseenter="showHandleMenu = true" @mouseleave="showHandleMenu = false">
<!-- Text node | 文本节点 -->
<div
class="text-node bg-[var(--bg-secondary)] rounded-xl border min-w-[280px] max-w-[350px] relative transition-all duration-200"
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'">
<!-- Header | 头部 -->
<div class="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)]">
<span
v-if="!isEditingLabel"
@dblclick="startEditLabel"
class="text-sm font-medium text-[var(--text-secondary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
title="双击编辑名称"
>{{ data.label }}</span>
<input
v-else
ref="labelInputRef"
v-model="editingLabelValue"
@blur="finishEditLabel"
@keydown.enter="finishEditLabel"
@keydown.escape="cancelEditLabel"
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] px-1 rounded outline-none border border-blue-500"
/>
<div class="flex items-center gap-1">
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="复制节点">
<n-icon :size="14">
<CopyOutline />
</n-icon>
</button>
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="删除节点">
<n-icon :size="14">
<TrashOutline />
</n-icon>
</button>
<!-- <button class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="展开">
<n-icon :size="14">
<ExpandOutline />
</n-icon>
</button> -->
</div>
</div>
<!-- Content | 内容 -->
<div class="p-3">
<div class="textarea-wrapper" ref="textareaWrapper">
<!-- 可编辑的文本区域支持 @ 引用图片显示参考 MaterialInput -->
<div
ref="editorRef"
class="editor-content"
contenteditable="true"
@input="handleInput"
@keydown="handleKeydown"
@paste="handlePaste"
@blur="updateContent"
@wheel.stop
@mousedown.stop
:data-placeholder="placeholder"
></div>
</div>
<!-- Polish button | 润色按钮 -->
<button
@click="handlePolish"
:disabled="isPolishing || !plainText.trim()"
class="mt-2 px-3 py-1.5 text-xs rounded-lg bg-[var(--bg-tertiary)] hover:bg-[var(--accent-color)] hover:text-white border border-[var(--border-color)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
>
<n-spin v-if="isPolishing" :size="12" />
<span v-else></span>
AI 润色
</button>
</div>
<!-- Handles | 连接点 -->
<NodeHandleMenu :nodeId="id" nodeType="text" :visible="showHandleMenu" :operations="operations" @select="handleSelect" />
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
</div>
<!-- Mentions picker | @ 选择器 -->
<MentionsPicker
v-model:visible="showMentionsPicker"
:position="mentionsPosition"
context="text"
@select="handleMentionSelect"
/>
</div>
</template>
<script setup>
/**
* Text node component | 文本节点组件
* Allows user to input and edit text content
*/
import { ref, watch, nextTick, computed, onMounted } from 'vue'
import { Handle, Position, useVueFlow } from '@vue-flow/core'
import { NIcon, NSpin } from 'naive-ui'
import { TrashOutline, ExpandOutline, CopyOutline, ImageOutline, VideocamOutline, ChatbubbleOutline, CreateOutline } from '@vicons/ionicons5'
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
import NodeHandleMenu from './NodeHandleMenu.vue'
import MentionsPicker from '../MentionsPicker.vue'
import { useChat } from '../../hooks'
import { useModelStore } from '../../stores/pinia'
import { parseMentions } from '../../hooks/useNodeRef'
const props = defineProps({
id: String,
data: Object
})
// Vue Flow instance | Vue Flow 实例
const { updateNodeInternals } = useVueFlow()
// API config state | API 配置状态
const modelStore = useModelStore()
const isApiConfigured = computed(() => !!modelStore.currentApiKey)
// Chat hook for polish | 润色用的 Chat hook
const { send: sendChat } = useChat({
systemPrompt: '你是一个专业的AI绘画提示词专家。将用户输入的内容美化成高质量的生图提示词包含风格、光线、構图、细节等要素。直接返回提示词不要其他解释。',
model: 'gpt-4o-mini'
})
// Local content state | 本地内容状态
const showHandleMenu = ref(false)
const content = ref(props.data?.content || '')
const placeholder = '请输入文本内容,输入 @ 可引用图片节点...'
// Label editing state | Label 编辑状态
const isEditingLabel = ref(false)
const editingLabelValue = ref('')
const labelInputRef = ref(null)
// Polish loading state | 润色加载状态
const isPolishing = ref(false)
// Mentions picker state | @ 选择器状态
const showMentionsPicker = ref(false)
const mentionsPosition = ref({ x: 0, y: 0 })
const editorRef = ref(null)
const textareaWrapper = ref(null)
const mentionSearchStart = ref(-1) // @ 触发搜索的起始位置
const lastContent = ref('') // 上一次的内容,用于检测变化
// ============ 参考 MaterialInput 的逻辑 ============
// 从 contenteditable 中提取纯文本(将 chip 转为 @label
const getEditableText = () => {
const el = editorRef.value
if (!el) return ''
let text = ''
const walk = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent
} else if (node.nodeType === Node.ELEMENT_NODE) {
if (node.classList?.contains('mention-chip')) {
text += `@[${node.dataset.nodeId}]`
} else if (node.tagName === 'BR') {
text += '\n'
} else {
node.childNodes.forEach(walk)
}
}
}
el.childNodes.forEach(walk)
return text
}
// 根据 DOM 光标位置计算纯文本中的位置(考虑 mention-chip 的转换)
const getTextPositionBeforeCursor = (editor, range) => {
const container = editor
let textLength = 0
let found = false
const walk = (node) => {
if (found) return
if (node.nodeType === Node.TEXT_NODE) {
const nodeLength = node.textContent.length
if (range.startContainer === node) {
textLength += range.startOffset
found = true
return
}
textLength += nodeLength
} else if (node.nodeType === Node.ELEMENT_NODE) {
if (node.classList?.contains('mention-chip')) {
// mention-chip 在纯文本中算作 @[nodeId]
const replacement = `@[${node.dataset.nodeId || ''}]`
if (range.startContainer === node || isNodeInside(node, range.startContainer)) {
// 光标在 mention-chip 内部
found = true
return
}
textLength += replacement.length
} else if (node.tagName === 'BR') {
textLength += 1
} else {
for (const child of node.childNodes) {
walk(child)
if (found) return
}
}
}
}
walk(container)
return textLength
}
// 检查节点是否在父节点内部
const isNodeInside = (parent, child) => {
let node = child
while (node) {
if (node === parent) return true
node = node.parentNode
}
return false
}
// 创建 mention chip 元素
const createMentionChip = (node) => {
const chip = document.createElement('span')
chip.className = 'mention-chip'
chip.contentEditable = 'false'
chip.dataset.nodeId = node.id
chip.dataset.label = node.data?.publicProps?.name || node.data?.label || '图片'
if (node.data?.url) {
const img = document.createElement('img')
img.src = node.data.url
img.className = 'mention-chip-thumb'
chip.appendChild(img)
} else {
const iconWrap = document.createElement('span')
iconWrap.className = 'mention-chip-icon'
iconWrap.textContent = '📷'
chip.appendChild(iconWrap)
}
const label = document.createElement('span')
label.className = 'mention-chip-label'
label.textContent = chip.dataset.label
chip.appendChild(label)
return chip
}
// 在 contenteditable 中插入 mention chip替换 @searchText
const insertMentionChipDOM = (node) => {
const el = editorRef.value
if (!el) return
// 遍历文本节点,找到最后一个 @
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
let lastAtNode = null
let lastAtOffset = -1
while (walker.nextNode()) {
const idx = walker.currentNode.textContent.lastIndexOf('@')
if (idx !== -1) {
lastAtNode = walker.currentNode
lastAtOffset = idx
}
}
if (!lastAtNode || lastAtOffset === -1) return
const chip = createMentionChip(node)
const spaceNode = document.createTextNode('\u00A0')
const beforeText = lastAtNode.textContent.substring(0, lastAtOffset)
if (beforeText) {
lastAtNode.textContent = beforeText
lastAtNode.parentNode.insertBefore(chip, lastAtNode.nextSibling)
lastAtNode.parentNode.insertBefore(spaceNode, chip.nextSibling)
} else {
const parent = lastAtNode.parentNode
parent.insertBefore(chip, lastAtNode)
parent.insertBefore(spaceNode, chip.nextSibling)
parent.removeChild(lastAtNode)
}
// 光标移到空格之后
const range = document.createRange()
range.setStartAfter(spaceNode)
range.collapse(true)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
// 同步文本
isInternalUpdate = true
content.value = getEditableText()
lastContent.value = content.value
nextTick(() => { isInternalUpdate = false })
}
// 设置 contenteditable 内容(纯文本)
const setEditableContent = (text) => {
if (!editorRef.value) return
editorRef.value.innerHTML = ''
if (text) {
editorRef.value.textContent = text
}
}
// 扫描 contenteditable 文本节点,将 @label 或 @[nodeId] 自动转为 chip
const convertTextMentionsToChips = () => {
const el = editorRef.value
if (!el) return
// 获取所有可引用的图片节点(需要公开的)
const imageNodes = nodes.value.filter(n => n.type === 'image' && n.data?.publicProps?.name)
if (imageNodes.length === 0) return
// 快速检查:无 @ 直接跳过
if (!el.textContent.includes('@')) return
// 优先匹配 @[nodeId] 格式
const nodeIdPattern = /@\[([^\]|]+)(?:\|([^\]]+))?\]/g
// 收集需要替换的文本节点(跳过 chip 内部)
const targets = []
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
while (walker.nextNode()) {
const node = walker.currentNode
if (node.parentElement?.closest('.mention-chip')) continue
nodeIdPattern.lastIndex = 0
if (nodeIdPattern.test(node.textContent)) {
targets.push(node)
}
}
if (targets.length === 0) return
// 替换文本节点为 chip + 文本片段
targets.forEach(textNode => {
const text = textNode.textContent
nodeIdPattern.lastIndex = 0
const fragment = document.createDocumentFragment()
let lastIdx = 0
let match
while ((match = nodeIdPattern.exec(text)) !== null) {
if (match.index > lastIdx) {
fragment.appendChild(document.createTextNode(text.slice(lastIdx, match.index)))
}
// 通过 nodeId 查找节点
const nodeId = match[1]
const node = imageNodes.find(n => n.id === nodeId)
if (node) {
fragment.appendChild(createMentionChip(node))
fragment.appendChild(document.createTextNode('\u00A0'))
} else {
fragment.appendChild(document.createTextNode(match[0]))
}
lastIdx = nodeIdPattern.lastIndex
}
if (lastIdx < text.length) {
fragment.appendChild(document.createTextNode(text.slice(lastIdx)))
}
textNode.parentNode.replaceChild(fragment, textNode)
})
}
// 防抖版本(用于输入事件,避免频繁 DOM 操作)
let _convertTimer = null
const debouncedConvertMentions = () => {
if (_convertTimer) clearTimeout(_convertTimer)
_convertTimer = setTimeout(convertTextMentionsToChips, 300)
}
// 聚焦 contenteditable 并将光标移到末尾
const focusEditableEnd = () => {
const el = editorRef.value
if (!el) return
el.focus()
const range = document.createRange()
range.selectNodeContents(el)
range.collapse(false)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
}
// Handle paste - 参考 MaterialInput纯文本粘贴
const handlePaste = (e) => {
// 纯文本粘贴(防止粘入富文本)
e.preventDefault()
const text = e.clipboardData?.getData('text/plain') || ''
document.execCommand('insertText', false, text)
}
// 内部更新标志
let isInternalUpdate = false
// @ 提及预览列表(已移除,改为在 editor 中直接显示)
// 获取纯文本(用于 AI 润色)
const plainText = computed(() => {
return content.value
})
// 将 @[nodeId] 转换为带图片的 HTML
const editorHtml = computed(() => {
let html = content.value
// 转义 HTML 特殊字符
html = html.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// 替换 @[nodeId] 为图片
html = html.replace(/@\[([^\]|]+)(?:\|([^\]]+))?\]/g, (match, nodeId) => {
const node = nodes.value.find(n => n.id === nodeId)
if (node?.type === 'image' && node.data?.url) {
const displayName = node.data?.publicProps?.name || node.data?.label || '图片'
return `<span class="mention-inline" data-node-id="${nodeId}"><img src="${node.data.url}" alt="${displayName}" />${displayName}</span>`
}
return match
})
// 换行符转换为 <br>
html = html.replace(/\n/g, '<br>')
return html
})
// Text node menu operations | 文本节点菜单操作
const operations = [
{ type: 'imageConfig', label: '生图', icon: ImageOutline },
{ type: 'videoConfig', label: '生视频', icon: VideocamOutline },
{ type: 'llmConfig', label: 'LLM', icon: ChatbubbleOutline }
]
// Handle menu select | 处理菜单选择
const handleSelect = (item) => {
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
const defaultData = {
imageConfig: { model: 'auto', size: '1024x1536', label: '文生图' },
videoConfig: { label: '视频生成' },
llmConfig: { label: 'LLM文本生成' }
}
const newId = addNode(item.type, { x: nodeX + 400, y: nodeY }, defaultData[item.type] || {})
addEdge({
source: props.id,
target: newId,
sourceHandle: 'right',
targetHandle: 'left'
})
setTimeout(() => updateNodeInternals(newId), 50)
window.$message?.success(`已创建${item.label}节点`)
}
// Handle input for @ trigger | 处理 @ 触发输入(参考 MaterialInput
const handleInput = (e) => {
const editor = e.target
isInternalUpdate = true
content.value = getEditableText()
lastContent.value = content.value
nextTick(() => { isInternalUpdate = false })
// 触发文本到 chip 的转换
debouncedConvertMentions()
// 获取光标位置
const selection = window.getSelection()
if (!selection.rangeCount) return
const range = selection.getRangeAt(0)
// 使用辅助函数计算纯文本中的光标位置
const cursorPos = getTextPositionBeforeCursor(editor, range)
const fullText = getEditableText()
const textBeforeCursor = fullText.slice(0, cursorPos)
// Check if cursor is after @ character | 检查光标是否在 @ 字符后
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
if (lastAtIndex !== -1) {
// Check if there's a space after @ (meaning user finished typing mention) | 检查 @ 后面是否有空格(用户已完成输入)
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1)
// Check if there's a complete @[...] mention | 检查是否有完整的 @[...] 配对
const bracketMatch = textAfterAt.match(/\[([^\]]*)\]/)
const hasCompleteMention = bracketMatch !== null
// Show picker only if: @ exists, no space after @, and not part of a complete @[...] mention
if (!textAfterAt.includes(' ') && !hasCompleteMention) {
// Calculate position | 计算位置
showMentionsPicker.value = true
mentionSearchStart.value = lastAtIndex
// Get editor position relative to viewport | 获取 editor 相对于视口的位置
const rect = editor.getBoundingClientRect()
mentionsPosition.value = {
x: rect.left + 10,
y: rect.bottom + 5
}
return
}
}
// Hide picker if conditions not met | 如果条件不满足,隐藏选择器
showMentionsPicker.value = false
}
// Handle keydown for mentions and Shift+Enter | 处理 @ 选择器和 Shift+Enter 换行
const handleKeydown = (e) => {
// 处理 @ 选择器
if (showMentionsPicker.value) {
// 回车键选中当前高亮的项
if (e.key === 'Enter') {
e.preventDefault()
// 触发 MentionsPicker 的选择事件,需要通过自定义事件来处理
// 由于无法直接访问 MentionsPicker 的内部状态,这里暂时不做处理
// 让事件继续传播到 MentionsPicker
return
}
if (e.key === 'Escape') {
e.preventDefault()
showMentionsPicker.value = false
// Remove the incomplete @ | 移除不完整的 @
const selection = window.getSelection()
if (!selection.rangeCount) return
const range = selection.getRangeAt(0)
const editor = editorRef.value
const cursorPos = range.startOffset
const textBeforeCursor = content.value.slice(0, cursorPos)
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
if (lastAtIndex !== -1) {
content.value = textBeforeCursor.slice(0, lastAtIndex) + content.value.slice(cursorPos)
lastContent.value = content.value
// Update editor content | 更新 editor 内容
nextTick(() => {
editor.innerHTML = editorHtml.value
// Set cursor position | 设置光标位置
const newRange = document.createRange()
newRange.setStart(editor.firstChild || editor, lastAtIndex)
newRange.collapse(true)
selection.removeAllRanges()
selection.addRange(newRange)
})
}
}
return
}
// 规范化 Shift+Enter 插入换行
if (e.key === 'Enter' && e.shiftKey) {
e.preventDefault()
document.execCommand('insertLineBreak')
}
}
// Handle mention selection | 处理 @ 引用选择(参考 MaterialInput
const handleMentionSelect = ({ nodeId }) => {
// 找到对应的图片节点
const node = nodes.value.find(n => n.id === nodeId)
if (!node) {
showMentionsPicker.value = false
return
}
// 插入 mention chip 到 DOM
insertMentionChipDOM(node)
// 更新 store
updateContent()
showMentionsPicker.value = false
}
// Watch for external data changes | 监听外部数据变化
watch(() => props.data?.content, (newVal) => {
if (newVal !== content.value) {
content.value = newVal || ''
lastContent.value = content.value
// Sync to editor | 同步到 editor
setEditableContent(content.value)
// 立即将文本中的 @label 转为 chip
nextTick(() => convertTextMentionsToChips())
}
})
// Watch content changes and sync to editor | 监听内容变化并同步到编辑器
watch(content, (newVal) => {
if (isInternalUpdate) return
setEditableContent(newVal)
// 立即将文本中的 @label 转为 chip
nextTick(() => convertTextMentionsToChips())
lastContent.value = newVal
})
// Initialize editor content | 初始化 editor 内容
onMounted(() => {
if (editorRef.value) {
if (props.data?.content) {
content.value = props.data.content
}
lastContent.value = content.value
// 使用 setEditableContent + convertTextMentionsToChips 确保正确创建 mention-chip
setEditableContent(content.value)
nextTick(() => convertTextMentionsToChips())
}
})
// Update content in store | 更新存储中的内容
const updateContent = () => {
updateNode(props.id, { content: content.value })
}
// Handle AI polish | 处理 AI 润色
const handlePolish = async () => {
const input = content.value.trim()
if (!input) return
// Check API configuration | 检查 API 配置
if (!isApiConfigured.value) {
window.$message?.warning('生成接口未就绪,请稍后重试')
return
}
isPolishing.value = true
const originalContent = content.value
try {
// Call chat API to polish the prompt | 调用 AI 润色提示词
const result = await sendChat(input, true)
if (result) {
content.value = result
updateNode(props.id, { content: result })
window.$message?.success('提示词已润色')
}
} catch (err) {
content.value = originalContent
window.$message?.error(err.message || '润色失败')
} finally {
isPolishing.value = false
}
}
// Start editing label | 开始编辑 label
const startEditLabel = () => {
editingLabelValue.value = props.data?.label || ''
isEditingLabel.value = true
nextTick(() => {
labelInputRef.value?.focus()
labelInputRef.value?.select()
})
}
// Finish editing label | 完成编辑 label
const finishEditLabel = () => {
const newLabel = editingLabelValue.value.trim()
if (newLabel && newLabel !== props.data?.label) {
updateNode(props.id, { label: newLabel })
}
isEditingLabel.value = false
}
// Cancel editing label | 取消编辑 label
const cancelEditLabel = () => {
isEditingLabel.value = false
}
// Handle delete | 处理删除
const handleDelete = () => {
removeNode(props.id)
}
// Handle duplicate | 处理复制
const handleDuplicate = () => {
const newNodeId = duplicateNode(props.id)
window.$message?.success('节点已复制')
if (newNodeId) {
setTimeout(() => {
updateNodeInternals(newNodeId)
}, 50)
}
}
// Handle image generation | 处理图片生成
const handleImageGen = () => {
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
// Create imageConfig node | 创建text生图配置节点
const configNodeId = addNode('imageConfig', { x: nodeX + 400, y: nodeY }, {
model: 'auto',
size: '1024x1536',
label: '文生图'
})
// Auto connect | 自动连接
addEdge({
source: props.id,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
setTimeout(() => {
updateNodeInternals(configNodeId)
}, 50)
}
// Handle video generation | 处理视频生成
const handleVideoGen = () => {
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
// Create videoConfig node | 创建视频配置节点
const configNodeId = addNode('videoConfig', { x: nodeX + 400, y: nodeY }, {
label: '视频生成'
})
// Auto connect | 自动连接
addEdge({
source: props.id,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
setTimeout(() => {
updateNodeInternals(configNodeId)
}, 50)
}
</script>
<style scoped>
.text-node-wrapper {
padding-right: 50px;
padding-top: 20px;
position: relative;
}
.text-node {
cursor: default;
position: relative;
}
/* Textarea wrapper - 参考 MaterialInput input-with-mention */
.textarea-wrapper {
position: relative;
}
/* Editor styles | 编辑器样式 - 参考 MaterialInput */
.editor-content {
min-height: 60px;
max-height: 120px;
padding: 8px 10px;
border: none;
border-radius: 8px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 14px;
line-height: 1.6;
outline: none;
overflow-y: auto;
word-break: break-word;
white-space: pre-wrap;
}
.editor-content:focus {
background: var(--bg-tertiary);
}
.editor-content:empty::before {
content: attr(data-placeholder);
color: var(--text-secondary);
opacity: 0.5;
pointer-events: none;
}
</style>
<style>
/* Inline mention in editor | editor 中内联提及 */
.editor-content :deep(.mention-inline) {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
}
.editor-content :deep(.mention-inline img) {
width: 16px;
height: 16px;
border-radius: 2px;
object-fit: cover;
}
/* Mentions preview | @ 提及预览 */
.mentions-preview {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--border-color);
}
/* Mention chip - 参考 MaterialInput 样式 */
.mention-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px 2px 2px;
margin: 0 2px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 13px;
vertical-align: middle;
cursor: default;
user-select: none;
line-height: 1.4;
color: var(--text-primary);
}
.mention-chip img {
width: 20px;
height: 20px;
border-radius: 4px;
object-fit: cover;
}
.mention-placeholder {
font-size: 12px;
}
.mention-name {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -1,540 +0,0 @@
<template>
<!-- Video config node wrapper | 视频配置节点包裹层 -->
<div class="video-config-node-wrapper relative" @mouseenter="showHandleMenu = true" @mouseleave="showHandleMenu = false">
<!-- Video config node | 视频配置节点 -->
<div class="video-config-node bg-[var(--bg-secondary)] rounded-xl border min-w-[300px] transition-all duration-200"
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'">
<!-- Header | 头部 -->
<div class="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)]">
<span
v-if="!isEditingLabel"
@dblclick="startEditLabel"
class="text-sm font-medium text-[var(--text-secondary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
title="双击编辑名称"
>{{ data.label || '视频生成' }}</span>
<input
v-else
ref="labelInputRef"
v-model="editingLabelValue"
@blur="finishEditLabel"
@keydown.enter="finishEditLabel"
@keydown.escape="cancelEditLabel"
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] px-1 rounded outline-none border border-blue-500"
/>
<div class="flex items-center gap-1">
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="复制节点">
<n-icon :size="14">
<CopyOutline />
</n-icon>
</button>
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="删除节点">
<n-icon :size="14">
<TrashOutline />
</n-icon>
</button>
</div>
</div>
<!-- Config options | 配置选项 -->
<div class="p-3 space-y-3">
<!-- Model selector | 模型选择 -->
<div class="flex items-center justify-between">
<span class="text-xs text-[var(--text-secondary)]">模型</span>
<n-dropdown :options="modelOptions" @select="handleModelSelect">
<button class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
{{ displayModelName }}
<n-icon :size="12"><ChevronDownOutline /></n-icon>
</button>
</n-dropdown>
</div>
<!-- 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)]">
{{ localRatio }}
<n-icon :size="12">
<ChevronForwardOutline />
</n-icon>
</button>
</n-dropdown>
</div>
<!-- Duration selector | 时长选择 -->
<div class="flex items-center justify-between">
<span class="text-xs text-[var(--text-secondary)]">时长</span>
<n-dropdown :options="durationOptions" @select="handleDurationSelect">
<button class="flex items-center gap-1 text-sm text-[var(--text-primary)] hover:text-[var(--accent-color)]">
{{ localDuration }}s
<n-icon :size="12">
<ChevronForwardOutline />
</n-icon>
</button>
</n-dropdown>
</div>
<!-- Connected inputs indicator | 连接输入指示 -->
<div
class="flex items-center gap-2 text-xs text-[var(--text-secondary)] py-1 border-t border-[var(--border-color)]">
<span class="px-2 py-0.5 rounded-full"
:class="connectedPrompt ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
提示词 {{ connectedPrompt ? '✓' : '○' }}
</span>
<span class="px-2 py-0.5 rounded-full"
:class="imagesByRole.firstFrame ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
首帧 {{ imagesByRole.firstFrame ? '✓' : '○' }}
</span>
<span class="px-2 py-0.5 rounded-full"
:class="imagesByRole.lastFrame ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
尾帧 {{ imagesByRole.lastFrame ? '✓' : '○' }}
</span>
<span class="px-2 py-0.5 rounded-full"
:class="imagesByRole.referenceImages.length > 0 ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
参考图 {{ imagesByRole.referenceImages.length > 0 ? `${imagesByRole.referenceImages.length}` : '○' }}
</span>
</div>
<!-- Progress bar | 进度条 -->
<!-- <div v-if="status === 'polling'" class="space-y-1">
<div class="flex justify-between text-xs text-[var(--text-secondary)]">
<span>生成中...</span>
<span>{{ progress.percentage }}%</span>
</div>
<n-progress type="line" :percentage="progress.percentage" :show-indicator="false" :height="4" />
</div> -->
<!-- Generate button | 生成按钮 -->
<button @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">
<n-spin v-if="isGenerating" :size="14" />
<template v-else>
<n-icon :size="16">
<VideocamOutline />
</n-icon>
生成视频
</template>
</button>
<!-- Error message | 错误信息 -->
<div v-if="error" class="text-xs text-red-500 mt-2">
{{ error.message || '生成失败' }}
</div>
<!-- Generated video preview | 生成视频预览 -->
<!-- <div v-if="generatedVideo?.url" class="mt-3 space-y-2">
<div class="text-xs text-[var(--text-secondary)]">生成结果:</div>
<div class="aspect-video rounded-lg overflow-hidden bg-black">
<video :src="generatedVideo.url" controls class="w-full h-full object-contain" />
</div>
</div> -->
</div>
<!-- Handles | 连接点 -->
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
<NodeHandleMenu :nodeId="id" nodeType="videoConfig" :visible="showHandleMenu" :operations="[]" />
</div>
</div>
</template>
<script setup>
/**
* Video config node component | 视频配置节点组件
* Configuration panel for video generation with API integration
*/
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { Handle, Position, useVueFlow } from '@vue-flow/core'
import { NIcon, NDropdown, NSpin } from 'naive-ui'
import { ChevronForwardOutline, ChevronDownOutline, TrashOutline, VideocamOutline, CopyOutline, CreateOutline } from '@vicons/ionicons5'
import { useVideoGeneration } from '../../hooks'
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes, edges } from '../../stores/canvas'
import NodeHandleMenu from './NodeHandleMenu.vue'
import { useModelStore } from '../../stores/pinia'
import { getModelRatioOptions, getModelDurationOptions, getModelConfig, DEFAULT_VIDEO_MODEL } from '../../stores/models'
// 使用 Pinia store 获取模型选项(根据渠道过滤)
const modelStore = useModelStore()
const props = defineProps({
id: String,
data: Object
})
// Vue Flow instance | Vue Flow 实例
const { updateNodeInternals } = useVueFlow()
// API config state | API 配置状态
const isConfigured = computed(() => !!modelStore.currentApiKey)
// Video generation hook | 视频生成 hook
const { loading, error, status, video: generatedVideo, progress, createVideoTaskOnly } = useVideoGeneration()
// 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)
// Label editing state | Label 编辑状态
const isEditingLabel = ref(false)
const editingLabelValue = ref('')
const labelInputRef = ref(null)
// Get connected images with roles | 获取连接的图片及其角色
const connectedImages = computed(() => {
const connectedEdges = edges.value.filter(e => e.target === props.id)
const images = []
for (const edge of connectedEdges) {
const sourceNode = nodes.value.find(n => n.id === edge.source)
if (sourceNode?.type === 'image' && sourceNode.data?.url) {
images.push({
nodeId: sourceNode.id,
edgeId: edge.id,
url: sourceNode.data.url,
base64: sourceNode.data.base64,
role: edge.data?.imageRole || 'first_frame_image' // Default to first frame | 默认首帧
})
}
}
return images
})
// Get images by role | 按角色获取图片
const imagesByRole = computed(() => {
const firstFrame = connectedImages.value.find(img => img.role === 'first_frame_image')
const lastFrame = connectedImages.value.find(img => img.role === 'last_frame_image')
const referenceImages = connectedImages.value.filter(img => img.role === 'input_reference')
return {
firstFrame,
lastFrame,
referenceImages
}
})
// Get current model config | 获取当前模型配置
const currentModelConfig = computed(() => getModelConfig(localModel.value))
// Model options from Pinia store (filtered by provider) | 从 Pinia store 获取模型选项(根据渠道过滤)
const modelOptions = computed(() => modelStore.allVideoModelOptions)
// Display model name | 显示模型名称
const displayModelName = computed(() => {
const model = modelOptions.value.find(m => m.key === localModel.value)
// 如果当前模型不在选项中,尝试从 allVideoModels 找到
if (!model) {
const allModel = modelStore.allVideoModels.find(m => m.key === localModel.value)
return allModel?.label || localModel.value || '选择模型'
}
return model?.label || localModel.value || '选择模型'
})
// Ratio options based on model | 基于模型的比例选项
const ratioOptions = computed(() => {
return getModelRatioOptions(localModel.value)
})
// Duration options based on model | 基于模型的时长选项
const durationOptions = computed(() => {
return getModelDurationOptions(localModel.value)
})
// Handle model selection | 处理模型选择
const handleModelSelect = (key) => {
localModel.value = key
// Update ratio and duration to model's default | 更新为模型默认比例和时长
const config = getModelConfig(key)
const updates = { model: key }
if (config?.defaultParams?.ratio) {
localRatio.value = config.defaultParams.ratio
updates.ratio = config.defaultParams.ratio
}
if (config?.defaultParams?.duration) {
localDuration.value = config.defaultParams.duration
updates.dur = config.defaultParams.duration
}
updateNode(props.id, updates)
}
// Handle duplicate | 处理复制
const handleDuplicate = () => {
const newNodeId = duplicateNode(props.id)
window.$message?.success('节点已复制')
if (newNodeId) {
setTimeout(() => {
updateNodeInternals(newNodeId)
}, 50)
}
}
// Handle ratio selection | 处理比例选择
const handleRatioSelect = (key) => {
localRatio.value = key
updateNode(props.id, { ratio: key })
}
// Handle duration selection | 处理时长选择
const handleDurationSelect = (key) => {
localDuration.value = key
updateNode(props.id, { dur: key })
}
// Get connected inputs by role | 根据角色获取连接的输入
const getConnectedInputs = () => {
const connectedEdges = edges.value.filter(e => e.target === props.id)
let prompt = ''
let first_frame_image = ''
let last_frame_image = ''
const images = [] // input_reference images | 参考图
for (const edge of connectedEdges) {
const sourceNode = nodes.value.find(n => n.id === edge.source)
if (!sourceNode) continue
if (sourceNode.type === 'text') {
prompt = sourceNode.data?.content || ''
} else if (sourceNode.type === 'llmConfig') {
// LLM node output as prompt | LLM 节点输出作为提示词
const content = sourceNode.data?.outputContent || ''
if (content) prompt = content
} else if (sourceNode.type === 'image' && sourceNode.data?.url) {
const imageData = sourceNode.data.base64 || sourceNode.data.url
const role = edge.data?.imageRole || 'first_frame_image'
if (role === 'first_frame_image') {
first_frame_image = imageData
} else if (role === 'last_frame_image') {
last_frame_image = imageData
} else if (role === 'input_reference') {
images.push(imageData)
}
}
}
return { prompt, first_frame_image, last_frame_image, images }
}
// Computed connected prompt | 计算连接的提示词
const connectedPrompt = computed(() => {
return getConnectedInputs().prompt
})
// Created video node ID | 创建的视频节点 ID
const createdVideoNodeId = ref(null)
// Handle generate action | 处理生成操作
const handleGenerate = async () => {
// 设置生成中状态
isGenerating.value = true
const { prompt, first_frame_image, last_frame_image, images } = getConnectedInputs()
const hasInput = prompt || first_frame_image || last_frame_image || images.length > 0
if (!hasInput) {
window.$message?.warning('请先连接文本节点或图片节点')
isGenerating.value = false
return
}
if (!isConfigured.value) {
window.$message?.warning('生成接口未就绪,请稍后重试')
isGenerating.value = false
return
}
// Get current node position | 获取当前节点位置
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
// Create video node with loading state | 创建带加载状态的视频节点
const videoNodeId = addNode('video', { x: nodeX + 350, y: nodeY }, {
url: '',
loading: true,
label: '视频生成中...'
})
createdVideoNodeId.value = videoNodeId
// Auto-connect videoConfig → video | 自动连接 视频配置 → 视频
addEdge({
source: props.id,
target: videoNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
setTimeout(() => {
updateNodeInternals(videoNodeId)
}, 50)
try {
// Build request params (raw form data) | 构建请求参数(原始表单数据)
// These will be transformed by inputTransform | 这些会被 inputTransform 转换
const params = {
model: localModel.value
}
// Add prompt if provided | 如果有提示词则添加
if (prompt) {
params.prompt = prompt
}
// Add first frame image | 添加首帧图片
if (first_frame_image) {
params.first_frame_image = first_frame_image
}
// Add last frame image | 添加尾帧图片
if (last_frame_image) {
params.last_frame_image = last_frame_image
}
// Add reference images (input_reference) | 添加参考图
if (images.length > 0) {
params.images = images
}
// Add ratio/size | 添加比例参数
if (localRatio.value) {
params.ratio = localRatio.value
}
// Add duration | 添加时长
if (localDuration.value) {
params.dur = localDuration.value
}
// 只创建任务,获取 taskId不在这里轮询
const { taskId: newTaskId, url } = await createVideoTaskOnly(params)
// 如果有直接 URL更新视频节点
if (url) {
updateNode(videoNodeId, {
url: url,
loading: false,
label: '视频生成',
model: localModel.value,
updatedAt: Date.now()
})
window.$message?.success('视频生成成功')
// Mark this config node as executed | 标记配置节点已执行
updateNode(props.id, { executed: true, outputNodeId: videoNodeId })
} else if (newTaskId) {
// 需要轮询,传递 taskId 给 VideoNode
updateNode(videoNodeId, {
taskId: newTaskId,
loading: true,
label: '视频生成中...',
model: localModel.value,
updatedAt: Date.now()
})
window.$message?.success('视频任务已创建')
// Mark this config node as executed | 标记配置节点已执行
updateNode(props.id, { executed: true, outputNodeId: videoNodeId })
}
} catch (err) {
// Update node to show error | 更新节点显示错误
updateNode(videoNodeId, {
loading: false,
error: err.message || '生成失败',
label: '生成失败',
updatedAt: Date.now()
})
window.$message?.error(err.message || '视频生成失败')
} finally {
isGenerating.value = false
}
}
// Start editing label | 开始编辑 label
const startEditLabel = () => {
editingLabelValue.value = props.data?.label || '视频生成'
isEditingLabel.value = true
nextTick(() => {
labelInputRef.value?.focus()
labelInputRef.value?.select()
})
}
// Finish editing label | 完成编辑 label
const finishEditLabel = () => {
const newLabel = editingLabelValue.value.trim()
if (newLabel && newLabel !== props.data?.label) {
updateNode(props.id, { label: newLabel })
}
isEditingLabel.value = false
}
// Cancel editing label | 取消编辑 label
const cancelEditLabel = () => {
isEditingLabel.value = false
}
// Handle delete | 处理删除
const handleDelete = () => {
removeNode(props.id)
}
// Initialize on mount | 挂载时初始化
onMounted(() => {
// 检查当前模型是否在可用模型列表中
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 })
}
})
// Watch for model changes from props | 监听 props 中模型变化
watch(() => props.data?.model, (newModel) => {
if (newModel && newModel !== localModel.value) {
localModel.value = newModel
}
})
// 修复 Vue Flow visibility: hidden 问题
// 当节点数据变化时,强制更新内部状态
watch(() => props.data, () => {
nextTick(() => {
updateNodeInternals(props.id)
})
}, { deep: true })
// Watch for auto-execute flag | 监听自动执行标志
watch(
() => props.data?.autoExecute,
(shouldExecute) => {
if (shouldExecute && !loading.value) {
// Clear the flag first to prevent re-triggering | 先清除标志防止重复触发
updateNode(props.id, { autoExecute: false })
// Delay to ensure node connections are established | 延迟确保节点连接已建立
setTimeout(() => {
handleGenerate()
}, 100)
}
},
{ immediate: true }
)
</script>
<style scoped>
.video-config-node-wrapper {
position: relative;
padding-top: 20px;
}
.video-config-node {
cursor: default;
position: relative;
}
</style>

View File

@@ -1,341 +0,0 @@
<template>
<!-- Video node wrapper | 视频节点包裹层 -->
<div class="video-node-wrapper relative" @mouseenter="showActions = true; showHandleMenu = true" @mouseleave="showActions = false; showHandleMenu = false">
<!-- Video node | 视频节点 -->
<div
class="video-node bg-[var(--bg-secondary)] rounded-xl border w-[400px] relative transition-all duration-200"
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'"
>
<!-- Header | 头部 -->
<div class="px-3 py-2 border-b border-[var(--border-color)]">
<div class="flex items-center justify-between">
<span
v-if="!isEditingLabel"
@dblclick="startEditLabel"
class="text-sm font-medium text-[var(--text-secondary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
title="双击编辑名称"
>{{ data.label }}</span>
<input
v-else
ref="labelInputRef"
v-model="editingLabelValue"
@blur="finishEditLabel"
@keydown.enter="finishEditLabel"
@keydown.escape="cancelEditLabel"
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] px-1 rounded outline-none border border-blue-500"
/>
<div class="flex items-center gap-1">
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="复制节点">
<n-icon :size="14">
<CopyOutline />
</n-icon>
</button>
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors" title="删除节点">
<n-icon :size="14">
<TrashOutline />
</n-icon>
</button>
</div>
</div>
<!-- Model name | 模型名称 -->
<div v-if="data.model" class="mt-1 text-xs text-[var(--text-secondary)] truncate">
{{ data.model }}
</div>
</div>
<!-- Video preview area | 视频预览区域 -->
<div class="p-3">
<!-- Loading state | 加载状态 -->
<div
v-if="(data.taskId && !data.url) || (data.loading && !data.taskId)"
class="aspect-video rounded-lg bg-gradient-to-br from-cyan-400 via-blue-300 to-amber-200 flex flex-col items-center justify-center gap-3 relative overflow-hidden"
>
<!-- Animated gradient overlay | 动画渐变遮罩 -->
<div class="absolute inset-0 bg-gradient-to-br from-cyan-500/20 via-blue-400/20 to-amber-300/20 animate-pulse"></div>
<!-- Loading image | 加载图片 -->
<div class="relative z-10">
<img
src="../../assets/loading.webp"
alt="Loading"
class="w-14 h-12"
/>
</div>
<span class="text-sm text-white font-medium relative z-10">{{ data.taskId ? '创作中,预计等待 1 分钟' : '任务创建中...' }}</span>
</div>
<!-- Error state | 错误状态 -->
<div
v-else-if="data.error"
class="aspect-video rounded-lg bg-red-50 dark:bg-red-900/20 flex flex-col items-center justify-center gap-2 border border-red-200 dark:border-red-800"
>
<n-icon :size="32" class="text-red-500"><CloseCircleOutline /></n-icon>
<span class="text-sm text-red-500">{{ data.error }}</span>
</div>
<!-- Video preview | 视频预览 -->
<div
v-else-if="data.url"
class="aspect-video rounded-lg overflow-hidden bg-black"
>
<video
:src="data.url"
controls
class="w-full h-full object-contain"
/>
</div>
<!-- Empty state | 空状态 -->
<div
v-else
class="aspect-video rounded-lg bg-[var(--bg-tertiary)] flex flex-col items-center justify-center gap-2 border-2 border-dashed border-[var(--border-color)] relative"
>
<n-icon :size="32" class="text-[var(--text-secondary)]"><VideocamOutline /></n-icon>
<span class="text-sm text-[var(--text-secondary)]">拖放视频或点击上传</span>
<input
type="file"
accept="video/*"
class="absolute inset-0 opacity-0 cursor-pointer"
@change="handleFileUpload"
/>
</div>
<!-- Duration info | 时长信息 -->
<div v-if="data.duration" class="mt-2 text-xs text-[var(--text-secondary)]">
时长: {{ formatDuration(data.duration) }}
</div>
</div>
<!-- Handles | 连接点 -->
<NodeHandleMenu :nodeId="id" nodeType="video" :visible="showHandleMenu" :operations="operations" @select="handleSelect" />
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
</div>
<!-- Right side - Action buttons | 右侧 - 操作按钮 -->
<div
v-show="showActions && data.url"
class="absolute right-10 top-20 -translate-y-1/2 translate-x-full flex flex-col gap-2 z-[1000]"
>
<!-- Preview button | 预览按钮 -->
<button
@click="handlePreview"
class="action-btn group p-2 bg-white rounded-lg transition-all border border-gray-200 flex items-center gap-0 hover:gap-1.5 w-max"
>
<n-icon :size="16" class="text-gray-600"><EyeOutline /></n-icon>
<span class="text-xs text-gray-600 max-w-0 overflow-hidden group-hover:max-w-[80px] transition-all duration-200 whitespace-nowrap">预览</span>
</button>
<!-- Download button | 下载按钮 -->
<button
@click="handleDownload"
class="action-btn group p-2 bg-white rounded-lg transition-all border border-gray-200 flex items-center gap-0 hover:gap-1.5 w-max"
>
<n-icon :size="16" class="text-gray-600"><DownloadOutline /></n-icon>
<span class="text-xs text-gray-600 max-w-0 overflow-hidden group-hover:max-w-[80px] transition-all duration-200 whitespace-nowrap">下载</span>
</button>
</div>
</div>
</template>
<script setup>
/**
* Video node component | 视频节点组件
* Displays and manages video content
*/
import { ref, nextTick, watch, onMounted } 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 NodeHandleMenu from './NodeHandleMenu.vue'
const props = defineProps({
id: String,
data: Object
})
// Vue Flow instance
const { updateNodeInternals } = useVueFlow()
// Get pollVideoTask from useVideoGeneration | 从 useVideoGeneration 获取轮询函数
const { pollVideoTask } = useVideoGeneration()
// Hover state | 悬浮状态
const showActions = ref(false)
const showHandleMenu = ref(false)
// Label editing state | Label 编辑状态
const isEditingLabel = ref(false)
const editingLabelValue = ref('')
const labelInputRef = ref(null)
// Video node menu operations | 视频节点菜单操作
const operations = [
{ type: 'videoConfig', label: '生视频', icon: VideocamOutline }
]
// 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)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
const newId = addNode('videoConfig', { x: nodeX + 400, y: nodeY }, { label: '视频生成' })
addEdge({
source: props.id,
target: newId,
sourceHandle: 'right',
targetHandle: 'left'
})
setTimeout(() => {
updateNodeInternals(newId)
}, 50)
window.$message?.success(`已创建视频生成节点`)
}
// Handle file upload | 处理文件上传
const handleFileUpload = (event) => {
const file = event.target.files[0]
if (file) {
const url = URL.createObjectURL(file)
updateNode(props.id, {
url,
updatedAt: Date.now()
})
}
}
// Format duration | 格式化时长
const formatDuration = (seconds) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// Start editing label | 开始编辑 label
const startEditLabel = () => {
editingLabelValue.value = props.data?.label || ''
isEditingLabel.value = true
nextTick(() => {
labelInputRef.value?.focus()
labelInputRef.value?.select()
})
}
// Finish editing label | 完成编辑 label
const finishEditLabel = () => {
const newLabel = editingLabelValue.value.trim()
if (newLabel && newLabel !== props.data?.label) {
updateNode(props.id, { label: newLabel })
}
isEditingLabel.value = false
}
// Cancel editing label | 取消编辑 label
const cancelEditLabel = () => {
isEditingLabel.value = false
}
// Handle delete | 处理删除
const handleDelete = () => {
removeNode(props.id)
}
// Handle preview | 处理预览
const handlePreview = () => {
if (props.data.url) {
window.open(props.data.url, '_blank')
}
}
// Handle download | 处理下载
const handleDownload = () => {
if (props.data.url) {
const link = document.createElement('a')
link.href = props.data.url
link.download = props.data.fileName || `video_${Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.$message?.success('视频下载中...')
}
}
// Handle duplicate | 处理复制
const handleDuplicate = () => {
const newId = duplicateNode(props.id)
if (newId) {
// Clear selection and select the new node | 清除选中并选中新节点
updateNode(props.id, { selected: false })
updateNode(newId, { selected: true })
window.$message?.success('节点已复制')
}
}
</script>
<style scoped>
.video-node-wrapper {
padding-right: 50px;
padding-top: 20px;
}
.video-node {
cursor: default;
}
</style>

View File

@@ -1,163 +0,0 @@
/**
* Models Configuration | 模型配置
* Centralized model configuration | 集中模型配置
*/
// SKG backend image size options | SKG 后端图片尺寸选项
export const SEEDREAM_SIZE_OPTIONS = [
{ label: '自动', key: 'auto' },
{ label: '竖图 2:3', key: '1024x1536' },
{ label: '方图 1:1', key: '1024x1024' },
{ label: '横图 3:2', key: '1536x1024' }
]
// Kept for compatibility with upstream model helpers.
export const SEEDREAM_4K_SIZE_OPTIONS = SEEDREAM_SIZE_OPTIONS
// SKG backend currently exposes model choice and size; quality is retained as a no-op UI field.
export const SEEDREAM_QUALITY_OPTIONS = [
{ label: '标准', key: 'standard' }
]
export const BANANA_SIZE_OPTIONS = [
{ label: '16:9', key: '16x9' },
{ label: '4:3', key: '4x3' },
{ label: '3:2', key: '3x2' },
{ label: '1:1', key: '1x1' },
{ label: '2:3', key: '2x3' },
{ label: '3:4', key: '3x4' },
{ label: '9:16', key: '9x16' },
]
// Image generation models | 图片生成模型
export const IMAGE_MODELS = [
{
label: '自动',
key: 'auto',
provider: ['chatfire'],
sizes: SEEDREAM_SIZE_OPTIONS.map(s => s.key),
qualities: SEEDREAM_QUALITY_OPTIONS,
defaultParams: {
size: '1024x1536',
quality: 'standard',
style: 'vivid'
}
},
{
label: 'GPT Image 2',
key: 'gpt-image-2',
provider: ['chatfire'],
sizes: SEEDREAM_SIZE_OPTIONS.map(s => s.key),
qualities: SEEDREAM_QUALITY_OPTIONS,
defaultParams: {
size: '1024x1536',
quality: 'standard',
style: 'vivid'
}
},
{
label: 'Gemini 图片',
key: 'gemini-3-pro-image-preview',
provider: ['chatfire'],
sizes: SEEDREAM_SIZE_OPTIONS.map(s => s.key),
qualities: SEEDREAM_QUALITY_OPTIONS,
defaultParams: {
size: '1024x1536',
quality: 'standard',
style: 'vivid'
}
},
]
// Video ratio options | 视频比例选项
export const VIDEO_RATIO_LIST = [
{ label: '竖屏 9:16', key: '720x1280' },
{ label: '横屏 16:9', key: '1280x720' },
{ label: '方形 1:1', key: '1024x1024' },
{ label: '竖屏 3:4', key: '960x1280' }
]
// Video resolution options for Seedance | Seedance 分辨率选项
export const SEEDANCE_RESOLUTION_OPTIONS = [
{ label: '480p', key: '480p' },
{ label: '720p', key: '720p' },
{ label: '1080p', key: '1080p' }
]
// Video generation models | 视频生成模型
export const VIDEO_MODELS = [
{
label: 'Seedance 2.0 Fast',
key: 'seedance',
provider: ['chatfire'],
type: 't2v+i2v',
ratios: ['720x1280', '1280x720', '1024x1024', '960x1280'],
durs: [
{ label: '5 秒', key: 5 },
{ label: '8 秒', key: 8 },
{ label: '10 秒', key: 10 },
{ label: '12 秒', key: 12 },
{ label: '15 秒', key: 15 }
],
resolutions: ['720p'],
defaultResolution: '720p',
defaultParams: { ratio: '720x1280', duration: 10, resolution: '720p' }
},
]
// Chat/LLM models | 对话模型
export const CHAT_MODELS = [
{ label: 'GPT-4o Mini', key: 'gpt-4o-mini', provider: ['openai'] },
{ label: 'GPT-4o', key: 'gpt-4o', provider: ['openai'] },
{ label: 'GPT-5.2', key: 'gpt-5.2', provider: ['openai'] },
{ label: 'DeepSeek Chat', key: 'deepseek-chat', provider: ['openai', 'chatfire'] },
{ label: '豆包 Seed Flash', key: 'doubao-seed-1-6-flash-250615', provider: ['chatfire'] },
{ label: 'Gemini 3 Pro', key: 'gemini-3-pro', provider: ['openai'] }
]
// Image size options | 图片尺寸选项
export const IMAGE_SIZE_OPTIONS = [
{ label: '自动', key: 'auto' },
{ label: '竖图 2:3', key: '1024x1536' },
{ label: '方图 1:1', key: '1024x1024' },
{ label: '横图 3:2', key: '1536x1024' }
]
// Image quality options | 图片质量选项
export const IMAGE_QUALITY_OPTIONS = [
{ label: '标准', key: 'standard' },
{ label: '高清', key: 'hd' }
]
// Image style options | 图片风格选项
export const IMAGE_STYLE_OPTIONS = [
{ label: '生动', key: 'vivid' },
{ label: '自然', key: 'natural' }
]
// Video ratio options | 视频比例选项
export const VIDEO_RATIO_OPTIONS = VIDEO_RATIO_LIST
// Video duration options | 视频时长选项
export const VIDEO_DURATION_OPTIONS = [
{ label: '5 秒', key: 5 },
{ label: '8 秒', key: 8 },
{ label: '10 秒', key: 10 },
{ label: '12 秒', key: 12 },
{ label: '15 秒', key: 15 }
]
// Default values | 默认值
export const DEFAULT_IMAGE_MODEL = 'auto'
export const DEFAULT_VIDEO_MODEL = 'seedance'
export const DEFAULT_CHAT_MODEL = 'gpt-4o-mini'
export const DEFAULT_IMAGE_SIZE = '1024x1536'
export const DEFAULT_VIDEO_RATIO = '720x1280'
export const DEFAULT_VIDEO_DURATION = 10
// Get model by key | 根据 key 获取模型
export const getModelByName = (key) => {
const allModels = [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS]
return allModels.find(m => m.key === key)
}

View File

@@ -1,272 +0,0 @@
/**
* API Provider Adapters | API 渠道适配器
* 适配不同 API 提供商的请求参数和响应格式
*/
// 渠道适配配置
export const PROVIDERS = {
chatfire: {
label: 'SKG 内部',
defaultBaseUrl: '/api',
// 端点路径
endpoints: {
chat: '/v1/chat/completions',
image: '/v1/images/generations',
video: '/v1/video/generations',
videoQuery: '/v1/video/task/{taskId}'
},
// 火宝渠道请求适配
requestAdapter: {
chat: (params) => {
const adapted = {
model: params.model,
messages: params.messages
}
if (params.temperature !== undefined) adapted.temperature = params.temperature
if (params.max_tokens !== undefined) adapted.max_tokens = params.max_tokens
if (params.stream !== undefined) adapted.stream = params.stream
return adapted
},
image: (params) => {
const adapted = {
model: params.model,
prompt: params.prompt
}
if (params.size) adapted.size = params.size
if (params.n) adapted.n = params.n
if (params.quality) adapted.quality = params.quality
if (params.style) adapted.style = params.style
if (params.image) adapted.image = params.image
return adapted
},
video: (params) => {
const model = params.model || ''
// Seedance 模型 - 使用 content 数组格式
if (model.includes('seedance')) {
const content = []
// 构建完整参数文本
// 格式: prompt --resolution 720p --ratio 16:9 --dur 5 --fps 24 --wm true --seed 11 --cf false
let textPrompt = params.prompt || ''
// 添加 resolution 参数
if (params.resolution) {
textPrompt += ` --resolution ${params.resolution}`
}
// 添加 ratio 参数 (图生视频用 16:9)
if (params.size) {
textPrompt += ` --ratio ${params.size}`
}
// 添加 duration 参数
if (params.seconds) {
textPrompt += ` --dur ${params.seconds}`
}
// 添加 fps (固定 24)
textPrompt += ` --fps 24`
// 添加水印参数 (默认 true)
textPrompt += ` --wm ${params.wm !== false ? 'true' : 'false'}`
// 添加 seed 参数 (可选)
if (params.seed !== undefined) {
textPrompt += ` --seed ${params.seed}`
}
// 添加 cf 参数 (默认 false)
textPrompt += ` --cf ${params.cf === true ? 'true' : 'false'}`
content.push({
type: 'text',
text: textPrompt
})
// 添加参考图(如果有)
if (params.first_frame_image) {
content.push({
type: 'image_url',
image_url: {
url: params.first_frame_image
}
})
}
const adapted = {
model: model,
content: content,
generate_audio: params.generateAudio !== false
}
return adapted
}
// Kling 模型 - 使用 kling 特定格式
if (model.includes('kling')) {
// 将 ratio 转换为 aspect_ratio 格式
const ratioMap = {
'16:9': '16:9',
'9:16': '9:16',
'1:1': '1:1',
'4:3': '4:3',
'3:4': '3:4'
}
const adapted = {
model_name: model,
mode: 'std',
prompt: params.prompt || '',
aspect_ratio: ratioMap[params.size] || '16:9',
duration: params.seconds || 5,
negative_prompt: '',
cfg_scale: 0.5
}
// 添加参考图(如果有)
if (params.first_frame_image) {
adapted.image = params.first_frame_image
}
return adapted
}
// 默认格式veo 等)
const adapted = {
model: params.model,
prompt: params.prompt || ''
}
if (params.first_frame_image) adapted.first_frame_image = params.first_frame_image
if (params.last_frame_image) adapted.last_frame_image = params.last_frame_image
if (params.size) adapted.size = params.size
if (params.seconds) adapted.seconds = params.seconds
return adapted
}
},
// 火宝渠道响应格式
responseAdapter: {
chat: (response) => {
if (response.choices && response.choices.length > 0) {
return response.choices[0].message?.content || ''
}
return ''
},
image: (response) => {
const data = response.data || response
return (Array.isArray(data) ? data : [data]).map(item => ({
url: item.url || item.b64_json || '',
revisedPrompt: item.revised_prompt || ''
}))
},
video: (response) => {
return {
url: response.data?.url || response.url || response.data?.[0]?.url || '',
...response
}
}
}
},
openai: {
label: 'OpenAI',
defaultBaseUrl: 'https://api.openai.com',
// 端点路径
endpoints: {
chat: '/v1/chat/completions',
image: '/v1/images/generations',
video: '/v1/videos',
videoQuery: '/v1/videos/{taskId}'
},
// 请求参数适配
requestAdapter: {
chat: (params) => {
const adapted = {
model: params.model,
messages: params.messages
}
// 添加可选参数
if (params.temperature !== undefined) adapted.temperature = params.temperature
if (params.max_tokens !== undefined) adapted.max_tokens = params.max_tokens
if (params.stream !== undefined) adapted.stream = params.stream
return adapted
},
image: (params) => {
const adapted = {
model: params.model,
prompt: params.prompt
}
if (params.size) adapted.size = params.size
if (params.n) adapted.n = params.n
if (params.quality) adapted.quality = params.quality
if (params.style) adapted.style = params.style
if (params.image) adapted.image = params.image
return adapted
},
video: (params) => {
const adapted = {
model: params.model,
prompt: params.prompt || ''
}
if (params.first_frame_image) adapted.first_frame_image = params.first_frame_image
if (params.last_frame_image) adapted.last_frame_image = params.last_frame_image
if (params.size) adapted.size = params.size
if (params.seconds) adapted.seconds = params.seconds
return adapted
}
},
// 响应数据适配
responseAdapter: {
chat: (response) => {
if (response.choices && response.choices.length > 0) {
return response.choices[0].message?.content || ''
}
return ''
},
image: (response) => {
const data = response.data || response
return (Array.isArray(data) ? data : [data]).map(item => ({
url: item.url || item.b64_json || '',
revisedPrompt: item.revised_prompt || ''
}))
},
video: (response) => {
return {
url: response.data?.url || response.url || response.data?.[0]?.url || '',
...response
}
}
}
},
// 默认使用 OpenAI 格式
default: 'chatfire'
}
// 获取渠道列表
export const getProviderList = () => {
return Object.entries(PROVIDERS)
.filter(([key]) => key !== 'default')
.map(([key, value]) => ({
key,
label: value.label
}))
}
// 获取默认渠道
export const getDefaultProvider = () => {
return PROVIDERS.default || 'chatfire'
}
// 获取渠道的默认 Base URL
export const getDefaultBaseUrl = (providerKey) => {
const config = getProviderConfig(providerKey)
return config.defaultBaseUrl || ''
}
// 获取渠道配置
export const getProviderConfig = (providerKey) => {
return PROVIDERS[providerKey] || PROVIDERS[PROVIDERS.default]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +0,0 @@
/**
* Hooks Entry | Hooks 入口
* Exports all hooks for easy import
*/
// API Configuration Hook | API 配置 Hook
export { useApiConfig } from './useApiConfig'
// Model Configuration Hook | 模型配置 Hook
export { useModelConfig } from './useModelConfig'
// Provider Hook | 渠道管理 Hook
export { useProvider } from './useProvider'
// API Operation Hooks | API 操作 Hooks
export {
useApiState,
useChat,
useImageGeneration,
useVideoGeneration,
useApi
} from './useApi'
// Workflow Orchestrator Hook | 工作流编排 Hook
export { useWorkflowOrchestrator } from './useWorkflowOrchestrator'

View File

@@ -1,299 +0,0 @@
import { ref, reactive, onUnmounted } from 'vue'
const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api'
const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`
const toAssetUrl = (path) => {
if (!path) return ''
if (/^(https?:|blob:|data:)/i.test(path)) return path
return apiUrl(path)
}
const parseApiError = async (response, fallback) => {
const text = await response.text().catch(() => '')
try {
const parsed = JSON.parse(text)
return parsed?.detail || parsed?.error || fallback
} catch {
return text || fallback
}
}
const requestJson = async (path, init = {}) => {
const response = await fetch(apiUrl(path), {
...init,
headers: {
...(init.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(init.headers || {})
}
})
if (!response.ok) {
throw new Error(await parseApiError(response, `${path} ${response.status}`))
}
return response.json()
}
const dataUrlToFile = (dataUrl, filename = 'reference.jpg') => {
const [meta, payload] = dataUrl.split(',')
const mime = /data:([^;]+)/.exec(meta)?.[1] || 'image/jpeg'
const binary = atob(payload || '')
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
return new File([bytes], filename, { type: mime })
}
const imageSourceToFile = async (source, filename = 'reference.jpg') => {
if (!source) return null
if (source instanceof File) return source
if (typeof source !== 'string') return null
if (source.startsWith('data:')) return dataUrlToFile(source, filename)
const url = source.startsWith('/jobs/') ? apiUrl(source) : source
const response = await fetch(url)
if (!response.ok) throw new Error(`读取参考图失败 ${response.status}`)
const blob = await response.blob()
return new File([blob], filename, { type: blob.type || 'image/jpeg' })
}
const createCreativeImageJob = async (file = null) => {
if (file) {
const form = new FormData()
form.append('file', file)
return requestJson('/creative/jobs/image', { method: 'POST', body: form })
}
return requestJson('/creative/jobs/image', { method: 'POST', body: JSON.stringify({}) })
}
const uploadReferenceFrame = async (jobId, file) => {
const form = new FormData()
form.append('file', file)
return requestJson(`/jobs/${jobId}/frames/upload`, { method: 'POST', body: form })
}
const newestGeneratedImage = (job, frameIdx = 0) => {
const frame = (job.frames || []).find(item => item.index === frameIdx) || job.frames?.[0]
return [...(frame?.generated_images || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
}
const newestGeneratedVideo = (job) => (
[...(job.generated_videos || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
)
const normalizeVideoSize = (value) => {
const raw = String(value || '').trim().toLowerCase()
const map = {
'9:16': '720x1280',
'9x16': '720x1280',
'vertical': '720x1280',
'portrait': '720x1280',
'16:9': '1280x720',
'16x9': '1280x720',
'horizontal': '1280x720',
'landscape': '1280x720',
'1:1': '1024x1024',
'1x1': '1024x1024',
'3:4': '960x1280',
'3x4': '960x1280'
}
if (/^\d+x\d+$/.test(raw)) return raw
return map[raw] || '720x1280'
}
export const useApiState = () => {
const loading = ref(false)
const error = ref(null)
const status = ref('idle')
const reset = () => {
loading.value = false
error.value = null
status.value = 'idle'
}
const setLoading = (isLoading) => {
loading.value = isLoading
status.value = isLoading ? 'running' : status.value
}
const setError = (err) => {
error.value = err
status.value = 'error'
loading.value = false
}
const setSuccess = () => {
status.value = 'success'
loading.value = false
error.value = null
}
return { loading, error, status, reset, setLoading, setError, setSuccess }
}
export const useChat = () => {
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
const messages = ref([])
const currentResponse = ref('')
let stopped = false
const send = async (content) => {
setLoading(true)
stopped = false
try {
const response = await requestJson('/creative/copy', {
method: 'POST',
body: JSON.stringify({ goal: content, seconds: 15 })
})
const variant = response.variants?.[0]
const result = variant?.image_prompt_en || variant?.video_prompt_en || content
if (!stopped) {
currentResponse.value = result
messages.value.push({ role: 'user', content })
messages.value.push({ role: 'assistant', content: result })
}
setSuccess()
return result
} catch (err) {
setError(err)
throw err
}
}
const stop = () => {
stopped = true
}
const clear = () => {
messages.value = []
currentResponse.value = ''
reset()
}
onUnmounted(() => stop())
return { loading, error, status, messages, currentResponse, send, stop, clear, reset }
}
export const useImageGeneration = () => {
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
const images = ref([])
const currentImage = ref(null)
const generate = async (params) => {
setLoading(true)
images.value = []
currentImage.value = null
try {
const refs = Array.isArray(params.image) ? params.image : (params.image ? [params.image] : [])
const firstRef = refs[0] ? await imageSourceToFile(refs[0], 'image-reference.jpg') : null
const job = await createCreativeImageJob(firstRef)
const updated = await requestJson(`/jobs/${job.id}/frames/0/generate`, {
method: 'POST',
body: JSON.stringify({
prompt: params.prompt || '',
model: params.model || 'auto',
size: params.size || '1024x1536',
mode: firstRef ? 'edit' : 'text'
})
})
const generated = newestGeneratedImage(updated, 0)
if (!generated?.url) throw new Error('图片生成完成但未返回地址')
const result = [{ ...generated, url: toAssetUrl(generated.url), jobId: updated.id, frameIdx: 0 }]
images.value = result
currentImage.value = result[0]
setSuccess()
return result
} catch (err) {
setError(err)
throw err
}
}
return { loading, error, status, images, currentImage, generate, reset }
}
export const useVideoGeneration = () => {
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
const video = ref(null)
const taskId = ref(null)
const progress = reactive({ attempt: 0, maxAttempts: 180, percentage: 0 })
const createVideoTaskOnly = async (params) => {
setLoading(true)
try {
const firstFile = params.first_frame_image ? await imageSourceToFile(params.first_frame_image, 'first-frame.jpg') : null
let job = await createCreativeImageJob(firstFile)
let lastFrameIdx = null
if (params.last_frame_image) {
const lastFile = await imageSourceToFile(params.last_frame_image, 'last-frame.jpg')
if (lastFile) {
job = await uploadReferenceFrame(job.id, lastFile)
lastFrameIdx = Math.max(...(job.frames || []).map(frame => frame.index))
}
}
const updated = await requestJson(`/jobs/${job.id}/frames/0/storyboard/video`, {
method: 'POST',
body: JSON.stringify({
prompt: params.prompt || '',
duration: Number(params.dur || params.duration || params.seconds || 10),
count: 1,
first_image: firstFile ? { kind: 'keyframe', frame_idx: 0 } : null,
last_image: lastFrameIdx !== null ? { kind: 'keyframe', frame_idx: lastFrameIdx } : null,
model: params.model || 'seedance',
size: normalizeVideoSize(params.ratio || params.size)
})
})
const created = newestGeneratedVideo(updated)
if (!created?.id) throw new Error('视频任务已提交但未返回任务编号')
const id = `skg:${updated.id}:${created.id}`
taskId.value = id
status.value = 'polling'
setSuccess()
return { taskId: id }
} catch (err) {
setError(err)
throw err
}
}
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 }
}
export const useApi = () => {
const chat = useChat()
const image = useImageGeneration()
const videoGen = useVideoGeneration()
return { config: {}, chat, image, video: videoGen }
}

View File

@@ -1,26 +0,0 @@
import { computed, ref } from 'vue'
/**
* Internal auth/session config.
* Upstream model credentials stay on the server and are not configured in this UI.
*/
export const useApiConfig = () => {
const apiKey = ref('internal-session')
const baseUrl = ref('/api')
const isConfigured = computed(() => true)
const setApiKey = () => {}
const setBaseUrl = () => {}
const configure = () => {}
const clear = () => {}
return {
apiKey,
baseUrl,
isConfigured,
setApiKey,
setBaseUrl,
configure,
clear
}
}

View File

@@ -1,387 +0,0 @@
/**
* Model Config Hook | 模型配置 Hook
* Manages model configuration with local storage persistence
*/
import { ref, computed, watch } from 'vue'
import { STORAGE_KEYS } from '@/utils'
import { useProvider } from './useProvider'
import {
CHAT_MODELS,
IMAGE_MODELS,
VIDEO_MODELS,
DEFAULT_CHAT_MODEL,
DEFAULT_IMAGE_MODEL,
DEFAULT_VIDEO_MODEL
} from '@/config/models'
/**
* 检查模型是否支持指定渠道
* @param {Object} model - 模型配置
* @param {string} provider - 渠道名称
* @returns {boolean} 是否支持
*/
const isModelSupported = (model, provider) => {
// 如果没有 provider 字段,默认支持所有渠道
if (!model.provider) {
return true
}
// 如果有 provider 字段,检查是否包含指定渠道
return model.provider.includes(provider)
}
/**
* Get stored JSON value from localStorage
*/
const getStoredJson = (key, defaultValue = []) => {
try {
const stored = localStorage.getItem(key)
return stored ? JSON.parse(stored) : defaultValue
} catch {
return defaultValue
}
}
/**
* Set stored JSON value to localStorage
*/
const setStoredJson = (key, value) => {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
// Ignore storage errors
}
}
/**
* Get stored string value from localStorage
*/
const getStored = (key, defaultValue = '') => {
try {
return localStorage.getItem(key) || defaultValue
} catch {
return defaultValue
}
}
/**
* Set stored string value to localStorage
*/
const setStored = (key, value) => {
try {
if (value) {
localStorage.setItem(key, value)
} else {
localStorage.removeItem(key)
}
} catch {
// Ignore storage errors
}
}
const getValidStoredModel = (key, defaultValue, builtInModels) => {
const stored = getStored(key, defaultValue)
return builtInModels.some(model => model.key === stored) ? stored : defaultValue
}
// Shared reactive state (singleton pattern)
const customChatModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, []))
const customImageModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, []))
const customVideoModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, []))
// 按渠道存储的自定义模型 | 结构: { 'openai': [{key, label}], 'chatfire': [{key, label}] }
const customChatModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER || 'custom-chat-models-by-provider', {}))
const customImageModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER || 'custom-image-models-by-provider', {}))
const customVideoModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER || 'custom-video-models-by-provider', {}))
const selectedChatModel = ref(getStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, DEFAULT_CHAT_MODEL))
const selectedImageModel = ref(getValidStoredModel(STORAGE_KEYS.SELECTED_IMAGE_MODEL, DEFAULT_IMAGE_MODEL, IMAGE_MODELS))
const selectedVideoModel = ref(getValidStoredModel(STORAGE_KEYS.SELECTED_VIDEO_MODEL, DEFAULT_VIDEO_MODEL, VIDEO_MODELS))
/**
* Model Configuration Hook
*/
export const useModelConfig = () => {
// Get current provider | 获取当前渠道
const { currentProvider } = useProvider()
// Combined models (built-in + custom, including provider-specific custom models)
const allChatModels = computed(() => [
...CHAT_MODELS.map(m => ({ ...m, isCustom: false })),
...customChatModels.value.map(m => ({
label: m.label || m.key,
key: m.key,
isCustom: true
})),
// 添加当前渠道的自定义模型
...(customChatModelsByProvider.value[currentProvider.value] || []).map(m => ({
label: m.label || m.key,
key: m.key,
isCustom: true,
provider: [currentProvider.value]
}))
])
const allImageModels = computed(() =>
IMAGE_MODELS.map(m => ({ ...m, isCustom: false }))
)
const allVideoModels = computed(() =>
VIDEO_MODELS.map(m => ({ ...m, isCustom: false }))
)
// Available models filtered by provider | 根据渠道过滤的可用模型
const availableChatModels = computed(() =>
allChatModels.value.filter(m => isModelSupported(m, currentProvider.value))
)
const availableImageModels = computed(() =>
allImageModels.value.filter(m => isModelSupported(m, currentProvider.value))
)
const availableVideoModels = computed(() =>
allVideoModels.value.filter(m => isModelSupported(m, currentProvider.value))
)
// All models (including models from all providers, not filtered) | 所有模型(不按渠道过滤)
const allAvailableChatModels = computed(() => allChatModels.value)
const allAvailableImageModels = computed(() => allImageModels.value)
const allAvailableVideoModels = computed(() => allVideoModels.value)
// 获取指定渠道的模型(包括内置 + 该渠道自定义)
const getModelsByProvider = (provider) => {
const chat = [
...CHAT_MODELS.filter(m => isModelSupported(m, provider)).map(m => ({ ...m, isCustom: false })),
...(customChatModelsByProvider.value[provider] || []).map(m => ({
label: m.label || m.key,
key: m.key,
isCustom: true,
provider: [provider]
}))
]
const image = IMAGE_MODELS
.filter(m => isModelSupported(m, provider))
.map(m => ({ ...m, isCustom: false }))
const video = VIDEO_MODELS
.filter(m => isModelSupported(m, provider))
.map(m => ({ ...m, isCustom: false }))
return { chat, image, video }
}
// Watch and persist changes
watch(customChatModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, val), { deep: true })
watch(customImageModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, val), { deep: true })
watch(customVideoModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, val), { deep: true })
// Watch and persist by provider changes
watch(customChatModelsByProvider, (val) => {
const key = STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER || 'custom-chat-models-by-provider'
setStoredJson(key, val)
}, { deep: true })
watch(customImageModelsByProvider, (val) => {
const key = STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER || 'custom-image-models-by-provider'
setStoredJson(key, val)
}, { deep: true })
watch(customVideoModelsByProvider, (val) => {
const key = STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER || 'custom-video-models-by-provider'
setStoredJson(key, val)
}, { deep: true })
watch(selectedChatModel, (val) => setStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, val))
watch(selectedImageModel, (val) => setStored(STORAGE_KEYS.SELECTED_IMAGE_MODEL, val))
watch(selectedVideoModel, (val) => setStored(STORAGE_KEYS.SELECTED_VIDEO_MODEL, val))
// Add custom model
const addCustomChatModel = (modelKey, label = '') => {
if (!modelKey || customChatModels.value.some(m => m.key === modelKey)) return false
customChatModels.value.push({ key: modelKey, label: label || modelKey })
return true
}
const addCustomImageModel = (modelKey, label = '') => {
if (!modelKey || customImageModels.value.some(m => m.key === modelKey)) return false
customImageModels.value.push({ key: modelKey, label: label || modelKey })
return true
}
const addCustomVideoModel = (modelKey, label = '') => {
if (!modelKey || customVideoModels.value.some(m => m.key === modelKey)) return false
customVideoModels.value.push({ key: modelKey, label: label || modelKey })
return true
}
// Remove custom model
const removeCustomChatModel = (modelKey) => {
const idx = customChatModels.value.findIndex(m => m.key === modelKey)
if (idx > -1) {
customChatModels.value.splice(idx, 1)
if (selectedChatModel.value === modelKey) {
selectedChatModel.value = DEFAULT_CHAT_MODEL
}
return true
}
return false
}
const removeCustomImageModel = (modelKey) => {
const idx = customImageModels.value.findIndex(m => m.key === modelKey)
if (idx > -1) {
customImageModels.value.splice(idx, 1)
if (selectedImageModel.value === modelKey) {
selectedImageModel.value = DEFAULT_IMAGE_MODEL
}
return true
}
return false
}
const removeCustomVideoModel = (modelKey) => {
const idx = customVideoModels.value.findIndex(m => m.key === modelKey)
if (idx > -1) {
customVideoModels.value.splice(idx, 1)
if (selectedVideoModel.value === modelKey) {
selectedVideoModel.value = DEFAULT_VIDEO_MODEL
}
return true
}
return false
}
// 按渠道添加自定义模型
const addCustomChatModelByProvider = (modelKey, provider, label = '') => {
if (!modelKey) return false
if (!customChatModelsByProvider.value[provider]) {
customChatModelsByProvider.value[provider] = []
}
if (customChatModelsByProvider.value[provider].some(m => m.key === modelKey)) return false
customChatModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey })
return true
}
const addCustomImageModelByProvider = (modelKey, provider, label = '') => {
if (!modelKey) return false
if (!customImageModelsByProvider.value[provider]) {
customImageModelsByProvider.value[provider] = []
}
if (customImageModelsByProvider.value[provider].some(m => m.key === modelKey)) return false
customImageModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey })
return true
}
const addCustomVideoModelByProvider = (modelKey, provider, label = '') => {
if (!modelKey) return false
if (!customVideoModelsByProvider.value[provider]) {
customVideoModelsByProvider.value[provider] = []
}
if (customVideoModelsByProvider.value[provider].some(m => m.key === modelKey)) return false
customVideoModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey })
return true
}
// 按渠道删除自定义模型
const removeCustomChatModelByProvider = (modelKey, provider) => {
if (!customChatModelsByProvider.value[provider]) return false
const idx = customChatModelsByProvider.value[provider].findIndex(m => m.key === modelKey)
if (idx > -1) {
customChatModelsByProvider.value[provider].splice(idx, 1)
return true
}
return false
}
const removeCustomImageModelByProvider = (modelKey, provider) => {
if (!customImageModelsByProvider.value[provider]) return false
const idx = customImageModelsByProvider.value[provider].findIndex(m => m.key === modelKey)
if (idx > -1) {
customImageModelsByProvider.value[provider].splice(idx, 1)
return true
}
return false
}
const removeCustomVideoModelByProvider = (modelKey, provider) => {
if (!customVideoModelsByProvider.value[provider]) return false
const idx = customVideoModelsByProvider.value[provider].findIndex(m => m.key === modelKey)
if (idx > -1) {
customVideoModelsByProvider.value[provider].splice(idx, 1)
return true
}
return false
}
// Get model by key
const getChatModel = (key) => allChatModels.value.find(m => m.key === key)
const getImageModel = (key) => allImageModels.value.find(m => m.key === key)
const getVideoModel = (key) => allVideoModels.value.find(m => m.key === key)
// Clear all custom models
const clearCustomModels = () => {
customChatModels.value = []
customImageModels.value = []
customVideoModels.value = []
selectedChatModel.value = DEFAULT_CHAT_MODEL
selectedImageModel.value = DEFAULT_IMAGE_MODEL
selectedVideoModel.value = DEFAULT_VIDEO_MODEL
}
return {
// All models (built-in + custom)
allChatModels,
allImageModels,
allVideoModels,
// Available models filtered by provider | 根据渠道过滤的可用模型
availableChatModels,
availableImageModels,
availableVideoModels,
// All models (including models from all providers, not filtered) | 所有模型(不按渠道过滤)
allAvailableChatModels,
allAvailableImageModels,
allAvailableVideoModels,
// Custom models only
customChatModels,
customImageModels,
customVideoModels,
// Selected models
selectedChatModel,
selectedImageModel,
selectedVideoModel,
// Add methods
addCustomChatModel,
addCustomImageModel,
addCustomVideoModel,
// Remove methods
removeCustomChatModel,
removeCustomImageModel,
removeCustomVideoModel,
// Get model
getChatModel,
getImageModel,
getVideoModel,
// Get models by provider (for ApiSettings)
getModelsByProvider,
// Custom models by provider
customChatModelsByProvider,
customImageModelsByProvider,
customVideoModelsByProvider,
// Add/Remove by provider methods
addCustomChatModelByProvider,
addCustomImageModelByProvider,
addCustomVideoModelByProvider,
removeCustomChatModelByProvider,
removeCustomImageModelByProvider,
removeCustomVideoModelByProvider,
// Clear
clearCustomModels
}
}

View File

@@ -1,103 +0,0 @@
/**
* 节点引用解析 Hook
* 用于解析文本中的 @[nodeId] 引用格式
*/
/**
* 解析文本中的 @ 引用
* @param {string} text - 待解析的文本
* @returns {Array<{nodeId: string, name?: string, order: number}>} 解析出的引用列表
*/
export function parseMentions(text) {
if (!text) return []
const mentions = []
// 匹配 @[nodeId] 或 @[nodeId|name] 格式
const regex = /@\[([^\]|]+)(?:\|([^\]]+))?\]/g
let match
let order = 0
while ((match = regex.exec(text)) !== null) {
mentions.push({
nodeId: match[1],
name: match[2] || null,
order: order++
})
}
return mentions
}
/**
* 检查文本是否包含对指定节点的 @ 引用
* @param {string} text - 待检查的文本
* @param {string} nodeId - 节点ID
* @returns {boolean} 是否包含引用
*/
export function hasMention(text, nodeId) {
const mentions = parseMentions(text)
return mentions.some(m => m.nodeId === nodeId)
}
/**
* 从文本中提取对指定节点的引用
* @param {string} text - 待解析的文本
* @param {string} nodeId - 节点ID
* @returns {Array<{nodeId: string, name?: string, order: number}>} 匹配的引用
*/
export function getMentionsToNode(text, nodeId) {
const mentions = parseMentions(text)
return mentions.filter(m => m.nodeId === nodeId)
}
/**
* 清理文本中的 @ 引用标记,保留引用名称(如果有)
* @param {string} text - 待清理的文本
* @param {string} placeholder - 替换引用的占位符,默认空字符串
* @returns {string} 清理后的文本
*/
export function cleanMentions(text, placeholder = '') {
if (!text) return ''
return text.replace(/@\[([^\]|]+)(?:\|([^\]]+))?\]/g, (_, nodeId, name) => {
return name || placeholder
})
}
/**
* 在文本中插入 @ 引用
* @param {string} text - 原文本
* @param {string} nodeId - 节点ID
* @param {string} name - 显示名称(可选)
* @param {number} position - 插入位置(默认末尾)
* @returns {string} 插入引用后的文本
*/
export function insertMention(text, nodeId, name = null, position = -1) {
const mention = name ? `@[${nodeId}|${name}]` : `@[${nodeId}]`
if (position < 0 || position >= text.length) {
return text + mention
}
return text.slice(0, position) + mention + text.slice(position)
}
/**
* 从文本中移除指定节点的 @ 引用
* @param {string} text - 原文本
* @param {string} nodeId - 节点ID
* @returns {string} 移除引用后的文本
*/
export function removeMention(text, nodeId) {
if (!text) return ''
return text.replace(new RegExp(`@\\[${nodeId}(?:\\|[^\\]]+)?\\]`, 'g'), '')
}
/**
* 获取文本中所有 @ 引用的节点ID列表去重
* @param {string} text - 待解析的文本
* @returns {string[]} 节点ID列表
*/
export function getMentionedNodeIds(text) {
const mentions = parseMentions(text)
return [...new Set(mentions.map(m => m.nodeId))]
}

View File

@@ -1,124 +0,0 @@
/**
* Provider Hook | 渠道管理 Hook
* 管理当前选中的 API 渠道,提供请求/响应适配功能
*/
import { ref, computed } from 'vue'
import { PROVIDERS, getProviderList, getDefaultProvider, getProviderConfig } from '@/config/providers'
// 存储键名
const STORAGE_KEY = 'api-provider'
/**
* Get stored value from localStorage | 从 localStorage 获取存储值
*/
const getStored = (key, defaultValue = '') => {
try {
return localStorage.getItem(key) || defaultValue
} catch {
return defaultValue
}
}
/**
* Set stored value to localStorage | 设置存储值到 localStorage
*/
const setStored = (key, value) => {
try {
localStorage.setItem(key, value)
} catch {
// ignore
}
}
/**
* Remove stored value from localStorage | 从 localStorage 移除存储值
*/
const removeStored = (key) => {
try {
localStorage.removeItem(key)
} catch {
// ignore
}
}
/**
* 获取存储的渠道
*/
const getStoredProvider = () => {
return getStored(STORAGE_KEY)
}
/**
* Provider Hook | 渠道管理 Hook
*/
export const useProvider = () => {
// 当前选中的渠道
const currentProvider = ref(getStoredProvider() || getDefaultProvider())
// 渠道列表
const providerList = getProviderList()
// 当前渠道配置
const providerConfig = computed(() => getProviderConfig(currentProvider.value))
// 当前渠道标签
const providerLabel = computed(() => providerConfig.value.label || currentProvider.value)
/**
* 设置当前渠道
*/
const setProvider = (provider) => {
if (PROVIDERS[provider]) {
currentProvider.value = provider
setStored(STORAGE_KEY, provider)
}
}
/**
* 清除渠道配置
*/
const clearProvider = () => {
currentProvider.value = getDefaultProvider()
removeStored(STORAGE_KEY)
}
/**
* 适配请求参数
* @param {string} type - 请求类型:'chat' | 'image' | 'video'
* @param {Object} params - 原始请求参数
*/
const adaptRequest = (type, params) => {
const config = providerConfig.value
if (config.requestAdapter && config.requestAdapter[type]) {
return config.requestAdapter[type](params)
}
// 如果没有适配器,返回原始参数
return params
}
/**
* 适配响应数据
* @param {string} type - 响应类型:'chat' | 'image' | 'video'
* @param {Object} response - 原始响应数据
*/
const adaptResponse = (type, response) => {
const config = providerConfig.value
if (config.responseAdapter && config.responseAdapter[type]) {
return config.responseAdapter[type](response)
}
// 如果没有适配器,返回原始响应
return response
}
return {
currentProvider,
providerList,
providerConfig,
providerLabel,
setProvider,
clearProvider,
adaptRequest,
adaptResponse
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +0,0 @@
/**
* Main entry point | 主入口
*/
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.mount('#app')

View File

@@ -1,24 +0,0 @@
/**
* Router configuration | 路由配置
*/
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('../views/Home.vue')
},
{
path: '/p/:id?',
name: 'Canvas',
component: () => import('../views/Canvas.vue')
}
]
const router = createRouter({
history: createWebHistory('/'),
routes
})
export default router

View File

@@ -1,10 +0,0 @@
/**
* API Store | API 状态存储
* Pure global state - internal session config lives in hooks/useApiConfig.js
* 纯全局状态 - 内部会话配置位于 hooks/useApiConfig.js
*/
// Re-export from hook for backward compatibility | 为向后兼容重新导出
export { useApiConfig } from '../hooks/useApiConfig'
// For components that need direct access to config state | 用于需要直接访问配置状态的组件

View File

@@ -1,559 +0,0 @@
/**
* Canvas store | 画布状态管理
* Manages nodes, edges and canvas state
*/
import { ref, watch } from 'vue'
import { updateProjectCanvas, getProjectCanvas } from './projects'
import { IMAGE_MODELS, VIDEO_MODELS, CHAT_MODELS, DEFAULT_IMAGE_MODEL, DEFAULT_VIDEO_MODEL, DEFAULT_CHAT_MODEL } from '../config/models'
// Node ID counter | 节点ID计数器
let nodeId = 0
const getNodeId = () => `node_${nodeId++}`
// Current project ID | 当前项目ID
export const currentProjectId = ref(null)
// Nodes and edges | 节点和边
export const nodes = ref([])
export const edges = ref([])
// Viewport state | 视口状态
export const canvasViewport = ref({ x: 100, y: 50, zoom: 0.8 })
// Selected node | 选中的节点
export const selectedNode = ref(null)
// Auto-save flag | 自动保存标志
let autoSaveEnabled = false
let saveTimeout = null
// History for undo/redo | 撤销/重做历史
const history = ref([])
const historyIndex = ref(-1)
const MAX_HISTORY = 50
let isRestoring = false
// Position change threshold for history | 位置变化阈值
const POSITION_THRESHOLD = 10
// Batch operation tracking | 批量操作跟踪
let isBatchOperation = false
let batchStartState = null
/**
* Save current state to history | 保存当前状态到历史
* @param {boolean} force - Force save even if batch operation | 强制保存,即使在批量操作中
*/
const saveToHistory = (force = false) => {
if (isRestoring) return
// If in batch operation and not forced, don't save | 如果在批量操作中且未强制保存,则不保存
if (isBatchOperation && !force) return
const state = {
nodes: JSON.parse(JSON.stringify(nodes.value)),
edges: JSON.parse(JSON.stringify(edges.value))
}
// Remove future history if we're not at the end | 如果不在末尾,删除未来历史
if (historyIndex.value < history.value.length - 1) {
history.value = history.value.slice(0, historyIndex.value + 1)
}
// Add new state | 添加新状态
history.value.push(state)
// Limit history size | 限制历史大小
if (history.value.length > MAX_HISTORY) {
history.value.shift()
} else {
historyIndex.value++
}
}
/**
* Start batch operation | 开始批量操作
* Records the starting state for batch operations
*/
export const startBatchOperation = () => {
isBatchOperation = true
batchStartState = {
nodes: JSON.parse(JSON.stringify(nodes.value)),
edges: JSON.parse(JSON.stringify(edges.value))
}
}
/**
* End batch operation and save to history | 结束批量操作并保存到历史
* Compares with start state to decide if save is needed
*/
export const endBatchOperation = () => {
if (!isBatchOperation || !batchStartState) {
isBatchOperation = false
return
}
// Check if there are significant changes | 检查是否有显著变化
const hasSignificantChanges = checkSignificantChanges(batchStartState, {
nodes: nodes.value,
edges: edges.value
})
if (hasSignificantChanges) {
saveToHistory(true)
}
isBatchOperation = false
batchStartState = null
}
/**
* Check if changes are significant enough to save | 检查变化是否足够显著需要保存
* @param {object} oldState - Previous state | 之前的状态
* @param {object} newState - New state | 新状态
* @returns {boolean} - Whether changes should be saved | 是否应该保存变化
*/
const checkSignificantChanges = (oldState, newState) => {
const oldNodes = oldState.nodes || []
const newNodes = newState.nodes || []
// Check for added or removed nodes | 检查添加或删除的节点
if (oldNodes.length !== newNodes.length) {
return true
}
// Check for new nodes (by comparing IDs) | 检查新节点
const oldNodeIds = new Set(oldNodes.map(n => n.id))
const newNodeIds = new Set(newNodes.map(n => n.id))
// Nodes added | 添加的节点
for (const id of newNodeIds) {
if (!oldNodeIds.has(id)) {
return true
}
}
// Nodes removed | 删除的节点
for (const id of oldNodeIds) {
if (!newNodeIds.has(id)) {
return true
}
}
// Check position changes for existing nodes | 检查现有节点的位置变化
for (const newNode of newNodes) {
const oldNode = oldNodes.find(n => n.id === newNode.id)
if (oldNode) {
const dx = Math.abs(newNode.position.x - oldNode.position.x)
const dy = Math.abs(newNode.position.y - oldNode.position.y)
// If any node moved beyond threshold, save | 如果任何节点移动超过阈值,则保存
if (dx > POSITION_THRESHOLD || dy > POSITION_THRESHOLD) {
return true
}
}
}
// Check for edge changes | 检查边的变化
const oldEdges = oldState.edges || []
const newEdges = newState.edges || []
if (oldEdges.length !== newEdges.length) {
return true
}
return false
}
// Add a new node | 添加新节点
export const addNode = (type, position = { x: 100, y: 100 }, data = {}) => {
const id = getNodeId()
const now = Date.now()
const newNode = {
id,
type,
position,
data: {
...getDefaultNodeData(type),
...data,
createdAt: data.createdAt || now,
updatedAt: data.updatedAt || now
}
}
nodes.value = [...nodes.value, newNode]
saveToHistory() // Save after adding node | 添加节点后保存
return id
}
/**
* Add multiple nodes in batch | 批量添加多个节点
* Uses batch operation to group all node additions into one history entry
* @param {Array} nodeSpecs - Array of node specs [{ type, position, data }, ...]
* @param {boolean} autoBatch - Whether to auto-manage batch operation (default: true)
* @returns {Array} - Array of created node IDs | 创建的节点ID数组
*/
export const addNodes = (nodeSpecs, autoBatch = true) => {
if (!nodeSpecs || nodeSpecs.length === 0) return []
// Start batch operation if auto | 如果自动管理则开始批量操作
if (autoBatch) {
startBatchOperation()
}
const 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)
})
// End batch operation if auto | 如果自动管理则结束批量操作并保存到历史
if (autoBatch) {
endBatchOperation()
}
return ids
}
// Get default data for node type | 获取节点类型的默认数据
const getDefaultNodeData = (type) => {
switch (type) {
case 'text':
return {
content: '',
label: '文本输入',
publicProps: {} // 公共属性(可被 @ 引用)
}
case 'imageConfig': {
const imageModel = IMAGE_MODELS.find(m => m.key === DEFAULT_IMAGE_MODEL) || IMAGE_MODELS[0]
return {
prompt: '',
model: DEFAULT_IMAGE_MODEL,
size: imageModel?.defaultParams?.size || '1x1',
quality: imageModel?.defaultParams?.quality || 'standard',
label: '文生图'
}
}
case 'videoConfig': {
const videoModel = VIDEO_MODELS.find(m => m.key === DEFAULT_VIDEO_MODEL) || VIDEO_MODELS[0]
return {
prompt: '',
ratio: videoModel?.defaultParams?.ratio || '16:9',
duration: videoModel?.defaultParams?.duration || 5,
model: DEFAULT_VIDEO_MODEL,
label: '图生视频'
}
}
case 'video':
return {
url: '',
duration: 0,
label: '视频节点'
}
case 'image':
return {
url: '',
label: '图片节点',
publicProps: { name: '图片' } // 公共属性(可被 @ 引用)
}
case 'llmConfig':
return {
systemPrompt: '',
model: DEFAULT_CHAT_MODEL,
outputFormat: 'text',
outputContent: '',
label: 'LLM文本生成',
publicProps: {} // 公共属性(可被 @ 引用)
}
default:
return {}
}
}
// Update node data | 更新节点数据
export const updateNode = (id, data) => {
nodes.value = nodes.value.map(node =>
node.id === id ? { ...node, data: { ...node.data, ...data } } : node
)
}
// Remove node | 删除节点
export const removeNode = (id) => {
nodes.value = nodes.value.filter(node => node.id !== id)
edges.value = edges.value.filter(edge => edge.source !== id && edge.target !== id)
saveToHistory() // Save after removing node | 删除节点后保存
}
// Duplicate node | 复制节点
export const duplicateNode = (id) => {
const sourceNode = nodes.value.find(node => node.id === id)
if (!sourceNode) return null
const newId = getNodeId()
// Calculate max z-index | 计算最大层级
const maxZIndex = Math.max(0, ...nodes.value.map(n => n.zIndex || 0))
const newNode = {
id: newId,
type: sourceNode.type,
position: {
x: sourceNode.position.x + 50,
y: sourceNode.position.y + 50
},
data: { ...sourceNode.data },
zIndex: maxZIndex + 1
}
nodes.value = [...nodes.value, newNode]
saveToHistory() // Save after duplicating node | 复制节点后保存
return newId
}
// Add edge | 添加边
export const addEdge = (params) => {
const newEdge = {
id: `edge_${params.source}_${params.target}`,
...params
}
edges.value = [...edges.value, newEdge]
saveToHistory() // Save after adding edge | 添加连线后保存
}
/**
* Add multiple edges in batch | 批量添加多条边
* Uses batch operation to group all edge additions into one history entry
* @param {Array} edgeSpecs - Array of edge specs [{ source, target, sourceHandle, targetHandle, type, data }, ...]
* @param {boolean} autoBatch - Whether to auto-manage batch operation (default: true)
* @returns {Array} - Array of created edge IDs | 创建的边ID数组
*/
export const addEdges = (edgeSpecs, autoBatch = true) => {
if (!edgeSpecs || edgeSpecs.length === 0) return []
// Start batch operation if auto | 如果自动管理则开始批量操作
if (autoBatch) {
startBatchOperation()
}
const ids = []
edgeSpecs.forEach(params => {
const newEdge = {
id: `edge_${params.source}_${params.target}`,
...params
}
edges.value = [...edges.value, newEdge]
ids.push(newEdge.id)
})
// End batch operation if auto | 如果自动管理则结束批量操作并保存到历史
if (autoBatch) {
endBatchOperation()
}
return ids
}
// Update edge data | 更新边数据
export const updateEdge = (id, data) => {
edges.value = edges.value.map(edge =>
edge.id === id ? { ...edge, data: { ...edge.data, ...data } } : edge
)
saveToHistory() // Save after updating edge | 更新连线后保存
}
// Remove edge | 删除边
export const removeEdge = (id) => {
edges.value = edges.value.filter(edge => edge.id !== id)
saveToHistory() // Save after removing edge | 删除连线后保存
}
// Clear canvas | 清空画布
export const clearCanvas = () => {
nodes.value = []
edges.value = []
nodeId = 0
}
// Initialize with sample data | 使用示例数据初始化
export const initSampleData = () => {
clearCanvas()
// Add text node | 添加文本节点
addNode('text', { x: 150, y: 150 }, {
content: '一只金毛寻回犬在草地上奔跑,摇着尾巴,脸上带着快乐的表情。它的毛发在阳光下闪耀,眼神充满了对自由的渴望,全身散发着阳光、友善的气息。',
label: '文本输入'
})
// Add image config node | 添加文生图配置节点
addNode('imageConfig', { x: 450, y: 150 }, {
prompt: '',
model: 'auto',
ratio: '16:9 | 4张 | 高清',
label: '文生图'
})
// Add edge between nodes | 添加节点之间的边
addEdge({
source: 'node_0',
target: 'node_1',
sourceHandle: 'right',
targetHandle: 'left'
})
}
/**
* Load project data | 加载项目数据
* @param {string} projectId - Project ID | 项目ID
*/
export const loadProject = (projectId) => {
autoSaveEnabled = false
isRestoring = true
currentProjectId.value = projectId
const canvasData = getProjectCanvas(projectId)
if (canvasData) {
// Restore nodes | 恢复节点
nodes.value = canvasData.nodes || []
edges.value = canvasData.edges || []
canvasViewport.value = canvasData.viewport || { x: 100, y: 50, zoom: 0.8 }
// Update node ID counter | 更新节点ID计数器
const maxId = nodes.value.reduce((max, node) => {
const match = node.id.match(/node_(\d+)/)
if (match) {
return Math.max(max, parseInt(match[1], 10))
}
return max
}, -1)
nodeId = maxId + 1
} else {
// Empty project | 空项目
clearCanvas()
}
// Initialize history with current state | 用当前状态初始化历史
history.value = [{
nodes: JSON.parse(JSON.stringify(nodes.value)),
edges: JSON.parse(JSON.stringify(edges.value))
}]
historyIndex.value = 0
// Enable auto-save after loading | 加载后启用自动保存
setTimeout(() => {
autoSaveEnabled = true
isRestoring = false
}, 100)
}
/**
* Save current project | 保存当前项目
*/
export const saveProject = () => {
if (!currentProjectId.value) return
updateProjectCanvas(currentProjectId.value, {
nodes: nodes.value,
edges: edges.value,
viewport: canvasViewport.value
})
}
/**
* Debounced auto-save | 防抖动自动保存
*/
const debouncedSave = () => {
if (!autoSaveEnabled || !currentProjectId.value) return
if (saveTimeout) {
clearTimeout(saveTimeout)
}
saveTimeout = setTimeout(() => {
saveProject()
}, 500)
}
/**
* Update viewport and save | 更新视口并保存
*/
export const updateViewport = (viewport) => {
canvasViewport.value = viewport
debouncedSave()
}
/**
* Undo last action | 撤销上一步操作
*/
export const undo = () => {
if (historyIndex.value <= 0) {
window.$message?.info('没有可撤销的操作')
return false
}
historyIndex.value--
restoreState(history.value[historyIndex.value])
return true
}
/**
* Redo last undone action | 重做上一步撤销的操作
*/
export const redo = () => {
if (historyIndex.value >= history.value.length - 1) {
window.$message?.info('没有可重做的操作')
return false
}
historyIndex.value++
restoreState(history.value[historyIndex.value])
return true
}
/**
* Restore state from history | 从历史恢复状态
*/
const restoreState = (state) => {
isRestoring = true
nodes.value = JSON.parse(JSON.stringify(state.nodes))
edges.value = JSON.parse(JSON.stringify(state.edges))
setTimeout(() => {
isRestoring = false
}, 100)
}
/**
* Check if can undo | 检查是否可以撤销
*/
export const canUndo = () => historyIndex.value > 0
/**
* Check if can redo | 检查是否可以重做
*/
export const canRedo = () => historyIndex.value < history.value.length - 1
/**
* Manually save current state to history | 手动保存当前状态到历史
* Used for edge deletions and other operations not covered by automatic saves
*/
export const manualSaveHistory = () => {
saveToHistory()
}
// Watch for changes and auto-save (only save to project, not history) | 监听变化并自动保存(仅保存项目,不保存历史)
watch([nodes, edges], () => {
debouncedSave()
}, { deep: true })

View File

@@ -1,213 +0,0 @@
/**
* Model Store | 模型状态管理
* Built-in models + custom models from local storage | 开源版内置模型 + 本地存储自定义模型
*/
import { ref, computed } from 'vue'
import {
IMAGE_MODELS,
VIDEO_MODELS,
CHAT_MODELS,
SEEDREAM_SIZE_OPTIONS,
SEEDREAM_4K_SIZE_OPTIONS,
SEEDREAM_QUALITY_OPTIONS,
SEEDANCE_RESOLUTION_OPTIONS,
VIDEO_RATIO_LIST,
VIDEO_RATIO_OPTIONS,
VIDEO_DURATION_OPTIONS,
DEFAULT_IMAGE_MODEL,
DEFAULT_VIDEO_MODEL,
DEFAULT_CHAT_MODEL,
DEFAULT_IMAGE_SIZE,
DEFAULT_VIDEO_RATIO,
DEFAULT_VIDEO_DURATION
} from '@/config/models'
import { useModelConfig } from '@/hooks/useModelConfig'
// Loading state (always false for built-in models) | 加载状态
const loading = ref(false)
const error = ref(null)
// Get model config hook | 获取模型配置 hook
const getModelConfigHook = () => {
try {
return useModelConfig()
} catch {
return null
}
}
/**
* Initialize models (no-op for built-in) | 初始化模型
*/
export const loadAllModels = async () => {
const modelConfig = getModelConfigHook()
if (modelConfig) {
return [...modelConfig.allImageModels.value, ...modelConfig.allVideoModels.value, ...modelConfig.allChatModels.value]
}
return [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS]
}
/**
* Get model config by name | 根据名称获取模型配置
*/
export const getModelConfig = (modelKey) => {
const modelConfig = getModelConfigHook()
if (modelConfig) {
return modelConfig.getImageModel(modelKey) ||
modelConfig.getVideoModel(modelKey) ||
modelConfig.getChatModel(modelKey)
}
const allModels = [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS]
return allModels.find(m => m.key === modelKey)
}
/**
* Get size options for image model | 获取图片模型尺寸选项
* Returns options based on model's sizes array and quality
*/
export const getModelSizeOptions = (modelKey, quality = 'standard') => {
const model = IMAGE_MODELS.find(m => m.key === modelKey)
// If model has getSizesByQuality function, use it | 如果模型有 getSizesByQuality 函数,使用它
if (model?.getSizesByQuality) {
return model.getSizesByQuality(quality)
}
if (!model?.sizes) return SEEDREAM_SIZE_OPTIONS
// Convert sizes array to dropdown options | 转换 sizes 数组为下拉选项
const sizeOptions = quality === '4k' ? SEEDREAM_4K_SIZE_OPTIONS : SEEDREAM_SIZE_OPTIONS
return model.sizes.map(size => {
const option = sizeOptions.find(o => o.key === size)
return option || { label: size, key: size }
})
}
/**
* Get quality options for image model | 获取图片模型画质选项
*/
export const getModelQualityOptions = (modelKey) => {
const model = IMAGE_MODELS.find(m => m.key === modelKey)
return model?.qualities || []
}
/**
* Get ratio options for video model | 获取视频模型比例选项
* Returns options based on model's ratios array
*/
export const getModelRatioOptions = (modelKey) => {
const model = VIDEO_MODELS.find(m => m.key === modelKey)
if (!model?.ratios) return VIDEO_RATIO_OPTIONS
// Convert ratios array to dropdown options | 转换 ratios 数组为下拉选项
return model.ratios.map(ratio => {
const option = VIDEO_RATIO_LIST.find(o => o.key === ratio)
return option || { label: ratio, key: ratio }
})
}
/**
* Get duration options for video model | 获取视频模型时长选项
* Returns options based on model's durs array
*/
export const getModelDurationOptions = (modelKey) => {
const model = VIDEO_MODELS.find(m => m.key === modelKey)
if (!model?.durs) return VIDEO_DURATION_OPTIONS
// durs is already in { label, key } format | durs 已经是 { label, key } 格式
return model.durs
}
/**
* Get resolution options for video model | 获取视频模型分辨率选项
* Returns options based on model's resolutions array
*/
export const getModelResolutionOptions = (modelKey) => {
const model = VIDEO_MODELS.find(m => m.key === modelKey)
if (!model?.resolutions) return SEEDANCE_RESOLUTION_OPTIONS
return model.resolutions.map(res => {
const option = SEEDANCE_RESOLUTION_OPTIONS.find(o => o.key === res)
return option || { label: res, key: res }
})
}
// Dropdown options (built-in + custom) | 下拉选项(内置 + 自定义)- 根据渠道过滤
export const imageModelOptions = computed(() => {
const modelConfig = getModelConfigHook()
return modelConfig ? modelConfig.availableImageModels.value : IMAGE_MODELS
})
export const videoModelOptions = computed(() => {
const modelConfig = getModelConfigHook()
return modelConfig ? modelConfig.availableVideoModels.value : VIDEO_MODELS
})
export const chatModelOptions = computed(() => {
const modelConfig = getModelConfigHook()
return modelConfig ? modelConfig.availableChatModels.value : CHAT_MODELS
})
// All model options (not filtered by provider) | 所有模型选项(不按渠道过滤)
export const allImageModelOptions = computed(() => {
const modelConfig = getModelConfigHook()
return modelConfig ? modelConfig.allAvailableImageModels.value : IMAGE_MODELS
})
export const allVideoModelOptions = computed(() => {
const modelConfig = getModelConfigHook()
return modelConfig ? modelConfig.allAvailableVideoModels.value : VIDEO_MODELS
})
export const allChatModelOptions = computed(() => {
const modelConfig = getModelConfigHook()
return modelConfig ? modelConfig.allAvailableChatModels.value : CHAT_MODELS
})
// Simple select options (for n-select) | 简单选择选项
export const imageModelSelectOptions = computed(() =>
imageModelOptions.value.map(m => ({ label: m.label, value: m.key }))
)
export const videoModelSelectOptions = computed(() =>
videoModelOptions.value.map(m => ({ label: m.label, value: m.key }))
)
export const chatModelSelectOptions = computed(() =>
chatModelOptions.value.map(m => ({ label: m.label, value: m.key }))
)
// All select options (not filtered by provider) | 所有选择选项(不按渠道过滤)
export const allImageModelSelectOptions = computed(() =>
allImageModelOptions.value.map(m => ({ label: m.label, value: m.key }))
)
export const allVideoModelSelectOptions = computed(() =>
allVideoModelOptions.value.map(m => ({ label: m.label, value: m.key }))
)
export const allChatModelSelectOptions = computed(() =>
allChatModelOptions.value.map(m => ({ label: m.label, value: m.key }))
)
// Export model arrays (reactive with custom models) | 导出模型数组(响应式,包含自定义模型)
export const imageModels = computed(() => imageModelOptions.value)
export const videoModels = computed(() => videoModelOptions.value)
export const chatModels = computed(() => chatModelOptions.value)
// Export defaults | 导出默认值
export {
DEFAULT_IMAGE_MODEL,
DEFAULT_VIDEO_MODEL,
DEFAULT_CHAT_MODEL,
DEFAULT_IMAGE_SIZE,
DEFAULT_VIDEO_RATIO,
DEFAULT_VIDEO_DURATION
}
// Export options | 导出选项
export { SEEDREAM_SIZE_OPTIONS, SEEDREAM_4K_SIZE_OPTIONS, SEEDREAM_QUALITY_OPTIONS, SEEDANCE_RESOLUTION_OPTIONS, VIDEO_RATIO_OPTIONS, VIDEO_DURATION_OPTIONS }
// Export state | 导出状态
export { loading, error }

View File

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

View File

@@ -1,576 +0,0 @@
/**
* Pinia Store: Model Config | 模型配置 Store
* 管理模型配置、渠道切换和模型选择
*/
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import {
CHAT_MODELS,
IMAGE_MODELS,
VIDEO_MODELS,
DEFAULT_CHAT_MODEL,
DEFAULT_IMAGE_MODEL,
DEFAULT_VIDEO_MODEL
} from '@/config/models'
import { PROVIDERS, getProviderList, getDefaultProvider, getProviderConfig, getDefaultBaseUrl } from '@/config/providers'
// 存储键名
const STORAGE_KEYS = {
PROVIDER: 'api-provider',
CUSTOM_CHAT_MODELS: 'custom-chat-models',
CUSTOM_IMAGE_MODELS: 'custom-image-models',
CUSTOM_VIDEO_MODELS: 'custom-video-models',
SELECTED_CHAT_MODEL: 'selected-chat-model',
SELECTED_IMAGE_MODEL: 'selected-image-model',
SELECTED_VIDEO_MODEL: 'selected-video-model',
CUSTOM_CHAT_MODELS_BY_PROVIDER: 'custom-chat-models-by-provider',
CUSTOM_IMAGE_MODELS_BY_PROVIDER: 'custom-image-models-by-provider',
CUSTOM_VIDEO_MODELS_BY_PROVIDER: 'custom-video-models-by-provider',
API_KEYS_BY_PROVIDER: 'api-keys-by-provider',
BASE_URLS_BY_PROVIDER: 'base-urls-by-provider'
}
/**
* Get stored value from localStorage
*/
const getStored = (key, defaultValue = '') => {
try {
return localStorage.getItem(key) || defaultValue
} catch {
return defaultValue
}
}
/**
* Set stored value to localStorage
*/
const setStored = (key, value) => {
try {
if (value) {
localStorage.setItem(key, value)
} else {
localStorage.removeItem(key)
}
} catch {
// ignore
}
}
const removeStored = (key) => {
try {
localStorage.removeItem(key)
} catch {
// ignore
}
}
/**
* Get stored JSON value from localStorage
*/
const getStoredJson = (key, defaultValue = []) => {
try {
const stored = localStorage.getItem(key)
return stored ? JSON.parse(stored) : defaultValue
} catch {
return defaultValue
}
}
/**
* Set stored JSON value to localStorage
*/
const setStoredJson = (key, value) => {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
// ignore
}
}
const getValidStoredModel = (key, defaultValue, builtInModels) => {
const stored = getStored(key, defaultValue)
return builtInModels.some(model => model.key === stored) ? stored : defaultValue
}
/**
* 检查模型是否支持指定渠道
*/
const isModelSupported = (model, provider) => {
if (!model.provider) {
return true
}
return model.provider.includes(provider)
}
export const useModelStore = defineStore('model', () => {
// ============ Provider 状态 | Provider State ============
// 当前选中的渠道
const storedProvider = getStored(STORAGE_KEYS.PROVIDER)
const currentProvider = ref(PROVIDERS[storedProvider] ? storedProvider : getDefaultProvider())
// 渠道列表
const providerList = computed(() => getProviderList())
// 当前渠道配置
const providerConfig = computed(() => getProviderConfig(currentProvider.value))
// 当前渠道标签
const providerLabel = computed(() => providerConfig.value.label || currentProvider.value)
// 设置当前渠道
const setProvider = (provider) => {
if (PROVIDERS[provider]) {
currentProvider.value = provider
setStored(STORAGE_KEYS.PROVIDER, provider)
}
}
// 清除渠道配置
const clearProvider = () => {
currentProvider.value = getDefaultProvider()
removeStored(STORAGE_KEYS.PROVIDER)
}
// 适配请求参数
const adaptRequest = (type, params) => {
const config = providerConfig.value
if (config.requestAdapter && config.requestAdapter[type]) {
return config.requestAdapter[type](params)
}
return params
}
// 适配响应数据
const adaptResponse = (type, response) => {
const config = providerConfig.value
if (config.responseAdapter && config.responseAdapter[type]) {
return config.responseAdapter[type](response)
}
return response
}
// ============ Custom Models 状态 | Custom Models State ============
// 全局自定义模型(不区分渠道)
const customChatModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, []))
const customImageModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, []))
const customVideoModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, []))
// 按渠道存储的自定义模型 | 结构: { 'skg': [{key, label}] }
const customChatModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER, {}))
const customImageModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER, {}))
const customVideoModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER, {}))
// 选中的模型
const selectedChatModel = ref(getStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, DEFAULT_CHAT_MODEL))
const selectedImageModel = ref(getValidStoredModel(STORAGE_KEYS.SELECTED_IMAGE_MODEL, DEFAULT_IMAGE_MODEL, IMAGE_MODELS))
const selectedVideoModel = ref(getValidStoredModel(STORAGE_KEYS.SELECTED_VIDEO_MODEL, DEFAULT_VIDEO_MODEL, VIDEO_MODELS))
// 按渠道存储的 API 配置
const apiKeysByProvider = ref(getStoredJson(STORAGE_KEYS.API_KEYS_BY_PROVIDER, {}))
const baseUrlsByProvider = ref(getStoredJson(STORAGE_KEYS.BASE_URLS_BY_PROVIDER, {}))
// 内部模式由服务端会话鉴权,不在浏览器暴露上游模型密钥。
const currentApiKey = computed(() => 'internal-session')
const currentBaseUrl = computed(() => baseUrlsByProvider.value[currentProvider.value] || getDefaultBaseUrl(currentProvider.value))
// 设置指定渠道凭据(兼容旧本地状态)
const setApiKeyByProvider = (provider, apiKey) => {
apiKeysByProvider.value[provider] = apiKey
}
// 设置指定渠道的 Base URL
const setBaseUrlByProvider = (provider, baseUrl) => {
baseUrlsByProvider.value[provider] = baseUrl
}
// 清除指定渠道的 API 配置
const clearApiConfigByProvider = (provider) => {
delete apiKeysByProvider.value[provider]
delete baseUrlsByProvider.value[provider]
}
// ============ Computed: All Models (built-in + custom + by provider) ============
const allChatModels = computed(() => [
...CHAT_MODELS.map(m => ({ ...m, isCustom: false })),
...customChatModels.value.map(m => ({
label: m.label || m.key,
key: m.key,
isCustom: true
})),
// 添加当前渠道的自定义模型
...(customChatModelsByProvider.value[currentProvider.value] || []).map(m => ({
label: m.label || m.key,
key: m.key,
isCustom: true,
provider: [currentProvider.value]
}))
])
const allImageModels = computed(() =>
IMAGE_MODELS.map(m => ({ ...m, isCustom: false }))
)
const allVideoModels = computed(() =>
VIDEO_MODELS.map(m => ({ ...m, isCustom: false }))
)
// ============ Computed: Available Models (filtered by provider) ============
// 按渠道过滤的可用模型
const availableChatModels = computed(() =>
allChatModels.value.filter(m => isModelSupported(m, currentProvider.value))
)
const availableImageModels = computed(() =>
allImageModels.value.filter(m => isModelSupported(m, currentProvider.value))
)
const availableVideoModels = computed(() =>
allVideoModels.value.filter(m => isModelSupported(m, currentProvider.value))
)
// ============ Computed: Model Options for UI (all models, not filtered by provider) ============
// 返回适合 n-dropdown 使用的选项格式(全部模型,不按渠道过滤)
const allImageModelOptions = computed(() =>
allImageModels.value.map(m => ({
label: m.label,
key: m.key
}))
)
const allVideoModelOptions = computed(() =>
allVideoModels.value.map(m => ({
label: m.label,
key: m.key
}))
)
const allChatModelOptions = computed(() =>
allChatModels.value.map(m => ({
label: m.label,
key: m.key
}))
)
// ============ Computed: Model Options for UI (filtered by provider - deprecated, use all* instead) ============
// 返回适合 n-dropdown 使用的选项格式
const imageModelOptions = computed(() =>
availableImageModels.value.map(m => ({
label: m.label,
key: m.key
}))
)
const videoModelOptions = computed(() =>
availableVideoModels.value.map(m => ({
label: m.label,
key: m.key
}))
)
const chatModelOptions = computed(() =>
availableChatModels.value.map(m => ({
label: m.label,
key: m.key
}))
)
// ============ Methods: Add/Remove Custom Models ============
const addCustomChatModel = (modelKey, label = '') => {
if (!modelKey || customChatModels.value.some(m => m.key === modelKey)) return false
customChatModels.value.push({ key: modelKey, label: label || modelKey })
return true
}
const addCustomImageModel = (modelKey, label = '') => {
if (!modelKey || customImageModels.value.some(m => m.key === modelKey)) return false
customImageModels.value.push({ key: modelKey, label: label || modelKey })
return true
}
const addCustomVideoModel = (modelKey, label = '') => {
if (!modelKey || customVideoModels.value.some(m => m.key === modelKey)) return false
customVideoModels.value.push({ key: modelKey, label: label || modelKey })
return true
}
const removeCustomChatModel = (modelKey) => {
const idx = customChatModels.value.findIndex(m => m.key === modelKey)
if (idx > -1) {
customChatModels.value.splice(idx, 1)
if (selectedChatModel.value === modelKey) {
selectedChatModel.value = DEFAULT_CHAT_MODEL
}
return true
}
return false
}
const removeCustomImageModel = (modelKey) => {
const idx = customImageModels.value.findIndex(m => m.key === modelKey)
if (idx > -1) {
customImageModels.value.splice(idx, 1)
if (selectedImageModel.value === modelKey) {
selectedImageModel.value = DEFAULT_IMAGE_MODEL
}
return true
}
return false
}
const removeCustomVideoModel = (modelKey) => {
const idx = customVideoModels.value.findIndex(m => m.key === modelKey)
if (idx > -1) {
customVideoModels.value.splice(idx, 1)
if (selectedVideoModel.value === modelKey) {
selectedVideoModel.value = DEFAULT_VIDEO_MODEL
}
return true
}
return false
}
// ============ Methods: Get Model Config ============
const getChatModel = (key) => allChatModels.value.find(m => m.key === key)
const getImageModel = (key) => allImageModels.value.find(m => m.key === key)
const getVideoModel = (key) => allVideoModels.value.find(m => m.key === key)
// ============ Methods: Get API Endpoints ============
// 获取图片端点
const getImageEndpoint = () => {
const endpoint = providerConfig.value.endpoints?.image || '/images/generations'
return `${currentBaseUrl.value}${endpoint}`
}
// 获取视频生成端点
const getVideoEndpoint = () => {
const endpoint = providerConfig.value.endpoints?.video || '/videos'
return `${currentBaseUrl.value}${endpoint}`
}
// 获取视频任务查询端点
const getVideoTaskEndpoint = () => {
const config = providerConfig.value
// 优先使用 videoQuery 端点,支持 {taskId} 占位符替换
let endpoint = config.endpoints?.videoQuery || config.endpoints?.video || '/videos'
return `${currentBaseUrl.value}${endpoint}`
}
// 获取聊天端点(支持参考图片)
const getChatEndpoint = () => {
const endpoint = providerConfig.value?.endpoints?.chat || '/chat/completions'
return `${currentBaseUrl.value}${endpoint}`
}
// ============ Methods: Get Models By Provider ============
const getModelsByProvider = (provider) => {
const chat = [
...CHAT_MODELS.filter(m => isModelSupported(m, provider)).map(m => ({ ...m, isCustom: false })),
...(customChatModelsByProvider.value[provider] || []).map(m => ({
label: m.label || m.key,
key: m.key,
isCustom: true,
provider: [provider]
}))
]
const image = IMAGE_MODELS
.filter(m => isModelSupported(m, provider))
.map(m => ({ ...m, isCustom: false }))
const video = VIDEO_MODELS
.filter(m => isModelSupported(m, provider))
.map(m => ({ ...m, isCustom: false }))
return { chat, image, video }
}
// ============ Methods: Add/Remove Custom Models By Provider ============
const addCustomChatModelByProvider = (modelKey, provider, label = '') => {
if (!modelKey) return false
if (!customChatModelsByProvider.value[provider]) {
customChatModelsByProvider.value[provider] = []
}
if (customChatModelsByProvider.value[provider].some(m => m.key === modelKey)) return false
customChatModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey })
return true
}
const addCustomImageModelByProvider = (modelKey, provider, label = '') => {
if (!modelKey) return false
if (!customImageModelsByProvider.value[provider]) {
customImageModelsByProvider.value[provider] = []
}
if (customImageModelsByProvider.value[provider].some(m => m.key === modelKey)) return false
customImageModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey })
return true
}
const addCustomVideoModelByProvider = (modelKey, provider, label = '') => {
if (!modelKey) return false
if (!customVideoModelsByProvider.value[provider]) {
customVideoModelsByProvider.value[provider] = []
}
if (customVideoModelsByProvider.value[provider].some(m => m.key === modelKey)) return false
customVideoModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey })
return true
}
const removeCustomChatModelByProvider = (modelKey, provider) => {
if (!customChatModelsByProvider.value[provider]) return false
const idx = customChatModelsByProvider.value[provider].findIndex(m => m.key === modelKey)
if (idx > -1) {
customChatModelsByProvider.value[provider].splice(idx, 1)
return true
}
return false
}
const removeCustomImageModelByProvider = (modelKey, provider) => {
if (!customImageModelsByProvider.value[provider]) return false
const idx = customImageModelsByProvider.value[provider].findIndex(m => m.key === modelKey)
if (idx > -1) {
customImageModelsByProvider.value[provider].splice(idx, 1)
return true
}
return false
}
const removeCustomVideoModelByProvider = (modelKey, provider) => {
if (!customVideoModelsByProvider.value[provider]) return false
const idx = customVideoModelsByProvider.value[provider].findIndex(m => m.key === modelKey)
if (idx > -1) {
customVideoModelsByProvider.value[provider].splice(idx, 1)
return true
}
return false
}
// 清除所有自定义模型
const clearCustomModels = () => {
customChatModels.value = []
customImageModels.value = []
customVideoModels.value = []
selectedChatModel.value = DEFAULT_CHAT_MODEL
selectedImageModel.value = DEFAULT_IMAGE_MODEL
selectedVideoModel.value = DEFAULT_VIDEO_MODEL
}
// ============ Watch & Persist ============
// 监听并持久化自定义模型
watch(customChatModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, val), { deep: true })
watch(customImageModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, val), { deep: true })
watch(customVideoModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, val), { deep: true })
// 监听并持久化按渠道的自定义模型
watch(customChatModelsByProvider, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER, val), { deep: true })
watch(customImageModelsByProvider, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER, val), { deep: true })
watch(customVideoModelsByProvider, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER, val), { deep: true })
// 监听并持久化选中的模型
watch(selectedChatModel, (val) => setStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, val))
watch(selectedImageModel, (val) => setStored(STORAGE_KEYS.SELECTED_IMAGE_MODEL, val))
watch(selectedVideoModel, (val) => setStored(STORAGE_KEYS.SELECTED_VIDEO_MODEL, val))
// 监听并持久化 API 配置
watch(apiKeysByProvider, (val) => setStoredJson(STORAGE_KEYS.API_KEYS_BY_PROVIDER, val), { deep: true })
watch(baseUrlsByProvider, (val) => setStoredJson(STORAGE_KEYS.BASE_URLS_BY_PROVIDER, val), { deep: true })
return {
// Provider
currentProvider,
providerList,
providerConfig,
providerLabel,
setProvider,
clearProvider,
adaptRequest,
adaptResponse,
// All models (built-in + custom)
allChatModels,
allImageModels,
allVideoModels,
// Available models filtered by provider
availableChatModels,
availableImageModels,
availableVideoModels,
// Model options for UI (dropdown format)
imageModelOptions,
videoModelOptions,
chatModelOptions,
// All model options (not filtered by provider)
allImageModelOptions,
allVideoModelOptions,
allChatModelOptions,
// Selected models
selectedChatModel,
selectedImageModel,
selectedVideoModel,
// Custom models
customChatModels,
customImageModels,
customVideoModels,
// Custom models by provider
customChatModelsByProvider,
customImageModelsByProvider,
customVideoModelsByProvider,
// Add/Remove methods
addCustomChatModel,
addCustomImageModel,
addCustomVideoModel,
removeCustomChatModel,
removeCustomImageModel,
removeCustomVideoModel,
// Add/Remove by provider methods
addCustomChatModelByProvider,
addCustomImageModelByProvider,
addCustomVideoModelByProvider,
removeCustomChatModelByProvider,
removeCustomImageModelByProvider,
removeCustomVideoModelByProvider,
// Get model
getChatModel,
getImageModel,
getVideoModel,
// Get API endpoints
getImageEndpoint,
getVideoEndpoint,
getVideoTaskEndpoint,
getChatEndpoint,
// Get models by provider
getModelsByProvider,
// Clear all custom models
clearCustomModels,
// API Config by provider
currentApiKey,
currentBaseUrl,
apiKeysByProvider,
baseUrlsByProvider,
setApiKeyByProvider,
setBaseUrlByProvider,
clearApiConfigByProvider
}
})

View File

@@ -1,497 +0,0 @@
/**
* Projects store | 项目状态管理
* Manages projects with localStorage persistence
*/
import { ref, computed, watch } from 'vue'
// Storage key | 存储键
const STORAGE_KEY = 'ai-canvas-projects'
// Generate unique ID | 生成唯一ID
const generateId = () => `project_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
// Projects list | 项目列表
export const projects = ref([])
// Current project ID | 当前项目ID
export const currentProjectId = ref(null)
export const projectSyncStatus = ref('idle')
export const projectSyncError = ref('')
const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api'
const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`
const remoteSaveTimers = new Map()
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 requestJson = async (path, init = {}) => {
const response = await fetch(apiUrl(path), {
...init,
headers: {
...(init.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(init.headers || {})
}
})
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(text || `${path} ${response.status}`)
}
return response.json()
}
const mergeProjectLists = (localItems, remoteItems) => {
const byId = new Map()
for (const item of remoteItems) byId.set(item.id, item)
for (const item of localItems) {
const existing = byId.get(item.id)
if (!existing || dateToSeconds(item.updatedAt) > dateToSeconds(existing.updatedAt)) {
byId.set(item.id, item)
}
}
return [...byId.values()].sort((a, b) => dateToSeconds(b.updatedAt) - dateToSeconds(a.updatedAt))
}
/**
* Load projects from localStorage | 从 localStorage 加载项目
*/
export const loadProjects = () => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored)
// Convert date strings back to Date objects | 将日期字符串转换回 Date 对象
projects.value = parsed.map(p => ({
...p,
createdAt: new Date(p.createdAt),
updatedAt: new Date(p.updatedAt)
}))
}
} catch (err) {
console.error('Failed to load projects:', err)
projects.value = []
}
}
const saveRemoteProjectNow = async (project) => {
if (!project?.id) return null
const response = await requestJson(`/canvas-projects/${encodeURIComponent(project.id)}`, {
method: 'PUT',
body: JSON.stringify(projectToApi(project))
})
return response.item ? projectFromApi(response.item) : null
}
const scheduleRemoteSave = (project, delay = 800) => {
if (!remoteAvailable || !project?.id) return
if (remoteSaveTimers.has(project.id)) {
clearTimeout(remoteSaveTimers.get(project.id))
}
remoteSaveTimers.set(project.id, setTimeout(async () => {
remoteSaveTimers.delete(project.id)
try {
projectSyncStatus.value = 'syncing'
await saveRemoteProjectNow(project)
projectSyncStatus.value = 'synced'
projectSyncError.value = ''
} catch (err) {
projectSyncStatus.value = 'error'
projectSyncError.value = err.message || '项目同步失败'
console.warn('Failed to sync project:', err)
}
}, delay))
}
const importLocalProjectsToServer = async (localItems) => {
if (!localItems.length) return []
const payload = { projects: localItems.map(projectToApi) }
const response = await requestJson('/canvas-projects/import', {
method: 'POST',
body: JSON.stringify(payload)
})
return (response.items || []).map(projectFromApi)
}
export const loadRemoteProjects = async () => {
try {
projectSyncStatus.value = 'syncing'
const localItems = [...projects.value]
const response = await requestJson('/canvas-projects')
remoteAvailable = true
const remoteItems = (response.items || []).map(projectFromApi)
const importedItems = await importLocalProjectsToServer(localItems)
const merged = mergeProjectLists(localItems, [...remoteItems, ...importedItems])
projects.value = merged
saveProjects({ remote: false })
projectSyncStatus.value = 'synced'
projectSyncError.value = ''
return merged
} catch (err) {
remoteAvailable = false
projectSyncStatus.value = 'error'
projectSyncError.value = err.message || '项目同步失败'
console.warn('Remote project sync unavailable:', err)
return projects.value
}
}
/**
* Clean node data for storage | 清理节点数据用于存储
* Removes base64 data URLs to reduce storage size | 移除 base64 数据减小存储大小
*/
const cleanNodeForStorage = (node) => {
if (!node.data) return node
const cleanedData = { ...node.data }
// Remove base64 data | 移除 base64 数据
if (cleanedData.base64) {
delete cleanedData.base64
}
// If url is a base64 data URL, keep it only if it's from external source | 如果 url 是 base64只有外部来源才保留
if (cleanedData.url?.startsWith?.('data:')) {
// For uploaded images, we can't persist them in localStorage | 上传的图片无法持久化到 localStorage
delete cleanedData.url
}
// Remove mask data | 移除蒙版数据
if (cleanedData.maskData) {
delete cleanedData.maskData
}
return { ...node, data: cleanedData }
}
/**
* Clean project for storage | 清理项目用于存储
*/
const cleanProjectForStorage = (project) => {
return {
...project,
canvasData: project.canvasData ? {
...project.canvasData,
nodes: project.canvasData.nodes?.map(cleanNodeForStorage) || []
} : project.canvasData,
// Remove base64 thumbnails | 移除 base64 缩略图
thumbnail: project.thumbnail?.startsWith?.('data:') ? '' : project.thumbnail
}
}
/**
* Save projects to localStorage | 保存项目到 localStorage
* Handles QuotaExceededError by compressing data | 通过压缩数据处理配额超限错误
*/
export const saveProjects = ({ remote = false } = {}) => {
// Always clean data before saving | 保存前始终清理数据
const cleanedProjects = projects.value.map(cleanProjectForStorage)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cleanedProjects))
} catch (err) {
if (err.name === 'QuotaExceededError') {
console.warn('localStorage quota exceeded, attempting aggressive cleanup...')
// Remove thumbnails and limit old projects | 移除缩略图并限制旧项目
const minimalProjects = cleanedProjects.map((project, index) => ({
...project,
thumbnail: '', // Remove all thumbnails | 移除所有缩略图
// Keep only essential canvas data for older projects | 旧项目只保留基本画布数据
canvasData: index > 10 ? { nodes: [], edges: [], viewport: project.canvasData?.viewport } : project.canvasData
}))
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(minimalProjects))
console.log('Saved with aggressive cleanup')
window.$message?.warning('存储空间不足,已自动清理部分数据')
} catch (retryErr) {
console.error('Still failed after aggressive cleanup:', retryErr)
// Last resort: only keep first 5 projects | 最后手段只保留前5个项目
try {
const essentialProjects = minimalProjects.slice(0, 5)
localStorage.setItem(STORAGE_KEY, JSON.stringify(essentialProjects))
projects.value = projects.value.slice(0, 5)
window.$message?.warning('存储空间严重不足,已保留最近 5 个项目')
} catch (finalErr) {
console.error('Cannot save even minimal data:', finalErr)
window.$message?.error('存储失败,请清理浏览器存储空间')
}
}
} else {
console.error('Failed to save projects:', err)
}
}
if (remote) {
for (const project of projects.value) scheduleRemoteSave(project)
}
}
/**
* Create a new project | 创建新项目
* @param {string} name - Project name | 项目名称
* @returns {string} - New project ID | 新项目ID
*/
export const createProject = (name = '未命名项目') => {
const id = generateId()
const now = new Date()
const newProject = {
id,
name,
thumbnail: '',
createdAt: now,
updatedAt: now,
// Canvas data | 画布数据
canvasData: {
nodes: [],
edges: [],
viewport: { x: 100, y: 50, zoom: 0.8 }
}
}
projects.value = [newProject, ...projects.value]
saveProjects()
scheduleRemoteSave(newProject, 0)
return id
}
/**
* Update project | 更新项目
* @param {string} id - Project ID | 项目ID
* @param {object} data - Update data | 更新数据
*/
export const updateProject = (id, data) => {
const index = projects.value.findIndex(p => p.id === id)
if (index === -1) return false
projects.value[index] = {
...projects.value[index],
...data,
updatedAt: new Date()
}
// Move to top of list | 移动到列表顶部
const [updated] = projects.value.splice(index, 1)
projects.value = [updated, ...projects.value]
saveProjects()
scheduleRemoteSave(updated)
return true
}
/**
* Update project canvas data | 更新项目画布数据
* @param {string} id - Project ID | 项目ID
* @param {object} canvasData - Canvas data (nodes, edges, viewport) | 画布数据
*/
export const updateProjectCanvas = (id, canvasData) => {
const project = projects.value.find(p => p.id === id)
if (!project) return false
project.canvasData = {
...project.canvasData,
...canvasData
}
project.updatedAt = new Date()
// Auto-update thumbnail from last edited image/video node | 自动从最后编辑的图片/视频节点更新缩略图
if (canvasData.nodes) {
const mediaNodes = canvasData.nodes
.filter(node => (node.type === 'image' || node.type === 'video') && node.data?.url)
.sort((a, b) => {
// Sort by last updated time | 按最后更新时间排序
const aTime = a.data?.updatedAt || a.data?.createdAt || 0
const bTime = b.data?.updatedAt || b.data?.createdAt || 0
return bTime - aTime
})
if (mediaNodes.length > 0) {
const latestNode = mediaNodes[0]
// Use thumbnail for video nodes, url for image nodes | 视频节点使用缩略图,图片节点使用 URL
if (latestNode.type === 'video') {
project.thumbnail = latestNode.data.thumbnail || latestNode.data.url
} else {
project.thumbnail = latestNode.data.url
}
}
}
saveProjects()
return true
}
/**
* Get project canvas data | 获取项目画布数据
* @param {string} id - Project ID | 项目ID
* @returns {object|null} - Canvas data or null | 画布数据或空
*/
export const getProjectCanvas = (id) => {
const project = projects.value.find(p => p.id === id)
return project?.canvasData || null
}
/**
* Delete project | 删除项目
* @param {string} id - Project ID | 项目ID
*/
export const deleteProject = (id) => {
projects.value = projects.value.filter(p => p.id !== id)
saveProjects()
if (remoteAvailable) {
requestJson(`/canvas-projects/${encodeURIComponent(id)}`, { method: 'DELETE' })
.catch(err => console.warn('Failed to delete remote project:', err))
}
}
/**
* Duplicate project | 复制项目
* @param {string} id - Source project ID | 源项目ID
* @returns {string|null} - New project ID or null | 新项目ID或空
*/
export const duplicateProject = (id) => {
const source = projects.value.find(p => p.id === id)
if (!source) return null
const newId = generateId()
const now = new Date()
const newProject = {
...JSON.parse(JSON.stringify(source)), // Deep clone | 深拷贝
id: newId,
name: `${source.name} (副本)`,
createdAt: now,
updatedAt: now
}
projects.value = [newProject, ...projects.value]
saveProjects()
scheduleRemoteSave(newProject, 0)
return newId
}
/**
* Rename project | 重命名项目
* @param {string} id - Project ID | 项目ID
* @param {string} name - New name | 新名称
*/
export const renameProject = (id, name) => {
return updateProject(id, { name })
}
/**
* Update project thumbnail | 更新项目缩略图
* @param {string} id - Project ID | 项目ID
* @param {string} thumbnail - Thumbnail URL (base64 or URL) | 缩略图URL
*/
export const updateProjectThumbnail = (id, thumbnail) => {
return updateProject(id, { thumbnail })
}
/**
* Get sorted projects | 获取排序后的项目列表
* @param {string} sortBy - Sort field (updatedAt, createdAt, name) | 排序字段
* @param {string} order - Sort order (asc, desc) | 排序顺序
*/
export const getSortedProjects = (sortBy = 'updatedAt', order = 'desc') => {
return computed(() => {
const sorted = [...projects.value]
sorted.sort((a, b) => {
let valueA = a[sortBy]
let valueB = b[sortBy]
if (valueA instanceof Date) {
valueA = valueA.getTime()
valueB = valueB.getTime()
}
if (typeof valueA === 'string') {
valueA = valueA.toLowerCase()
valueB = valueB.toLowerCase()
}
if (order === 'asc') {
return valueA > valueB ? 1 : -1
} else {
return valueA < valueB ? 1 : -1
}
})
return sorted
})
}
/**
* Initialize projects store | 初始化项目存储
*/
export const initProjectsStore = async () => {
if (initPromise) return initPromise
initPromise = (async () => {
loadProjects()
await loadRemoteProjects()
return projects.value
})()
return initPromise
}
// Export for debugging | 导出用于调试
if (typeof window !== 'undefined') {
window.__aiCanvasProjects = {
projects,
loadProjects,
saveProjects,
createProject,
deleteProject
}
}

View File

@@ -1,25 +0,0 @@
/**
* Theme store | 主题状态管理
* Handles dark/light mode switching
*/
import { ref, watch } from 'vue'
// Get initial theme from localStorage or system preference | 从本地存储或系统偏好获取初始主题
const getInitialTheme = () => {
const stored = localStorage.getItem('theme')
if (stored) return stored === 'dark'
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
export const isDark = ref(getInitialTheme())
// Watch and apply theme changes | 监听并应用主题变化
watch(isDark, (value) => {
document.documentElement.classList.toggle('dark', value)
localStorage.setItem('theme', value ? 'dark' : 'light')
}, { immediate: true })
// Toggle theme | 切换主题
export const toggleTheme = () => {
isDark.value = !isDark.value
}

View File

@@ -1,89 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Base styles | 基础样式 */
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
width: 100%;
height: 100%;
overflow: hidden;
}
/* Light mode variables | 浅色模式变量 */
:root {
--bg-primary: #f3f6f8;
--bg-secondary: rgba(255, 255, 255, 0.82);
--bg-tertiary: rgba(229, 236, 240, 0.82);
--text-primary: #111827;
--text-secondary: #667085;
--text-tertiary: #98a2b3;
--border-color: rgba(17, 24, 39, 0.1);
--accent-color: #07a5a5;
--accent-hover: #078b8b;
}
/* Dark mode variables | 深色模式变量 */
.dark {
--bg-primary: #0b1117;
--bg-secondary: rgba(20, 28, 37, 0.86);
--bg-tertiary: rgba(42, 54, 66, 0.86);
--text-primary: #f7fafc;
--text-secondary: #a6b0bb;
--text-tertiary: #77828e;
--border-color: rgba(255, 255, 255, 0.12);
--accent-color: #2dd4bf;
--accent-hover: #14b8a6;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s, color 0.3s;
}
header,
.glass-panel {
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
}
/* Vue Flow styles override | Vue Flow 样式覆盖 */
.vue-flow {
background-color: var(--bg-primary);
}
.vue-flow__node {
border-radius: 8px;
/* box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); */
}
.vue-flow__node.selected {
/* border: 1px solid green; */
}
.vue-flow__edge-path {
stroke: var(--border-color);
stroke-width: 2;
}
.vue-flow__handle {
width: 10px;
height: 10px;
background-color: var(--accent-color) !important;
border: 2px solid var(--bg-secondary);
}

View File

@@ -1,59 +0,0 @@
/**
* Constants | 常量配置
*/
// API Base URL | API 基础 URL
export const DEFAULT_API_BASE_URL = '/api'
// API Endpoints | API 端点
export const API_ENDPOINTS = {
// Model | 模型
MODEL_PAGE: '/model/page',
MODEL_FULL_NAME: '/model/fullName',
MODEL_TYPES: '/model/types',
// Image | 图片
IMAGE_GENERATIONS: '/images/generations',
// Video | 视频
VIDEO_GENERATIONS: '/videos',
VIDEO_TASK: '/videos',
// Chat | 对话
CHAT_COMPLETIONS: '/chat/completions'
}
// Error Codes | 错误码
export const ERROR_CODES = {
INVALID_API_KEY: 'INVALID_API_KEY',
RATE_LIMIT: 'RATE_LIMIT',
NETWORK_ERROR: 'NETWORK_ERROR',
TIMEOUT: 'TIMEOUT',
UNKNOWN: 'UNKNOWN'
}
// Video Poll Config | 视频轮询配置
export const VIDEO_POLL_CONFIG = {
MAX_ATTEMPTS: 120,
POLL_INTERVAL: 5000
}
// Default Chat Config | 默认问答配置
export const DEFAULT_CHAT_CONFIG = {
supportImage: false,
supportFile: false,
supportWeb: false,
supportDeepThink: false
}
// Local Storage Keys | 本地存储键
export const STORAGE_KEYS = {
API_KEY: 'apiKey',
BASE_URL: 'apiBaseUrl',
CUSTOM_CHAT_MODELS: 'customChatModels',
CUSTOM_IMAGE_MODELS: 'customImageModels',
CUSTOM_VIDEO_MODELS: 'customVideoModels',
SELECTED_CHAT_MODEL: 'selectedChatModel',
SELECTED_IMAGE_MODEL: 'selectedImageModel',
SELECTED_VIDEO_MODEL: 'selectedVideoModel'
}

View File

@@ -1,9 +0,0 @@
/**
* Utils Index | 工具函数索引
*/
export * from './constants'
export * from './schema'
import request, { setBaseUrl, getBaseUrl } from './request'
export { request, setBaseUrl, getBaseUrl }

View File

@@ -1,88 +0,0 @@
/**
* HTTP Request Utility | HTTP 请求工具
* Axios-based request with interceptors
*/
import axios from 'axios'
// Base URL from environment or default
// Create axios instance | 创建 axios 实例
const instance = axios.create({
baseURL: "/api",
timeout: 30000000
})
// Request interceptor | 请求拦截器
instance.interceptors.request.use(
(config) => {
return config
},
(error) => {
console.error('Request error:', error)
return Promise.reject(error)
}
)
// Response interceptor | 响应拦截器
instance.interceptors.response.use(
(res) => {
const { data, code, message } = res.data || {}
// Handle stream response | 处理流响应
if (res.config.responseType === 'stream') {
return res.data
}
// Handle blob response | 处理 blob 响应
if (res.data instanceof Blob) {
return res.data
}
// Success response | 成功响应
if (code === 200 || res.status === 200) {
return res.data
}
// Error response | 错误响应
window.$message?.error(message || 'Request failed')
return Promise.reject(res.data)
},
(error) => {
const { response } = error
if (response) {
const { status, data } = response
const message = data?.message || data?.error?.message || error.message
if (status === 401) {
window.$message?.error('登录已过期,请重新进入工作台')
} else if (status === 429) {
window.$message?.error('请求过于频繁,请稍后再试')
} else {
window.$message?.error(message || '请求失败')
}
} else {
window.$message?.error(error.message || '网络错误')
}
return Promise.reject(error)
}
)
/**
* Set API base URL | 设置 API 基础 URL
* @param {string} url - Base URL
*/
export const setBaseUrl = (url) => {
instance.defaults.baseURL = url
}
/**
* Get current base URL | 获取当前基础 URL
* @returns {string}
*/
export const getBaseUrl = () => {
return instance.defaults.baseURL
}
export default instance

View File

@@ -1,105 +0,0 @@
/**
* API Utils | API 工具函数
* Simplified for open source version | 开源版简化版
*/
/**
* Get nested value from object | 获取嵌套对象的值
* @param {Object} obj - Source object
* @param {string} path - Path like "data.url" or "choices.0.message"
* @returns {*} Value at path
*/
export const getNestedValue = (obj, path) => {
if (!obj || !path) return obj
const paths = path.split('.')
let value = obj
for (const p of paths) {
value = value?.[p]
}
return value
}
/**
* Build request body with FormData support | 构建请求体,支持 FormData
* @param {Object} params - Request parameters
* @param {string} requestType - 'json' or 'formdata'
* @returns {Object|FormData} Request body
*/
export const buildRequestBody = (params, requestType = 'json') => {
if (requestType !== 'formdata') {
return params
}
const fd = new FormData()
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) {
value.forEach((item, idx) => {
if (item instanceof File) {
fd.append(`${key}[${idx}]`, item, item.name)
} else if (typeof item === 'object' && item !== null) {
fd.append(`${key}[${idx}]`, JSON.stringify(item))
} else {
fd.append(`${key}[${idx}]`, item)
}
})
} else if (value instanceof File) {
fd.append(key, value, value.name)
} else if (typeof value === 'object' && value !== null) {
fd.append(key, JSON.stringify(value))
} else if (value !== undefined && value !== null && value !== '') {
fd.append(key, value)
}
}
return fd
}
/**
* Parse API result based on output schema | 根据输出 schema 解析 API 结果
* @param {Object} result - API response
* @param {Object} outputSchema - Output schema with displayField
* @param {string} resultType - Result type: 'image', 'video', 'chat'
* @returns {Array} Parsed results
*/
export const parseApiResult = (result, outputSchema, resultType = 'image') => {
if (!result) return []
// Default field based on result type
const defaultField = resultType === 'video' ? 'video_url' : (resultType === 'image' ? 'data' : null)
const displayField = outputSchema?.displayField || defaultField
// No displayField, try default parsing
if (!displayField) {
if (result?.data) {
return Array.isArray(result.data) ? result.data : [result.data]
}
return [result]
}
// Parse displayField path
// Supports: "data", "data[].url", "choices[].message.content"
if (displayField.includes('[]')) {
// Array path like data[].url
const [arrayPath, ...rest] = displayField.split('[]')
const fieldPath = rest.join('[]').replace(/^\./, '') // Remove leading dot
// Get array
let data = arrayPath ? getNestedValue(result, arrayPath) : result
if (!Array.isArray(data)) {
data = data ? [data] : []
}
// Extract field from each element if fieldPath exists
if (fieldPath) {
return data.map(item => getNestedValue(item, fieldPath)).filter(Boolean)
}
return data
} else {
// Simple path like "data"
const data = getNestedValue(result, displayField)
return Array.isArray(data) ? data : (data ? [data] : [])
}
}

View File

@@ -1,876 +0,0 @@
<template>
<!-- Canvas page | 画布页面 -->
<div class="h-screen w-screen flex flex-col bg-[var(--bg-primary)]">
<!-- Header | 顶部导航 -->
<AppHeader class="bg-[var(--bg-secondary)]">
<template #left>
<button
@click="goBack"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
>
<n-icon :size="20"><ChevronBackOutline /></n-icon>
</button>
<n-dropdown :options="projectOptions" @select="handleProjectAction">
<button class="flex items-center gap-1 hover:bg-[var(--bg-tertiary)] px-2 py-1 rounded-lg transition-colors">
<span class="font-medium">{{ projectName }}</span>
<n-icon :size="16"><ChevronDownOutline /></n-icon>
</button>
</n-dropdown>
</template>
<template #right>
<button
@click="showDownloadModal = true"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
:class="{ 'text-[var(--accent-color)]': hasDownloadableAssets }"
title="批量下载素材"
>
<n-icon :size="20"><DownloadOutline /></n-icon>
</button>
<button
@click="showApiSettings = true"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
:class="{ 'text-[var(--accent-color)]': isApiConfigured }"
title="API 设置"
>
<n-icon :size="20"><SettingsOutline /></n-icon>
</button>
</template>
</AppHeader>
<!-- Main canvas area | 主画布区域 -->
<div class="flex-1 relative overflow-hidden">
<!-- Vue Flow canvas | Vue Flow 画布 -->
<VueFlow
:key="flowKey"
v-model:nodes="nodes"
v-model:edges="edges"
v-model:viewport="viewport"
:node-types="nodeTypes"
:edge-types="edgeTypes"
:default-viewport="canvasViewport"
:min-zoom="0.1"
:max-zoom="2"
:snap-to-grid="true"
:snap-grid="[20, 20]"
@connect="onConnect"
@node-click="onNodeClick"
@pane-click="onPaneClick"
@viewport-change="handleViewportChange"
@edges-change="onEdgesChange"
class="canvas-flow"
>
<Background v-if="showGrid" :gap="20" :size="1" />
<MiniMap
v-if="!isMobile"
position="bottom-right"
:pannable="true"
:zoomable="true"
/>
</VueFlow>
<!-- Left toolbar | 左侧工具栏 -->
<aside class="absolute left-4 top-1/2 -translate-y-1/2 flex flex-col gap-1 p-2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-lg z-10">
<button
@click="showNodeMenu = !showNodeMenu"
class="w-10 h-10 flex items-center justify-center rounded-xl bg-[var(--accent-color)] text-white hover:bg-[var(--accent-hover)] transition-colors"
title="添加节点"
>
<n-icon :size="20"><AddOutline /></n-icon>
</button>
<button
@click="showWorkflowPanel = true"
class="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-[var(--bg-tertiary)] transition-colors"
title="工作流模板"
>
<n-icon :size="20"><AppsOutline /></n-icon>
</button>
<div class="w-full h-px bg-[var(--border-color)] my-1"></div>
<button
v-for="tool in tools"
:key="tool.id"
@click="tool.action"
:disabled="tool.disabled && tool.disabled()"
class="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
:title="tool.name"
>
<n-icon :size="20"><component :is="tool.icon" /></n-icon>
</button>
</aside>
<!-- Node menu popup | 节点菜单弹窗 -->
<div
v-if="showNodeMenu"
class="absolute left-20 top-1/2 -translate-y-1/2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-lg p-2 z-20"
>
<button
v-for="nodeType in nodeTypeOptions"
:key="nodeType.type"
@click="addNewNode(nodeType.type)"
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors text-left"
>
<n-icon :size="20" :color="nodeType.color"><component :is="nodeType.icon" /></n-icon>
<span class="text-sm">{{ nodeType.name }}</span>
</button>
</div>
<!-- Bottom controls | 底部控制 -->
<div class="absolute bottom-4 left-4 flex items-center gap-2 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)] p-1">
<!-- <button
@click="showGrid = !showGrid"
:class="showGrid ? 'bg-[var(--accent-color)] text-white' : 'hover:bg-[var(--bg-tertiary)]'"
class="p-2 rounded transition-colors"
title="切换网格"
>
<n-icon :size="16"><GridOutline /></n-icon>
</button> -->
<button
@click="fitView({ padding: 0.2 })"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded transition-colors"
title="适应视图"
>
<n-icon :size="16"><LocateOutline /></n-icon>
</button>
<div class="flex items-center gap-1 px-2">
<button @click="zoomOut" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
<n-icon :size="14"><RemoveOutline /></n-icon>
</button>
<span class="text-xs min-w-[40px] text-center">{{ Math.round(viewport.zoom * 100) }}%</span>
<button @click="zoomIn" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
<n-icon :size="14"><AddOutline /></n-icon>
</button>
</div>
</div>
<!-- Bottom input panel (floating) | 底部输入面板悬浮 -->
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 w-full max-w-2xl px-4 z-20">
<!-- Processing indicator | 处理中指示器 -->
<div
v-if="isProcessing"
class="mb-3 p-3 bg-[var(--bg-primary)] rounded-xl border border-[var(--accent-color)] animate-pulse"
>
<div class="flex items-center gap-2 text-sm text-[var(--accent-color)] mb-2">
<n-spin :size="14" />
<span>正在生成提示词...</span>
</div>
<div v-if="currentResponse" class="text-sm text-[var(--text-primary)] whitespace-pre-wrap">
{{ currentResponse }}
</div>
</div>
<div class="bg-[var(--bg-primary)] rounded-xl border border-[var(--border-color)] p-3">
<textarea
v-model="chatInput"
:placeholder="inputPlaceholder"
:disabled="isProcessing"
class="w-full bg-transparent resize-none outline-none text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] min-h-[40px] max-h-[120px] disabled:opacity-50"
rows="1"
@keydown.enter.exact="handleEnterKey"
@keydown.enter.ctrl="sendMessage"
/>
<div class="flex items-center justify-between mt-2">
<div class="flex items-center gap-2">
<button
@click="handlePolish"
:disabled="isProcessing || !chatInput.trim()"
class="px-3 py-1.5 text-xs rounded-lg bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="AI 润色提示词"
>
AI 润色
</button>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<n-switch v-model:value="autoExecute" size="small" />
自动执行
</label>
<button
@click="sendMessage"
:disabled="isProcessing"
class="w-8 h-8 rounded-xl bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] flex items-center justify-center transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<n-spin v-if="isProcessing" :size="16" />
<n-icon v-else :size="20" color="white"><SendOutline /></n-icon>
</button>
</div>
</div>
</div>
<!-- Quick suggestions | 快捷建议 -->
<div class="flex flex-wrap items-center justify-center gap-2 mt-2">
<span class="text-xs text-[var(--text-secondary)]">推荐</span>
<button
v-for="tag in suggestions"
:key="tag"
@click="chatInput = tag"
class="px-2 py-0.5 text-xs rounded-full bg-[var(--bg-secondary)]/80 border border-[var(--border-color)] hover:border-[var(--accent-color)] transition-colors"
>
{{ tag }}
</button>
<button class="p-1 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
<n-icon :size="14"><RefreshOutline /></n-icon>
</button>
</div>
</div>
</div>
<!-- API Settings Modal | API 设置弹窗 -->
<ApiSettings v-model:show="showApiSettings" />
<!-- Rename Modal | 重命名弹窗 -->
<n-modal v-model:show="showRenameModal" preset="dialog" title="重命名项目">
<n-input v-model:value="renameValue" placeholder="请输入项目名称" />
<template #action>
<n-button @click="showRenameModal = false">取消</n-button>
<n-button type="primary" @click="confirmRename">确定</n-button>
</template>
</n-modal>
<!-- Delete Confirm Modal | 删除确认弹窗 -->
<n-modal v-model:show="showDeleteModal" preset="dialog" title="删除项目" type="warning">
<p>确定要删除项目{{ projectName }}此操作不可恢复</p>
<template #action>
<n-button @click="showDeleteModal = false">取消</n-button>
<n-button type="error" @click="confirmDelete">删除</n-button>
</template>
</n-modal>
<!-- Download Modal | 下载弹窗 -->
<DownloadModal v-model:show="showDownloadModal" />
<!-- Workflow Panel | 工作流面板 -->
<WorkflowPanel v-model:show="showWorkflowPanel" @add-workflow="handleAddWorkflow" />
</div>
</template>
<script setup>
/**
* Canvas view component | 画布视图组件
* Main infinite canvas with Vue Flow integration
*/
import { ref, computed, onMounted, onUnmounted, watch, nextTick, markRaw } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { VueFlow, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { MiniMap } from '@vue-flow/minimap'
import { NIcon, NSwitch, NDropdown, NMessageProvider, NSpin, NModal, NInput, NButton } from 'naive-ui'
import {
ChevronBackOutline,
ChevronDownOutline,
SettingsOutline,
AddOutline,
ImageOutline,
SendOutline,
RefreshOutline,
TextOutline,
VideocamOutline,
ColorPaletteOutline,
BookmarkOutline,
ArrowUndoOutline,
ArrowRedoOutline,
GridOutline,
LocateOutline,
RemoveOutline,
DownloadOutline,
AppsOutline,
ChatbubbleOutline
} 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 { useModelStore } from '../stores/pinia'
import { projects, initProjectsStore, updateProject, renameProject, currentProject } from '../stores/projects'
// API Settings component | API 设置组件
import ApiSettings from '../components/ApiSettings.vue'
import DownloadModal from '../components/DownloadModal.vue'
import WorkflowPanel from '../components/WorkflowPanel.vue'
import AppHeader from '../components/AppHeader.vue'
// API Config state | API 配置状态
const modelStore = useModelStore()
const isApiConfigured = computed(() => !!modelStore.currentApiKey)
// Initialize models on page load | 页面加载时初始化模型
onMounted(() => {
loadAllModels()
})
// Chat templates | 问答模板
const CHAT_TEMPLATES = {
imagePrompt: {
name: '生图提示词',
systemPrompt: '你是一个专业的AI绘画提示词专家。将用户输入的内容美化成高质量的生图提示词包含风格、光线、構图、细节等要素。直接返回提示词不要其他解释。',
model: 'gpt-4o-mini'
},
videoPrompt: {
name: '视频提示词',
systemPrompt: '你是一个专业的AI视频提示词专家。将用户输入的内容美化成高质量的视频生成提示词包含运动、场景、镜头等要素。直接返回提示词不要其他解释。',
model: 'gpt-4o-mini'
}
}
// Current template | 当前模板
const currentTemplate = ref('imagePrompt')
// Chat hook with image prompt template | 问答 hook
const {
loading: chatLoading,
status: chatStatus,
currentResponse,
send: sendChat
} = useChat({
systemPrompt: CHAT_TEMPLATES.imagePrompt.systemPrompt,
model: CHAT_TEMPLATES.imagePrompt.model
})
// Workflow orchestrator hook | 工作流编排 hook
const {
isAnalyzing: workflowAnalyzing,
isExecuting: workflowExecuting,
currentStep: workflowStep,
totalSteps: workflowTotalSteps,
executionLog: workflowLog,
analyzeIntent,
executeWorkflow,
createTextToImageWorkflow,
createMultiAngleStoryboard,
WORKFLOW_TYPES
} = useWorkflowOrchestrator()
// Custom node components | 自定义节点组件
import TextNode from '../components/nodes/TextNode.vue'
import ImageConfigNode from '../components/nodes/ImageConfigNode.vue'
import VideoNode from '../components/nodes/VideoNode.vue'
import ImageNode from '../components/nodes/ImageNode.vue'
import VideoConfigNode from '../components/nodes/VideoConfigNode.vue'
import LLMConfigNode from '../components/nodes/LLMConfigNode.vue'
import ImageRoleEdge from '../components/edges/ImageRoleEdge.vue'
import PromptOrderEdge from '../components/edges/PromptOrderEdge.vue'
import ImageOrderEdge from '../components/edges/ImageOrderEdge.vue'
const router = useRouter()
const route = useRoute()
// Vue Flow instance | Vue Flow 实例
const { viewport, zoomIn, zoomOut, fitView, updateNodeInternals } = useVueFlow()
// Register custom node types | 注册自定义节点类型
const nodeTypes = {
text: markRaw(TextNode),
imageConfig: markRaw(ImageConfigNode),
video: markRaw(VideoNode),
image: markRaw(ImageNode),
videoConfig: markRaw(VideoConfigNode),
llmConfig: markRaw(LLMConfigNode)
}
// Register custom edge types | 注册自定义边类型
const edgeTypes = {
imageRole: markRaw(ImageRoleEdge),
promptOrder: markRaw(PromptOrderEdge),
imageOrder: markRaw(ImageOrderEdge)
}
// UI state | UI状态
const showNodeMenu = ref(false)
const chatInput = ref('')
const autoExecute = ref(false)
const isMobile = ref(false)
const showGrid = ref(true)
const showApiSettings = ref(false)
const isProcessing = ref(false)
// Flow key for forcing re-render on project switch | 项目切换时强制重新渲染的 key
const flowKey = ref(Date.now())
// Modal state | 弹窗状态
const showRenameModal = ref(false)
const showDeleteModal = ref(false)
const showDownloadModal = ref(false)
const showWorkflowPanel = ref(false)
const renameValue = ref('')
// Check if has downloadable assets | 检查是否有可下载素材
const hasDownloadableAssets = computed(() => {
return nodes.value.some(n =>
(n.type === 'image' || n.type === 'video') && n.data?.url
)
})
// Project info | 项目信息
const projectName = computed(() => {
const project = projects.value.find(p => p.id === route.params.id)
return project?.name || '未命名项目'
})
// Project dropdown options | 项目下拉选项
const projectOptions = [
{ label: '重命名', key: 'rename' },
{ label: '复制', key: 'duplicate' },
{ label: '删除', key: 'delete' }
]
// Toolbar tools | 工具栏工具
const tools = [
{ id: 'text', name: '文本', icon: TextOutline, action: () => addNewNode('text') },
{ id: 'image', name: '图片', icon: ImageOutline, action: () => addNewNode('image') },
{ id: 'imageConfig', name: '文生图', icon: ColorPaletteOutline, action: () => addNewNode('imageConfig') },
{ id: 'videoConfig', name: '视频生成', icon: VideocamOutline, action: () => addNewNode('videoConfig') },
{ id: 'undo', name: '撤销', icon: ArrowUndoOutline, action: () => undo(), disabled: () => !canUndo() },
{ id: 'redo', name: '重做', icon: ArrowRedoOutline, action: () => redo(), disabled: () => !canRedo() }
]
// Node type options for menu | 节点类型菜单选项
const nodeTypeOptions = [
{ type: 'text', name: '文本节点', icon: TextOutline, color: '#3b82f6' },
{ type: 'llmConfig', name: 'LLM文本生成', icon: ChatbubbleOutline, color: '#a855f7' },
{ type: 'imageConfig', name: '文生图配置', icon: ColorPaletteOutline, color: '#22c55e' },
{ type: 'videoConfig', name: '视频生成配置', icon: VideocamOutline, color: '#f59e0b' },
{ type: 'image', name: '图片节点', icon: ImageOutline, color: '#8b5cf6' },
{ type: 'video', name: '视频节点', icon: VideocamOutline, color: '#ef4444' }
]
// Input placeholder | 输入占位符
const inputPlaceholder = '你可以试着说"帮我生成一个二次元的卡通角色"'
// Quick suggestions | 快捷建议
const suggestions = [
'像个魔法森林',
'三只不同的小猫',
'生成多角度分镜',
'夏日田野环绕漫步'
]
// 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
// 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 })
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
setTimeout(() => {
updateNodeInternals(nodeId)
}, 50)
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
// Create nodes from workflow template | 从工作流模板创建节点
const startPosition = { x: viewportCenterX - 300, y: viewportCenterY - 200 }
const { nodes: newNodes, edges: newEdges } = workflow.createNodes(startPosition, options)
// Start batch operation manually | 手动开始批量操作
startBatchOperation()
// Add nodes to canvas in batch | 批量将节点添加到画布
const nodeSpecs = newNodes.map(node => ({
type: node.type,
position: 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]
})
// Add edges to canvas in batch | 批量将边添加到画布
const edgeSpecs = newEdges.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
}))
// Add edges (autoBatch=false to use manual batch) | 添加边autoBatch=false 以使用手动批量)
addEdges(edgeSpecs, false)
// End batch operation and save to history | 结束批量操作并保存到历史
endBatchOperation()
// Delay node internals update | 延迟节点内部更新
setTimeout(() => {
// Update node internals | 更新节点内部
nodeIds.forEach(nodeId => {
updateNodeInternals(nodeId)
})
}, 100)
window.$message?.success(`已添加工作流: ${workflow.name}`)
}
// Handle connection | 处理连接
const onConnect = (params) => {
// Check connection types | 检查连接类型
const sourceNode = nodes.value.find(n => n.id === params.source)
const targetNode = nodes.value.find(n => n.id === params.target)
if (sourceNode?.type === 'image' && targetNode?.type === 'videoConfig') {
// Use imageRole edge type | 使用图片角色边类型
addEdge({
...params,
type: 'imageRole',
data: { imageRole: 'first_frame_image' } // Default to first frame | 默认首帧
})
} else if (sourceNode?.type === 'text' && targetNode?.type === 'imageConfig') {
// Use promptOrder edge type | 使用提示词顺序边类型
// Calculate next order number | 计算下一个顺序号
const existingTextEdges = edges.value.filter(e =>
e.target === params.target && e.type === 'promptOrder'
)
const nextOrder = existingTextEdges.length + 1
addEdge({
...params,
type: 'promptOrder',
data: { promptOrder: nextOrder }
})
} else if (sourceNode?.type === 'image' && targetNode?.type === 'imageConfig') {
// Use imageOrder edge type | 使用图片顺序边类型
// Calculate next order number | 计算下一个顺序号
const existingImageEdges = edges.value.filter(e =>
e.target === params.target && e.type === 'imageOrder'
)
// Get @ mentioned image count from connected TextNodes | 获取已连接 TextNode 中 @ 提及的图片数量
let mentionedImageCount = 0
const connectedTextEdges = edges.value.filter(e => e.target === params.target)
for (const edge of connectedTextEdges) {
const sourceNode = nodes.value.find(n => n.id === edge.source)
if (sourceNode?.type === 'text') {
const content = sourceNode.data?.content || ''
// Count @ mentions of image nodes | 统计图片节点的 @ 提及
const mentionRegex = /@\[([^\]|]+)(?:\|([^\]]+))?\]/g
let match
while ((match = mentionRegex.exec(content)) !== null) {
const mentionedNode = nodes.value.find(n => n.id === match[1])
if (mentionedNode?.type === 'image') {
mentionedImageCount++
}
}
}
}
// Next order = existing edges + mentioned image count + 1 | 下一个序号 = 现有边数 + @提及图片数 + 1
const nextOrder = existingImageEdges.length + mentionedImageCount + 1
addEdge({
...params,
type: 'imageOrder',
data: { imageOrder: nextOrder }
})
} else if (sourceNode?.type === 'llmConfig' && targetNode?.type === 'imageConfig') {
// LLM output as prompt for image generation | LLM 输出作为图片生成提示词
const existingTextEdges = edges.value.filter(e =>
e.target === params.target && e.type === 'promptOrder'
)
const nextOrder = existingTextEdges.length + 1
addEdge({
...params,
type: 'promptOrder',
data: { promptOrder: nextOrder }
})
} else if (sourceNode?.type === 'llmConfig' && targetNode?.type === 'videoConfig') {
// LLM output as prompt for video generation | LLM 输出作为视频生成提示词
addEdge({
...params,
type: 'promptOrder',
data: { promptOrder: 1 }
})
} else {
addEdge(params)
}
}
const onNodeClick = (event) => {
// nodes.value.forEach(node => {
// updateNode(node.id, { selected: false })
// })
// // Select clicked node | 选中的节点
// const clickedNode = nodes.value.find(n => n.id === event.node.id)
// if (clickedNode) {
// updateNode(event.node.id, { selected: true })
// }
}
// Handle viewport change | 处理视口变化
const handleViewportChange = (newViewport) => {
updateViewport(newViewport)
}
// Handle edges change | 处理边变化
const onEdgesChange = (changes) => {
// Check if any edge is being removed | 检查是否有边被删除
const hasRemoval = changes.some(change => change.type === 'remove')
if (hasRemoval) {
// Trigger history save after edge removal | 边删除后触发历史保存
nextTick(() => {
manualSaveHistory()
})
}
}
// Handle pane click | 处理画布点击
const onPaneClick = () => {
showNodeMenu.value = false
// Clear all selections | 清除所有选中
// nodes.value = nodes.value.map(node => ({
// ...node,
// selected: false
// }))
}
// Handle project action | 处理项目操作
const handleProjectAction = (key) => {
switch (key) {
case 'rename':
renameValue.value = projectName.value
showRenameModal.value = true
break
case 'duplicate':
// TODO: Implement duplicate
window.$message?.info('复制功能开发中')
break
case 'delete':
showDeleteModal.value = true
break
}
}
// Confirm rename | 确认重命名
const confirmRename = () => {
const projectId = route.params.id
if (renameValue.value.trim()) {
renameProject(projectId, renameValue.value.trim())
window.$message?.success('已重命名')
}
showRenameModal.value = false
}
// Confirm delete | 确认删除
const confirmDelete = () => {
const projectId = route.params.id
// deleteProject(projectId) // TODO: import deleteProject
showDeleteModal.value = false
window.$message?.success('项目已删除')
router.push('/')
}
// Handle Enter key | 处理回车键
const handleEnterKey = (e) => {
e.preventDefault()
sendMessage()
}
// Handle AI polish | 处理 AI 润色
const handlePolish = async () => {
const input = chatInput.value.trim()
if (!input) return
// Check API configuration | 检查 API 配置
if (!isApiConfigured.value) {
window.$message?.warning('生成接口未就绪,请稍后重试')
showApiSettings.value = true
return
}
isProcessing.value = true
const originalInput = chatInput.value
try {
// Call chat API to polish the prompt | 调用 AI 润色提示词
const result = await sendChat(input, true)
if (result) {
chatInput.value = result
window.$message?.success('提示词已润色')
}
} catch (err) {
chatInput.value = originalInput
window.$message?.error(err.message || '润色失败')
} finally {
isProcessing.value = false
}
}
// Send message | 发送消息
const sendMessage = async () => {
const input = chatInput.value.trim()
if (!input) return
// Check API configuration | 检查 API 配置
if (!isApiConfigured.value) {
window.$message?.warning('生成接口未就绪,请稍后重试')
showApiSettings.value = true
return
}
isProcessing.value = true
const content = chatInput.value
chatInput.value = ''
try {
// Calculate position to avoid overlap | 计算位置避免重叠
let maxY = 0
if (nodes.value.length > 0) {
maxY = Math.max(...nodes.value.map(n => n.position.y))
}
const baseX = 100
const baseY = maxY + 200
if (autoExecute.value) {
// Auto-execute mode: analyze intent and execute workflow | 自动执行模式:分析意图并执行工作流
window.$message?.info('正在分析工作流...')
try {
// Analyze user intent | 分析用户意图
const result = await analyzeIntent(content)
// Ensure we have valid workflow params | 确保有效的工作流参数
const workflowParams = {
workflow_type: result?.workflow_type || WORKFLOW_TYPES.TEXT_TO_IMAGE,
image_prompt: result?.image_prompt || content,
video_prompt: result?.video_prompt || content,
character: result?.character,
shots: result?.shots,
multi_angle: result?.multi_angle,
picture_book: result?.picture_book
}
window.$message?.info(`执行工作流: ${result?.description || '文生图'}`)
// Execute the workflow | 执行工作流
await executeWorkflow(workflowParams, { x: baseX, y: baseY })
window.$message?.success('工作流已启动')
} catch (err) {
console.error('Workflow error:', err)
// Fallback to simple text-to-image | 回退到文生图
window.$message?.warning('使用默认文生图工作流')
await createTextToImageWorkflow(content, { x: baseX, y: baseY })
}
} else {
// Manual mode: just create nodes | 手动模式:仅创建节点
const textNodeId = addNode('text', { x: baseX, y: baseY }, {
content: content,
label: '提示词'
})
const imageConfigNodeId = addNode('imageConfig', { x: baseX + 400, y: baseY }, {
label: '文生图'
})
addEdge({
source: textNodeId,
target: imageConfigNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
}
} catch (err) {
window.$message?.error(err.message || '创建失败')
} finally {
isProcessing.value = false
}
}
// Go back to home | 返回首页
const goBack = () => {
router.push('/')
}
// Check if mobile | 检测是否移动端
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
}
// Load project by ID | 根据ID加载项目
const loadProjectById = (projectId) => {
// Update flow key to force VueFlow re-render | 更新 key 强制 VueFlow 重新渲染
flowKey.value = Date.now()
if (projectId && projectId !== 'new') {
loadProject(projectId)
} else {
// New project - clear canvas | 新项目 - 清空画布
clearCanvas()
}
}
// Watch for route changes | 监听路由变化
watch(
() => route.params.id,
(newId, oldId) => {
if (newId && newId !== oldId) {
// Save current project before switching | 切换前保存当前项目
if (oldId) {
saveProject()
}
// Load new project | 加载新项目
loadProjectById(newId)
}
}
)
// Initialize | 初始化
onMounted(async () => {
checkMobile()
window.addEventListener('resize', checkMobile)
// Initialize projects store | 初始化项目存储
await initProjectsStore()
// Load project data | 加载项目数据
loadProjectById(route.params.id)
// Check for initial prompt from home page | 检查来自首页的初始提示词
const initialPrompt = sessionStorage.getItem('ai-canvas-initial-prompt')
if (initialPrompt) {
sessionStorage.removeItem('ai-canvas-initial-prompt')
chatInput.value = initialPrompt
// Auto-send the message | 自动发送消息
nextTick(() => {
sendMessage()
})
}
})
// Cleanup on unmount | 卸载时清理
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
// Save project before leaving | 离开前保存项目
saveProject()
})
</script>
<style>
/* Import Vue Flow styles | 引入 Vue Flow 样式 */
@import '@vue-flow/core/dist/style.css';
@import '@vue-flow/core/dist/theme-default.css';
@import '@vue-flow/minimap/dist/style.css';
.canvas-flow {
width: 100%;
height: 100%;
}
</style>

View File

@@ -1,421 +0,0 @@
<template>
<!-- Home page | 首页 -->
<div class="min-h-screen h-screen overflow-y-auto bg-[var(--bg-primary)]">
<!-- Header | 顶部导航 -->
<AppHeader>
<template #right>
<button
@click="showApiSettings = true"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
:class="{ 'text-[var(--accent-color)]': isApiConfigured }"
title="API 设置"
>
<n-icon :size="20"><SettingsOutline /></n-icon>
</button>
</template>
</AppHeader>
<!-- Main content | 主要内容 -->
<main class="max-w-5xl mx-auto px-4 py-8 md:py-16">
<!-- Welcome section | 欢迎区域 -->
<section class="text-center mb-12">
<div class="flex items-center justify-center gap-4 mb-8">
<img src="/skg-logo-black.svg" alt="SKG" class="h-12 w-auto md:h-16 dark:invert" />
<h1 class="sr-only">SKG</h1>
</div>
<!-- Input area | 输入区域 -->
<div class="max-w-2xl mx-auto">
<div class="bg-[var(--bg-secondary)] rounded-2xl border border-[var(--border-color)] p-4 shadow-sm">
<textarea
v-model="inputText"
placeholder="输入你的创意,开始新项目"
class="w-full bg-transparent resize-none outline-none text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] min-h-[80px]"
@keydown.enter.ctrl="handleCreateWithInput"
/>
<div class="flex items-center justify-between mt-2">
<div class="flex items-center gap-2">
<!-- <button class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
<n-icon :size="18"><AddOutline /></n-icon>
</button>
<button class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
<n-icon :size="18"><ImageOutline /></n-icon>
</button> -->
</div>
<div class="flex items-center gap-3">
<button
@click="handleCreateWithInput"
class="w-8 h-8 rounded-xl bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] flex items-center justify-center transition-colors"
>
<n-icon :size="20" color="white"><SendOutline /></n-icon>
</button>
</div>
</div>
</div>
<!-- Quick suggestions | 快捷建议 -->
<div class="flex flex-wrap items-center justify-center gap-2 mt-4">
<span class="text-sm text-[var(--text-secondary)]">推荐</span>
<button
v-for="tag in suggestions"
:key="tag"
@click="inputText = tag"
class="px-3 py-1.5 text-sm rounded-full bg-[var(--bg-secondary)] border border-[var(--border-color)] hover:border-[var(--accent-color)] transition-colors"
>
{{ tag }}
</button>
<button class="p-1.5 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
<n-icon :size="16"><RefreshOutline /></n-icon>
</button>
</div>
</div>
</section>
<!-- My projects section | 我的项目区域 -->
<section ref="projectsSection">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-[var(--text-primary)]">我的项目</h2>
<button
@click="createNewProject"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white transition-colors"
>
<n-icon :size="16"><AddOutline /></n-icon>
新建项目
</button>
</div>
<!-- Empty state | 空状态 -->
<div v-if="projects.length === 0" class="text-center py-12 bg-[var(--bg-secondary)] rounded-xl border border-dashed border-[var(--border-color)]">
<n-icon :size="48" class="text-[var(--text-secondary)] mb-4"><FolderOutline /></n-icon>
<p class="text-[var(--text-secondary)] mb-4">还没有项目创建一个开始吧</p>
<button
@click="createNewProject"
class="px-4 py-2 text-sm rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white transition-colors"
>
创建第一个项目
</button>
</div>
<!-- Projects grid | 项目网格 -->
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div
v-for="project in projects"
:key="project.id"
class="group relative"
>
<!-- Project card | 项目卡片 -->
<div
@click="openProject(project)"
class="cursor-pointer"
>
<div
class="aspect-video rounded-xl overflow-hidden bg-[var(--bg-tertiary)] mb-2 border border-[var(--border-color)] relative"
@mouseenter="handleThumbnailHover(project, true)"
@mouseleave="handleThumbnailHover(project, false)"
>
<!-- Thumbnail or placeholder | 缩略图或占位 -->
<template v-if="project.thumbnail">
<!-- Video thumbnail | 视频缩略图 -->
<video
v-if="isVideoUrl(project.thumbnail)"
:ref="el => setVideoRef(project.id, el)"
:src="project.thumbnail"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
muted
loop
playsinline
/>
<!-- Image thumbnail | 图片缩略图 -->
<img
v-else
:src="project.thumbnail"
:alt="project.name"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</template>
<div v-else class="w-full h-full flex items-center justify-center">
<n-icon :size="32" class="text-[var(--text-secondary)]"><DocumentOutline /></n-icon>
</div>
<!-- Hover overlay | 悬浮遮罩 -->
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<span class="text-white text-sm">打开项目</span>
</div>
</div>
<p class="text-sm text-[var(--text-primary)] truncate">{{ project.name }}</p>
<p class="text-xs text-[var(--text-secondary)]">{{ formatDate(project.updatedAt) }}</p>
</div>
<!-- Project actions | 项目操作 -->
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<n-dropdown :options="getProjectActions(project)" @select="(key) => handleProjectAction(key, project)" placement="bottom-end">
<button
@click.stop
class="p-1.5 bg-white/90 dark:bg-gray-800/90 rounded-lg shadow hover:bg-white dark:hover:bg-gray-800 transition-colors"
>
<n-icon :size="16"><EllipsisHorizontalOutline /></n-icon>
</button>
</n-dropdown>
</div>
</div>
</div>
</section>
</main>
<!-- Left sidebar | 左侧边栏 -->
<aside class="fixed left-4 top-1/2 -translate-y-1/2 hidden md:flex flex-col gap-2 p-2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-sm">
<button
@click="createNewProject"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
title="新建项目"
>
<n-icon :size="20"><DocumentOutline /></n-icon>
</button>
<button
@click="scrollToProjects"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
title="我的项目"
>
<n-icon :size="20"><FolderOutline /></n-icon>
</button>
</aside>
<!-- API Settings Modal | API 设置弹窗 -->
<ApiSettings v-model:show="showApiSettings" @saved="refreshApiConfig" />
<!-- Rename modal | 重命名弹窗 -->
<n-modal v-model:show="showRenameModal" preset="dialog" title="重命名项目">
<n-input v-model:value="renameValue" placeholder="请输入项目名称" />
<template #action>
<n-button @click="showRenameModal = false">取消</n-button>
<n-button type="primary" @click="confirmRename">确定</n-button>
</template>
</n-modal>
</div>
</template>
<script setup>
/**
* Home view component | 首页视图组件
* Entry point with project list and creation input
*/
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { NIcon, NDropdown, NModal, NInput, NButton, useDialog } from 'naive-ui'
import {
AddOutline,
ImageOutline,
SendOutline,
RefreshOutline,
DocumentOutline,
FolderOutline,
EllipsisHorizontalOutline,
CreateOutline,
CopyOutline,
SettingsOutline,
TrashOutline
} from '@vicons/ionicons5'
import {
projects,
initProjectsStore,
createProject,
deleteProject,
duplicateProject,
renameProject
} from '../stores/projects'
import { useModelStore } from '../stores/pinia'
import ApiSettings from '../components/ApiSettings.vue'
import AppHeader from '../components/AppHeader.vue'
const router = useRouter()
const dialog = useDialog()
const modelStore = useModelStore()
// API Settings state | API 设置状态
const showApiSettings = ref(false)
const isApiConfigured = computed(() => !!modelStore.currentApiKey)
// Refresh API config state | 刷新 API 配置状态
const refreshApiConfig = () => {
// 通过 computed 自动更新,不需要手动刷新
}
// Video refs for hover play | 视频引用用于悬停播放
const videoRefs = new Map()
// Set video ref | 设置视频引用
const setVideoRef = (projectId, el) => {
if (el) {
videoRefs.set(projectId, el)
} else {
videoRefs.delete(projectId)
}
}
// Handle thumbnail hover | 处理缩略图悬停
const handleThumbnailHover = (project, isHovering) => {
if (!isVideoUrl(project.thumbnail)) return
const video = videoRefs.get(project.id)
if (!video) return
if (isHovering) {
video.play().catch(() => {
// Ignore play errors (e.g., autoplay policy)
})
} else {
video.pause()
video.currentTime = 0 // Reset to start
}
}
// Input state | 输入状态
const inputText = ref('')
// Rename modal state | 重命名弹窗状态
const showRenameModal = ref(false)
const renameValue = ref('')
const renameTargetId = ref(null)
// Suggestions tags | 建议标签
const suggestions = [
'雨中魔法森林',
'日式街面美食摄影',
'瀑布水流飞溅',
'雨天富声旁边花语'
]
// Format date | 格式化日期
const formatDate = (date) => {
if (!date) return ''
const d = new Date(date)
const now = new Date()
const diff = now - d
// Less than 1 minute | 小于1分钟
if (diff < 60000) return '刚刚'
// Less than 1 hour | 小于1小时
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
// Less than 1 day | 小于1天
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
// Less than 7 days | 小于7天
if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`
// Format as date | 格式化为日期
return `${d.getMonth() + 1}/${d.getDate()}`
}
// Get project actions | 获取项目操作选项
const getProjectActions = (project) => [
{ label: '重命名', key: 'rename', icon: () => h(NIcon, null, { default: () => h(CreateOutline) }) },
{ label: '复制', key: 'duplicate', icon: () => h(NIcon, null, { default: () => h(CopyOutline) }) },
{ type: 'divider' },
{ label: '删除', key: 'delete', icon: () => h(NIcon, null, { default: () => h(TrashOutline) }) }
]
// Handle project action | 处理项目操作
const handleProjectAction = (key, project) => {
switch (key) {
case 'rename':
renameTargetId.value = project.id
renameValue.value = project.name
showRenameModal.value = true
break
case 'duplicate':
const newId = duplicateProject(project.id)
if (newId) {
window.$message?.success('项目已复制')
}
break
case 'delete':
dialog.warning({
title: '删除项目',
content: `确定要删除项目「${project.name}」吗?此操作不可恢复。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
deleteProject(project.id)
window.$message?.success('项目已删除')
}
})
break
}
}
// Confirm rename | 确认重命名
const confirmRename = () => {
if (renameTargetId.value && renameValue.value.trim()) {
renameProject(renameTargetId.value, renameValue.value.trim())
window.$message?.success('已重命名')
}
showRenameModal.value = false
renameTargetId.value = null
renameValue.value = ''
}
// Check internal API before navigation | 跳转前检查内部接口
const checkApiKeyAndNavigate = (callback) => {
if (!isApiConfigured.value) {
dialog.warning({
title: '生成接口未就绪',
content: '当前登录会话还不能使用生成接口,请稍后重试或联系管理员。',
positiveText: '知道了'
})
return false
}
callback()
return true
}
// Create new project | 创建新项目
const createNewProject = () => {
checkApiKeyAndNavigate(() => {
const id = createProject('未命名项目')
router.push(`/p/${id}`)
})
}
// Create project with input text | 使用输入文本创建项目
const handleCreateWithInput = () => {
checkApiKeyAndNavigate(() => {
const name = inputText.value.trim() || '未命名项目'
const id = createProject(name)
// Store the input text to be used as initial prompt
sessionStorage.setItem('ai-canvas-initial-prompt', inputText.value.trim())
inputText.value = ''
router.push(`/p/${id}`)
})
}
// Open existing project | 打开已有项目
const openProject = (project) => {
checkApiKeyAndNavigate(() => {
router.push(`/p/${project.id}`)
})
}
// Check if URL is a video | 检查 URL 是否为视频
const isVideoUrl = (url) => {
if (!url || typeof url !== 'string') return false
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv']
return videoExtensions.some(ext => url.toLowerCase().includes(ext))
}
// Import h for render functions | 导入 h 用于渲染函数
import { h } from 'vue'
// Projects section ref | 项目区域引用
const projectsSection = ref(null)
// Scroll to projects section | 滚动到项目区域
const scrollToProjects = () => {
if (projectsSection.value) {
projectsSection.value.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
// Initialize projects store on mount | 挂载时初始化项目存储
onMounted(async () => {
await initProjectsStore()
})
</script>

View File

@@ -1,27 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
}
}
},
},
plugins: [],
}

View File

@@ -1,22 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
base: '/',
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
server: {
proxy: {
'/api': {
target: 'http://localhost:4291',
changeOrigin: true
}
}
}
})

File diff suppressed because it is too large Load Diff

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