diff --git a/RULES.md b/RULES.md index 5c68990..6a916f5 100644 --- a/RULES.md +++ b/RULES.md @@ -12,7 +12,7 @@ - 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解 - 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`) - 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译) -- 当前产品方向(2026-05-26 Postgres 持久化版):默认入口是多人通用的 SKG 营销内容生产平台,`https://marketing.skg.com` 登录后直接进入个人生成画布,`/canvas/` 只作为旧链接兼容跳转到根域名。终端可见品牌位只放 SKG logo,不在主界面展示“生图生视频”“SKG 生成画布”或长系统名。画布本体尽量恢复 `chatfire-AI/huobao-canvas` 的成熟交互,不再削成三模式单输入框:保留首页推荐词、画布底部推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载等上游能力;多角度分镜、故事板、图转视频、绘本等工作流按上游结构创建节点。API 接入是例外:生成调用继续走本项目后端 `/api` 和当前登录 Cookie,不要求员工在浏览器配置个人 API Key;图片/视频模型选择只显示后端已经接通的媒体模型,不能让浏览器本地自定义或旧缓存模型进入生成下拉。API 设置弹窗只保留模型/端点配置外观,不能出现上游注册链接或外部品牌。用户登录后仍只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离;画布项目以服务端 Postgres 为主持久化,浏览器 `localStorage` 只作为缓存和首次导入来源,图片/视频资产按登录用户写入后端 job。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。 +- 当前产品方向(2026-05-26 Postgres 持久化版):默认入口是多人通用的 SKG 营销内容生产平台,`https://marketing.skg.com` 登录后直接进入个人生成画布,`/canvas/` 只作为旧链接兼容跳转到根域名。终端可见品牌位只放 SKG logo,不在主界面展示“生图生视频”“SKG 生成画布”或长系统名。画布本体尽量恢复 `chatfire-AI/huobao-canvas` 的成熟交互,不再削成三模式单输入框:保留首页推荐词、画布底部推荐词、AI 润色、自动执行、公共工作流、我的工作流、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载等上游能力;多角度分镜、故事板、图转视频、绘本等工作流按上游结构创建节点。API 接入是例外:生成调用继续走本项目后端 `/api` 和当前登录 Cookie,不要求员工在浏览器配置个人 API Key;图片/视频模型选择只显示后端已经接通的媒体模型,不能让浏览器本地自定义或旧缓存模型进入生成下拉。API 设置弹窗只保留模型/端点配置外观,不能出现上游注册链接或外部品牌。用户登录后仍只看到自己的任务、结果、详情页、画布项目和个人工作流模板,继续沿用后端 owner 隔离;画布项目和我的工作流以服务端 Postgres 为主持久化,浏览器 `localStorage` 只作为项目缓存和首次导入来源,图片/视频资产按登录用户写入后端 job。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。 ## 部署事实 - 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik) diff --git a/api/db.py b/api/db.py index f7a3dd1..04ce407 100644 --- a/api/db.py +++ b/api/db.py @@ -95,6 +95,22 @@ def init_schema() -> bool: ) """, """ + CREATE TABLE IF NOT EXISTS canvas_workflows ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES app_users(uid) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + thumbnail TEXT NOT NULL DEFAULT '', + workflow_data JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + version INTEGER NOT NULL DEFAULT 1, + source TEXT NOT NULL DEFAULT 'canvas', + metadata JSONB NOT NULL DEFAULT '{}'::jsonb + ) + """, + """ CREATE TABLE IF NOT EXISTS job_index ( job_id TEXT PRIMARY KEY, owner_id TEXT NOT NULL DEFAULT '', @@ -202,6 +218,7 @@ def init_schema() -> bool: """, "CREATE INDEX IF NOT EXISTS idx_canvas_projects_owner_updated ON canvas_projects(owner_id, updated_at DESC) WHERE deleted_at IS NULL", "CREATE INDEX IF NOT EXISTS idx_canvas_projects_visibility_updated ON canvas_projects(visibility, updated_at DESC) WHERE deleted_at IS NULL", + "CREATE INDEX IF NOT EXISTS idx_canvas_workflows_owner_updated ON canvas_workflows(owner_id, updated_at DESC) WHERE deleted_at IS NULL", "CREATE INDEX IF NOT EXISTS idx_job_index_owner_updated ON job_index(owner_id, updated_at DESC)", "CREATE INDEX IF NOT EXISTS idx_generated_assets_owner_created ON generated_assets(owner_id, created_at DESC)", "CREATE INDEX IF NOT EXISTS idx_prompt_library_visibility ON prompt_library_index(visibility, updated_at DESC)", @@ -445,6 +462,7 @@ def upsert_canvas_project(user: dict, project: dict) -> dict | None: WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN NULL ELSE canvas_projects.deleted_at END + WHERE canvas_projects.owner_id = EXCLUDED.owner_id RETURNING id, name, thumbnail, visibility, canvas_data, created_at, updated_at, version, owner_id """, ( @@ -488,6 +506,130 @@ def soft_delete_canvas_project(user: dict, project_id: str) -> bool: return bool(_execute_safely("soft_delete_canvas_project", run)) +def list_canvas_workflows(user: dict) -> list[dict]: + uid = str(user.get("uid") or "") + + def run(): + with _connect() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + w.id, w.name, w.description, w.thumbnail, w.workflow_data, + w.created_at, w.updated_at, w.version, w.owner_id, + u.name AS owner_name, u.email AS owner_email, u.provider AS owner_provider + FROM canvas_workflows w + LEFT JOIN app_users u ON u.uid = w.owner_id + WHERE w.deleted_at IS NULL + AND w.owner_id = %s + ORDER BY w.updated_at DESC + LIMIT 500 + """, + (uid,), + ) + rows = cur.fetchall() + return [dict(row) for row in rows] + + return _execute_safely("list_canvas_workflows", run) or [] + + +def upsert_canvas_workflow(user: dict, workflow: dict) -> dict | None: + uid = str(user.get("uid") or "") + if not uid: + return None + workflow_id = str(workflow.get("id") or "").strip() + if not workflow_id: + workflow_id = f"workflow_{int(time.time() * 1000)}_{uuid.uuid4().hex[:9]}" + name = str(workflow.get("name") or "未命名工作流").strip() or "未命名工作流" + description = str(workflow.get("description") or "").strip() + thumbnail = str(workflow.get("thumbnail") or "") + workflow_data = workflow.get("workflow_data") or workflow.get("workflowData") or {"nodes": [], "edges": [], "viewport": {"x": 100, "y": 50, "zoom": 0.8}} + created_at = _dt(workflow.get("created_at") or workflow.get("createdAt")) + updated_at = _dt(workflow.get("updated_at") or workflow.get("updatedAt")) + + def run(): + with _connect() as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO canvas_workflows ( + id, owner_id, name, description, thumbnail, workflow_data, + created_at, updated_at, version, source, metadata + ) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,1,%s,%s) + ON CONFLICT (id) DO UPDATE SET + name = CASE + WHEN canvas_workflows.owner_id = EXCLUDED.owner_id THEN EXCLUDED.name + ELSE canvas_workflows.name + END, + description = CASE + WHEN canvas_workflows.owner_id = EXCLUDED.owner_id THEN EXCLUDED.description + ELSE canvas_workflows.description + END, + thumbnail = CASE + WHEN canvas_workflows.owner_id = EXCLUDED.owner_id THEN EXCLUDED.thumbnail + ELSE canvas_workflows.thumbnail + END, + workflow_data = CASE + WHEN canvas_workflows.owner_id = EXCLUDED.owner_id THEN EXCLUDED.workflow_data + ELSE canvas_workflows.workflow_data + END, + updated_at = CASE + WHEN canvas_workflows.owner_id = EXCLUDED.owner_id THEN EXCLUDED.updated_at + ELSE canvas_workflows.updated_at + END, + version = CASE + WHEN canvas_workflows.owner_id = EXCLUDED.owner_id THEN canvas_workflows.version + 1 + ELSE canvas_workflows.version + END, + deleted_at = CASE + WHEN canvas_workflows.owner_id = EXCLUDED.owner_id THEN NULL + ELSE canvas_workflows.deleted_at + END + WHERE canvas_workflows.owner_id = EXCLUDED.owner_id + RETURNING id, name, description, thumbnail, workflow_data, created_at, updated_at, version, owner_id + """, + ( + workflow_id, + uid, + name, + description, + thumbnail, + _json(workflow_data), + created_at, + updated_at, + str(workflow.get("source") or "canvas"), + _json({"source_project_id": workflow.get("source_project_id") or workflow.get("sourceProjectId") or ""}), + ), + ) + row = cur.fetchone() + conn.commit() + return dict(row) if row else None + + return _execute_safely("upsert_canvas_workflow", run) + + +def soft_delete_canvas_workflow(user: dict, workflow_id: str) -> bool: + uid = str(user.get("uid") or "") + + def run(): + with _connect() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE canvas_workflows + SET deleted_at = now(), updated_at = now(), version = version + 1 + WHERE id = %s AND owner_id = %s AND deleted_at IS NULL + """, + (workflow_id, uid), + ) + changed = cur.rowcount > 0 + conn.commit() + return changed + + return bool(_execute_safely("soft_delete_canvas_workflow", run)) + + def index_job(job: dict, state_path: str = "") -> None: job_id = str(job.get("id") or "") if not job_id: diff --git a/api/main.py b/api/main.py index 1274b50..ce34ca6 100644 --- a/api/main.py +++ b/api/main.py @@ -2189,6 +2189,18 @@ class CanvasProjectImportReq(BaseModel): projects: list[CanvasProjectWriteReq] = Field(default_factory=list) +class CanvasWorkflowWriteReq(BaseModel): + id: str = "" + name: str = "未命名工作流" + description: str = "" + thumbnail: str = "" + workflow_data: dict = Field(default_factory=dict) + created_at: float = 0.0 + updated_at: float = 0.0 + source: str = "canvas" + source_project_id: str = "" + + def _ts(value) -> float: if hasattr(value, "timestamp"): return float(value.timestamp()) @@ -2220,6 +2232,23 @@ def _canvas_project_public(row: dict) -> dict: } +def _canvas_workflow_public(row: dict) -> dict: + return { + "id": str(row.get("id") or ""), + "name": str(row.get("name") or ""), + "description": str(row.get("description") or ""), + "thumbnail": str(row.get("thumbnail") or ""), + "workflow_data": row.get("workflow_data") or {}, + "created_at": _ts(row.get("created_at")), + "updated_at": _ts(row.get("updated_at")), + "version": int(row.get("version") or 1), + "owner_id": str(row.get("owner_id") or ""), + "owner_name": str(row.get("owner_name") or ""), + "owner_email": str(row.get("owner_email") or ""), + "owner_provider": str(row.get("owner_provider") or ""), + } + + @app.get("/canvas-projects") def list_canvas_projects(request: Request) -> dict: _require_db() @@ -2239,6 +2268,8 @@ def create_canvas_project(req: CanvasProjectWriteReq, request: Request) -> dict: row = db.upsert_canvas_project(user, req.model_dump()) if not row: raise HTTPException(500, "canvas project save failed") + if str(row.get("owner_id") or "") != _session_user_id(user): + raise HTTPException(403, "canvas project belongs to another user") db.audit(user, "canvas_project.create", "canvas_project", str(row.get("id") or ""), req.model_dump(exclude={"canvas_data"}), request, str(row.get("visibility") or "private")) return {"ok": True, "item": _canvas_project_public(row)} @@ -2296,6 +2327,58 @@ def import_canvas_projects(req: CanvasProjectImportReq, request: Request) -> dic return {"ok": True, "items": imported} +@app.get("/canvas-workflows") +def list_canvas_workflows(request: Request) -> dict: + _require_db() + user = data_user_from_request(request) + db.upsert_user(user, request) + return { + "ok": True, + "items": [_canvas_workflow_public(row) for row in db.list_canvas_workflows(user)], + } + + +@app.post("/canvas-workflows") +def create_canvas_workflow(req: CanvasWorkflowWriteReq, request: Request) -> dict: + _require_db() + user = data_user_from_request(request) + db.upsert_user(user, request) + row = db.upsert_canvas_workflow(user, req.model_dump()) + if not row: + raise HTTPException(500, "canvas workflow save failed") + if str(row.get("owner_id") or "") != _session_user_id(user): + raise HTTPException(403, "canvas workflow belongs to another user") + db.audit(user, "canvas_workflow.create", "canvas_workflow", str(row.get("id") or ""), {"name": req.name, "source_project_id": req.source_project_id}, request, "private") + return {"ok": True, "item": _canvas_workflow_public(row)} + + +@app.put("/canvas-workflows/{workflow_id}") +def put_canvas_workflow(workflow_id: str, req: CanvasWorkflowWriteReq, request: Request) -> dict: + _require_db() + user = data_user_from_request(request) + db.upsert_user(user, request) + payload = req.model_dump() + payload["id"] = workflow_id + row = db.upsert_canvas_workflow(user, payload) + if not row: + raise HTTPException(500, "canvas workflow save failed") + if str(row.get("owner_id") or "") != _session_user_id(user): + raise HTTPException(403, "canvas workflow belongs to another user") + db.audit(user, "canvas_workflow.save", "canvas_workflow", workflow_id, {"name": req.name}, request, "private") + return {"ok": True, "item": _canvas_workflow_public(row)} + + +@app.delete("/canvas-workflows/{workflow_id}") +def delete_canvas_workflow(workflow_id: str, request: Request) -> dict: + _require_db() + user = data_user_from_request(request) + ok = db.soft_delete_canvas_workflow(user, workflow_id) + if not ok: + raise HTTPException(404, "canvas workflow not found") + db.audit(user, "canvas_workflow.delete", "canvas_workflow", workflow_id, request=request, visibility="private") + return {"ok": True, "id": workflow_id} + + def _parse_library_metadata(raw: str) -> dict: if not raw.strip(): return {} diff --git a/docs/source-analysis.html b/docs/source-analysis.html index ed40e5e..6e00e77 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -583,16 +583,17 @@

