2 Commits

Author SHA1 Message Date
13fa5a08da docs: record personal workflow deployment 2026-05-26 11:21:25 +08:00
5290812353 feat: add personal canvas workflows 2026-05-26 11:18:28 +08:00
7 changed files with 699 additions and 33 deletions

View File

@@ -12,12 +12,13 @@
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解 - 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md` - 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`
- 第一冲刺:步骤 1-4下载 / 拆轨 / 关键帧 / ASR+翻译) - 第一冲刺:步骤 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 - 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik
- Agent Cut 独立预览服务器:`2.24.28.41`Ubuntu 24.04 / Docker Compose / 裸端口 `4290`),部署目录 `/opt/skg-marketing-studio`Compose 入口 `docker-compose.standalone.yml`,访问地址 `http://2.24.28.41:4290/agent/`。该入口用于“一分钟二创出片终端”预览:用户只提交 TikTok 链接和产品图,后端 `AgentRun` 状态机负责下载、抽帧、规划、生成、自动重跑、审片和合成。 - Agent Cut 独立预览服务器:`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` - 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-26`5290812` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260526031841.tgz`。本次新增 Postgres 表 `canvas_workflows``/canvas-workflows` 个人模板接口,画布工作流面板“我的工作流”可保存当前节点结构、刷新列表、删除模板,并在插回画布时重新生成节点 ID、按视口重排、重连边保存前会清理已生成图片/视频、任务进度、错误和 LLM 输出等运行态。本地验证 `python3 -m py_compile api/main.py api/db.py``cd web && npm run build` 通过;生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过web/API/Postgres Up、`/` 302、`/login/` 200、未登录 `/api/health` 401、`api:health ok db connected`),生产 web 静态 bundle 命中 `保存当前工作流``canvas-workflows`API 容器查询 `to_regclass('public.canvas_workflows')` 返回 `canvas_workflows`
- 最近部署验证2026-05-25`84d9de6` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,画布图片/视频模型选择收口到当前后端真实可用媒体模型。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525105910.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。补验:外部访问 `https://marketing.skg.com/` 未登录返回 302 到 `/login/?next=/``https://marketing.skg.com/p/test` 未登录返回 302 到 `/login/?next=/p/test`;容器内 `/health` 返回 `image_options=auto,gpt-image-2,gemini-3-pro-image-preview``video_options=seedance:Seedance 2.0 Fast:doubao-seedance-2-0-fast-260128``video_duration_options=5,8,10,12,15`,图片尺寸为 `auto,1024x1536,1024x1024,1536x1024`,视频画幅为 `720x1280,1280x720,1024x1024,960x1280`;生产静态 bundle 命中 `GPT Image 2 / Gemini 图片 / Seedance 2.0 Fast / 1024x1536 / 720x1280`,未命中 `Nano Banana / Seedream / doubao-seedream / doubao-seedance-1 / sora-2 / Kling / Veo 3` - 最近部署验证2026-05-25`84d9de6` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,画布图片/视频模型选择收口到当前后端真实可用媒体模型。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525105910.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。补验:外部访问 `https://marketing.skg.com/` 未登录返回 302 到 `/login/?next=/``https://marketing.skg.com/p/test` 未登录返回 302 到 `/login/?next=/p/test`;容器内 `/health` 返回 `image_options=auto,gpt-image-2,gemini-3-pro-image-preview``video_options=seedance:Seedance 2.0 Fast:doubao-seedance-2-0-fast-260128``video_duration_options=5,8,10,12,15`,图片尺寸为 `auto,1024x1536,1024x1024,1536x1024`,视频画幅为 `720x1280,1280x720,1024x1024,960x1280`;生产静态 bundle 命中 `GPT Image 2 / Gemini 图片 / Seedance 2.0 Fast / 1024x1536 / 720x1280`,未命中 `Nano Banana / Seedream / doubao-seedream / doubao-seedance-1 / sora-2 / Kling / Veo 3`
- 生产配置验证2026-05-25 23:49 CST已在服务器 `/opt/skg-marketing-studio/deploy/.env.production` 补齐飞书 OAuth 应用配置,并仅重建 `skg-marketing-api` 使环境变量生效;敏感 App Secret 不入库。验证结果:`https://marketing.skg.com/api/auth/config` 返回 `feishu_enabled=true``password_enabled=true``data_isolation_enabled=true``GET https://marketing.skg.com/api/auth/feishu/start?next=/` 返回 302 跳转到飞书授权页;容器内 `/health` 返回 `auth_modes.feishu=True` - 生产配置验证2026-05-25 23:49 CST已在服务器 `/opt/skg-marketing-studio/deploy/.env.production` 补齐飞书 OAuth 应用配置,并仅重建 `skg-marketing-api` 使环境变量生效;敏感 App Secret 不入库。验证结果:`https://marketing.skg.com/api/auth/config` 返回 `feishu_enabled=true``password_enabled=true``data_isolation_enabled=true``GET https://marketing.skg.com/api/auth/feishu/start?next=/` 返回 302 跳转到飞书授权页;容器内 `/health` 返回 `auth_modes.feishu=True`
- 最近部署验证2026-05-26`c9d8fa7` 对应 Postgres 持久化代码已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`。生产新增 `skg-marketing-postgres` 容器,数据库持久化在服务器 `./data/postgres``DATABASE_URL` / `POSTGRES_PASSWORD` 只写服务器 `deploy/.env.production`。部署前脚本备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525225145.tgz`;生产 Docker 重建后脚本内验证通过web/API/Postgres 容器 Up、Postgres healthy、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok db connected``api:ytdlp_cookie_args []`)。文档/元数据同步后又执行 `./scripts/deploy-prod-safe.sh --no-build`,实际走过 Postgres `pg_dump` 备份路径并生成 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525230444.tgz`,复验同样通过。补验:容器内 `/health` 返回 `database.enabled=true``database.connected=true``/api/auth/config` 返回 `feishu_enabled=true``password_enabled=true``data_isolation_enabled=true`;画布项目 API 可创建、读取、软删除记录;数据库索引计数为 users=1、jobs=26、assets=129、canvas_active=0、canvas_deleted=1、audit=2。 - 最近部署验证2026-05-26`c9d8fa7` 对应 Postgres 持久化代码已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`。生产新增 `skg-marketing-postgres` 容器,数据库持久化在服务器 `./data/postgres``DATABASE_URL` / `POSTGRES_PASSWORD` 只写服务器 `deploy/.env.production`。部署前脚本备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525225145.tgz`;生产 Docker 重建后脚本内验证通过web/API/Postgres 容器 Up、Postgres healthy、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok db connected``api:ytdlp_cookie_args []`)。文档/元数据同步后又执行 `./scripts/deploy-prod-safe.sh --no-build`,实际走过 Postgres `pg_dump` 备份路径并生成 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260525230444.tgz`,复验同样通过。补验:容器内 `/health` 返回 `database.enabled=true``database.connected=true``/api/auth/config` 返回 `feishu_enabled=true``password_enabled=true``data_isolation_enabled=true`;画布项目 API 可创建、读取、软删除记录;数据库索引计数为 users=1、jobs=26、assets=129、canvas_active=0、canvas_deleted=1、audit=2。

142
api/db.py
View File

@@ -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 ( CREATE TABLE IF NOT EXISTS job_index (
job_id TEXT PRIMARY KEY, job_id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL DEFAULT '', 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_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_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_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_generated_assets_owner_created ON generated_assets(owner_id, created_at DESC)",
"CREATE INDEX IF NOT EXISTS idx_prompt_library_visibility ON prompt_library_index(visibility, updated_at DESC)", "CREATE INDEX IF NOT EXISTS idx_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 WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN NULL
ELSE canvas_projects.deleted_at ELSE canvas_projects.deleted_at
END END
WHERE canvas_projects.owner_id = EXCLUDED.owner_id
RETURNING id, name, thumbnail, visibility, canvas_data, created_at, updated_at, version, 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)) 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: def index_job(job: dict, state_path: str = "") -> None:
job_id = str(job.get("id") or "") job_id = str(job.get("id") or "")
if not job_id: if not job_id:

View File

