7 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
0d5c32630f docs: record multilingual asr deployment 2026-05-22 09:30:59 +08:00
7ae92ae4d9 auto-save 2026-05-22 09:27 (~2) 2026-05-22 09:27:54 +08:00
15 changed files with 3663 additions and 3501 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 21, 2026 at 17:15
- 生成时间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 · 时间未知
- Codex019e4913-d34e-7943-925e-ec1b60ddf937 · 时间未知
- Codex019e4d33-ad65-7673-934e-815226984ea6 · 时间未知
- Cursor未找到匹配当前项目的最近会话
## Claude 最近会话
@@ -92,51 +92,39 @@
## Codex 最近会话
- Session ID019e4913-d34e-7943-925e-ec1b60ddf937
- Transcript/Users/kangwan/.codex/sessions/2026/05/21/rollout-2026-05-21T13-48-16-019e4913-d34e-7943-925e-ec1b60ddf937.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 做脱敏
### 最近用户要求
- 那我现在直接给你个服务器你来弄好api什么的都配好公司的 界面设计也弄好 你能行么
- 上来就是1分钟的 没事
- https://www.figma.com/design/petlyV2zio31BdZghC1alb/iOS-15-UI-Kit-for-Figma--Community-?node-id=5-54&p=f&t=l1NYiFeSZNk5bWtp-0 设计都在这里找ssh root@2.24.28.41 根密码应该可以在记忆里看到
- 音频解析 也需需要有中文识别 和多语言识别
- 脚本那边是自动识别语音识别的内容然后生成脚本文案么?
- 分镜那边
### Codex 最近回复
> 推送第一次被沙箱拦住了,我按规则用提升权限重试 `git push origin main`。`.memory/worklog.json` 又被会话心跳改了一次,这类辅助日志我先不再反复追提交,避免变成无限心跳提交
> 对,问题聚焦在分镜这边就更清楚了:我已经看了分镜面板当前实现,下面按“现在实际怎么跑”说
> 已上线。页面我已经打开了,公网入口是:
> 分镜这边现在是“半自动”,不是完全端到端自动重写整片脚本。
>
> `http://2.24.28.41:4290/agent/`
> 当前链路是:
>
> 现在流程是你要的极简形态:左侧只放 TikTok 链接和产品图上传,中间是 Agent Terminal 实时日志,右侧是成片播放器、分镜 contact sheet 和下载入口。后端不是“裸 Codex 乱跑”,而是 `AgentRun` 状态机下载、素材准备、12 段 x 5 秒规划、生成、失败重试、审片、合成,最终约 1 分钟
> 1. 音频解析完成后,`job.transcript` 里有逐句时间轴:原语言文案 + 中文镜像
> 2. 分镜面板会自动按每个 transcript 段生成一行分镜,保留原音频时间点和原文案依据。
> 3. 每行会先自动套默认三字段:
> - `文案`
> - `场景一句话`
> - `人物 + 产品 + 动作`
> 4. 这些默认值目前主要是按段落位置和角色分类生成,比如 hook / pain / proof / solution / cta不是每句都先让大模型深度理解原文后重写。
> 5. 当你点单行生成视频候选或同步规划时,才会走 `quickPlanStoryboard`,把当前三字段、参考帧、产品信息扩成完整分镜 prompt。
> 6. 你改中文字段后,系统会自动优化对应英文主字段,英文用于后续生视频。
>
> 已验证:
> - 远端 `skg-agent-api` / `skg-agent-web` 容器正常运行
> - 未登录 `/agent/` 正确跳 `/login/`
> - 登录后 `/agent/` 返回 200
> - `/api/agent-runs` 返回数组
> - 容器内 `/health` 为 `ok:true` 且 `auth_configured:true`
> - 本地 `python3 -m py_compile api/main.py` 通过
> - 本地 `web/npm run build` 通过
> 所以准确说:分镜会自动读取语音识别结果来切分时间轴、显示原文依据、初始化分镜行;但“根据识别内容自动生成更贴合原视频语义的新脚本文案”这层现在还不够强,偏模板化。
>
> 已推送到 Gitea`77d23a0 feat: add one-click agent cut terminal`
>
> 最后状态:`git status -sb` 显示 `main...origin/main` 已同步;只剩 `.memory/worklog.json` 被会话心跳再次改动,我没有继续追提交这个自动日志,避免无限心跳提交。
>
> 我没有额外代你点一次真实 1 分钟生成,因为那会立刻消耗视频 API现在页面已经可以你上传产品图后直接触发。
>
> <oai-mem-citation>
> <citation_entries>
> MEMORY.md:72-85|note=[used host context pointer for 2.24.28.41]
> </citation_entries>
> <rollout_ids>
> 019e44c6-d8fe-7090-949d-f57d527a6f36
> </rollout_ids>
> </…
> 如果你想要的效果是:音频一识别完,分镜区就自动根据原音频内容生成每句对应的新 SKG 脚本文案那需要再补一层“transcript -> 三字段候选”的自动脚本生成逻辑。这个方向是合理的。
## Cursor 最近会话
@@ -146,7 +134,7 @@
- 当前分支main
- 未提交变更1 项
- 最近提交:feat: add one-click agent cut terminal
- 最近提交:docs: record multilingual asr deployment
- 变更文件:
- M .memory/worklog.json

File diff suppressed because it is too large Load Diff

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/`
@@ -18,6 +18,7 @@
- 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-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-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底部仍由发送区主按钮确认生成。
@@ -65,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`
@@ -120,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