2026-05-25 媒体模型接入收口:图片和视频模型选择只暴露当前后端真实可用项:图片为 autogpt-image-2gemini-3-pro-image-preview;视频当前只接通 Seedance 2.0 Fast(真实模型 doubao-seedance-2-0-fast-260128)。旧上游的 Nano Banana、Seedream、Kling、Veo 或浏览器本地自定义媒体模型不能进入生成下拉,避免同事选到实际不可用的模型。

2026-05-26 公司沉淀版:画布项目从浏览器本地存储升级为服务端 Postgres 持久化;localStorage 只作为离线缓存和首次导入来源。后端同时建立用户、任务、资源索引和审计表,保留原有 state.json 文件作为任务详情真源,避免一次迁移动到大文件资产结构。

2026-05-26 AI 润色中性化:画布 AI 润色 不再复用 SKG 广告文案接口 /creative/copy。后端新增 POST /prompt/polish,前端 useChat、根画布输入框、文本节点和自动执行意图分析改走中性提示词/通用生成接口:只优化用户已经给出的主体、风格、镜头和细节,不主动添加 SKG、按摩产品、TikTok 广告话术或用户没有提到的品牌。润色会按人物意图分流:原提示词没有人物时明确保持物体/场景/产品构图且不新增人物;原提示词明确有人物、人像、模特或角色时,才补充“虚构 AI 角色、非真人、非公众人物”的合规描述。

