5 Commits

Author SHA1 Message Date
04a822ac79 feat: add feishu multi-user auth 2026-05-24 00:31:06 +08:00
90dde14ac3 chore: ignore local verification artifacts 2026-05-24 00:00:58 +08:00
3146266383 feat: redesign creative studio entry 2026-05-23 23:55:05 +08:00
0e55945352 auto-save 2026-05-23 23:45 (~2) 2026-05-23 23:45:06 +08:00
d551c45006 auto-save 2026-05-23 23:36 (~2) 2026-05-23 23:39:41 +08:00
15 changed files with 1634 additions and 1469 deletions

2
.gitignore vendored
View File

@@ -17,6 +17,7 @@ __pycache__/
deploy/.env.production
deploy/.htpasswd
secrets/
.backups/
# api
api/.venv/
@@ -26,6 +27,7 @@ asset_library/*
prompt_library/*
!prompt_library/.gitkeep
_trash/
output/
# web
web/.next/

View File

@@ -1,6 +1,6 @@
# 项目接力
- 生成时间May 22, 2026 at 09:01
- 生成时间May 23, 2026 at 23:37
- 项目SKG Marketing Studio / SKG 营销内容工作台
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 状态active
@@ -9,7 +9,7 @@
## 最近助手会话概览
- Claudea9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
- Codex019e49d1-d114-7d52-8cf0-fbb4585f5ce6 · 时间未知
- Codex019e4d33-ad65-7673-934e-815226984ea6 · 时间未知
- Cursor未找到匹配当前项目的最近会话
## Claude 最近会话
@@ -92,57 +92,39 @@
## Codex 最近会话
- Session ID019e49d1-d114-7d52-8cf0-fbb4585f5ce6
- Transcript/Users/kangwan/.codex/sessions/2026/05/21/rollout-2026-05-21T17-15-47-019e49d1-d114-7d52-8cf0-fbb4585f5ce6.jsonl
- Session ID019e4d33-ad65-7673-934e-815226984ea6
- Transcript/Users/kangwan/.codex/sessions/2026/05/22/rollout-2026-05-22T09-01-32-019e4d33-ad65-7673-934e-815226984ea6.jsonl
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 分支main
- 敏感字段:已对 token / key / password / secret 做脱敏
### 最近用户要求
- 分镜这个板块,我每次点击视频 都变成了选择 其实我就是想打开看下而已 你这个逻辑不太对 你看下
- 为什么要选择呢 你给我点击下载的按钮不就好了
- 而且每个生成的视频当然是自动保存呀 还需要我点击保存干啥
- 音频解析 也需需要有中文识别 和多语言识别
- 脚本那边是自动识别语音识别的内容然后生成脚本文案么?
- 分镜那边
### Codex 最近回复
> 生产部署正在跑第二轮远端备份和同步已经完成Web 镜像在构建;我等脚本验证结束
> 对,问题聚焦在分镜这边就更清楚了:我已经看了分镜面板当前实现,下面按“现在实际怎么跑”说
> 对,这个点你说得对。生成的视频本来就会写进当前 job 的 `generated_videos`,完成后自动回填 mp4不应该让用户理解成还要点“保存视频”
> 分镜这边现在是“半自动”,不是完全端到端自动重写整片脚本
>
> 我已经改完并上线
> 当前链路是
>
> - 分镜视频候选轨现在显示 `自动保存`
> - 视频候选普通点击只负责打开预览
> - 右上角提供显式 `下载` 按钮
> - “保存全部 / 保存本条规划”改成了“同步规划 / 同步本条规划”,只表达同步三字段文案规划,不再像保存视频
> - 源码解析页也同步更新了这条交互语义
> 1. 音频解析完成后,`job.transcript` 里有逐句时间轴:原语言文案 + 中文镜像。
> 2. 分镜面板会自动按每个 transcript 段生成一行分镜,保留原音频时间点和原文案依据。
> 3. 每行会先自动套默认三字段:
> - `文案`
> - `场景一句话`
> - `人物 + 产品 + 动作`
> 4. 这些默认值目前主要是按段落位置和角色分类生成,比如 hook / pain / proof / solution / cta不是每句都先让大模型深度理解原文后重写。
> 5. 当你点单行生成视频候选或同步规划时,才会走 `quickPlanStoryboard`,把当前三字段、参考帧、产品信息扩成完整分镜 prompt。
> 6. 你改中文字段后,系统会自动优化对应英文主字段,英文用于后续生视频。
>
> 验证和发布:
> 所以准确说:分镜会自动读取语音识别结果来切分时间轴、显示原文依据、初始化分镜行;但“根据识别内容自动生成更贴合原视频语义的新脚本文案”这层现在还不够强,偏模板化。
>
> - `web/npm run build` 通过
> - 已推送 Gitea
> - `4efb2ce fix: make storyboard video clicks previewable`
> - `eb4347a fix: clarify storyboard videos auto-save`
> - 已执行 `./scripts/deploy-prod-safe.sh` 上线,生产验证通过:`/login/ 200`、`/api/health 401`、容器内 `api:health ok`
>
> 当前 `git status -sb`
>
> ```text
> ## main...origin/main
> M .memory/worklog.json
> ```
>
> 只剩自动日志 `.memory/worklog.json`,业务代码和文档都已提交并推送。
>
> 补充:`npm run lint` 现在跑不了,因为项目里没有可执行的 `eslint``tsc --noEmit` 仍有一批既有的 dashboard/ui 旧类型错误,不是这次改动引入的。
>
> <oai-mem-citation>
> <citation_entries>
> MEMORY.md:214-286|note=[used SKG worktable context and docs update expectation]
> </citation_entries>
> <rollout_ids>
> 019e2fef-97a7-7523-a1ff-051a8c6…
> 如果你想要的效果是:音频一识别完,分镜区就自动根据原音频内容生成每句对应的新 SKG 脚本文案那需要再补一层“transcript -> 三字段候选”的自动脚本生成逻辑。这个方向是合理的。
## Cursor 最近会话
@@ -152,7 +134,7 @@
- 当前分支main
- 未提交变更1 项
- 最近提交:fix: clarify storyboard videos auto-save
- 最近提交:docs: record multilingual asr deployment
- 变更文件:
- M .memory/worklog.json

View File

@@ -1,169 +1,5 @@
{
"entries": [
{
"files_changed": 4,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 4 项未提交变更 · 最近提交fix: rename source video section title",
"ts": "2026-05-19T08:54:36Z",
"type": "session-heartbeat"
},
{
"files_changed": 3,
"hash": "7a7a3b5",
"message": "fix: narrow transcript timeline panel",
"ts": "2026-05-19T16:54:51+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交fix: narrow transcript timeline panel",
"ts": "2026-05-19T09:04:36Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交fix: narrow transcript timeline panel",
"ts": "2026-05-19T09:14:36Z",
"type": "session-heartbeat"
},
{
"files_changed": 2,
"hash": "7c69825",
"message": "auto-save 2026-05-19 17:21 (~2)",
"ts": "2026-05-19T17:22:01+08:00",
"type": "commit"
},
{
"files_changed": 3,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 3 项未提交变更 · 最近提交auto-save 2026-05-19 17:21 (~2)",
"ts": "2026-05-19T09:24:36Z",
"type": "session-heartbeat"
},
{
"files_changed": 3,
"hash": "1f6eabc",
"message": "fix: add temporary filmstrip frame picker",
"ts": "2026-05-19T17:27:09+08:00",
"type": "commit"
},
{
"files_changed": 4,
"hash": "d461c65",
"message": "fix: lift filmstrip preview above frame",
"ts": "2026-05-19T17:33:37+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交fix: lift filmstrip preview above frame",
"ts": "2026-05-19T09:34:36Z",
"type": "session-heartbeat"
},
{
"files_changed": 4,
"hash": "cb991e7",
"message": "fix: enlarge filmstrip frames in place",
"ts": "2026-05-19T17:42:02+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交fix: enlarge filmstrip frames in place",
"ts": "2026-05-19T09:44:37Z",
"type": "session-heartbeat"
},
{
"files_changed": 2,
"hash": "8bceb90",
"message": "auto-save 2026-05-19 17:49 (~2)",
"ts": "2026-05-19T17:50:00+08:00",
"type": "commit"
},
{
"files_changed": 3,
"hash": "883e1d4",
"message": "fix: align filmstrip frames with waveform",
"ts": "2026-05-19T17:53:17+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交fix: align filmstrip frames with waveform",
"ts": "2026-05-19T09:54:37Z",
"type": "session-heartbeat"
},
{
"files_changed": 2,
"hash": "97016dc",
"message": "fix: hide waveform filmstrip labels",
"ts": "2026-05-19T17:59:58+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交fix: hide waveform filmstrip labels",
"ts": "2026-05-19T10:04:37Z",
"type": "session-heartbeat"
},
{
"files_changed": 2,
"hash": "20f730a",
"message": "auto-save 2026-05-19 18:06 (~2)",
"ts": "2026-05-19T18:06:17+08:00",
"type": "commit"
},
{
"files_changed": 3,
"hash": "818d785",
"message": "fix: enlarge filmstrip hover near waveform",
"ts": "2026-05-19T18:08:17+08:00",
"type": "commit"
},
{
"files_changed": 2,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 2 项未提交变更 · 最近提交fix: enlarge filmstrip hover near waveform",
"ts": "2026-05-19T10:14:37Z",
"type": "session-heartbeat"
},
{
"files_changed": 3,
"hash": "f574ab4",
"message": "fix: refine waveform filmstrip controls",
"ts": "2026-05-19T18:16:57+08:00",
"type": "commit"
},
{
"files_changed": 2,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 2 项未提交变更 · 最近提交fix: refine waveform filmstrip controls",
"ts": "2026-05-19T10:24:37Z",
"type": "session-heartbeat"
},
{
"files_changed": 4,
"hash": "b099876",
"message": "auto-save 2026-05-19 18:27 (~4)",
"ts": "2026-05-19T18:28:00+08:00",
"type": "commit"
},
{
"files_changed": 3,
"hash": "7604ed1",
"message": "fix: lift filmstrip hover preview",
"ts": "2026-05-19T18:29:12+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交fix: lift filmstrip hover preview",
"ts": "2026-05-19T10:34:37Z",
"type": "session-heartbeat"
},
{
"files_changed": 2,
"hash": "d503ca6",
"message": "auto-save 2026-05-19 18:38 (~2)",
"ts": "2026-05-19T18:38:51+08:00",
"type": "commit"
},
{
"files_changed": 3,
"hash": "ce5f3b4",
@@ -3175,37 +3011,195 @@
"type": "assistant-session"
},
{
"ts": "2026-05-22T09:05:48+08:00",
"type": "commit",
"message": "auto-save 2026-05-22 09:01 (~2)",
"files_changed": 2,
"hash": "2342892",
"message": "auto-save 2026-05-22 09:01 (~2)",
"ts": "2026-05-22T09:05:48+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交auto-save 2026-05-22 09:01 (~2)",
"ts": "2026-05-22T01:11:31Z",
"type": "session-heartbeat"
},
{
"files_changed": 3,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 3 项未提交变更 · 最近提交auto-save 2026-05-22 09:01 (~2)",
"ts": "2026-05-22T01:21:31Z",
"type": "session-heartbeat"
},
{
"files_changed": 5,
"hash": "3c357b0",
"message": "auto-save 2026-05-22 09:22 (~5)",
"ts": "2026-05-22T09:22:27+08:00",
"type": "commit"
},
{
"files_changed": 7,
"hash": "6427935",
"message": "fix: support multilingual audio transcription",
"ts": "2026-05-22T09:26:59+08:00",
"type": "commit"
},
{
"files_changed": 2,
"hash": "7ae92ae",
"message": "auto-save 2026-05-22 09:27 (~2)",
"ts": "2026-05-22T09:27:54+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "0d5c326",
"message": "docs: record multilingual asr deployment",
"ts": "2026-05-22T09:30:59+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record multilingual asr deployment",
"ts": "2026-05-22T01:31:31Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record multilingual asr deployment",
"ts": "2026-05-22T01:41:31Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record multilingual asr deployment",
"ts": "2026-05-22T01:51:31Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record multilingual asr deployment",
"ts": "2026-05-22T02:01:32Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record multilingual asr deployment",
"ts": "2026-05-22T02:11:59Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record multilingual asr deployment",
"ts": "2026-05-22T02:21:59Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record multilingual asr deployment",
"ts": "2026-05-22T02:31:59Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record multilingual asr deployment",
"ts": "2026-05-22T02:41:59Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record multilingual asr deployment",
"ts": "2026-05-22T02:51:59Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record multilingual asr deployment",
"ts": "2026-05-22T03:05:43Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"message": "Codex 会话结束 · 持续 0 秒 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交docs: record multilingual asr deployment",
"ts": "2026-05-22T03:15:25Z",
"type": "session-end"
},
{
"files_changed": 2,
"message": "启动 Codex 接力会话 · 已载入 Claude / Codex 最近会话,等待下一条指令 · 分支 main · 2 项未提交变更 · 最近提交docs: record multilingual asr deployment",
"ts": "2026-05-23T15:37:18Z",
"type": "assistant-session"
},
{
"ts": "2026-05-23T23:39:41+08:00",
"type": "commit",
"message": "auto-save 2026-05-23 23:36 (~2)",
"hash": "d551c45",
"files_changed": 2
},
{
"ts": "2026-05-22T01:11:31Z",
"ts": "2026-05-23T23:45:06+08:00",
"type": "commit",
"message": "auto-save 2026-05-23 23:45 (~2)",
"hash": "0e55945",
"files_changed": 2
},
{
"ts": "2026-05-23T15:47:20Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交auto-save 2026-05-22 09:01 (~2)",
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交auto-save 2026-05-23 23:45 (~2)",
"files_changed": 1
},
{
"ts": "2026-05-22T01:21:31Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 3 项未提交变更 · 最近提交:auto-save 2026-05-22 09:01 (~2)",
"ts": "2026-05-23T23:50:30+08:00",
"type": "commit",
"message": "auto-save 2026-05-23 23:50 (~3)",
"hash": "e13bb0b",
"files_changed": 3
},
{
"ts": "2026-05-22T09:22:27+08:00",
"ts": "2026-05-23T23:55:05+08:00",
"type": "commit",
"message": "auto-save 2026-05-22 09:22 (~5)",
"hash": "3c357b0",
"files_changed": 5
"message": "feat: redesign creative studio entry",
"hash": "3146266",
"files_changed": 6
},
{
"ts": "2026-05-22T09:26:59+08:00",
"ts": "2026-05-23T15:57:18Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交feat: redesign creative studio entry",
"files_changed": 1
},
{
"ts": "2026-05-24T00:00:58+08:00",
"type": "commit",
"message": "fix: support multilingual audio transcription",
"hash": "6427935",
"files_changed": 7
"message": "chore: ignore local verification artifacts",
"hash": "90dde14",
"files_changed": 1
},
{
"ts": "2026-05-23T16:07:18Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交chore: ignore local verification artifacts",
"files_changed": 1
},
{
"ts": "2026-05-23T16:17:18Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交chore: ignore local verification artifacts",
"files_changed": 1
},
{
"ts": "2026-05-24T00:23:22+08:00",
"type": "commit",
"message": "auto-save 2026-05-24 00:23 (~2)",
"hash": "91a7831",
"files_changed": 2
},
{
"ts": "2026-05-23T16:27:19Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 13 项未提交变更 · 最近提交auto-save 2026-05-24 00:23 (~2)",
"files_changed": 13
}
]
}

View File

@@ -33,15 +33,21 @@
"type" : "api_key"
},
{
"description" : "生产网页登录;用户名写 RULES.md密码只放服务器 \/root\/skg-marketing-studio-login.txt后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET",
"description" : "生产网页登录备用账号;飞书免登录为主路径,备用账号密码只放服务器 \/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" : "SKG 信息流广告快速复刻工作台:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频路提取原文案\/字幕、中文翻译、讲话人、语速节奏、背景音乐\/环境声\/音效;视觉路自动抽 12 张动作\/节奏参考帧,转换层按真人重构、卡通重构、元素重构、自主描述四个方向生成全新主体 6 视图,再汇合产品素材池、分镜口播和视频候选生成。",
"kind" : "app",
"name" : "SKG Marketing Studio \/ SKG 营销内容工作台",
"name" : "SKG 营销内容工作台",
"ownership" : "company",
"pin_order" : 1778664997,
"pinned" : true,
@@ -58,10 +64,10 @@
}
],
"quick_login" : {
"label" : "SKG Marketing Studio \/ SKG 营销内容工作台",
"password" : "c413cdc5bbbf2ca042",
"url" : "https:\/\/marketing.skg.com",
"username" : "skg"
"label" : "SKG 营销内容工作台",
"password" : "",
"url" : "https:\/\/marketing.skg.com\/login\/",
"username" : "飞书免登录;备用账号见 credentials.WEB_LOGIN"
},
"stack" : [
"Next.js + Python(yt-dlp\/ffmpeg) + OpenAI-compatible LLM + GPT Image 2 + Azure OpenAI TTS + Seedance\/Kling\/Veo video gateway"

View File

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

View File

@@ -1,4 +1,4 @@
# SKG AI 素材管线 - TK 二创验证
# SKG 营销内容工作台
## 启动
- 后台启动(不弹 Terminal`./scripts/start-dev-background.sh`(通过 macOS launchd 后台托管;前端 4290 + 后端 4291日志写入 `.logs/`
@@ -66,14 +66,15 @@
- 当前音频解析:`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`
- TikTok 下载登录态:公开视频默认不带 cookies 直接下载,生产环境变量必须显式保持 `YTDLP_COOKIES_FILE=``YTDLP_COOKIES_FROM_BROWSER=` 为空,防止容器读取不存在的浏览器 cookies。只有 TikTok 明确要求登录态时,才使用服务器私有 cookies 文件 `./secrets/tiktok_cookies.txt` 挂载到 API 容器 `/run/secrets/tiktok_cookies.txt` 并配置 `YTDLP_COOKIES_FILE=/run/secrets/tiktok_cookies.txt``yt-dlp` 会在任务结束时回写 cookies因此不要把该挂载设为只读不要使用云端浏览器读取方案也不要把 cookies 入库。生产容器严禁使用 `YTDLP_COOKIES_FROM_BROWSER=chrome`
- 登录凭证:用户名写下方快捷登录密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt`生产环境变量 `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`
- 登录凭证:生产入口以飞书免登录为主;飞书 OAuth 的 `FEISHU_APP_ID` / `FEISHU_APP_SECRET` 只放服务器 `deploy/.env.production`,回调地址固定为 `https://marketing.skg.com/api/auth/feishu/callback` 并需要在飞书开放平台应用安全设置中登记。原账号密码登录保留为备用入口,用户名写下方快捷登录密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt``WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`。开启 `AUTH_DATA_ISOLATION_ENABLED=true` 后,新建任务、素材任务和一键出片记录按登录用户隔离;历史无 owner 的旧任务只对备用账号可见,飞书用户互不可见。
- 禁止手动裸 `rsync --delete` 到服务器;必须使用 `./scripts/deploy-prod-safe.sh`。如遇极端情况必须手动同步,命令必须同时包含 protect/exclude`.git``.memory``.logs``.pids``data``jobs``secrets``api/jobs``api/.env``api/.env.local``api/.env.production``deploy/.env.production``web/node_modules``web/.next``web/out`。不要把本地 `api/.env``deploy/.env.production` 覆盖到 `/opt/skg-marketing-studio`,也不要删除服务器 `data/jobs`,否则会清空案例、登录和模型配置。
## 快捷登录
- 登录地址:`https://marketing.skg.com/login/`
- 用户名:`skg`
- 密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
- 说明当前是生产入口应用内登录页数据库密码、API Key、服务器 root 密码不要写这里
- 主路径:飞书免登录
- 备用用户名:`skg`
- 备用密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
- 说明:当前是生产入口应用内登录页;飞书 App Secret、数据库密码、API Key、服务器 root 密码不要写这里
## 元数据回写清单
- 新增或变更公网地址后,必须同步更新 `.project.json.urls`
@@ -121,7 +122,12 @@
- `AZURE_OPENAI_BASE_URL` / `AZURE_OPENAI_API_KEY`:微软 Azure OpenAI 协议配音网关;本地未单独配置 Key 时回退复用 `LLM_API_KEY`
- `AZURE_TTS_MODEL` / `AZURE_TTS_VOICE_ID` / `AZURE_TTS_VOICE_POOL` / `AZURE_TTS_PATH` / `AZURE_TTS_PATHS`Azure OpenAI TTS 模型、默认音色、音色池和 OpenAI 协议语音路径;后端会按 `AZURE_TTS_PATHS` 依次尝试,便于区分路径不对和整条语音服务不可用
- `POE_API_KEY` / `VIDEO_API_KEY`:视频生成通道 Key只能放本地环境变量
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产备用网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库。即使只开飞书免登录,也必须配置 `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列表和详情访问只返回本人数据。
- `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

@@ -8,6 +8,16 @@ WEB_AUTH_PASSWORD=
WEB_AUTH_SESSION_SECRET=
WEB_AUTH_COOKIE_NAME=skg_marketing_session
WEB_AUTH_COOKIE_SECURE=false
AUTH_DATA_ISOLATION_ENABLED=true
# 飞书免登录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

View File

@@ -4,6 +4,7 @@ import asyncio
import base64
import hashlib
import hmac
import io
import json
import os
import random
@@ -17,12 +18,13 @@ import uuid
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Literal
from urllib.parse import urlencode
import httpx
from dotenv import load_dotenv
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel, ConfigDict, Field
load_dotenv()
@@ -194,7 +196,30 @@ WEB_AUTH_PASSWORD = os.getenv("WEB_AUTH_PASSWORD", "").strip()
WEB_AUTH_SESSION_SECRET = os.getenv("WEB_AUTH_SESSION_SECRET", "").strip()
WEB_AUTH_COOKIE_NAME = os.getenv("WEB_AUTH_COOKIE_NAME", "skg_marketing_session").strip() or "skg_marketing_session"
WEB_AUTH_COOKIE_SECURE = os.getenv("WEB_AUTH_COOKIE_SECURE", "true").strip().lower() not in {"0", "false", "no"}
WEB_AUTH_CONFIGURED = bool(WEB_AUTH_USERNAME and WEB_AUTH_PASSWORD and WEB_AUTH_SESSION_SECRET)
FEISHU_APP_ID = (os.getenv("FEISHU_APP_ID") or os.getenv("FEISHU_CLIENT_ID") or "").strip()
FEISHU_APP_SECRET = (os.getenv("FEISHU_APP_SECRET") or os.getenv("FEISHU_CLIENT_SECRET") or "").strip()
FEISHU_REDIRECT_URI = os.getenv("FEISHU_REDIRECT_URI", "").strip()
FEISHU_OAUTH_SCOPE = os.getenv("FEISHU_OAUTH_SCOPE", "").strip()
FEISHU_AUTHORIZE_URL = os.getenv(
"FEISHU_AUTHORIZE_URL",
"https://accounts.feishu.cn/open-apis/authen/v1/authorize",
).strip()
FEISHU_TOKEN_URL = os.getenv(
"FEISHU_TOKEN_URL",
"https://open.feishu.cn/open-apis/authen/v2/oauth/token",
).strip()
FEISHU_USER_INFO_URL = os.getenv(
"FEISHU_USER_INFO_URL",
"https://open.feishu.cn/open-apis/authen/v1/user_info",
).strip()
FEISHU_STATE_COOKIE_NAME = os.getenv("FEISHU_STATE_COOKIE_NAME", "skg_feishu_oauth_state").strip() or "skg_feishu_oauth_state"
FEISHU_ALLOWED_EMAIL_DOMAINS = os.getenv("FEISHU_ALLOWED_EMAIL_DOMAINS", "").strip()
FEISHU_ALLOWED_EMAILS = os.getenv("FEISHU_ALLOWED_EMAILS", "").strip()
FEISHU_ALLOWED_TENANT_KEYS = os.getenv("FEISHU_ALLOWED_TENANT_KEYS", "").strip()
AUTH_DATA_ISOLATION_ENABLED = os.getenv("AUTH_DATA_ISOLATION_ENABLED", "true").strip().lower() not in {"0", "false", "no", "off"}
PASSWORD_AUTH_CONFIGURED = bool(WEB_AUTH_USERNAME and WEB_AUTH_PASSWORD and WEB_AUTH_SESSION_SECRET)
FEISHU_AUTH_CONFIGURED = bool(FEISHU_APP_ID and FEISHU_APP_SECRET and WEB_AUTH_SESSION_SECRET)
WEB_AUTH_CONFIGURED = bool(PASSWORD_AUTH_CONFIGURED or FEISHU_AUTH_CONFIGURED)
def default_video_gateway_paths(base_url: str) -> tuple[str, str, str]:
@@ -794,6 +819,11 @@ class SubjectAgentState(BaseModel):
class Job(BaseModel):
id: str
url: str
owner_id: str = ""
owner_name: str = ""
owner_email: str = ""
owner_provider: str = ""
tenant_key: str = ""
status: JobStatus = "created"
progress: int = 0
message: str = ""
@@ -827,7 +857,17 @@ AUDIO_WORKERS_LOCK = threading.Lock()
def ensure_auth_configured() -> None:
if not WEB_AUTH_CONFIGURED:
raise HTTPException(503, "WEB_AUTH_USERNAME、WEB_AUTH_PASSWORD 或 WEB_AUTH_SESSION_SECRET 未配置")
raise HTTPException(503, "WEB_AUTH_SESSION_SECRET 以及账号密码或飞书 OAuth 未配置")
def ensure_password_auth_configured() -> None:
if not PASSWORD_AUTH_CONFIGURED:
raise HTTPException(503, "账号密码登录未配置")
def ensure_feishu_auth_configured() -> None:
if not FEISHU_AUTH_CONFIGURED:
raise HTTPException(503, "飞书免登录未配置")
def _auth_signature(body: str) -> str:
@@ -846,16 +886,80 @@ def _decode_auth_body(body: str) -> dict:
return data if isinstance(data, dict) else {}
def make_auth_token(username: str, ttl_seconds: int) -> str:
body = _encode_auth_body({
"u": username,
def _csv_values(raw: str) -> set[str]:
return {item.strip().lower() for item in raw.split(",") if item.strip()}
def _normalize_next_url(value: str | None) -> str:
value = (value or "/").strip() or "/"
if not value.startswith("/") or value.startswith("//"):
return "/"
return value
def _public_base_url(request: Request) -> str:
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
host = request.headers.get("host") or request.url.netloc
return f"{proto}://{host}".rstrip("/")
def _feishu_redirect_uri(request: Request) -> str:
if FEISHU_REDIRECT_URI:
return FEISHU_REDIRECT_URI
return f"{_public_base_url(request)}/api/auth/feishu/callback"
def _session_user_id(payload: dict | None) -> str:
payload = payload or {}
explicit = str(payload.get("uid") or "").strip()
if explicit:
return explicit
provider = str(payload.get("provider") or "").strip().lower()
if provider == "feishu":
for key in ("open_id", "union_id", "email", "u"):
value = str(payload.get(key) or "").strip()
if value:
return f"feishu:{value.lower() if key == 'email' else value}"
username = str(payload.get("u") or "").strip() or "anonymous"
return f"password:{username}"
def _public_session(payload: dict) -> dict:
return {
"uid": _session_user_id(payload),
"provider": str(payload.get("provider") or "password"),
"username": str(payload.get("u") or payload.get("name") or ""),
"name": str(payload.get("name") or payload.get("u") or ""),
"email": str(payload.get("email") or ""),
"open_id": str(payload.get("open_id") or ""),
"union_id": str(payload.get("union_id") or ""),
"tenant_key": str(payload.get("tenant_key") or ""),
"avatar_url": str(payload.get("avatar_url") or ""),
}
def make_auth_token(user: str | dict, ttl_seconds: int) -> str:
if isinstance(user, str):
payload = {
"u": user,
"name": user,
"provider": "password",
"uid": f"password:{user}",
}
else:
payload = dict(user)
payload["uid"] = _session_user_id(payload)
payload.setdefault("u", payload.get("name") or payload.get("email") or payload["uid"])
payload.setdefault("name", payload.get("u") or payload["uid"])
payload.update({
"exp": int(time.time()) + ttl_seconds,
"n": secrets.token_hex(8),
})
body = _encode_auth_body(payload)
return f"{body}.{_auth_signature(body)}"
def verify_auth_token(token: str) -> str | None:
def verify_auth_token(token: str) -> dict | None:
if not WEB_AUTH_CONFIGURED or "." not in token:
return None
body, supplied_sig = token.rsplit(".", 1)
@@ -867,14 +971,228 @@ def verify_auth_token(token: str) -> str | None:
expires_at = int(payload.get("exp") or 0)
except Exception:
return None
if username != WEB_AUTH_USERNAME or expires_at < int(time.time()):
if expires_at < int(time.time()):
return None
return username
provider = str(payload.get("provider") or "").strip().lower()
if not provider:
provider = "password" if username else ""
if provider == "password":
if not PASSWORD_AUTH_CONFIGURED or username != WEB_AUTH_USERNAME:
return None
payload["provider"] = "password"
payload["uid"] = f"password:{username}"
payload.setdefault("name", username)
return _public_session(payload)
if provider == "feishu":
if not FEISHU_AUTH_CONFIGURED:
return None
payload["provider"] = "feishu"
payload["uid"] = _session_user_id(payload)
return _public_session(payload)
return None
def auth_session_from_request(request: Request) -> dict | None:
token = request.cookies.get(WEB_AUTH_COOKIE_NAME, "")
return verify_auth_token(token)
def auth_username_from_request(request: Request) -> str | None:
token = request.cookies.get(WEB_AUTH_COOKIE_NAME, "")
return verify_auth_token(token)
session = auth_session_from_request(request)
return str(session.get("username") or session.get("name") or session.get("uid")) if session else None
def data_user_from_request(request: Request) -> dict:
session = auth_session_from_request(request)
if session:
return session
if not WEB_AUTH_CONFIGURED:
return {"uid": "local:dev", "provider": "local", "username": "local-dev", "name": "local-dev", "email": "", "tenant_key": ""}
raise HTTPException(401, "unauthorized")
def _is_password_session(user: dict | None) -> bool:
return bool(user and str(user.get("provider") or "") == "password")
def assign_owner(model: Job | "AgentRun", user: dict) -> None:
model.owner_id = _session_user_id(user)
model.owner_name = str(user.get("name") or user.get("username") or model.owner_id)
model.owner_email = str(user.get("email") or "")
model.owner_provider = str(user.get("provider") or "")
model.tenant_key = str(user.get("tenant_key") or "")
def user_can_access_job(job: Job | None, user: dict | None) -> bool:
if not job:
return False
if not AUTH_DATA_ISOLATION_ENABLED or not WEB_AUTH_CONFIGURED:
return True
owner_id = str(getattr(job, "owner_id", "") or "").strip()
if owner_id:
return bool(user and owner_id == _session_user_id(user))
return _is_password_session(user)
def _load_agent_run_for_access(run_id: str):
run = AGENT_RUNS.get(run_id)
if not run and agent_run_path(run_id).exists():
try:
run = AgentRun.model_validate_json(agent_run_path(run_id).read_text(encoding="utf-8"))
AGENT_RUNS[run_id] = run
except Exception:
return None
return run
def user_can_access_agent_run(run_id: str, user: dict | None) -> bool:
if not AUTH_DATA_ISOLATION_ENABLED or not WEB_AUTH_CONFIGURED:
return True
run = _load_agent_run_for_access(run_id)
if not run:
return False
owner_id = str(getattr(run, "owner_id", "") or "").strip()
if owner_id:
return bool(user and owner_id == _session_user_id(user))
return user_can_access_job(JOBS.get(run.job_id), user) or _is_password_session(user)
JOB_PATH_RE = re.compile(r"^/jobs/([0-9a-f]{8,32})(?:/|$)")
COPY_TO_JOB_PATH_RE = re.compile(r"^/asset-library/[^/]+/[^/]+/copy-to-job/([0-9a-f]{8,32})(?:/|$)")
AGENT_RUN_PATH_RE = re.compile(r"^/agent-runs/([0-9a-f]{8,32})(?:/|$)")
def _extract_protected_job_id(path: str) -> str:
for pattern in (JOB_PATH_RE, COPY_TO_JOB_PATH_RE):
match = pattern.match(path)
if match:
return match.group(1)
return ""
def _feishu_oauth_state(next_url: str) -> str:
body = _encode_auth_body({
"kind": "feishu_oauth_state",
"next": _normalize_next_url(next_url),
"exp": int(time.time()) + 600,
"n": secrets.token_hex(12),
})
return f"{body}.{_auth_signature(body)}"
def _verify_feishu_oauth_state(token: str) -> dict | None:
if not token or "." not in token:
return None
body, supplied_sig = token.rsplit(".", 1)
if not hmac.compare_digest(_auth_signature(body), supplied_sig):
return None
try:
payload = _decode_auth_body(body)
except Exception:
return None
if payload.get("kind") != "feishu_oauth_state" or int(payload.get("exp") or 0) < int(time.time()):
return None
payload["next"] = _normalize_next_url(str(payload.get("next") or "/"))
return payload
def _feishu_authorize_url(request: Request, state: str) -> str:
params = {
"client_id": FEISHU_APP_ID,
"redirect_uri": _feishu_redirect_uri(request),
"response_type": "code",
"state": state,
}
if FEISHU_OAUTH_SCOPE:
params["scope"] = FEISHU_OAUTH_SCOPE
return f"{FEISHU_AUTHORIZE_URL}?{urlencode(params)}"
def _exchange_feishu_code(code: str, redirect_uri: str) -> str:
payload = {
"grant_type": "authorization_code",
"client_id": FEISHU_APP_ID,
"client_secret": FEISHU_APP_SECRET,
"code": code,
"redirect_uri": redirect_uri,
}
with httpx.Client(timeout=20) as client:
response = client.post(FEISHU_TOKEN_URL, json=payload)
response.raise_for_status()
data = response.json()
if data.get("code") not in (None, 0, "0"):
raise HTTPException(401, f"飞书授权失败:{data.get('msg') or data.get('message') or data.get('code')}")
token_data = data.get("data") if isinstance(data.get("data"), dict) else data
token = str(
token_data.get("access_token")
or token_data.get("user_access_token")
or token_data.get("accessToken")
or ""
).strip()
if not token:
raise HTTPException(401, "飞书授权未返回 user_access_token")
return token
def _fetch_feishu_user(access_token: str) -> dict:
with httpx.Client(timeout=20) as client:
response = client.get(FEISHU_USER_INFO_URL, headers={"Authorization": f"Bearer {access_token}"})
response.raise_for_status()
data = response.json()
if data.get("code") not in (None, 0, "0"):
raise HTTPException(401, f"飞书用户信息获取失败:{data.get('msg') or data.get('message') or data.get('code')}")
user = data.get("data") if isinstance(data.get("data"), dict) else data
if not isinstance(user, dict):
raise HTTPException(401, "飞书用户信息格式异常")
return user
def _build_feishu_session(user: dict) -> dict:
email = str(user.get("email") or user.get("enterprise_email") or "").strip().lower()
open_id = str(user.get("open_id") or "").strip()
union_id = str(user.get("union_id") or "").strip()
tenant_key = str(user.get("tenant_key") or "").strip()
name = str(user.get("name") or user.get("en_name") or user.get("nickname") or email or open_id or union_id or "Feishu User").strip()
avatar_url = str(
user.get("avatar_url")
or user.get("avatar_thumb")
or user.get("avatar_middle")
or user.get("avatar_big")
or ""
).strip()
session = {
"provider": "feishu",
"u": name,
"name": name,
"email": email,
"open_id": open_id,
"union_id": union_id,
"tenant_key": tenant_key,
"avatar_url": avatar_url,
}
session["uid"] = _session_user_id(session)
return session
def _validate_feishu_session(session: dict) -> None:
allowed_emails = _csv_values(FEISHU_ALLOWED_EMAILS)
allowed_domains = {item.lstrip("@") for item in _csv_values(FEISHU_ALLOWED_EMAIL_DOMAINS)}
allowed_tenants = _csv_values(FEISHU_ALLOWED_TENANT_KEYS)
email = str(session.get("email") or "").lower()
domain = email.rsplit("@", 1)[1] if "@" in email else ""
tenant_key = str(session.get("tenant_key") or "").lower()
if allowed_emails and email not in allowed_emails:
raise HTTPException(403, "当前飞书账号不在允许登录名单")
if allowed_domains and domain not in allowed_domains:
raise HTTPException(403, "当前飞书账号邮箱域不允许登录")
if allowed_tenants and tenant_key not in allowed_tenants:
raise HTTPException(403, "当前飞书租户不允许登录")
def job_dir(job_id: str) -> Path:
@@ -1474,7 +1792,7 @@ async def lifespan(_: FastAPI):
yield
app = FastAPI(title="SKG TK 二创 API", lifespan=lifespan)
app = FastAPI(title="SKG 营销内容工作台 API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
@@ -1484,17 +1802,56 @@ app.add_middleware(
)
@app.middleware("http")
async def enforce_data_isolation(request: Request, call_next):
path = request.url.path
if AUTH_DATA_ISOLATION_ENABLED and WEB_AUTH_CONFIGURED:
try:
user = data_user_from_request(request)
except HTTPException:
user = None
job_id = _extract_protected_job_id(path)
if job_id and not user_can_access_job(JOBS.get(job_id), user):
return JSONResponse({"detail": "job not found"}, status_code=404)
run_match = AGENT_RUN_PATH_RE.match(path)
if run_match and not user_can_access_agent_run(run_match.group(1), user):
return JSONResponse({"detail": "agent run not found"}, status_code=404)
return await call_next(request)
@app.get("/auth/check")
def auth_check(request: Request) -> Response:
ensure_auth_configured()
if not auth_username_from_request(request):
if not auth_session_from_request(request):
raise HTTPException(401, "unauthorized")
return Response(status_code=204)
@app.get("/auth/config")
def auth_config() -> dict:
return {
"ok": True,
"auth_configured": WEB_AUTH_CONFIGURED,
"password_enabled": PASSWORD_AUTH_CONFIGURED,
"feishu_enabled": FEISHU_AUTH_CONFIGURED,
"data_isolation_enabled": AUTH_DATA_ISOLATION_ENABLED,
}
@app.get("/auth/me")
def auth_me(request: Request) -> dict:
session = auth_session_from_request(request)
if not session:
raise HTTPException(401, "unauthorized")
return {"ok": True, "user": session}
@app.post("/auth/login")
def auth_login(payload: AuthLoginPayload, response: Response) -> dict:
ensure_auth_configured()
ensure_password_auth_configured()
username = payload.username.strip()
password = payload.password
valid_user = hmac.compare_digest(username, WEB_AUTH_USERNAME)
@@ -1515,6 +1872,66 @@ def auth_login(payload: AuthLoginPayload, response: Response) -> dict:
return {"ok": True, "username": WEB_AUTH_USERNAME}
@app.get("/auth/feishu/start")
def auth_feishu_start(request: Request) -> RedirectResponse:
ensure_feishu_auth_configured()
next_url = _normalize_next_url(request.query_params.get("next"))
state = _feishu_oauth_state(next_url)
response = RedirectResponse(_feishu_authorize_url(request, state), status_code=302)
response.set_cookie(
key=FEISHU_STATE_COOKIE_NAME,
value=state,
max_age=600,
httponly=True,
secure=WEB_AUTH_COOKIE_SECURE,
samesite="lax",
path="/",
)
return response
@app.get("/auth/feishu/callback")
def auth_feishu_callback(request: Request) -> RedirectResponse:
ensure_feishu_auth_configured()
if request.query_params.get("error"):
raise HTTPException(401, f"飞书授权取消或失败:{request.query_params.get('error')}")
code = str(request.query_params.get("code") or "").strip()
supplied_state = str(request.query_params.get("state") or "").strip()
cookie_state = request.cookies.get(FEISHU_STATE_COOKIE_NAME, "")
if not code:
raise HTTPException(400, "missing feishu code")
if not supplied_state or not cookie_state or not hmac.compare_digest(supplied_state, cookie_state):
raise HTTPException(401, "invalid feishu state")
state_payload = _verify_feishu_oauth_state(supplied_state)
if not state_payload:
raise HTTPException(401, "expired feishu state")
access_token = _exchange_feishu_code(code, _feishu_redirect_uri(request))
session = _build_feishu_session(_fetch_feishu_user(access_token))
_validate_feishu_session(session)
ttl_seconds = 60 * 60 * 24 * 30
response = RedirectResponse(_normalize_next_url(str(state_payload.get("next") or "/")), status_code=302)
response.set_cookie(
key=WEB_AUTH_COOKIE_NAME,
value=make_auth_token(session, ttl_seconds),
max_age=ttl_seconds,
httponly=True,
secure=WEB_AUTH_COOKIE_SECURE,
samesite="lax",
path="/",
)
response.delete_cookie(
key=FEISHU_STATE_COOKIE_NAME,
path="/",
secure=WEB_AUTH_COOKIE_SECURE,
samesite="lax",
)
return response
@app.post("/auth/logout")
def auth_logout(response: Response) -> dict:
response.delete_cookie(
@@ -4275,6 +4692,32 @@ class TranslateReq(BaseModel):
target: Literal["en", "zh"] = "en"
class CreativeCopyReq(BaseModel):
goal: str
product: str = ""
audience: str = ""
platform: str = "TikTok / Reels"
tone: str = "direct"
seconds: int = 20
source_text: str = ""
class CreativeCopyVariant(BaseModel):
title: str = ""
hook_zh: str = ""
script_zh: str = ""
script_en: str = ""
image_prompt_en: str = ""
video_prompt_en: str = ""
caption_zh: str = ""
hashtags: list[str] = Field(default_factory=list)
class CreativeCopyResp(BaseModel):
model: str
variants: list[CreativeCopyVariant]
class ScriptRewriteSegmentReq(BaseModel):
index: int
start: float = 0.0
@@ -4339,6 +4782,74 @@ def _ensure_english(text: str) -> str:
return text
def _creative_copy_fallback(req: CreativeCopyReq) -> CreativeCopyResp:
goal = req.goal.strip() or "展示 SKG 产品的核心卖点"
product = req.product.strip() or "SKG 健康科技产品"
seconds = max(6, min(60, int(req.seconds or 20)))
script_zh = (
f"开场 0-3 秒:直接展示{product}和使用场景,提出一个具体痛点。\n"
f"中段 3-{max(4, seconds - 5)} 秒:用三个连续镜头说明{goal},画面保持产品清晰可见。\n"
f"结尾 {max(4, seconds - 5)}-{seconds} 秒:给出一句明确行动口播,收在产品近景。"
)
script_en = _ensure_english(script_zh)
image_prompt = _ensure_english(
f"{product}, premium health-tech product advertising image, clean lifestyle scene, clear product visibility, natural lighting, vertical composition"
)
video_prompt = _ensure_english(
f"{seconds}-second vertical short video ad for {product}. {goal}. Start with the product in use, show one clear benefit, keep camera motion smooth, realistic lifestyle lighting, no medical treatment claims."
)
return CreativeCopyResp(
model="fallback",
variants=[
CreativeCopyVariant(
title="快速成片版",
hook_zh=f"{product},把一个日常痛点变成一个清楚的使用理由。",
script_zh=script_zh,
script_en=script_en,
image_prompt_en=image_prompt,
video_prompt_en=video_prompt,
caption_zh=f"{product}{goal}",
hashtags=["#SKG", "#健康科技", "#短视频广告"],
)
],
)
def _parse_creative_copy_response(raw: str, req: CreativeCopyReq) -> CreativeCopyResp:
text = (raw or "").strip()
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I).strip()
text = re.sub(r"\s*```$", "", text).strip()
match = re.search(r"\{[\s\S]*\}", text)
json_text = match.group(0) if match else text
try:
data = json.loads(json_text)
except Exception:
return _creative_copy_fallback(req)
raw_items = data.get("variants") if isinstance(data, dict) else None
if not isinstance(raw_items, list):
return _creative_copy_fallback(req)
variants: list[CreativeCopyVariant] = []
for item in raw_items[:3]:
if not isinstance(item, dict):
continue
hashtags = item.get("hashtags") or []
if not isinstance(hashtags, list):
hashtags = []
variants.append(CreativeCopyVariant(
title=str(item.get("title") or "").strip()[:80],
hook_zh=str(item.get("hook_zh") or "").strip()[:180],
script_zh=str(item.get("script_zh") or "").strip()[:900],
script_en=_ensure_english(str(item.get("script_en") or item.get("script_zh") or "").strip())[:1200],
image_prompt_en=_ensure_english(str(item.get("image_prompt_en") or "").strip())[:1200],
video_prompt_en=_ensure_english(str(item.get("video_prompt_en") or "").strip())[:1400],
caption_zh=str(item.get("caption_zh") or "").strip()[:240],
hashtags=[str(tag).strip()[:40] for tag in hashtags if str(tag).strip()][:8],
))
if not variants:
return _creative_copy_fallback(req)
return CreativeCopyResp(model=REWRITE_MODEL if LLM_API_KEY else "fallback", variants=variants)
@app.post("/translate")
def translate_text(req: TranslateReq) -> dict:
"""单条文本翻译(给生图自定义提取元素 zh→en 用)"""
@@ -4374,6 +4885,44 @@ def translate_text(req: TranslateReq) -> dict:
raise HTTPException(500, f"translate failed: {e}")
@app.post("/creative/copy", response_model=CreativeCopyResp)
def generate_creative_copy(req: CreativeCopyReq) -> CreativeCopyResp:
goal = req.goal.strip()
if not goal:
raise HTTPException(400, "goal required")
if not LLM_API_KEY:
return _creative_copy_fallback(req)
seconds = max(6, min(60, int(req.seconds or 20)))
prompt = (
"You are creating practical short-form ad material for an SKG AI creative tool. "
"Return strict JSON only. Create 3 distinct variants that can be pasted directly into image/video generation. "
"Avoid medical treatment claims; describe comfort, relaxation, daily use, visual proof, and product clarity instead. "
"Every variant must include title, hook_zh, script_zh, script_en, image_prompt_en, video_prompt_en, caption_zh, hashtags.\n\n"
f"Goal: {goal}\n"
f"Product: {req.product.strip() or 'SKG health-tech product'}\n"
f"Audience: {req.audience.strip() or 'short-form shoppers'}\n"
f"Platform: {req.platform.strip() or 'TikTok / Reels'}\n"
f"Tone: {req.tone.strip() or 'direct'}\n"
f"Length: {seconds}s\n"
f"Source/reference text:\n{req.source_text.strip()[:1500]}"
)
try:
resp = llm().chat.completions.create(
model=REWRITE_MODEL,
messages=[
{"role": "system", "content": "Return valid JSON only. No markdown. No explanation."},
{"role": "user", "content": prompt},
],
response_format={"type": "json_object"},
temperature=0.72,
max_tokens=2200,
)
return _parse_creative_copy_response(resp.choices[0].message.content or "", req)
except Exception as e:
print(f"[creative copy fallback] {e}", flush=True)
return _creative_copy_fallback(req)
def _fallback_script_rewrite_item(segment: ScriptRewriteSegmentReq, author_intent: str = "") -> dict:
source = (segment.source or "").strip()
intent = _ensure_english(author_intent or "")
@@ -4510,6 +5059,11 @@ def health() -> dict:
"ok": True,
"llm_configured": bool(LLM_API_KEY),
"auth_configured": WEB_AUTH_CONFIGURED,
"auth_modes": {
"password": PASSWORD_AUTH_CONFIGURED,
"feishu": FEISHU_AUTH_CONFIGURED,
"data_isolation": AUTH_DATA_ISOLATION_ENABLED,
},
"base_url": LLM_BASE_URL or "openai-default",
"asr_base_url": ASR_BASE_URL or LLM_BASE_URL or "openai-default",
"image_base_url": IMAGE_BASE_URL or LLM_BASE_URL or "openai-default",
@@ -4557,6 +5111,9 @@ def health() -> dict:
class JobSummary(BaseModel):
id: str
url: str
owner_name: str = ""
owner_email: str = ""
owner_provider: str = ""
status: JobStatus
progress: int = 0
message: str = ""
@@ -4572,16 +5129,22 @@ class JobSummary(BaseModel):
@app.get("/jobs", response_model=list[JobSummary])
def list_jobs(limit: int | None = None) -> list[JobSummary]:
"""所有 job 的精简列表,按磁盘 state.json mtime 倒序(最新优先)。前端无 ?job= 时用它回填历史。"""
def list_jobs(request: Request, limit: int | None = None) -> list[JobSummary]:
"""当前用户可见 job 的精简列表,按磁盘 state.json mtime 倒序(最新优先)。"""
user = data_user_from_request(request)
items: list[JobSummary] = []
for job_id, job in JOBS.items():
if not user_can_access_job(job, user):
continue
state_path = JOBS_DIR / job_id / "state.json"
mtime = state_path.stat().st_mtime if state_path.exists() else 0.0
thumb = f"/jobs/{job_id}/frames/{job.frames[0].index}.jpg" if job.frames else ""
items.append(JobSummary(
id=job.id,
url=job.url,
owner_name=job.owner_name,
owner_email=job.owner_email,
owner_provider=job.owner_provider,
status=job.status,
progress=job.progress,
message=job.message,
@@ -4602,11 +5165,13 @@ def list_jobs(limit: int | None = None) -> list[JobSummary]:
@app.post("/jobs", response_model=Job)
async def create_job(req: CreateJobReq, bg: BackgroundTasks) -> Job:
async def create_job(req: CreateJobReq, bg: BackgroundTasks, request: Request) -> Job:
if not req.url.strip():
raise HTTPException(400, "url required")
user = data_user_from_request(request)
job_id = uuid.uuid4().hex[:12]
job = Job(id=job_id, url=req.url.strip())
assign_owner(job, user)
JOBS[job_id] = job
save_state(job)
bg.add_task(pipeline_download, job_id)
@@ -4640,13 +5205,14 @@ async def retry_job_download(job_id: str, bg: BackgroundTasks) -> Job:
@app.post("/jobs/upload", response_model=Job)
async def create_job_from_upload(bg: BackgroundTasks, file: UploadFile = File(...)) -> Job:
async def create_job_from_upload(bg: BackgroundTasks, request: Request, file: UploadFile = File(...)) -> Job:
if not file.filename:
raise HTTPException(400, "file required")
ext = Path(file.filename).suffix.lower()
if ext not in {".mp4", ".mov", ".webm", ".mkv", ".m4v"}:
raise HTTPException(400, f"unsupported video format: {ext}")
user = data_user_from_request(request)
job_id = uuid.uuid4().hex[:12]
d = job_dir(job_id)
mp4 = d / "source.mp4"
@@ -4657,12 +5223,63 @@ async def create_job_from_upload(bg: BackgroundTasks, file: UploadFile = File(..
raise HTTPException(500, "upload failed")
job = Job(id=job_id, url=f"upload://{file.filename}")
assign_owner(job, user)
JOBS[job_id] = job
save_state(job)
bg.add_task(pipeline_download, job_id)
return job
def _write_creative_reference_frame(job_id: str, file_bytes: bytes | None = None) -> tuple[int, int]:
frames_dir = job_dir(job_id) / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
out = frames_dir / "000.jpg"
if file_bytes:
try:
with Image.open(io.BytesIO(file_bytes)) as raw:
im = ImageOps.exif_transpose(raw).convert("RGB")
im.thumbnail((1600, 1600), Image.LANCZOS)
width, height = im.size
im.save(out, "JPEG", quality=92)
return width, height
except Exception as e:
raise HTTPException(400, f"invalid image file: {e}")
im = Image.new("RGB", (1024, 1024), (246, 248, 246))
im.save(out, "JPEG", quality=92)
return im.size
@app.post("/creative/jobs/image", response_model=Job)
async def create_creative_image_job(request: Request, file: UploadFile | None = File(default=None)) -> Job:
user = data_user_from_request(request)
job_id = uuid.uuid4().hex[:12]
file_bytes: bytes | None = None
source_label = "blank"
if file and file.filename:
ext = Path(file.filename).suffix.lower()
if ext not in {".jpg", ".jpeg", ".png", ".webp"}:
raise HTTPException(400, f"unsupported image format: {ext}")
file_bytes = await file.read()
source_label = file.filename
width, height = _write_creative_reference_frame(job_id, file_bytes)
frame = KeyFrame(index=0, timestamp=0, url=f"/jobs/{job_id}/frames/0.jpg")
job = Job(
id=job_id,
url=f"creative://{source_label}",
status="frames_extracted",
progress=100,
message="创作任务已就绪",
width=width,
height=height,
duration=0,
frames=[frame],
)
assign_owner(job, user)
JOBS[job_id] = job
save_state(job)
return job
@app.post("/jobs/{job_id}/analyze", response_model=Job)
async def trigger_analyze(
job_id: str,
@@ -8044,6 +8661,11 @@ class AgentRunLog(BaseModel):
class AgentRun(BaseModel):
id: str
job_id: str
owner_id: str = ""
owner_name: str = ""
owner_email: str = ""
owner_provider: str = ""
tenant_key: str = ""
status: Literal["draft", "queued", "executing", "reviewing", "completed", "failed"] = "queued"
stage: str = "queued"
progress: int = 0
@@ -8384,14 +9006,17 @@ def agent_run_worker(run_id: str, product_refs: list[dict]) -> None:
@app.post("/agent-runs", response_model=AgentRun)
async def create_agent_run(
request: Request,
tk_url: str = Form(...),
product_files: list[UploadFile] | None = File(None),
) -> AgentRun:
if not tk_url.strip():
raise HTTPException(400, "tk_url required")
user = data_user_from_request(request)
job_id = uuid.uuid4().hex[:12]
run_id = uuid.uuid4().hex[:12]
job = Job(id=job_id, url=tk_url.strip())
assign_owner(job, user)
JOBS[job_id] = job
save_state(job)
@@ -8400,6 +9025,7 @@ async def create_agent_run(
refs.append(await save_agent_product_upload(job_id, upload, index))
run = AgentRun(id=run_id, job_id=job_id, status="queued", stage="queued", progress=1)
assign_owner(run, user)
save_agent_run(run)
agent_log(run, f"任务已入队 · job={job_id} · 产品图 {len(refs)}", status="queued", stage="queued", progress=1)
threading.Thread(target=agent_run_worker, args=(run_id, refs), daemon=True).start()
@@ -8407,14 +9033,15 @@ async def create_agent_run(
@app.get("/agent-runs", response_model=list[AgentRun])
def list_agent_runs(limit: int = 20) -> list[AgentRun]:
def list_agent_runs(request: Request, limit: int = 20) -> list[AgentRun]:
user = data_user_from_request(request)
for p in AGENT_RUNS_DIR.iterdir():
if p.is_dir() and (p / "state.json").exists() and p.name not in AGENT_RUNS:
try:
AGENT_RUNS[p.name] = AgentRun.model_validate_json((p / "state.json").read_text(encoding="utf-8"))
except Exception:
pass
items = list(AGENT_RUNS.values())
items = [item for item in AGENT_RUNS.values() if user_can_access_agent_run(item.id, user)]
items.sort(key=lambda item: item.updated_at, reverse=True)
return items[:max(1, min(100, limit))]

View File

@@ -15,6 +15,17 @@ WEB_AUTH_PASSWORD=
WEB_AUTH_SESSION_SECRET=
WEB_AUTH_COOKIE_NAME=skg_marketing_session
WEB_AUTH_COOKIE_SECURE=true
AUTH_DATA_ISOLATION_ENABLED=true
# 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

View File

@@ -20,6 +20,20 @@ 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;

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -4,6 +4,7 @@ import type { FormEvent } from "react"
import { useEffect, useMemo, useState } from "react"
import {
ArrowRight,
Building2,
CheckCircle2,
Eye,
EyeOff,
@@ -14,8 +15,14 @@ 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
}
export default function LoginPage() {
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null)
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [remember, setRemember] = useState(true)
@@ -25,6 +32,21 @@ export default function LoginPage() {
const [status, setStatus] = useState<LoginStatus>("idle")
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 })
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) => {
const centerX = window.innerWidth / 2
@@ -38,6 +60,8 @@ export default function LoginPage() {
}, [])
const disabled = status === "loading" || status === "success"
const feishuEnabled = Boolean(authConfig?.feishu_enabled)
const passwordEnabled = authConfig?.password_enabled ?? true
const mood: LoginCharacterMood = useMemo(() => {
if (status === "success") return "success"
@@ -50,6 +74,7 @@ export default function LoginPage() {
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setHasError(false)
if (!passwordEnabled) return
if (!username.trim() || !password) {
setHasError(true)
return
@@ -75,6 +100,11 @@ export default function LoginPage() {
}
}
function onFeishuLogin() {
setStatus("loading")
window.location.href = "/api/auth/feishu/start?next=/"
}
return (
<main className="login-page login-page--oasis login-page--source relative min-h-screen overflow-hidden text-white">
<OasisCanvas />
@@ -89,7 +119,29 @@ export default function LoginPage() {
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
</div>
<form className="login-source-form-pane w-full" onSubmit={onSubmit}>
<div className="space-y-3">
{feishuEnabled ? (
<button
className="mb-3 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
type="button"
disabled={disabled}
onClick={onFeishuLogin}
>
<Building2 className="h-4 w-4" />
<ArrowRight className="h-4 w-4" />
</button>
) : null}
{feishuEnabled && passwordEnabled ? (
<div className="mb-3 flex items-center gap-3 text-xs text-white/35">
<span className="h-px flex-1 bg-white/10" />
<span></span>
<span className="h-px flex-1 bg-white/10" />
</div>
) : null}
{passwordEnabled ? (
<div className="space-y-3">
<label className="block">
<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" />
@@ -135,9 +187,11 @@ export default function LoginPage() {
</button>
</span>
</label>
</div>
</div>
) : null}
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
{passwordEnabled ? (
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
<input
className="h-4 w-4 rounded border-white/20 bg-black/30 accent-[#c89b3c]"
type="checkbox"
@@ -146,7 +200,8 @@ export default function LoginPage() {
onChange={(event) => setRemember(event.target.checked)}
/>
<span></span>
</label>
</label>
) : null}
{status === "success" ? (
<div className="mt-3">
@@ -156,13 +211,15 @@ export default function LoginPage() {
</div>
) : null}
<button
{passwordEnabled ? (
<button
className="mt-4 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
type="submit"
disabled={disabled}
>
<ArrowRight className="h-4 w-4" />
</button>
</button>
) : null}
</form>
</section>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -316,6 +316,54 @@ export async function getRuntimeHealth(): Promise<RuntimeHealth> {
return res.json()
}
export interface CreativeCopyVariant {
title: string
hook_zh: string
script_zh: string
script_en: string
image_prompt_en: string
video_prompt_en: string
caption_zh: string
hashtags: string[]
}
export interface CreativeCopyResult {
model: string
variants: CreativeCopyVariant[]
}
export async function generateCreativeCopy(body: {
goal: string
product?: string
audience?: string
platform?: string
tone?: string
seconds?: number
source_text?: string
}): Promise<CreativeCopyResult> {
const res = await fetch(`${API_BASE}/creative/copy`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw apiError("generateCreativeCopy", res.status, txt)
}
return res.json()
}
export async function createCreativeImageJob(file?: File | null): Promise<Job> {
const fd = new FormData()
if (file) fd.append("file", file)
const res = await fetch(`${API_BASE}/creative/jobs/image`, { method: "POST", body: fd })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw apiError("createCreativeImageJob", res.status, txt)
}
return res.json()
}
// 把 ImageRef 解析成可显示的 src URL
export function resolveImageRefUrl(jobId: string, ref: ImageRef): string {
if (ref.kind === "keyframe") {
@@ -953,6 +1001,11 @@ export interface ProductRefStateItem {
export interface Job {
id: string
url: string
owner_id?: string
owner_name?: string
owner_email?: string
owner_provider?: string
tenant_key?: string
status: JobStatus
progress: number
message?: string
@@ -975,6 +1028,11 @@ export interface BackendHealth {
ok: boolean
llm_configured: boolean
auth_configured?: boolean
auth_modes?: {
password?: boolean
feishu?: boolean
data_isolation?: boolean
}
base_url: string
models?: {
asr?: string
@@ -1071,6 +1129,9 @@ export async function deleteJob(id: string): Promise<{ ok: boolean; id: string }
export interface JobSummary {
id: string
url: string
owner_name?: string
owner_email?: string
owner_provider?: string
status: JobStatus
progress: number
message: string