@@ -2189,6 +2189,18 @@ class CanvasProjectImportReq(BaseModel):
projects: list[CanvasProjectWriteReq] = Field(default_factory=list) 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: def _ts(value) -> float:
if hasattr(value, "timestamp"): if hasattr(value, "timestamp"):
return float(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") @app.get("/canvas-projects")
def list_canvas_projects(request: Request) -> dict: def list_canvas_projects(request: Request) -> dict:
_require_db() _require_db()
@@ -2239,6 +2268,8 @@ def create_canvas_project(req: CanvasProjectWriteReq, request: Request) -> dict:
row = db.upsert_canvas_project(user, req.model_dump()) row = db.upsert_canvas_project(user, req.model_dump())
if not row: if not row:
raise HTTPException(500, "canvas project save failed") 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")) 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)} 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} 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: def _parse_library_metadata(raw: str) -> dict:
if not raw.strip(): if not raw.strip():
return {} return {}

View File

@@ -583,16 +583,17 @@
<p><strong>2026-05-25 媒体模型接入收口:</strong>图片和视频模型选择只暴露当前后端真实可用项:图片为 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>;视频当前只接通 <code>Seedance 2.0 Fast</code>(真实模型 <code>doubao-seedance-2-0-fast-260128</code>)。旧上游的 Nano Banana、Seedream、Kling、Veo 或浏览器本地自定义媒体模型不能进入生成下拉,避免同事选到实际不可用的模型。</p> <p><strong>2026-05-25 媒体模型接入收口:</strong>图片和视频模型选择只暴露当前后端真实可用项:图片为 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>;视频当前只接通 <code>Seedance 2.0 Fast</code>(真实模型 <code>doubao-seedance-2-0-fast-260128</code>)。旧上游的 Nano Banana、Seedream、Kling、Veo 或浏览器本地自定义媒体模型不能进入生成下拉,避免同事选到实际不可用的模型。</p>
<p><strong>2026-05-26 公司沉淀版:</strong>画布项目从浏览器本地存储升级为服务端 Postgres 持久化;<code>localStorage</code> 只作为离线缓存和首次导入来源。后端同时建立用户、任务、资源索引和审计表,保留原有 <code>state.json</code> 文件作为任务详情真源,避免一次迁移动到大文件资产结构。</p> <p><strong>2026-05-26 公司沉淀版:</strong>画布项目从浏览器本地存储升级为服务端 Postgres 持久化;<code>localStorage</code> 只作为离线缓存和首次导入来源。后端同时建立用户、任务、资源索引和审计表,保留原有 <code>state.json</code> 文件作为任务详情真源,避免一次迁移动到大文件资产结构。</p>
<p><strong>2026-05-26 AI 润色中性化:</strong>画布 <code>AI 润色</code> 不再复用 SKG 广告文案接口 <code>/creative/copy</code>。后端新增 <code>POST /prompt/polish</code>,前端 <code>useChat</code>、根画布输入框、文本节点和自动执行意图分析改走中性提示词/通用生成接口:只优化用户已经给出的主体、风格、镜头和细节,不主动添加 SKG、按摩产品、TikTok 广告话术或用户没有提到的品牌。润色会按人物意图分流:原提示词没有人物时明确保持物体/场景/产品构图且不新增人物;原提示词明确有人物、人像、模特或角色时,才补充“虚构 AI 角色、非真人、非公众人物”的合规描述。</p> <p><strong>2026-05-26 AI 润色中性化:</strong>画布 <code>AI 润色</code> 不再复用 SKG 广告文案接口 <code>/creative/copy</code>。后端新增 <code>POST /prompt/polish</code>,前端 <code>useChat</code>、根画布输入框、文本节点和自动执行意图分析改走中性提示词/通用生成接口:只优化用户已经给出的主体、风格、镜头和细节,不主动添加 SKG、按摩产品、TikTok 广告话术或用户没有提到的品牌。润色会按人物意图分流:原提示词没有人物时明确保持物体/场景/产品构图且不新增人物;原提示词明确有人物、人像、模特或角色时,才补充“虚构 AI 角色、非真人、非公众人物”的合规描述。</p>
<p><strong>2026-05-26 我的工作流云端版:</strong>工作流面板从只有公共模板扩展为“公共工作流 / 我的工作流”两类。当前画布可以保存成当前登录用户自己的云端工作流模板,后续在同一账号的其他电脑或浏览器打开后可插回画布;保存时只沉淀节点结构、连线、配置和提示词,主动清掉已生成图片、视频、任务进度、错误和运行态字段,避免把一次性生成结果误当模板复用。</p>
</div> </div>
<p>当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 画布项目同步到服务端 Postgres → 用提示词、推荐词、AI 润色工作流模板创建节点 → 画布自动执行或手动连接图片/视频/文本节点 → 生成结果沉淀在当前个人画布 → 需要时进入详情页继续编辑”。画布不再被削成三模式入口;首帧、尾帧、参考图、图生视频、多角度分镜、故事板和绘本等上游概念按节点能力保留。底层生成仍由 <code>web/canvas-app/src/hooks/useApi.js</code> 适配到本项目 <code>/creative/jobs/image</code><code>/jobs/{id}/frames/{idx}/generate</code><code>/jobs/{id}/frames/{idx}/storyboard/video</code>AI 润色和通用 LLM 文本生成走 <code>/prompt/polish</code> 并保持中性专业,不再默认套入 SKG 广告语境。生成资产按当前登录用户写入个人 job。图片尺寸只显示 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>;视频画幅只显示 <code>720x1280</code><code>1280x720</code><code>1024x1024</code><code>960x1280</code>;视频时长只显示 <code>5/8/10/12/15</code> 秒。多人互不影响依赖后端 <code>owner_id</code>、画布项目 owner 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。</p> <p>当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 画布项目同步到服务端 Postgres → 用提示词、推荐词、AI 润色、公共工作流或我的工作流创建节点 → 画布自动执行或手动连接图片/视频/文本节点 → 生成结果沉淀在当前个人画布 → 可把当前节点结构保存为我的工作流 → 需要时进入详情页继续编辑”。画布不再被削成三模式入口;首帧、尾帧、参考图、图生视频、多角度分镜、故事板和绘本等上游概念按节点能力保留。底层生成仍由 <code>web/canvas-app/src/hooks/useApi.js</code> 适配到本项目 <code>/creative/jobs/image</code><code>/jobs/{id}/frames/{idx}/generate</code><code>/jobs/{id}/frames/{idx}/storyboard/video</code>AI 润色和通用 LLM 文本生成走 <code>/prompt/polish</code> 并保持中性专业,不再默认套入 SKG 广告语境。生成资产按当前登录用户写入个人 job。图片尺寸只显示 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>;视频画幅只显示 <code>720x1280</code><code>1280x720</code><code>1024x1024</code><code>960x1280</code>;视频时长只显示 <code>5/8/10/12/15</code> 秒。多人互不影响依赖后端 <code>owner_id</code>、画布项目 owner、我的工作流 owner 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。</p>
<div class="pipeline"> <div class="pipeline">
<div class="step"><div class="num">01</div><h3>个人任务</h3><p><code>GET /jobs</code> 按当前登录用户过滤;旧无 owner 任务只对备用账号可见。</p></div> <div class="step"><div class="num">01</div><h3>个人任务</h3><p><code>GET /jobs</code> 按当前登录用户过滤;旧无 owner 任务只对备用账号可见。</p></div>
<div class="step"><div class="num">02</div><h3>进入画布</h3><p>用户直接在根域名个人画布里操作;项目列表优先读取服务端 <code>/canvas-projects</code>,本地旧项目会首次导入。</p></div> <div class="step"><div class="num">02</div><h3>进入画布</h3><p>用户直接在根域名个人画布里操作;项目列表优先读取服务端 <code>/canvas-projects</code>,本地旧项目会首次导入。</p></div>
<div class="step"><div class="num">03</div><h3>组织节点</h3><p>可通过底部 prompt、AI 润色、自动执行、手动添加节点工作流模板创建文本、图片、视频、LLM、配置和参考图节点。</p></div> <div class="step"><div class="num">03</div><h3>组织节点</h3><p>可通过底部 prompt、AI 润色、自动执行、手动添加节点、公共工作流或我的工作流创建文本、图片、视频、LLM、配置和参考图节点。</p></div>
<div class="step"><div class="num">04</div><h3>参考素材</h3><p>首帧、尾帧、参考图和图片节点按上游节点语义保留;提交到后端时由 <code>useApi.js</code> 转成 <code>first_image</code><code>last_image</code> 或图片编辑参考。</p></div> <div class="step"><div class="num">04</div><h3>参考素材</h3><p>首帧、尾帧、参考图和图片节点按上游节点语义保留;提交到后端时由 <code>useApi.js</code> 转成 <code>first_image</code><code>last_image</code> 或图片编辑参考。</p></div>
<div class="step"><div class="num">05</div><h3>工作流执行</h3><p>自动执行会根据提示词创建文生图、图转视频、故事板、多角度分镜或绘本等节点组;手动模式下用户可自行连接节点。</p></div> <div class="step"><div class="num">05</div><h3>工作流执行</h3><p>自动执行会根据提示词创建文生图、图转视频、故事板、多角度分镜或绘本等节点组;手动模式下用户可自行连接节点。</p></div>
<div class="step"><div class="num">06</div><h3>生成图片 / 视频</h3><p><code>generateImage</code><code>generateStoryboardVideo</code> 继续走 SKG 后端 <code>/api</code>;视频提交后先写入 <code>queued</code> 占位,再由后端队列按并发上限启动。</p></div> <div class="step"><div class="num">06</div><h3>生成图片 / 视频</h3><p><code>generateImage</code><code>generateStoryboardVideo</code> 继续走 SKG 后端 <code>/api</code>;视频提交后先写入 <code>queued</code> 占位,再由后端队列按并发上限启动。</p></div>
<div class="step"><div class="num">07</div><h3>结果沉淀</h3><p>生成图、视频 URL、任务状态和下载入口回填到画布节点画布结构保存到 Postgres完整任务结果仍可进入 <code>/detail/?job=</code> 查看。</p></div> <div class="step"><div class="num">07</div><h3>结果沉淀</h3><p>生成图、视频 URL、任务状态和下载入口回填到画布节点画布项目和个人工作流结构保存到 Postgres完整任务结果仍可进入 <code>/detail/?job=</code> 查看。</p></div>
<div class="step"><div class="num">08</div><h3>详情页</h3><p><code>/detail/?job=&lt;id&gt;</code> 展示参考图、全量生成图、视频候选、提示词和营销图文,并支持继续生成。</p></div> <div class="step"><div class="num">08</div><h3>详情页</h3><p><code>/detail/?job=&lt;id&gt;</code> 展示参考图、全量生成图、视频候选、提示词和营销图文,并支持继续生成。</p></div>
<div class="step"><div class="num">09</div><h3>高级复刻</h3><p><code>AdRecreationBoard</code><code>/agent/</code> 作为高级入口保留,不再是默认路径。</p></div> <div class="step"><div class="num">09</div><h3>高级复刻</h3><p><code>AdRecreationBoard</code><code>/agent/</code> 作为高级入口保留,不再是默认路径。</p></div>
</div> </div>
@@ -608,9 +609,10 @@
<tr><td><code>web/next.config.mjs</code></td><td>Next.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator并移除 Next 16 已不支持的 <code>eslint</code> 顶层配置,避免本地 dev 出现配置 Issue 提示。</td></tr> <tr><td><code>web/next.config.mjs</code></td><td>Next.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator并移除 Next 16 已不支持的 <code>eslint</code> 顶层配置,避免本地 dev 出现配置 Issue 提示。</td></tr>
<tr><td><code>web/app/globals.css</code></td><td>全局主题变量、登录页视觉样式、信息流工作台玻璃拟态 token、ReactFlow 样式引用,以及本地开发态 <code>nextjs-portal</code> 遮挡隐藏规则。工作台在 <code>skg-board-theme</code> 内按 Figma 本地 MCP 参考改成黑灰玻璃系统:深灰背景、<code>#383838</code> 胶囊侧栏、<code>rgba(255,255,255,.1)</code> 玻璃面、<code>backdrop-filter: blur(5px)</code><code>20px</code> 圆角、<code>10px 10px 10px rgba(0,0,0,.3)</code> 阴影和绿黄状态色;新增 <code>skg-board-shell</code><code>skg-board-rail</code><code>skg-glass-card</code><code>skg-glass-card--flat</code><code>skg-status-orb</code> 等样式。侧栏改为跟随视口拉满工作台可用高度的悬停胶囊,桌面最小 600px展开时在同一侧栏内承载素材输入抽屉。明暗主题已分开维护 shell、panel、glass、stat、action 和音频波形 token暗色压低灰雾和面板底色明亮模式改为暖白工作台避免指标卡、按钮和波形继续残留黑底/白线;顶部指标卡增加紫、黄绿、琥珀、青绿、绿色光斑变量,接近原版多色玻璃卡效果。主/次按钮、指标卡和空状态继续走统一类,避免各板块散写不同玻璃效果。</td></tr> <tr><td><code>web/app/globals.css</code></td><td>全局主题变量、登录页视觉样式、信息流工作台玻璃拟态 token、ReactFlow 样式引用,以及本地开发态 <code>nextjs-portal</code> 遮挡隐藏规则。工作台在 <code>skg-board-theme</code> 内按 Figma 本地 MCP 参考改成黑灰玻璃系统:深灰背景、<code>#383838</code> 胶囊侧栏、<code>rgba(255,255,255,.1)</code> 玻璃面、<code>backdrop-filter: blur(5px)</code><code>20px</code> 圆角、<code>10px 10px 10px rgba(0,0,0,.3)</code> 阴影和绿黄状态色;新增 <code>skg-board-shell</code><code>skg-board-rail</code><code>skg-glass-card</code><code>skg-glass-card--flat</code><code>skg-status-orb</code> 等样式。侧栏改为跟随视口拉满工作台可用高度的悬停胶囊,桌面最小 600px展开时在同一侧栏内承载素材输入抽屉。明暗主题已分开维护 shell、panel、glass、stat、action 和音频波形 token暗色压低灰雾和面板底色明亮模式改为暖白工作台避免指标卡、按钮和波形继续残留黑底/白线;顶部指标卡增加紫、黄绿、琥珀、青绿、绿色光斑变量,接近原版多色玻璃卡效果。主/次按钮、指标卡和空状态继续走统一类,避免各板块散写不同玻璃效果。</td></tr>
<tr><td><code>web/app/page.tsx</code></td><td>旧 React 单对话框生成台源码仍保留,便于以后回滚或抽能力;当前生产根域名已经由 <code>web/canvas-app/</code> 画布产物覆盖,不再把这个 React 首页作为默认首屏。该页面里的模式也已收敛为文生图、文生视频、图生视频;图生视频只显示“上传图片”,不把“首帧/首尾帧”作为用户入口。旧 TK 复刻工作台组件仍保留在 <code>web/components/ad-recreation-board.tsx</code>,但不再作为默认首页渲染。</td></tr> <tr><td><code>web/app/page.tsx</code></td><td>旧 React 单对话框生成台源码仍保留,便于以后回滚或抽能力;当前生产根域名已经由 <code>web/canvas-app/</code> 画布产物覆盖,不再把这个 React 首页作为默认首屏。该页面里的模式也已收敛为文生图、文生视频、图生视频;图生视频只显示“上传图片”,不把“首帧/首尾帧”作为用户入口。旧 TK 复刻工作台组件仍保留在 <code>web/components/ad-recreation-board.tsx</code>,但不再作为默认首页渲染。</td></tr>
<tr><td><code>web/canvas-app/</code></td><td>SKG 内部画布应用:从 <code>chatfire-AI/huobao-canvas</code> 交互逻辑改造而来。当前策略是“保留成熟画布能力,替换品牌/路由/API”Vue Flow 节点画布、项目列表、推荐词、AI 润色、自动执行、工作流模板、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载都保留;可见品牌收敛为 SKG logo不展示上游注册链接或外部品牌。生产路径固定为根域名 <code>/</code>,内部路由用 <code>/p/:id?</code>;项目列表画布 JSON 优先同步到服务端 Postgres浏览器本地存储只是缓存/导入来源;来源说明保存在 <code>THIRD_PARTY_NOTICES.md</code>,不展示给终端用户。</td></tr> <tr><td><code>web/canvas-app/</code></td><td>SKG 内部画布应用:从 <code>chatfire-AI/huobao-canvas</code> 交互逻辑改造而来。当前策略是“保留成熟画布能力,替换品牌/路由/API”Vue Flow 节点画布、项目列表、推荐词、AI 润色、自动执行、公共工作流、我的工作流、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载都保留;可见品牌收敛为 SKG logo不展示上游注册链接或外部品牌。生产路径固定为根域名 <code>/</code>,内部路由用 <code>/p/:id?</code>;项目列表画布 JSON 和个人工作流模板优先同步到服务端 Postgres浏览器本地存储只是缓存/导入来源;来源说明保存在 <code>THIRD_PARTY_NOTICES.md</code>,不展示给终端用户。</td></tr>
<tr><td><code>web/canvas-app/src/stores/projects.js</code></td><td>画布项目 Pinia store启动时先读本地 <code>localStorage["ai-canvas-projects"]</code> 作为缓存,再调用 <code>GET /canvas-projects</code> 拉服务端项目;如果发现本地旧项目,会调用 <code>POST /canvas-projects/import</code> 导入到当前登录用户。新建、重命名、画布节点变更、复制和删除会同步到 <code>/canvas-projects</code>,本地缓存只用于快速打开和网络异常兜底。</td></tr> <tr><td><code>web/canvas-app/src/stores/projects.js</code></td><td>画布项目 Pinia store启动时先读本地 <code>localStorage["ai-canvas-projects"]</code> 作为缓存,再调用 <code>GET /canvas-projects</code> 拉服务端项目;如果发现本地旧项目,会调用 <code>POST /canvas-projects/import</code> 导入到当前登录用户。新建、重命名、画布节点变更、复制和删除会同步到 <code>/canvas-projects</code>,本地缓存只用于快速打开和网络异常兜底。</td></tr>
<tr><td><code>web/canvas-app/src/views/Canvas.vue</code></td><td>画布主交互:恢复上游底部 prompt composer、<code>AI 润色</code><code>自动执行</code>、推荐词、节点菜单、工作流面板、API/模型设置入口和批量下载入口。自动执行会调用 <code>useWorkflowOrchestrator</code> 分析提示词创建文生图、图转视频、故事板、多角度分镜或绘本节点组手动模式只创建文本节点用户自行连接节点。底部推荐词来自共享短词池4 个一组单行展示,刷新按钮在 30 组内轮换,不改变输入面板高度</td></tr> <tr><td><code>web/canvas-app/src/stores/workflows.js</code></td><td>我的工作流 store调用 <code>GET/POST/DELETE /canvas-workflows</code> 读取、保存和删除当前登录用户自己的云端工作流模板。保存前会清理节点里的 <code>base64</code>、生成 URL、任务进度、错误、视频结果和 LLM 输出等运行态字段,只保留可复用的节点结构、连线、配置和提示词</td></tr>
<tr><td><code>web/canvas-app/src/views/Canvas.vue</code></td><td>画布主交互:恢复上游底部 prompt composer、<code>AI 润色</code><code>自动执行</code>、推荐词、节点菜单、工作流面板、API/模型设置入口和批量下载入口。自动执行会调用 <code>useWorkflowOrchestrator</code> 分析提示词,创建文生图、图转视频、故事板、多角度分镜或绘本节点组;手动模式只创建文本节点,用户自行连接节点。工作流面板支持公共模板和我的工作流:公共模板走本地 <code>createNodes()</code>,我的工作流从云端 <code>workflow_data</code> 插回当前画布,并重新生成节点 ID、按视口中心重排、按映射重连边。底部推荐词来自共享短词池4 个一组单行展示,刷新按钮在 30 组内轮换,不改变输入面板高度。</td></tr>
<tr><td><code>web/canvas-app/src/config/suggestions.js</code></td><td>首页和画布共用的推荐词配置:维护 <code>QUICK_SUGGESTION_GROUPS</code>,当前为 30 组 / 120 个短词,每组 4 个,控制刷新按钮的轮换范围;词条保持短小,避免推荐栏换行或顶起 composer。</td></tr> <tr><td><code>web/canvas-app/src/config/suggestions.js</code></td><td>首页和画布共用的推荐词配置:维护 <code>QUICK_SUGGESTION_GROUPS</code>,当前为 30 组 / 120 个短词,每组 4 个,控制刷新按钮的轮换范围;词条保持短小,避免推荐栏换行或顶起 composer。</td></tr>
<tr><td><code>web/canvas-app/src/config/models.js</code></td><td>画布媒体模型和规格的前端白名单:图片只内置 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>,尺寸只内置 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>;视频只内置 <code>seedance</code> / <code>Seedance 2.0 Fast</code>,画幅和时长对齐后端 <code>/health</code> 能力边界。<code>useModelConfig.js</code> 和 Pinia 模型 store 会忽略浏览器本地自定义图片/视频模型,防止旧缓存把不可用模型带回生成下拉。</td></tr> <tr><td><code>web/canvas-app/src/config/models.js</code></td><td>画布媒体模型和规格的前端白名单:图片只内置 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>,尺寸只内置 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>;视频只内置 <code>seedance</code> / <code>Seedance 2.0 Fast</code>,画幅和时长对齐后端 <code>/health</code> 能力边界。<code>useModelConfig.js</code> 和 Pinia 模型 store 会忽略浏览器本地自定义图片/视频模型,防止旧缓存把不可用模型带回生成下拉。</td></tr>
<tr><td><code>web/canvas-app/src/hooks/useApi.js</code></td><td>画布到本项目后端的适配层:不再读取浏览器 API Key而是使用当前登录会话 Cookie 调用 <code>/api</code>。文生图 / 图生图先创建轻量 creative job再调用 <code>/frames/0/generate</code>;文生视频 / 图生视频调用 <code>/storyboard/video</code> 并轮询 <code>/jobs/{id}</code>,完成后把图片或 mp4 URL 写回画布节点。<code>useChat</code> 已从 SKG 广告文案接口切到 <code>/prompt/polish</code>AI 润色显式使用 image/video prompt 模式LLM 节点使用通用 chat 模式,避免自动注入用户没有提到的 SKG 或营销语境;后端会判断原提示词是否有人物意图,无人物时禁止新增人物,有人物时才声明虚构 AI 角色。</td></tr> <tr><td><code>web/canvas-app/src/hooks/useApi.js</code></td><td>画布到本项目后端的适配层:不再读取浏览器 API Key而是使用当前登录会话 Cookie 调用 <code>/api</code>。文生图 / 图生图先创建轻量 creative job再调用 <code>/frames/0/generate</code>;文生视频 / 图生视频调用 <code>/storyboard/video</code> 并轮询 <code>/jobs/{id}</code>,完成后把图片或 mp4 URL 写回画布节点。<code>useChat</code> 已从 SKG 广告文案接口切到 <code>/prompt/polish</code>AI 润色显式使用 image/video prompt 模式LLM 节点使用通用 chat 模式,避免自动注入用户没有提到的 SKG 或营销语境;后端会判断原提示词是否有人物意图,无人物时禁止新增人物,有人物时才声明虚构 AI 角色。</td></tr>
@@ -644,8 +646,8 @@
<h3>后端核心</h3> <h3>后端核心</h3>
<table> <table>
<tbody> <tbody>
<tr><td><code>api/main.py</code></td><td>FastAPI 单文件后端登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回同时承载全局 <code>prompt_library</code><code>asset_library</code> 的磁盘索引、CRUD、删除保护和复制到 job API。启动时会初始化 Postgres schema、扫描现有 <code>state.json</code> / 资源库并写入索引;新增 <code>/canvas-projects</code> 系列接口把画布项目按当前登录用户持久化。轻量创作入口 <code>POST /creative/jobs/image</code> 把上传图片或空白底图写成一个只有 0 号关键帧的 <code>Job</code>,让首页直接复用生图/生视频接口;该接口兼容无 body / JSON 空对象 / 正常 multipart 上传,避免无首帧文生图或文生视频时空 multipart 被 FastAPI 在业务前置解析阶段拒绝;<code>POST /prompt/polish</code> 用于中性 AI 润色和通用 LLM 文本生成,只保留用户明确给出的主体、品牌、产品、地点、风格和意图,不默认加入 SKG、按摩产品或短视频广告话术并通过 <code>_prompt_has_person_intent</code> / <code>_prompt_person_guard</code> 区分“无人画面不新增人物”和“有人物画面声明虚构 AI 角色”;<code>/health</code> 返回 <code>database</code><code>image_options</code><code>image_size_options</code><code>video_options</code><code>video_size_options</code><code>video_duration_options</code><code>video_max_duration_seconds</code><code>/frames/{idx}/generate</code><code>model</code> 字段用于图片模型偏好,<code>size</code> 字段用于图片输出尺寸;<code>/storyboard/video</code> 继续使用 <code>model</code> 字段选择视频别名,并先校验画幅与时长能力边界,然后把 <code>GeneratedVideo</code> 写成 <code>queued</code> 占位并进入进程内视频队列。队列默认 <code>VIDEO_QUEUE_MAX_CONCURRENT=2</code><code>VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1</code>,同一用户连续提交不会占满全局并发;排队任务会回写 <code>queue_position</code><code>queue_size</code><code>queue_message</code>。旧 <code>AgentRun</code> 一键出片状态机、TK 复刻接口和 <code>POST /creative/copy</code> 作为明确的 SKG 营销文案接口继续保留。</td></tr> <tr><td><code>api/main.py</code></td><td>FastAPI 单文件后端登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回同时承载全局 <code>prompt_library</code><code>asset_library</code> 的磁盘索引、CRUD、删除保护和复制到 job API。启动时会初始化 Postgres schema、扫描现有 <code>state.json</code> / 资源库并写入索引;<code>/canvas-projects</code> 系列接口把画布项目按当前登录用户持久化<code>/canvas-workflows</code> 系列接口把我的工作流按当前登录用户持久化为可复用模板。轻量创作入口 <code>POST /creative/jobs/image</code> 把上传图片或空白底图写成一个只有 0 号关键帧的 <code>Job</code>,让首页直接复用生图/生视频接口;该接口兼容无 body / JSON 空对象 / 正常 multipart 上传,避免无首帧文生图或文生视频时空 multipart 被 FastAPI 在业务前置解析阶段拒绝;<code>POST /prompt/polish</code> 用于中性 AI 润色和通用 LLM 文本生成,只保留用户明确给出的主体、品牌、产品、地点、风格和意图,不默认加入 SKG、按摩产品或短视频广告话术并通过 <code>_prompt_has_person_intent</code> / <code>_prompt_person_guard</code> 区分“无人画面不新增人物”和“有人物画面声明虚构 AI 角色”;<code>/health</code> 返回 <code>database</code><code>image_options</code><code>image_size_options</code><code>video_options</code><code>video_size_options</code><code>video_duration_options</code><code>video_max_duration_seconds</code><code>/frames/{idx}/generate</code><code>model</code> 字段用于图片模型偏好,<code>size</code> 字段用于图片输出尺寸;<code>/storyboard/video</code> 继续使用 <code>model</code> 字段选择视频别名,并先校验画幅与时长能力边界,然后把 <code>GeneratedVideo</code> 写成 <code>queued</code> 占位并进入进程内视频队列。队列默认 <code>VIDEO_QUEUE_MAX_CONCURRENT=2</code><code>VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1</code>,同一用户连续提交不会占满全局并发;排队任务会回写 <code>queue_position</code><code>queue_size</code><code>queue_message</code>。旧 <code>AgentRun</code> 一键出片状态机、TK 复刻接口和 <code>POST /creative/copy</code> 作为明确的 SKG 营销文案接口继续保留。</td></tr>
<tr><td><code>api/db.py</code></td><td>Postgres 适配层:在 <code>DATABASE_URL</code> 存在且 <code>psycopg</code> 可用时启用;负责建表、健康检查、用户 upsert、审计日志、画布项目 CRUD以及把 <code>Job</code><code>AgentRun</code>、提示词库和素材库写入索引表。数据库不可用时本地开发会降级为 disabled生产 <code>verify-prod-docker.sh</code> 会要求 <code>database.connected=true</code></td></tr> <tr><td><code>api/db.py</code></td><td>Postgres 适配层:在 <code>DATABASE_URL</code> 存在且 <code>psycopg</code> 可用时启用;负责建表、健康检查、用户 upsert、审计日志、画布项目 CRUD、我的工作流 CRUD以及把 <code>Job</code><code>AgentRun</code>、提示词库和素材库写入索引表。数据库不可用时本地开发会降级为 disabled生产 <code>verify-prod-docker.sh</code> 会要求 <code>database.connected=true</code></td></tr>
<tr><td><code>video_model_options()</code></td><td>视频模型能力出口:如果 <code>seedance</code><code>kling</code><code>veo3</code><code>veo</code> 等业务别名实际都映射到同一个真实模型,会按真实模型去重,只给前端返回一个可用选项;当前生产真实模型为 <code>doubao-seedance-2-0-fast-260128</code>,前端显示为 <code>Seedance 2.0 Fast</code>。后续只有在服务器真的配置了不同可用视频模型时,才应把新的模型重新暴露给画布。</td></tr> <tr><td><code>video_model_options()</code></td><td>视频模型能力出口:如果 <code>seedance</code><code>kling</code><code>veo3</code><code>veo</code> 等业务别名实际都映射到同一个真实模型,会按真实模型去重,只给前端返回一个可用选项;当前生产真实模型为 <code>doubao-seedance-2-0-fast-260128</code>,前端显示为 <code>Seedance 2.0 Fast</code>。后续只有在服务器真的配置了不同可用视频模型时,才应把新的模型重新暴露给画布。</td></tr>
<tr><td><code>api/product_library/skg-products</code></td><td>内置 SKG 白底产品图库:<code>manifest.json</code> 记录从桌面产品图筛出的 gallery 白底图和桌面 4 张产品角度图,<code>images/</code> 存 45 张参考图。</td></tr> <tr><td><code>api/product_library/skg-products</code></td><td>内置 SKG 白底产品图库:<code>manifest.json</code> 记录从桌面产品图筛出的 gallery 白底图和桌面 4 张产品角度图,<code>images/</code> 存 45 张参考图。</td></tr>
<tr><td><code>api/character_library/skg-characters</code></td><td>内置相似主体形象库:从桌面 5 套策划形象导入,<code>manifest.json</code> 记录运动阳光男、都市型男、优雅白领女、运动辣妹、绅士大叔,每套含 7 张透明骨架参考图和一段 <code>prompt_brief</code>。相似主体生成时优先使用文字 brief 作为创意方向,避免把内置图作为强参考图复制。</td></tr> <tr><td><code>api/character_library/skg-characters</code></td><td>内置相似主体形象库:从桌面 5 套策划形象导入,<code>manifest.json</code> 记录运动阳光男、都市型男、优雅白领女、运动辣妹、绅士大叔,每套含 7 张透明骨架参考图和一段 <code>prompt_brief</code>。相似主体生成时优先使用文字 brief 作为创意方向,避免把内置图作为强参考图复制。</td></tr>
@@ -670,6 +672,7 @@
-> 生视频generateStoryboardVideo(job.id, 0, { prompt, model, first_image?, duration }) → jobs/&lt;jobId&gt;/storyboard_videos -> 生视频generateStoryboardVideo(job.id, 0, { prompt, model, first_image?, duration }) → jobs/&lt;jobId&gt;/storyboard_videos
-> 当前结果:图片 / 视频节点自动排列到画布 -> 当前结果:图片 / 视频节点自动排列到画布
-> 画布项目web/canvas-app/src/stores/projects.js → GET/POST/PUT/DELETE /canvas-projects → Postgres canvas_projects -> 画布项目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=&lt;id&gt; → getJob → 展示参考图、生成图、视频、提示词、图文方案 → 可继续生成 / 删除 / 复制 -> 任务详情页web/app/detail/page.tsx?job=&lt;id&gt; → getJob → 展示参考图、生成图、视频、提示词、图文方案 → 可继续生成 / 删除 / 复制
旧版 TK 复刻链路(最后版本保留): 旧版 TK 复刻链路(最后版本保留):
@@ -687,7 +690,7 @@ web/app/page.tsx
后端主链路: 后端主链路:
api/main.py api/main.py
-> Auth session / Feishu OAuth / Job owner / AgentRun owner / KeyFrame / KeyElement / StoryboardScene / AudioScript -> 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 配音预留 -> 下载 / 上传 / 音频提取 / ASR / 翻译 / 声音背景音分析 / 抽帧 / Vision brief / GPT 图像生成 / 产品视角识别 / 分镜保存 / 首尾帧生成 / 后续 Azure OpenAI 配音预留
-> jobs/&lt;jobId&gt;/state.json + agent_runs/&lt;runId&gt;/state.json + 图片文件落盘API 层按登录用户过滤列表和详情</pre> -> jobs/&lt;jobId&gt;/state.json + agent_runs/&lt;runId&gt;/state.json + 图片文件落盘API 层按登录用户过滤列表和详情</pre>
</section> </section>
@@ -700,6 +703,11 @@ api/main.py
<div><strong>主要源码</strong><span><code>web/app/page.tsx</code>;前端 API client 在 <code>web/lib/api.ts</code>;轻量创作后端在 <code>api/main.py</code><code>/creative/jobs/image</code><code>/creative/copy</code><code>/prompt/polish</code>,实际图片和视频生成继续复用 <code>/jobs/{id}/frames/{idx}/generate</code><code>/jobs/{id}/frames/{idx}/storyboard/video</code></span></div> <div><strong>主要源码</strong><span><code>web/app/page.tsx</code>;前端 API client 在 <code>web/lib/api.ts</code>;轻量创作后端在 <code>api/main.py</code><code>/creative/jobs/image</code><code>/creative/copy</code><code>/prompt/polish</code>,实际图片和视频生成继续复用 <code>/jobs/{id}/frames/{idx}/generate</code><code>/jobs/{id}/frames/{idx}/storyboard/video</code></span></div>
<div><strong>适合怎么描述</strong><span>“首页只有一个对话框,三个模式是文生图、文生视频、图生视频;图生视频上传图片后手写提示词生成”。</span></div> <div><strong>适合怎么描述</strong><span>“首页只有一个对话框,三个模式是文生图、文生视频、图生视频;图生视频上传图片后手写提示词生成”。</span></div>
</div> </div>
<div class="flow-row">
<div><strong>你看到的区域</strong><span>个人生成画布 / 工作流面板</span></div>
<div><strong>主要源码</strong><span><code>web/canvas-app/src/views/Canvas.vue</code><code>web/canvas-app/src/components/WorkflowPanel.vue</code><code>web/canvas-app/src/stores/projects.js</code><code>web/canvas-app/src/stores/workflows.js</code>;后端项目接口在 <code>/canvas-projects</code>,我的工作流接口在 <code>/canvas-workflows</code></span></div>
<div><strong>适合怎么描述</strong><span>“画布当前节点怎么保存成我的工作流、我的工作流为什么空、插回画布时节点位置和连线怎么处理、同一飞书账号换电脑后为什么能看到同一批个人模板”。</span></div>
</div>
<div class="flow-row"> <div class="flow-row">
<div><strong>你看到的区域</strong><span>任务详情页</span></div> <div><strong>你看到的区域</strong><span>任务详情页</span></div>
<div><strong>主要源码</strong><span><code>web/app/detail/page.tsx</code>;通过 <code>/detail/?job=&lt;id&gt;</code> 读取任务;复用 <code>getJob</code><code>generateImage</code><code>generateStoryboardVideo</code><code>generateCreativeCopy</code><code>deleteGeneratedImage</code><code>deleteGeneratedVideo</code></span></div> <div><strong>主要源码</strong><span><code>web/app/detail/page.tsx</code>;通过 <code>/detail/?job=&lt;id&gt;</code> 读取任务;复用 <code>getJob</code><code>generateImage</code><code>generateStoryboardVideo</code><code>generateCreativeCopy</code><code>deleteGeneratedImage</code><code>deleteGeneratedVideo</code></span></div>
@@ -794,6 +802,18 @@ api/main.py
canvas_data: { nodes, edges, viewport, ... }, canvas_data: { nodes, edges, viewport, ... },
version, version,
created_at, updated_at, deleted_at created_at, updated_at, deleted_at
}</pre>
</div>
<div class="card">
<h3>CanvasWorkflow</h3>
<p>我的工作流的云端个人模板模型。它保存可复用的画布结构,不保存一次生成出来的媒体结果;前端写入前会清掉图片 URL、视频 URL、任务进度、错误、LLM 输出和生成结果列表,插回画布时重新生成节点 ID 并重连边。</p>
<pre>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
}</pre> }</pre>
</div> </div>
<div class="card"> <div class="card">
@@ -801,6 +821,7 @@ api/main.py
<p>Postgres 不替代大文件和完整 job state它负责跨用户、跨浏览器和后续后台管理需要的结构化索引。任务详情、媒体文件和资源库原始 manifest 仍保留在现有目录里。</p> <p>Postgres 不替代大文件和完整 job state它负责跨用户、跨浏览器和后续后台管理需要的结构化索引。任务详情、媒体文件和资源库原始 manifest 仍保留在现有目录里。</p>
<pre>app_users <pre>app_users
canvas_projects canvas_projects
canvas_workflows
job_index job_index
generated_assets generated_assets
prompt_library_index prompt_library_index
@@ -1085,6 +1106,7 @@ ProductRefStateItem {
<tr><td>历史列表</td><td><code>GET /jobs</code></td><td><code>listJobs</code></td><td>当前登录用户可见 job 精简列表id/url/status/thumbnail/mtime/owner…按 state.json mtime 倒序。前端 URL 无 <code>?job=</code> 时拉它回填本人历史;带 <code>limit</code> 可截断。开启数据隔离时,飞书用户只看到自己的任务,历史无 owner 的旧任务只对备用账号可见。</td></tr> <tr><td>历史列表</td><td><code>GET /jobs</code></td><td><code>listJobs</code></td><td>当前登录用户可见 job 精简列表id/url/status/thumbnail/mtime/owner…按 state.json mtime 倒序。前端 URL 无 <code>?job=</code> 时拉它回填本人历史;带 <code>limit</code> 可截断。开启数据隔离时,飞书用户只看到自己的任务,历史无 owner 的旧任务只对备用账号可见。</td></tr>
<tr><td>创建任务</td><td><code>POST /jobs</code></td><td><code>createJob</code></td><td>提交 TK 链接,后台开始下载;后端会把当前登录用户写入 <code>Job.owner_*</code>,后续详情、素材文件、删除和生成接口都通过统一中间件校验归属。下载阶段默认不带 cookies生产环境必须显式保持 <code>YTDLP_COOKIES_FILE=</code><code>YTDLP_COOKIES_FROM_BROWSER=</code> 为空,避免容器内误读被打进镜像的开发 <code>api/.env</code></td></tr> <tr><td>创建任务</td><td><code>POST /jobs</code></td><td><code>createJob</code></td><td>提交 TK 链接,后台开始下载;后端会把当前登录用户写入 <code>Job.owner_*</code>,后续详情、素材文件、删除和生成接口都通过统一中间件校验归属。下载阶段默认不带 cookies生产环境必须显式保持 <code>YTDLP_COOKIES_FILE=</code><code>YTDLP_COOKIES_FROM_BROWSER=</code> 为空,避免容器内误读被打进镜像的开发 <code>api/.env</code></td></tr>
<tr><td>画布项目</td><td><code>GET /canvas-projects</code><br><code>POST /canvas-projects</code><br><code>PUT /canvas-projects/{id}</code><br><code>GET /canvas-projects/{id}</code><br><code>DELETE /canvas-projects/{id}</code><br><code>POST /canvas-projects/import</code></td><td><code>web/canvas-app/src/stores/projects.js</code></td><td>根域名画布项目的服务端持久化接口。列表和详情按当前登录用户过滤;写入时保存画布 JSON、缩略图、可见性、版本和更新时间删除为软删除。首次上线后本地 <code>localStorage</code> 旧项目会通过 import 导入到当前用户,之后服务端 Postgres 是主存储。</td></tr> <tr><td>画布项目</td><td><code>GET /canvas-projects</code><br><code>POST /canvas-projects</code><br><code>PUT /canvas-projects/{id}</code><br><code>GET /canvas-projects/{id}</code><br><code>DELETE /canvas-projects/{id}</code><br><code>POST /canvas-projects/import</code></td><td><code>web/canvas-app/src/stores/projects.js</code></td><td>根域名画布项目的服务端持久化接口。列表和详情按当前登录用户过滤;写入时保存画布 JSON、缩略图、可见性、版本和更新时间删除为软删除。首次上线后本地 <code>localStorage</code> 旧项目会通过 import 导入到当前用户,之后服务端 Postgres 是主存储。</td></tr>
<tr><td>我的工作流</td><td><code>GET /canvas-workflows</code><br><code>POST /canvas-workflows</code><br><code>PUT /canvas-workflows/{id}</code><br><code>DELETE /canvas-workflows/{id}</code></td><td><code>web/canvas-app/src/stores/workflows.js</code><br><code>WorkflowPanel.vue</code></td><td>工作流面板“我的工作流”页的个人模板接口。列表、保存、更新和删除都按当前登录用户过滤;保存的是清理过运行态的 <code>workflow_data.nodes/edges/viewport</code>,用于跨设备复用画布结构。插回画布时前端会按当前视口中心重排节点、重建节点 ID并用旧 ID 到新 ID 的映射重连边,避免和现有画布节点冲突。</td></tr>
<tr><td>画布生成</td><td><code>POST /creative/jobs/image</code><br><code>POST /jobs/{id}/frames/upload</code><br><code>POST /jobs/{id}/frames/{idx}/generate</code><br><code>POST /jobs/{id}/frames/{idx}/storyboard/video</code><br><code>GET /jobs/{id}</code></td><td><code>web/canvas-app/src/hooks/useApi.js</code></td><td>画布项目结构保存在 <code>/canvas-projects</code>;一旦生成图片或视频,就通过同一套 creative job / frame / storyboard video 接口写入当前登录用户自己的 job 目录。文生图会创建空白 creative job 后生成图片;图生视频会把上传图转成 frame 并作为视频参考图提交,提交视频后用 <code>skg:{jobId}:{videoId}</code> 作为画布侧任务 id 轮询 <code>/jobs/{id}</code>,直到视频状态完成或失败。</td></tr> <tr><td>画布生成</td><td><code>POST /creative/jobs/image</code><br><code>POST /jobs/{id}/frames/upload</code><br><code>POST /jobs/{id}/frames/{idx}/generate</code><br><code>POST /jobs/{id}/frames/{idx}/storyboard/video</code><br><code>GET /jobs/{id}</code></td><td><code>web/canvas-app/src/hooks/useApi.js</code></td><td>画布项目结构保存在 <code>/canvas-projects</code>;一旦生成图片或视频,就通过同一套 creative job / frame / storyboard video 接口写入当前登录用户自己的 job 目录。文生图会创建空白 creative job 后生成图片;图生视频会把上传图转成 frame 并作为视频参考图提交,提交视频后用 <code>skg:{jobId}:{videoId}</code> 作为画布侧任务 id 轮询 <code>/jobs/{id}</code>,直到视频状态完成或失败。</td></tr>
<tr><td>AI 润色 / LLM 节点</td><td><code>POST /prompt/polish</code></td><td><code>web/canvas-app/src/hooks/useApi.js</code><br><code>web/canvas-app/src/api/chat.js</code></td><td>中性的提示词润色和通用文本生成接口。根画布和文本节点传 <code>mode=image</code>、默认输出英文提示词LLM 节点和自动执行意图分析传 <code>mode=chat</code>、保持输入语言。接口会遵守 system prompt但明确禁止自动添加用户没有提到的 SKG、按摩产品、短视频广告 framing、营销标题或 hashtag。人物安全词按输入条件加入原文无人物语义时追加“不新增人物/脸/身体/人群”;原文有人像、模特、角色、数字人等语义时才追加“虚构 AI 角色、非真人、非公众人物、不可识别私人个体”。</td></tr> <tr><td>AI 润色 / LLM 节点</td><td><code>POST /prompt/polish</code></td><td><code>web/canvas-app/src/hooks/useApi.js</code><br><code>web/canvas-app/src/api/chat.js</code></td><td>中性的提示词润色和通用文本生成接口。根画布和文本节点传 <code>mode=image</code>、默认输出英文提示词LLM 节点和自动执行意图分析传 <code>mode=chat</code>、保持输入语言。接口会遵守 system prompt但明确禁止自动添加用户没有提到的 SKG、按摩产品、短视频广告 framing、营销标题或 hashtag。人物安全词按输入条件加入原文无人物语义时追加“不新增人物/脸/身体/人群”;原文有人像、模特、角色、数字人等语义时才追加“虚构 AI 角色、非真人、非公众人物、不可识别私人个体”。</td></tr>
<tr><td>一键出片终端</td><td><code>POST /agent-runs</code><br><code>GET /agent-runs</code><br><code>GET /agent-runs/{id}</code><br><code>GET /agent-runs/{id}/final.mp4</code><br><code>GET /agent-runs/{id}/contact.jpg</code></td><td><code>web/app/agent/page.tsx</code></td><td>快速出片页的唯一主接口。前端提交 TikTok 链接和最多 6 张产品图;后端创建同 owner 的 <code>Job</code><code>AgentRun</code>后台执行下载、产品图归一化、透明骨架主体参考复制、12 段镜头计划、视频生成、失败镜头自动重跑一次、审片接触表和 ffmpeg 最终合成。列表、详情、最终 mp4 和接触表同样按 owner 隔离。</td></tr> <tr><td>一键出片终端</td><td><code>POST /agent-runs</code><br><code>GET /agent-runs</code><br><code>GET /agent-runs/{id}</code><br><code>GET /agent-runs/{id}/final.mp4</code><br><code>GET /agent-runs/{id}/contact.jpg</code></td><td><code>web/app/agent/page.tsx</code></td><td>快速出片页的唯一主接口。前端提交 TikTok 链接和最多 6 张产品图;后端创建同 owner 的 <code>Job</code><code>AgentRun</code>后台执行下载、产品图归一化、透明骨架主体参考复制、12 段镜头计划、视频生成、失败镜头自动重跑一次、审片接触表和 ffmpeg 最终合成。列表、详情、最终 mp4 和接触表同样按 owner 隔离。</td></tr>
@@ -1244,6 +1266,19 @@ ProductRefStateItem {
<h2>变更记录</h2> <h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p> <p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog"> <div class="changelog">
<article class="change">
<header>
<h3>2026-05-26 · 我的工作流接入云端个人模板</h3>
<span class="tag violet">Canvas</span>
<span class="tag blue">Backend</span>
<span class="tag green">Data</span>
</header>
<div class="body">
<p><strong>问题:</strong>工作流面板只有公共模板,“我的工作流”为空;用户在当前画布里整理好的节点组合无法保存成自己的模板,也不能在另一台电脑用同一账号复用。</p>
<p><strong>改动:</strong>新增 Postgres 表 <code>canvas_workflows</code><code>GET/POST/PUT/DELETE /canvas-workflows</code> 接口,按当前登录用户保存个人工作流模板。前端新增 <code>web/canvas-app/src/stores/workflows.js</code><code>WorkflowPanel.vue</code> 的“我的工作流”页提供保存当前、刷新和删除;<code>Canvas.vue</code> 保存当前节点/连线,插入个人模板时重新生成节点 ID、按当前视口重排并按 ID 映射重连边。</p>
<p><strong>影响:</strong>同一飞书账号换电脑后应能看到自己的工作流模板;保存时会清掉生成图片、视频、进度、错误和 LLM 输出等运行态,只保留可复用结构和配置。当前仍是个人私有模板,不是公共模板库或公司共享模板库。</p>
</div>
</article>
<article class="change"> <article class="change">
<header> <header>
<h3>2026-05-26 · 推荐词扩展为 30 组共享短词池</h3> <h3>2026-05-26 · 推荐词扩展为 30 组共享短词池</h3>

View File

@@ -42,11 +42,55 @@
</div> </div>
<!-- My workflows | 我的工作流 --> <!-- My workflows | 我的工作流 -->
<div v-else class="empty-state"> <div v-else class="my-workflows">
<n-icon :size="36" class="text-gray-500"> <div class="my-toolbar">
<FolderOpenOutline /> <button class="save-current-btn" @click="$emit('save-current')" title="保存当前工作流">
</n-icon> <n-icon :size="15"><BookmarkOutline /></n-icon>
<p class="text-gray-500 text-sm mt-2">暂无自定义工作流</p> <span>保存当前</span>
</button>
<button class="refresh-btn" @click="$emit('refresh-workflows')" title="刷新我的工作流">
<n-icon :size="16"><RefreshOutline /></n-icon>
</button>
</div>
<div v-if="loadingMyWorkflows" class="empty-state">
<n-icon :size="30" class="text-gray-500">
<RefreshOutline />
</n-icon>
<p class="text-gray-500 text-sm mt-2">正在加载...</p>
</div>
<div v-else-if="myWorkflows.length" class="workflow-grid">
<div
v-for="workflow in myWorkflows"
:key="workflow.id"
class="workflow-card my-workflow-card"
@click="handleAddWorkflow(workflow)"
>
<button
class="delete-workflow-btn"
title="删除工作流"
@click.stop="$emit('delete-workflow', workflow)"
>
<n-icon :size="13"><TrashOutline /></n-icon>
</button>
<div class="card-cover">
<img v-if="workflow.thumbnail" :src="workflow.thumbnail" :alt="workflow.name" class="cover-img" />
<n-icon v-else :size="34" class="cover-icon">
<BookmarkOutline />
</n-icon>
</div>
<div class="card-title">{{ workflow.name }}</div>
<div class="card-meta">{{ formatWorkflowMeta(workflow) }}</div>
</div>
</div>
<div v-else class="empty-state">
<n-icon :size="36" class="text-gray-500">
<FolderOpenOutline />
</n-icon>
<p class="text-gray-500 text-sm mt-2">暂无自定义工作流</p>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -69,15 +113,23 @@ import {
BookOutline, BookOutline,
PersonOutline, PersonOutline,
CartOutline, CartOutline,
ChatbubbleOutline ChatbubbleOutline,
BookmarkOutline,
RefreshOutline,
TrashOutline
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import { WORKFLOW_TEMPLATES } from '../config/workflows' import { WORKFLOW_TEMPLATES } from '../config/workflows'
const props = defineProps({ 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 | 当前标签 // Active tab | 当前标签
const activeTab = ref('public') const activeTab = ref('public')
@@ -113,6 +165,11 @@ const handleAddWorkflow = (workflow) => {
visible.value = false visible.value = false
} }
const formatWorkflowMeta = (workflow) => {
const count = workflow.workflowData?.nodes?.length || 0
return `${count} 个节点`
}
// Handle click outside | 点击外部关闭 // Handle click outside | 点击外部关闭
const handleClickOutside = () => { const handleClickOutside = () => {
visible.value = false visible.value = false
@@ -216,6 +273,53 @@ const vClickOutside = {
padding: 16px; 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 | 工作流网格 */
.workflow-grid { .workflow-grid {
display: grid; display: grid;
@@ -229,6 +333,10 @@ const vClickOutside = {
transition: transform 0.2s; transition: transform 0.2s;
} }
.my-workflow-card {
position: relative;
}
.workflow-card:hover { .workflow-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
} }
@@ -269,6 +377,31 @@ const vClickOutside = {
white-space: nowrap; 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 | 空状态 */
.empty-state { .empty-state {
display: flex; display: flex;

View File

@@ -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)
}

View File

@@ -240,11 +240,28 @@
</template> </template>
</n-modal> </n-modal>
<!-- Save Workflow Modal | 保存工作流弹窗 -->
<n-modal v-model:show="showSaveWorkflowModal" preset="dialog" title="保存当前工作流">
<n-input v-model:value="workflowNameValue" placeholder="请输入工作流名称" />
<template #action>
<n-button @click="showSaveWorkflowModal = false">取消</n-button>
<n-button type="primary" :loading="isSavingWorkflow" @click="confirmSaveWorkflow">保存</n-button>
</template>
</n-modal>
<!-- Download Modal | 下载弹窗 --> <!-- Download Modal | 下载弹窗 -->
<DownloadModal v-model:show="showDownloadModal" /> <DownloadModal v-model:show="showDownloadModal" />
<!-- Workflow Panel | 工作流面板 --> <!-- Workflow Panel | 工作流面板 -->
<WorkflowPanel v-model:show="showWorkflowPanel" @add-workflow="handleAddWorkflow" /> <WorkflowPanel
v-model:show="showWorkflowPanel"
:my-workflows="myWorkflows"
:loading-my-workflows="loadingMyWorkflows"
@add-workflow="handleAddWorkflow"
@save-current="openSaveWorkflowModal"
@delete-workflow="handleDeleteWorkflow"
@refresh-workflows="refreshMyWorkflows"
/>
</div> </div>
</template> </template>
@@ -285,6 +302,7 @@ import { loadAllModels } from '../stores/models'
import { useChat, useWorkflowOrchestrator } from '../hooks' import { useChat, useWorkflowOrchestrator } from '../hooks'
import { useModelStore } from '../stores/pinia' import { useModelStore } from '../stores/pinia'
import { projects, initProjectsStore, updateProject, renameProject, currentProject } from '../stores/projects' import { projects, initProjectsStore, updateProject, renameProject, currentProject } from '../stores/projects'
import { myWorkflows, workflowSyncStatus, loadMyWorkflows, saveMyWorkflow, deleteMyWorkflow } from '../stores/workflows'
// API Settings component | API 设置组件 // API Settings component | API 设置组件
import ApiSettings from '../components/ApiSettings.vue' import ApiSettings from '../components/ApiSettings.vue'
@@ -397,7 +415,10 @@ const showRenameModal = ref(false)
const showDeleteModal = ref(false) const showDeleteModal = ref(false)
const showDownloadModal = ref(false) const showDownloadModal = ref(false)
const showWorkflowPanel = ref(false) const showWorkflowPanel = ref(false)
const showSaveWorkflowModal = ref(false)
const renameValue = ref('') const renameValue = ref('')
const workflowNameValue = ref('')
const isSavingWorkflow = ref(false)
// Check if has downloadable assets | 检查是否有可下载素材 // Check if has downloadable assets | 检查是否有可下载素材
const hasDownloadableAssets = computed(() => { const hasDownloadableAssets = computed(() => {
@@ -413,6 +434,8 @@ const projectName = computed(() => {
return project?.name || '未命名项目' return project?.name || '未命名项目'
}) })
const loadingMyWorkflows = computed(() => workflowSyncStatus.value === 'syncing')
// Project dropdown options | 项目下拉选项 // Project dropdown options | 项目下拉选项
const projectOptions = [ const projectOptions = [
{ label: '重命名', key: 'rename' }, { label: '重命名', key: 'rename' },
@@ -472,42 +495,55 @@ const addNewNode = async (type) => {
showNodeMenu.value = false showNodeMenu.value = false
} }
// Handle add workflow from panel | 处理从面板添加工作流 const viewportCenterPosition = () => ({
const handleAddWorkflow = ({ workflow, options }) => { x: -viewport.value.x / viewport.value.zoom + (window.innerWidth / 2) / viewport.value.zoom,
// Calculate viewport center position | 计算视口中心位置 y: -viewport.value.y / viewport.value.zoom + (window.innerHeight / 2) / viewport.value.zoom
const viewportCenterX = -viewport.value.x / viewport.value.zoom + (window.innerWidth / 2) / viewport.value.zoom })
const viewportCenterY = -viewport.value.y / viewport.value.zoom + (window.innerHeight / 2) / viewport.value.zoom
// Create nodes from workflow template | 从工作流模板创建节点 const insertWorkflowNodes = (workflowNodes, workflowEdges, workflowName, { reposition = false } = {}) => {
const startPosition = { x: viewportCenterX - 300, y: viewportCenterY - 200 } const sourceNodes = (workflowNodes || []).filter(node => node?.type)
const { nodes: newNodes, edges: newEdges } = workflow.createNodes(startPosition, options) 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 | 手动开始批量操作 // Start batch operation manually | 手动开始批量操作
startBatchOperation() startBatchOperation()
// Add nodes to canvas in batch | 批量将节点添加到画布 // Add nodes to canvas in batch | 批量将节点添加到画布
const nodeSpecs = newNodes.map(node => ({ const nodeSpecs = sourceNodes.map(node => ({
type: node.type, 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 data: node.data
})) }))
const nodeIds = addNodes(nodeSpecs, false) const nodeIds = addNodes(nodeSpecs, false)
// Map old node IDs to new IDs | 映射旧节点ID到新ID // Map old node IDs to new IDs | 映射旧节点ID到新ID
const idMap = {} const idMap = {}
newNodes.forEach((node, index) => { sourceNodes.forEach((node, index) => {
idMap[node.id] = nodeIds[index] if (node.id) idMap[node.id] = nodeIds[index]
}) })
// Add edges to canvas in batch | 批量将边添加到画布 // Add edges to canvas in batch | 批量将边添加到画布
const edgeSpecs = newEdges.map(edge => ({ const edgeSpecs = (workflowEdges || []).map(edge => ({
source: idMap[edge.source] || edge.source, source: idMap[edge.source] || edge.source,
target: idMap[edge.target] || edge.target, target: idMap[edge.target] || edge.target,
sourceHandle: edge.sourceHandle || 'right', sourceHandle: edge.sourceHandle || 'right',
targetHandle: edge.targetHandle || 'left', targetHandle: edge.targetHandle || 'left',
type: edge.type, type: edge.type,
data: edge.data data: edge.data
})) })).filter(edge => edge.source && edge.target)
// Add edges (autoBatch=false to use manual batch) | 添加边autoBatch=false 以使用手动批量) // Add edges (autoBatch=false to use manual batch) | 添加边autoBatch=false 以使用手动批量)
addEdges(edgeSpecs, false) addEdges(edgeSpecs, false)
@@ -523,7 +559,76 @@ const handleAddWorkflow = ({ workflow, options }) => {
}) })
}, 100) }, 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 | 处理连接 // Handle connection | 处理连接
@@ -841,6 +946,12 @@ watch(
} }
) )
watch(showWorkflowPanel, (visible) => {
if (visible && workflowSyncStatus.value === 'idle') {
refreshMyWorkflows()
}
})
// Initialize | 初始化 // Initialize | 初始化
onMounted(async () => { onMounted(async () => {
checkMobile() checkMobile()
@@ -851,6 +962,7 @@ onMounted(async () => {
// Load project data | 加载项目数据 // Load project data | 加载项目数据
loadProjectById(route.params.id) loadProjectById(route.params.id)
refreshMyWorkflows()
// Check for initial prompt from home page | 检查来自首页的初始提示词 // Check for initial prompt from home page | 检查来自首页的初始提示词
const initialPrompt = sessionStorage.getItem('ai-canvas-initial-prompt') const initialPrompt = sessionStorage.getItem('ai-canvas-initial-prompt')