+

2026-05-26 我的工作流云端版:工作流面板从只有公共模板扩展为“公共工作流 / 我的工作流”两类。当前画布可以保存成当前登录用户自己的云端工作流模板,后续在同一账号的其他电脑或浏览器打开后可插回画布;保存时只沉淀节点结构、连线、配置和提示词,主动清掉已生成图片、视频、任务进度、错误和运行态字段,避免把一次性生成结果误当模板复用。

-

当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 画布项目同步到服务端 Postgres → 用提示词、推荐词、AI 润色或工作流模板创建节点 → 画布自动执行或手动连接图片/视频/文本节点 → 生成结果沉淀在当前个人画布 → 需要时进入详情页继续编辑”。画布不再被削成三模式入口;首帧、尾帧、参考图、图生视频、多角度分镜、故事板和绘本等上游概念按节点能力保留。底层生成仍由 web/canvas-app/src/hooks/useApi.js 适配到本项目 /creative/jobs/image/jobs/{id}/frames/{idx}/generate/jobs/{id}/frames/{idx}/storyboard/video,AI 润色和通用 LLM 文本生成走 /prompt/polish 并保持中性专业,不再默认套入 SKG 广告语境。生成资产按当前登录用户写入个人 job。图片尺寸只显示 auto1024x15361024x10241536x1024;视频画幅只显示 720x12801280x7201024x1024960x1280;视频时长只显示 5/8/10/12/15 秒。多人互不影响依赖后端 owner_id、画布项目 owner 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。

+

当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 画布项目同步到服务端 Postgres → 用提示词、推荐词、AI 润色、公共工作流或我的工作流创建节点 → 画布自动执行或手动连接图片/视频/文本节点 → 生成结果沉淀在当前个人画布 → 可把当前节点结构保存为我的工作流 → 需要时进入详情页继续编辑”。画布不再被削成三模式入口;首帧、尾帧、参考图、图生视频、多角度分镜、故事板和绘本等上游概念按节点能力保留。底层生成仍由 web/canvas-app/src/hooks/useApi.js 适配到本项目 /creative/jobs/image/jobs/{id}/frames/{idx}/generate/jobs/{id}/frames/{idx}/storyboard/video,AI 润色和通用 LLM 文本生成走 /prompt/polish 并保持中性专业,不再默认套入 SKG 广告语境。生成资产按当前登录用户写入个人 job。图片尺寸只显示 auto1024x15361024x10241536x1024;视频画幅只显示 720x12801280x7201024x1024960x1280;视频时长只显示 5/8/10/12/15 秒。多人互不影响依赖后端 owner_id、画布项目 owner、我的工作流 owner 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。

01

个人任务

GET /jobs 按当前登录用户过滤;旧无 owner 任务只对备用账号可见。

02

进入画布

用户直接在根域名个人画布里操作;项目列表优先读取服务端 /canvas-projects,本地旧项目会首次导入。

-
03

组织节点

可通过底部 prompt、AI 润色、自动执行、手动添加节点或工作流模板创建文本、图片、视频、LLM、配置和参考图节点。

+
03

组织节点

可通过底部 prompt、AI 润色、自动执行、手动添加节点、公共工作流或我的工作流创建文本、图片、视频、LLM、配置和参考图节点。

04

参考素材

首帧、尾帧、参考图和图片节点按上游节点语义保留;提交到后端时由 useApi.js 转成 first_imagelast_image 或图片编辑参考。

05

工作流执行

自动执行会根据提示词创建文生图、图转视频、故事板、多角度分镜或绘本等节点组;手动模式下用户可自行连接节点。

06

生成图片 / 视频

generateImagegenerateStoryboardVideo 继续走 SKG 后端 /api;视频提交后先写入 queued 占位,再由后端队列按并发上限启动。

-
07

结果沉淀

生成图、视频 URL、任务状态和下载入口回填到画布节点;画布结构保存到 Postgres,完整任务结果仍可进入 /detail/?job= 查看。

+
07

结果沉淀

生成图、视频 URL、任务状态和下载入口回填到画布节点;画布项目和个人工作流结构保存到 Postgres,完整任务结果仍可进入 /detail/?job= 查看。

08

详情页

/detail/?job=<id> 展示参考图、全量生成图、视频候选、提示词和营销图文,并支持继续生成。

09

高级复刻

AdRecreationBoard/agent/ 作为高级入口保留,不再是默认路径。

