2 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
14 changed files with 684 additions and 105 deletions

1
.gitignore vendored
View File

@@ -27,6 +27,7 @@ asset_library/*
prompt_library/*
!prompt_library/.gitkeep
_trash/
output/
# web
web/.next/

View File

@@ -1,51 +1,5 @@
{
"entries": [
{
"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",
@@ -3201,6 +3155,51 @@
"message": "auto-save 2026-05-23 23:50 (~3)",
"hash": "e13bb0b",
"files_changed": 3
},
{
"ts": "2026-05-23T23:55:05+08:00",
"type": "commit",
"message": "feat: redesign creative studio entry",
"hash": "3146266",
"files_changed": 6
},
{
"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": "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(
@@ -4642,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",
@@ -4689,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 = ""
@@ -4704,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,
@@ -4734,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)
@@ -4772,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"
@@ -4789,6 +5223,7 @@ 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)
@@ -4815,7 +5250,8 @@ def _write_creative_reference_frame(job_id: str, file_bytes: bytes | None = None
@app.post("/creative/jobs/image", response_model=Job)
async def create_creative_image_job(file: UploadFile | None = File(default=None)) -> 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"
@@ -4838,6 +5274,7 @@ async def create_creative_image_job(file: UploadFile | None = File(default=None)
duration=0,
frames=[frame],
)
assign_owner(job, user)
JOBS[job_id] = job
save_state(job)
return job
@@ -8224,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
@@ -8564,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)
@@ -8580,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()
@@ -8587,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,7 +12,7 @@ const _playfairDisplay = Playfair_Display({
})
export const metadata: Metadata = {
title: "SKG Creative Studio",
title: "SKG 营销内容工作台",
description: "SKG AI 图片、视频和文案创作台",
}

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>

View File

@@ -265,7 +265,7 @@ export default function Home() {
<Sparkles className="h-5 w-5" />
</div>
<div>
<h1 className="text-xl font-semibold tracking-normal">SKG Creative Studio</h1>
<h1 className="text-xl font-semibold tracking-normal">SKG </h1>
<p className="text-sm text-[#5f6f69]"></p>
</div>
</div>

View File

@@ -1001,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
@@ -1023,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
@@ -1119,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