@@ -608,9 +609,10 @@ web/next.config.mjsNext.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 eslint 顶层配置,避免本地 dev 出现配置 Issue 提示。 web/app/globals.css全局主题变量、登录页视觉样式、信息流工作台玻璃拟态 token、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。工作台在 skg-board-theme 内按 Figma 本地 MCP 参考改成黑灰玻璃系统:深灰背景、#383838 胶囊侧栏、rgba(255,255,255,.1) 玻璃面、backdrop-filter: blur(5px)20px 圆角、10px 10px 10px rgba(0,0,0,.3) 阴影和绿黄状态色;新增 skg-board-shellskg-board-railskg-glass-cardskg-glass-card--flatskg-status-orb 等样式。侧栏改为跟随视口拉满工作台可用高度的悬停胶囊,桌面最小 600px,展开时在同一侧栏内承载素材输入抽屉。明暗主题已分开维护 shell、panel、glass、stat、action 和音频波形 token;暗色压低灰雾和面板底色,明亮模式改为暖白工作台,避免指标卡、按钮和波形继续残留黑底/白线;顶部指标卡增加紫、黄绿、琥珀、青绿、绿色光斑变量,接近原版多色玻璃卡效果。主/次按钮、指标卡和空状态继续走统一类,避免各板块散写不同玻璃效果。 web/app/page.tsx旧 React 单对话框生成台源码仍保留,便于以后回滚或抽能力;当前生产根域名已经由 web/canvas-app/ 画布产物覆盖,不再把这个 React 首页作为默认首屏。该页面里的模式也已收敛为文生图、文生视频、图生视频;图生视频只显示“上传图片”,不把“首帧/首尾帧”作为用户入口。旧 TK 复刻工作台组件仍保留在 web/components/ad-recreation-board.tsx,但不再作为默认首页渲染。 - web/canvas-app/SKG 内部画布应用:从 chatfire-AI/huobao-canvas 交互逻辑改造而来。当前策略是“保留成熟画布能力,替换品牌/路由/API”:Vue Flow 节点画布、项目列表、推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载都保留;可见品牌收敛为 SKG logo,不展示上游注册链接或外部品牌。生产路径固定为根域名 /,内部路由用 /p/:id?;项目列表和画布 JSON 优先同步到服务端 Postgres,浏览器本地存储只是缓存/导入来源;来源说明保存在 THIRD_PARTY_NOTICES.md,不展示给终端用户。 + web/canvas-app/SKG 内部画布应用:从 chatfire-AI/huobao-canvas 交互逻辑改造而来。当前策略是“保留成熟画布能力,替换品牌/路由/API”:Vue Flow 节点画布、项目列表、推荐词、AI 润色、自动执行、公共工作流、我的工作流、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载都保留;可见品牌收敛为 SKG logo,不展示上游注册链接或外部品牌。生产路径固定为根域名 /,内部路由用 /p/:id?;项目列表、画布 JSON 和个人工作流模板优先同步到服务端 Postgres,浏览器本地存储只是缓存/导入来源;来源说明保存在 THIRD_PARTY_NOTICES.md,不展示给终端用户。 web/canvas-app/src/stores/projects.js画布项目 Pinia store:启动时先读本地 localStorage["ai-canvas-projects"] 作为缓存,再调用 GET /canvas-projects 拉服务端项目;如果发现本地旧项目,会调用 POST /canvas-projects/import 导入到当前登录用户。新建、重命名、画布节点变更、复制和删除会同步到 /canvas-projects,本地缓存只用于快速打开和网络异常兜底。 - web/canvas-app/src/views/Canvas.vue画布主交互:恢复上游底部 prompt composer、AI 润色自动执行、推荐词、节点菜单、工作流面板、API/模型设置入口和批量下载入口。自动执行会调用 useWorkflowOrchestrator 分析提示词,创建文生图、图转视频、故事板、多角度分镜或绘本节点组;手动模式只创建文本节点,用户自行连接节点。底部推荐词来自共享短词池,4 个一组单行展示,刷新按钮在 30 组内轮换,不改变输入面板高度。 + web/canvas-app/src/stores/workflows.js我的工作流 store:调用 GET/POST/DELETE /canvas-workflows 读取、保存和删除当前登录用户自己的云端工作流模板。保存前会清理节点里的 base64、生成 URL、任务进度、错误、视频结果和 LLM 输出等运行态字段,只保留可复用的节点结构、连线、配置和提示词。 + web/canvas-app/src/views/Canvas.vue画布主交互:恢复上游底部 prompt composer、AI 润色自动执行、推荐词、节点菜单、工作流面板、API/模型设置入口和批量下载入口。自动执行会调用 useWorkflowOrchestrator 分析提示词,创建文生图、图转视频、故事板、多角度分镜或绘本节点组;手动模式只创建文本节点,用户自行连接节点。工作流面板支持公共模板和我的工作流:公共模板走本地 createNodes(),我的工作流从云端 workflow_data 插回当前画布,并重新生成节点 ID、按视口中心重排、按映射重连边。底部推荐词来自共享短词池,4 个一组单行展示,刷新按钮在 30 组内轮换,不改变输入面板高度。 web/canvas-app/src/config/suggestions.js首页和画布共用的推荐词配置:维护 QUICK_SUGGESTION_GROUPS,当前为 30 组 / 120 个短词,每组 4 个,控制刷新按钮的轮换范围;词条保持短小,避免推荐栏换行或顶起 composer。 web/canvas-app/src/config/models.js画布媒体模型和规格的前端白名单:图片只内置 autogpt-image-2gemini-3-pro-image-preview,尺寸只内置 auto1024x15361024x10241536x1024;视频只内置 seedance / Seedance 2.0 Fast,画幅和时长对齐后端 /health 能力边界。useModelConfig.js 和 Pinia 模型 store 会忽略浏览器本地自定义图片/视频模型,防止旧缓存把不可用模型带回生成下拉。 web/canvas-app/src/hooks/useApi.js画布到本项目后端的适配层:不再读取浏览器 API Key,而是使用当前登录会话 Cookie 调用 /api。文生图 / 图生图先创建轻量 creative job,再调用 /frames/0/generate;文生视频 / 图生视频调用 /storyboard/video 并轮询 /jobs/{id},完成后把图片或 mp4 URL 写回画布节点。useChat 已从 SKG 广告文案接口切到 /prompt/polish:AI 润色显式使用 image/video prompt 模式,LLM 节点使用通用 chat 模式,避免自动注入用户没有提到的 SKG 或营销语境;后端会判断原提示词是否有人物意图,无人物时禁止新增人物,有人物时才声明虚构 AI 角色。 @@ -644,8 +646,8 @@

后端核心

- - + + @@ -670,6 +672,7 @@ -> 生视频:generateStoryboardVideo(job.id, 0, { prompt, model, first_image?, duration }) → jobs/<jobId>/storyboard_videos -> 当前结果:图片 / 视频节点自动排列到画布 -> 画布项目:web/canvas-app/src/stores/projects.js → GET/POST/PUT/DELETE /canvas-projects → Postgres canvas_projects + -> 我的工作流:web/canvas-app/src/stores/workflows.js → GET/POST/PUT/DELETE /canvas-workflows → Postgres canvas_workflows -> 任务详情页:web/app/detail/page.tsx?job=<id> → getJob → 展示参考图、生成图、视频、提示词、图文方案 → 可继续生成 / 删除 / 复制 旧版 TK 复刻链路(最后版本保留): @@ -687,7 +690,7 @@ web/app/page.tsx 后端主链路: api/main.py -> Auth session / Feishu OAuth / Job owner / AgentRun owner / KeyFrame / KeyElement / StoryboardScene / AudioScript - -> api/db.py / Postgres: app_users / canvas_projects / job_index / generated_assets / prompt_library_index / asset_library_index / agent_run_index / audit_events + -> api/db.py / Postgres: app_users / canvas_projects / canvas_workflows / job_index / generated_assets / prompt_library_index / asset_library_index / agent_run_index / audit_events -> 下载 / 上传 / 音频提取 / ASR / 翻译 / 声音背景音分析 / 抽帧 / Vision brief / GPT 图像生成 / 产品视角识别 / 分镜保存 / 首尾帧生成 / 后续 Azure OpenAI 配音预留 -> jobs/<jobId>/state.json + agent_runs/<runId>/state.json + 图片文件落盘;API 层按登录用户过滤列表和详情 @@ -700,6 +703,11 @@ api/main.py
主要源码web/app/page.tsx;前端 API client 在 web/lib/api.ts;轻量创作后端在 api/main.py/creative/jobs/image/creative/copy/prompt/polish,实际图片和视频生成继续复用 /jobs/{id}/frames/{idx}/generate/jobs/{id}/frames/{idx}/storyboard/video
适合怎么描述“首页只有一个对话框,三个模式是文生图、文生视频、图生视频;图生视频上传图片后手写提示词生成”。
+
+
你看到的区域个人生成画布 / 工作流面板
+
主要源码web/canvas-app/src/views/Canvas.vueweb/canvas-app/src/components/WorkflowPanel.vueweb/canvas-app/src/stores/projects.jsweb/canvas-app/src/stores/workflows.js;后端项目接口在 /canvas-projects,我的工作流接口在 /canvas-workflows
+
适合怎么描述“画布当前节点怎么保存成我的工作流、我的工作流为什么空、插回画布时节点位置和连线怎么处理、同一飞书账号换电脑后为什么能看到同一批个人模板”。
+
你看到的区域任务详情页
主要源码web/app/detail/page.tsx;通过 /detail/?job=<id> 读取任务;复用 getJobgenerateImagegenerateStoryboardVideogenerateCreativeCopydeleteGeneratedImagedeleteGeneratedVideo
@@ -794,6 +802,18 @@ api/main.py canvas_data: { nodes, edges, viewport, ... }, version, created_at, updated_at, deleted_at +} +
+
+

CanvasWorkflow

+

我的工作流的云端个人模板模型。它保存可复用的画布结构,不保存一次生成出来的媒体结果;前端写入前会清掉图片 URL、视频 URL、任务进度、错误、LLM 输出和生成结果列表,插回画布时重新生成节点 ID 并重连边。

+
CanvasWorkflow {
+  id,
+  owner_id, owner_name, owner_email, owner_provider,
+  name, description, thumbnail,
+  workflow_data: { nodes, edges, viewport },
+  version,
+  created_at, updated_at, deleted_at
 }
@@ -801,6 +821,7 @@ api/main.py

Postgres 不替代大文件和完整 job state;它负责跨用户、跨浏览器和后续后台管理需要的结构化索引。任务详情、媒体文件和资源库原始 manifest 仍保留在现有目录里。

app_users
 canvas_projects
+canvas_workflows
 job_index
 generated_assets
 prompt_library_index
@@ -1085,6 +1106,7 @@ ProductRefStateItem {
             
+ @@ -1244,6 +1266,19 @@ ProductRefStateItem {

变更记录

这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

+
+
+

2026-05-26 · 我的工作流接入云端个人模板

+ Canvas + Backend + Data +
+
+

问题:工作流面板只有公共模板,“我的工作流”为空;用户在当前画布里整理好的节点组合无法保存成自己的模板,也不能在另一台电脑用同一账号复用。

+

改动:新增 Postgres 表 canvas_workflowsGET/POST/PUT/DELETE /canvas-workflows 接口,按当前登录用户保存个人工作流模板。前端新增 web/canvas-app/src/stores/workflows.jsWorkflowPanel.vue 的“我的工作流”页提供保存当前、刷新和删除;Canvas.vue 保存当前节点/连线,插入个人模板时重新生成节点 ID、按当前视口重排,并按 ID 映射重连边。

+

影响:同一飞书账号换电脑后应能看到自己的工作流模板;保存时会清掉生成图片、视频、进度、错误和 LLM 输出等运行态,只保留可复用结构和配置。当前仍是个人私有模板,不是公共模板库或公司共享模板库。

+
+

2026-05-26 · 推荐词扩展为 30 组共享短词池

diff --git a/web/canvas-app/src/components/WorkflowPanel.vue b/web/canvas-app/src/components/WorkflowPanel.vue index da2ce28..836fc78 100644 --- a/web/canvas-app/src/components/WorkflowPanel.vue +++ b/web/canvas-app/src/components/WorkflowPanel.vue @@ -42,11 +42,55 @@
-
- - - -

暂无自定义工作流

+
+
+ + +
+ +
+ + + +

正在加载...

+
+ +
+
+ +
+ + + + +
+
{{ workflow.name }}
+
{{ formatWorkflowMeta(workflow) }}
+
+
+ +
+ + + +

暂无自定义工作流

+
@@ -69,15 +113,23 @@ import { BookOutline, PersonOutline, CartOutline, - ChatbubbleOutline + ChatbubbleOutline, + BookmarkOutline, + RefreshOutline, + TrashOutline } from '@vicons/ionicons5' import { WORKFLOW_TEMPLATES } from '../config/workflows' const props = defineProps({ - show: Boolean + show: Boolean, + myWorkflows: { + type: Array, + default: () => [] + }, + loadingMyWorkflows: Boolean }) -const emit = defineEmits(['update:show', 'add-workflow']) +const emit = defineEmits(['update:show', 'add-workflow', 'save-current', 'delete-workflow', 'refresh-workflows']) // Active tab | 当前标签 const activeTab = ref('public') @@ -113,6 +165,11 @@ const handleAddWorkflow = (workflow) => { visible.value = false } +const formatWorkflowMeta = (workflow) => { + const count = workflow.workflowData?.nodes?.length || 0 + return `${count} 个节点` +} + // Handle click outside | 点击外部关闭 const handleClickOutside = () => { visible.value = false @@ -216,6 +273,53 @@ const vClickOutside = { padding: 16px; } +.my-workflows { + display: flex; + flex-direction: column; + gap: 14px; +} + +.my-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.save-current-btn, +.refresh-btn, +.delete-workflow-btn { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-color); + background: var(--bg-tertiary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; +} + +.save-current-btn { + gap: 6px; + height: 30px; + padding: 0 10px; + border-radius: 8px; + font-size: 12px; +} + +.refresh-btn { + width: 30px; + height: 30px; + border-radius: 8px; +} + +.save-current-btn:hover, +.refresh-btn:hover, +.delete-workflow-btn:hover { + border-color: var(--accent-color); + color: var(--accent-color); +} + /* Workflow grid | 工作流网格 */ .workflow-grid { display: grid; @@ -229,6 +333,10 @@ const vClickOutside = { transition: transform 0.2s; } +.my-workflow-card { + position: relative; +} + .workflow-card:hover { transform: translateY(-2px); } @@ -269,6 +377,31 @@ const vClickOutside = { white-space: nowrap; } +.card-meta { + margin-top: 3px; + font-size: 11px; + color: var(--text-secondary); + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.delete-workflow-btn { + position: absolute; + right: 6px; + top: 6px; + width: 24px; + height: 24px; + border-radius: 7px; + opacity: 0; + z-index: 2; +} + +.my-workflow-card:hover .delete-workflow-btn { + opacity: 1; +} + /* Empty state | 空状态 */ .empty-state { display: flex; diff --git a/web/canvas-app/src/stores/workflows.js b/web/canvas-app/src/stores/workflows.js new file mode 100644 index 0000000..cc5cf1c --- /dev/null +++ b/web/canvas-app/src/stores/workflows.js @@ -0,0 +1,160 @@ +/** + * Personal workflow store | 个人工作流状态管理 + */ +import { ref } from 'vue' + +const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api' +const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}` + +export const myWorkflows = ref([]) +export const workflowSyncStatus = ref('idle') +export const workflowSyncError = ref('') + +const requestJson = async (path, init = {}) => { + const response = await fetch(apiUrl(path), { + ...init, + headers: { + ...(init.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), + ...(init.headers || {}) + } + }) + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new Error(text || `${path} ${response.status}`) + } + return response.json() +} + +const secondsToDate = (value) => { + if (value instanceof Date) return value + const num = Number(value || 0) + return new Date(num > 100000000000 ? num : num * 1000) +} + +const dateToSeconds = (value) => { + if (value instanceof Date) return value.getTime() / 1000 + const parsed = new Date(value) + return Number.isFinite(parsed.getTime()) ? parsed.getTime() / 1000 : Date.now() / 1000 +} + +const workflowFromApi = (item) => ({ + id: item.id, + name: item.name || '未命名工作流', + description: item.description || '', + thumbnail: item.thumbnail || '', + workflowData: item.workflow_data || { + nodes: [], + edges: [], + viewport: { x: 100, y: 50, zoom: 0.8 } + }, + ownerId: item.owner_id || '', + ownerName: item.owner_name || '', + version: item.version || 1, + createdAt: secondsToDate(item.created_at), + updatedAt: secondsToDate(item.updated_at) +}) + +const runtimeKeys = [ + 'base64', + 'maskData', + 'loading', + 'error', + 'taskId', + 'progress', + 'status', + 'thumbnail', + 'outputContent', + 'executed', + 'revisedPrompt', + 'generatedImages', + 'generatedVideo' +] + +const cleanNodeForWorkflow = (node) => { + const data = { ...(node.data || {}) } + for (const key of runtimeKeys) delete data[key] + + if (node.type === 'image') { + data.url = '' + } + if (node.type === 'video') { + data.url = '' + data.duration = 0 + } + if (node.type === 'llmConfig') { + data.outputContent = '' + } + + delete data.createdAt + delete data.updatedAt + + return { + id: node.id, + type: node.type, + position: { + x: Number(node.position?.x || 0), + y: Number(node.position?.y || 0) + }, + data + } +} + +const cleanEdgeForWorkflow = (edge) => ({ + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle || 'right', + targetHandle: edge.targetHandle || 'left', + type: edge.type, + data: edge.data || {} +}) + +export const cleanCanvasForWorkflow = (canvasData) => ({ + nodes: (canvasData?.nodes || []).map(cleanNodeForWorkflow), + edges: (canvasData?.edges || []).map(cleanEdgeForWorkflow), + viewport: canvasData?.viewport || { x: 100, y: 50, zoom: 0.8 } +}) + +export const loadMyWorkflows = async () => { + try { + workflowSyncStatus.value = 'syncing' + const response = await requestJson('/canvas-workflows') + myWorkflows.value = (response.items || []).map(workflowFromApi) + workflowSyncStatus.value = 'synced' + workflowSyncError.value = '' + return myWorkflows.value + } catch (err) { + workflowSyncStatus.value = 'error' + workflowSyncError.value = err.message || '工作流加载失败' + throw err + } +} + +export const saveMyWorkflow = async ({ name, description = '', canvasData, sourceProjectId = '' }) => { + const now = new Date() + const payload = { + name: (name || '').trim() || '未命名工作流', + description, + thumbnail: '', + workflow_data: cleanCanvasForWorkflow(canvasData), + created_at: dateToSeconds(now), + updated_at: dateToSeconds(now), + source: 'canvas', + source_project_id: sourceProjectId + } + + workflowSyncStatus.value = 'syncing' + const response = await requestJson('/canvas-workflows', { + method: 'POST', + body: JSON.stringify(payload) + }) + const item = workflowFromApi(response.item) + myWorkflows.value = [item, ...myWorkflows.value.filter(workflow => workflow.id !== item.id)] + workflowSyncStatus.value = 'synced' + workflowSyncError.value = '' + return item +} + +export const deleteMyWorkflow = async (id) => { + await requestJson(`/canvas-workflows/${encodeURIComponent(id)}`, { method: 'DELETE' }) + myWorkflows.value = myWorkflows.value.filter(workflow => workflow.id !== id) +} diff --git a/web/canvas-app/src/views/Canvas.vue b/web/canvas-app/src/views/Canvas.vue index f6cb02d..70702e3 100644 --- a/web/canvas-app/src/views/Canvas.vue +++ b/web/canvas-app/src/views/Canvas.vue @@ -240,11 +240,28 @@ + + + + + + - + @@ -285,6 +302,7 @@ import { loadAllModels } from '../stores/models' import { useChat, useWorkflowOrchestrator } from '../hooks' import { useModelStore } from '../stores/pinia' import { projects, initProjectsStore, updateProject, renameProject, currentProject } from '../stores/projects' +import { myWorkflows, workflowSyncStatus, loadMyWorkflows, saveMyWorkflow, deleteMyWorkflow } from '../stores/workflows' // API Settings component | API 设置组件 import ApiSettings from '../components/ApiSettings.vue' @@ -397,7 +415,10 @@ const showRenameModal = ref(false) const showDeleteModal = ref(false) const showDownloadModal = ref(false) const showWorkflowPanel = ref(false) +const showSaveWorkflowModal = ref(false) const renameValue = ref('') +const workflowNameValue = ref('') +const isSavingWorkflow = ref(false) // Check if has downloadable assets | 检查是否有可下载素材 const hasDownloadableAssets = computed(() => { @@ -413,6 +434,8 @@ const projectName = computed(() => { return project?.name || '未命名项目' }) +const loadingMyWorkflows = computed(() => workflowSyncStatus.value === 'syncing') + // Project dropdown options | 项目下拉选项 const projectOptions = [ { label: '重命名', key: 'rename' }, @@ -472,42 +495,55 @@ const addNewNode = async (type) => { showNodeMenu.value = false } -// Handle add workflow from panel | 处理从面板添加工作流 -const handleAddWorkflow = ({ workflow, options }) => { - // Calculate viewport center position | 计算视口中心位置 - const viewportCenterX = -viewport.value.x / viewport.value.zoom + (window.innerWidth / 2) / viewport.value.zoom - const viewportCenterY = -viewport.value.y / viewport.value.zoom + (window.innerHeight / 2) / viewport.value.zoom +const viewportCenterPosition = () => ({ + x: -viewport.value.x / viewport.value.zoom + (window.innerWidth / 2) / viewport.value.zoom, + y: -viewport.value.y / viewport.value.zoom + (window.innerHeight / 2) / viewport.value.zoom +}) - // Create nodes from workflow template | 从工作流模板创建节点 - const startPosition = { x: viewportCenterX - 300, y: viewportCenterY - 200 } - const { nodes: newNodes, edges: newEdges } = workflow.createNodes(startPosition, options) +const insertWorkflowNodes = (workflowNodes, workflowEdges, workflowName, { reposition = false } = {}) => { + const sourceNodes = (workflowNodes || []).filter(node => node?.type) + if (!sourceNodes.length) { + window.$message?.warning('这个工作流没有可插入的节点') + return + } + + // Calculate viewport center position | 计算视口中心位置 + const center = viewportCenterPosition() + const startPosition = { x: center.x - 300, y: center.y - 200 } + const minX = Math.min(...sourceNodes.map(node => Number(node.position?.x || 0))) + const minY = Math.min(...sourceNodes.map(node => Number(node.position?.y || 0))) // Start batch operation manually | 手动开始批量操作 startBatchOperation() // Add nodes to canvas in batch | 批量将节点添加到画布 - const nodeSpecs = newNodes.map(node => ({ + const nodeSpecs = sourceNodes.map(node => ({ type: node.type, - position: node.position, + position: reposition + ? { + x: startPosition.x + Number(node.position?.x || 0) - minX, + y: startPosition.y + Number(node.position?.y || 0) - minY + } + : node.position, data: node.data })) const nodeIds = addNodes(nodeSpecs, false) // Map old node IDs to new IDs | 映射旧节点ID到新ID const idMap = {} - newNodes.forEach((node, index) => { - idMap[node.id] = nodeIds[index] + sourceNodes.forEach((node, index) => { + if (node.id) idMap[node.id] = nodeIds[index] }) // Add edges to canvas in batch | 批量将边添加到画布 - const edgeSpecs = newEdges.map(edge => ({ + const edgeSpecs = (workflowEdges || []).map(edge => ({ source: idMap[edge.source] || edge.source, target: idMap[edge.target] || edge.target, sourceHandle: edge.sourceHandle || 'right', targetHandle: edge.targetHandle || 'left', type: edge.type, data: edge.data - })) + })).filter(edge => edge.source && edge.target) // Add edges (autoBatch=false to use manual batch) | 添加边(autoBatch=false 以使用手动批量) addEdges(edgeSpecs, false) @@ -523,7 +559,76 @@ const handleAddWorkflow = ({ workflow, options }) => { }) }, 100) - window.$message?.success(`已添加工作流: ${workflow.name}`) + window.$message?.success(`已添加工作流: ${workflowName}`) +} + +// Handle add workflow from panel | 处理从面板添加工作流 +const handleAddWorkflow = ({ workflow, options }) => { + if (typeof workflow?.createNodes === 'function') { + const center = viewportCenterPosition() + const startPosition = { x: center.x - 300, y: center.y - 200 } + const { nodes: newNodes, edges: newEdges } = workflow.createNodes(startPosition, options) + insertWorkflowNodes(newNodes, newEdges, workflow.name, { reposition: false }) + return + } + + const workflowData = workflow?.workflowData || workflow?.workflow_data + insertWorkflowNodes(workflowData?.nodes, workflowData?.edges, workflow?.name || '我的工作流', { reposition: true }) +} + +const refreshMyWorkflows = async () => { + try { + await loadMyWorkflows() + } catch (err) { + window.$message?.error(err.message || '工作流加载失败') + } +} + +const openSaveWorkflowModal = () => { + if (!nodes.value.length) { + window.$message?.warning('当前画布没有可保存的节点') + return + } + workflowNameValue.value = `${projectName.value} 工作流` + showSaveWorkflowModal.value = true +} + +const confirmSaveWorkflow = async () => { + const name = workflowNameValue.value.trim() + if (!name) { + window.$message?.warning('请填写工作流名称') + return + } + + try { + isSavingWorkflow.value = true + await saveMyWorkflow({ + name, + canvasData: { + nodes: nodes.value, + edges: edges.value, + viewport: viewport.value || canvasViewport.value + }, + sourceProjectId: String(route.params.id || '') + }) + showSaveWorkflowModal.value = false + window.$message?.success('已保存到我的工作流') + } catch (err) { + window.$message?.error(err.message || '工作流保存失败') + } finally { + isSavingWorkflow.value = false + } +} + +const handleDeleteWorkflow = async (workflow) => { + if (!workflow?.id) return + if (!window.confirm(`确定删除工作流「${workflow.name}」吗?`)) return + try { + await deleteMyWorkflow(workflow.id) + window.$message?.success('已删除工作流') + } catch (err) { + window.$message?.error(err.message || '工作流删除失败') + } } // Handle connection | 处理连接 @@ -841,6 +946,12 @@ watch( } ) +watch(showWorkflowPanel, (visible) => { + if (visible && workflowSyncStatus.value === 'idle') { + refreshMyWorkflows() + } +}) + // Initialize | 初始化 onMounted(async () => { checkMobile() @@ -851,6 +962,7 @@ onMounted(async () => { // Load project data | 加载项目数据 loadProjectById(route.params.id) + refreshMyWorkflows() // Check for initial prompt from home page | 检查来自首页的初始提示词 const initialPrompt = sessionStorage.getItem('ai-canvas-initial-prompt')
api/main.pyFastAPI 单文件后端:登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回;同时承载全局 prompt_libraryasset_library 的磁盘索引、CRUD、删除保护和复制到 job API。启动时会初始化 Postgres schema、扫描现有 state.json / 资源库并写入索引;新增 /canvas-projects 系列接口把画布项目按当前登录用户持久化。轻量创作入口 POST /creative/jobs/image 把上传图片或空白底图写成一个只有 0 号关键帧的 Job,让首页直接复用生图/生视频接口;该接口兼容无 body / JSON 空对象 / 正常 multipart 上传,避免无首帧文生图或文生视频时空 multipart 被 FastAPI 在业务前置解析阶段拒绝;POST /prompt/polish 用于中性 AI 润色和通用 LLM 文本生成,只保留用户明确给出的主体、品牌、产品、地点、风格和意图,不默认加入 SKG、按摩产品或短视频广告话术,并通过 _prompt_has_person_intent / _prompt_person_guard 区分“无人画面不新增人物”和“有人物画面声明虚构 AI 角色”;/health 返回 databaseimage_optionsimage_size_optionsvideo_optionsvideo_size_optionsvideo_duration_optionsvideo_max_duration_seconds/frames/{idx}/generatemodel 字段用于图片模型偏好,size 字段用于图片输出尺寸;/storyboard/video 继续使用 model 字段选择视频别名,并先校验画幅与时长能力边界,然后把 GeneratedVideo 写成 queued 占位并进入进程内视频队列。队列默认 VIDEO_QUEUE_MAX_CONCURRENT=2VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1,同一用户连续提交不会占满全局并发;排队任务会回写 queue_positionqueue_sizequeue_message。旧 AgentRun 一键出片状态机、TK 复刻接口和 POST /creative/copy 作为明确的 SKG 营销文案接口继续保留。
api/db.pyPostgres 适配层:在 DATABASE_URL 存在且 psycopg 可用时启用;负责建表、健康检查、用户 upsert、审计日志、画布项目 CRUD,以及把 JobAgentRun、提示词库和素材库写入索引表。数据库不可用时本地开发会降级为 disabled,生产 verify-prod-docker.sh 会要求 database.connected=true
api/main.pyFastAPI 单文件后端:登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回;同时承载全局 prompt_libraryasset_library 的磁盘索引、CRUD、删除保护和复制到 job API。启动时会初始化 Postgres schema、扫描现有 state.json / 资源库并写入索引;/canvas-projects 系列接口把画布项目按当前登录用户持久化,/canvas-workflows 系列接口把我的工作流按当前登录用户持久化为可复用模板。轻量创作入口 POST /creative/jobs/image 把上传图片或空白底图写成一个只有 0 号关键帧的 Job,让首页直接复用生图/生视频接口;该接口兼容无 body / JSON 空对象 / 正常 multipart 上传,避免无首帧文生图或文生视频时空 multipart 被 FastAPI 在业务前置解析阶段拒绝;POST /prompt/polish 用于中性 AI 润色和通用 LLM 文本生成,只保留用户明确给出的主体、品牌、产品、地点、风格和意图,不默认加入 SKG、按摩产品或短视频广告话术,并通过 _prompt_has_person_intent / _prompt_person_guard 区分“无人画面不新增人物”和“有人物画面声明虚构 AI 角色”;/health 返回 databaseimage_optionsimage_size_optionsvideo_optionsvideo_size_optionsvideo_duration_optionsvideo_max_duration_seconds/frames/{idx}/generatemodel 字段用于图片模型偏好,size 字段用于图片输出尺寸;/storyboard/video 继续使用 model 字段选择视频别名,并先校验画幅与时长能力边界,然后把 GeneratedVideo 写成 queued 占位并进入进程内视频队列。队列默认 VIDEO_QUEUE_MAX_CONCURRENT=2VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1,同一用户连续提交不会占满全局并发;排队任务会回写 queue_positionqueue_sizequeue_message。旧 AgentRun 一键出片状态机、TK 复刻接口和 POST /creative/copy 作为明确的 SKG 营销文案接口继续保留。
api/db.pyPostgres 适配层:在 DATABASE_URL 存在且 psycopg 可用时启用;负责建表、健康检查、用户 upsert、审计日志、画布项目 CRUD、我的工作流 CRUD,以及把 JobAgentRun、提示词库和素材库写入索引表。数据库不可用时本地开发会降级为 disabled,生产 verify-prod-docker.sh 会要求 database.connected=true
video_model_options()视频模型能力出口:如果 seedanceklingveo3veo 等业务别名实际都映射到同一个真实模型,会按真实模型去重,只给前端返回一个可用选项;当前生产真实模型为 doubao-seedance-2-0-fast-260128,前端显示为 Seedance 2.0 Fast。后续只有在服务器真的配置了不同可用视频模型时,才应把新的模型重新暴露给画布。
api/product_library/skg-products内置 SKG 白底产品图库:manifest.json 记录从桌面产品图筛出的 gallery 白底图和桌面 4 张产品角度图,images/ 存 45 张参考图。
api/character_library/skg-characters内置相似主体形象库:从桌面 5 套策划形象导入,manifest.json 记录运动阳光男、都市型男、优雅白领女、运动辣妹、绅士大叔,每套含 7 张透明骨架参考图和一段 prompt_brief。相似主体生成时优先使用文字 brief 作为创意方向,避免把内置图作为强参考图复制。
历史列表GET /jobslistJobs当前登录用户可见 job 精简列表(id/url/status/thumbnail/mtime/owner…),按 state.json mtime 倒序。前端 URL 无 ?job= 时拉它回填本人历史;带 limit 可截断。开启数据隔离时,飞书用户只看到自己的任务,历史无 owner 的旧任务只对备用账号可见。
创建任务POST /jobscreateJob提交 TK 链接,后台开始下载;后端会把当前登录用户写入 Job.owner_*,后续详情、素材文件、删除和生成接口都通过统一中间件校验归属。下载阶段默认不带 cookies;生产环境必须显式保持 YTDLP_COOKIES_FILE=YTDLP_COOKIES_FROM_BROWSER= 为空,避免容器内误读被打进镜像的开发 api/.env
画布项目GET /canvas-projects
POST /canvas-projects
PUT /canvas-projects/{id}
GET /canvas-projects/{id}
DELETE /canvas-projects/{id}
POST /canvas-projects/import
web/canvas-app/src/stores/projects.js根域名画布项目的服务端持久化接口。列表和详情按当前登录用户过滤;写入时保存画布 JSON、缩略图、可见性、版本和更新时间;删除为软删除。首次上线后本地 localStorage 旧项目会通过 import 导入到当前用户,之后服务端 Postgres 是主存储。
我的工作流GET /canvas-workflows
POST /canvas-workflows
PUT /canvas-workflows/{id}
DELETE /canvas-workflows/{id}
web/canvas-app/src/stores/workflows.js
WorkflowPanel.vue
工作流面板“我的工作流”页的个人模板接口。列表、保存、更新和删除都按当前登录用户过滤;保存的是清理过运行态的 workflow_data.nodes/edges/viewport,用于跨设备复用画布结构。插回画布时前端会按当前视口中心重排节点、重建节点 ID,并用旧 ID 到新 ID 的映射重连边,避免和现有画布节点冲突。
画布生成POST /creative/jobs/image
POST /jobs/{id}/frames/upload
POST /jobs/{id}/frames/{idx}/generate
POST /jobs/{id}/frames/{idx}/storyboard/video
GET /jobs/{id}
web/canvas-app/src/hooks/useApi.js画布项目结构保存在 /canvas-projects;一旦生成图片或视频,就通过同一套 creative job / frame / storyboard video 接口写入当前登录用户自己的 job 目录。文生图会创建空白 creative job 后生成图片;图生视频会把上传图转成 frame 并作为视频参考图提交,提交视频后用 skg:{jobId}:{videoId} 作为画布侧任务 id 轮询 /jobs/{id},直到视频状态完成或失败。
AI 润色 / LLM 节点POST /prompt/polishweb/canvas-app/src/hooks/useApi.js
web/canvas-app/src/api/chat.js
中性的提示词润色和通用文本生成接口。根画布和文本节点传 mode=image、默认输出英文提示词;LLM 节点和自动执行意图分析传 mode=chat、保持输入语言。接口会遵守 system prompt,但明确禁止自动添加用户没有提到的 SKG、按摩产品、短视频广告 framing、营销标题或 hashtag。人物安全词按输入条件加入:原文无人物语义时追加“不新增人物/脸/身体/人群”;原文有人像、模特、角色、数字人等语义时才追加“虚构 AI 角色、非真人、非公众人物、不可识别私人个体”。
一键出片终端POST /agent-runs
GET /agent-runs
GET /agent-runs/{id}
GET /agent-runs/{id}/final.mp4
GET /agent-runs/{id}/contact.jpg
web/app/agent/page.tsx快速出片页的唯一主接口。前端提交 TikTok 链接和最多 6 张产品图;后端创建同 owner 的 JobAgentRun,后台执行下载、产品图归一化、透明骨架主体参考复制、12 段镜头计划、视频生成、失败镜头自动重跑一次、审片接触表和 ffmpeg 最终合成。列表、详情、最终 mp4 和接触表同样按 owner 隔离。