# 全能创作 · 前后端契约(V1) > 2026-09-02 定案。改这份文件 = 改前后端两侧,必须同步。 > 设计稿:`影擎 - 欢迎回来.html` 的 `omni-create-page` / `omni-session-page` / `omni-history-page`。 ## 0 · 定案(不再讨论) | 项 | 定案 | | --- | --- | | 视频链路 | **单次出片**:AI 把方案编译成一个长 prompt,一次 Seedance 2.5 调用(≤30 秒)。方案卡里的「镜头 1-4 矩阵」只是给用户看的说明,不拆成 4 个任务 | | 会话模式 | 发起时选定 `video` / `image`,**会话内不可切换**。顶栏模型/分辨率/比例/时长跟着 mode 固定 | | 追问形态 | **对话流里的卡片**(进消息历史、可回看),支持 单选 / 多选 / 填空 / 选素材 | | 策略卡 · 方案卡 | **AI 真生成**(新 skill `omni-creative-strategy`),不是模板 | | @ 引用范围 | 商品(卖点+主图) · 角色/模特(锁脸) · 场景/资产库任意图。**不含**历史项目;**不含**本会话产物(产物靠记忆层自动带) | | 图片会话 | 无确认闸门,聊完直接生成 | | 视频会话 | **5 步闸门**:澄清(`ask_user`) → 策略确认 → 方案确认 → Prompt 确认 → **参数+积分确认** → 生成 | | 重新生成 | 对话流**往下叠加**新结果,旧的留在上面。历史页封面取最新一版 | | 编排模型 | `doubao-seed-2-1-pro-260628`(火山直连),`gpt-5.5`(tokenssr)兜底 | | 生成模型 | 图 `doubao-seedream-5-0-260128` / `gpt-image-2`;视频 `doubao-seedance-2-5-260628` | **改图 = 重新生成**,不做局部编辑。带上一版当参考图重跑一次。 --- ## 1 · 数据模型 ### CreationConversation(会话) ``` id uuid team FK accounts.Team (TeamOwnedModel) created_by FK accounts.User title str(120) 首条用户消息前 20 字,可改名 mode "video" | "image" 发起时定死 preset str(64) 预设名,"" = 自由创作 params json {model, resolution, ratio, duration} 会话级参数 pinned_refs json [Ref] 本会话锁定的实体(每轮无条件带上 → 锁脸锁商品) memory json {summary: str, artifacts: [ArtifactRef], turn_count: int, stage: clarify|strategy|plan|prompt|confirm|done, pending_video_prompt?: str} status "running" | "completed" | "failed" agent_status "idle" | "planning" | "awaiting_user" 整理方案态(与 status 正交);一团队同时只能有一个 planning agent_started_at datetime|null planning 开始时间(超时清扫用) last_active_at / is_deleted / purged_at ``` ### CreationMessage(消息) ``` id uuid conversation FK CreationConversation role "user" | "assistant" | "system" kind 见 §2 消息类型 text str 纯文字内容(bubble 用) payload json 按 kind 而定,见 §2 refs json [Ref] 本条消息引用的实体 task FK ai.AITask null 生成类消息挂的任务 seq int 会话内自增,渲染顺序 created_at ``` ### Ref(引用,结构化 —— 不许只存 "@净颜精华" 字符串) ```json { "type": "product|model|character|scene|asset", "id": "uuid", "name": "净颜精华", "cover": "https://..." } ``` 后端 `resolve_refs()` 把 Ref → (文字事实, 参考图 URL 列表)。 参考图顺序固定:**角色/模特 → 场景 → 商品**(这是 @图N 的语义依据,不跟用户 @ 的先后), 最多 6 张、同图去重;真人图带 `review_status`/`review_remote_id`,视频路据此换成火山 `asset://`。 | type | 落到哪张表 | 参考图取哪张 | | --- | --- | --- | | `product` | products.Product | 真实上传主图(排除 AI 生成图) | | `model` | assets.Model(模特库) | **三视图优先**,无则形象图 | | `character` | Asset category=person | 角色定妆照 | | `scene` | Asset category=scene | 场景图 | | `asset` | Asset in_library=True | 该图本身 | 查不到的引用(被删/跨团队)进 `missing`,**不抛异常** —— 由 agent 在对话里说明。 --- ## 2 · 消息类型(kind)与 payload | kind | 谁产 | payload | 对应设计稿 | | --- | --- | --- | --- | | `text` | 双方 | — (用 text 字段) | `.omni-chat-bubble` | | `elicit` | AI | `{fields: [Field], submitted: bool, answers: {}}`;步骤确认另带 `interaction:"step_confirm"`, `step: strategy\|plan\|prompt` | 追问卡 / 步骤确认条 | | `strategy` | AI | `{target, trust, belief, direction}` | `.omni-strategy-card` | | `plan` | AI | 见下 | `.omni-video-plan-card` | | `prompt_file` | AI | `{title, body, ref_count}` | `.omni-prompt-file-card` | | `confirm` | AI | `{estimated_credits: int, label: "开始生成"}` | 方案卡下的确认条 | | `generating` | AI | `{task_id, kind: "video"\|"image"}` | `.omni-process-card` | | `result` | AI | `{task_id, assets: [{id,url,cover,type}], model, resolution, ratio}` | `.omni-result-card` | | `error` | AI | `{code, message, refunded: bool}` | 气泡红态 | ### Field(追问控件) ```json { "key": "product", "label": "这条视频推哪个商品?", "type": "single|multi|text|asset", "required": true, "options": [{"value":"uuid","label":"净颜精华","cover":"https://..."}], "asset_types": ["product","character"], // type=asset 时,决定弹哪个选择器 "placeholder": "比如:通勤补妆" // type=text 时 } ``` 用户提交 → `POST .../messages/` 带 `{kind:"elicit_answer", reply_to: , answers:{...}}`。 后端把 answers 塞回上下文,继续 agent 循环。 ### plan payload ```json { "usp": "核心效果:一句话讲清为什么值得选", "points": ["使用感受:质地与触感", "转化理由:限时优惠"], "timeline": [{"start":0,"end":2.7,"stage":"Hook","desc":"..."}, ...], "matrix": {"shots": 4, "rows": [{"point":"主打卖点 USP","hits":[1,3]}, ...]}, "voice_chars": [51, 60], "ref_count": 3 } ``` --- ## 3 · 接口 | 方法 | 路径 | 说明 | | --- | --- | --- | | GET | `/api/ai/mentions/?q=&types=product,model,character,scene,asset&limit=` | @ 检索,返回 `{results:[Ref], type_labels:{}}`。**新增** | | GET/POST | `/api/ai/creations/` | 会话列表 / 新建(带首条消息即开聊) | | GET/PATCH/DELETE | `/api/ai/creations/{id}/` | 详情(含全部消息) / 改名 / 软删 | | GET | `/api/ai/creations/{id}/messages/?after_seq=` | 增量拉消息(轮询时只补新的) | | POST | `/api/ai/creations/{id}/send/` | 发消息 → **202** `{conversation_id, agent_status}`(Celery 整理方案)+ poll;闸门短路径 **200**;`kind=confirm` → JSON 201;团队冲突 **409** | | POST | `/api/ai/creations/{id}/cancel/` | 终止整理方案 → **200** `{conversation_id, agent_status: idle}`(仅 `planning`);非 planning **409**。Redis 标 `omni:agent:cancel:{id}` + 立刻释放 `omni:agent:{team_id}`;Celery 在工具轮间隙停跑,已落库消息保留。不影响 confirm 出片 | | GET | `/api/ai/creations/{id}/tasks/{task_id}/poll/` | 轮询生成任务(视频用) | `POST send/` 请求体: ```json { "text": "帮我做一条净颜精华的口播种草", "refs": [Ref], "attachments": [{"asset_id":"uuid"}], "kind": "text" | "elicit_answer" | "confirm", "reply_to": "msg_uuid", // elicit_answer / confirm 时必填 "answers": {} // elicit_answer 时必填 } ``` ### SSE 事件(`text/event-stream`,每帧 `data: {json}\n\n`) 沿用 script_agent 的事件名,新增 3 个。**DRF 必须挂 `ServerSentEventRenderer`,否则 406。** | event type | 载荷 | 前端动作 | | --- | --- | --- | | `tool` | `{id,label,status:running\|done\|error}` | 工具卡(理解需求/查商品/写方案…) | | `reasoning` | `{text}` | 思考流,纯展示 | | `delta` | `{text}` | 逐字追加到当前气泡 | | `message` | `{message: CreationMessage}` | **新增**。一条完整消息落库了,整块渲染(策略卡/方案卡/追问卡/结果卡都走它) | | `task` | `{task_id, kind}` | **新增**。异步生成已提交,前端开始轮询 | | `credits` | `{estimated, balance}` | **新增**。确认按钮旁的积分 | | `done` | `{}` | 收流 | | `error` | `{detail, error}` | 已回滚积分 | > **2026-09-11 更新:**`kind=text|elicit_answer` 的整理方案循环已迁到 Celery(`run_creation_agent_turn_task`)。 > 离开/刷新页面不中断;前端按 `agent_status===planning` 轮询 `GET creations/{id}/` / `messages?after_seq=`。 > 团队级 Redis 锁 `omni:agent:{team_id}` + DB `agent_status` 保证同时只有一个会话在整理方案;冲突返回 409「当前已有对话正在整理方案,请等待完成后再试」。 > 下方 SSE 事件表保留作 `stream_creation_agent` 兼容/单测说明;生产主路径不再对浏览器推 SSE。 **铁律:视频 5–10 分钟,绝不在 SSE 里等。** 工具立刻返回 task_id → 落一条 `generating` 消息 → 发 `task` 事件 → 收流。前端轮询完成后把该消息替换成 `result`。 --- ## 4 · Agent 工具清单 | 工具 | 参数 | 复用 | | --- | --- | --- | | `ask_user` | `{fields: [Field]}` | 澄清缺信息。**一调即中断** | | `search_library` | `{query, types}` | 复用 mentions 检索 | | `write_strategy` | strategy payload | 出策略卡 + `step_confirm`,**中断**等确认 | | `write_plan` | plan payload + `video_prompt`(先缓存) | 出方案卡 + `step_confirm`,**中断**;不同轮出 Prompt/积分卡 | | `write_prompt` | `{video_prompt}` | 出 `prompt_file` + `step_confirm`,**中断** | | `generate_image` | `{prompt, ref_asset_ids, count, ratio}` | 现有 `GenerateImageView` 服务层 | 循环上限:单条用户消息最多 **8 轮**工具调用、最多 **1 次**计费生成(防「多做几版」烧积分)。 **三条已固化进代码的铁律:** 1. `ask_user` 一被调用就**中断循环**等人回答 —— 继续跑等于自问自答。 2. 生成工具**只提交不等待**,立刻发 `task` 事件收流,前端轮询回填。 3. SSE 帧必须用 `DjangoJSONEncoder` —— 消息里带 UUID 和 datetime, 标准 `json.dumps` 会 TypeError 把整条流当场打断。 会话 mode 决定暴露哪些工具:图片会话只有 `generate_image`,视频会话只有 `write_strategy` / `write_plan` / `write_prompt`,互相看不见。 ### 视频确认闸门(5 步 · 小云雀式) ``` 聊 → ① ask_user?(缺信息则停) → ② write_strategy → 策略卡 + step_confirm(**停**;确认/改) → ③ write_plan → 方案卡 + step_confirm(**停**;确认/改) · video_prompt 先写入 memory.pending_video_prompt → ④ 用户确认方案后平台(或 write_prompt)出 prompt_file + step_confirm(**停**) → ⑤ 用户确认 Prompt 后平台出积分 confirm 卡(模型/分辨率/比例/时长) → 用户点「开始生成」→ submit_free_video(不再跑编排模型) ``` `memory.stage` 取值:`clarify|strategy|plan|prompt|confirm|done`,用于 resume 与系统提示约束。 每一步确认用 `elicit` + `interaction:"step_confirm"` + `step`;前端「按这个继续」走 `elicit_answer`,`我想改` 打开输入框,打字反馈走 text(后端标 revise 并只重写该步)。 **最终「开始生成」后不再跑编排模型。** `video_prompt` 在方案步缓存、Prompt 确认后写入 确认卡 payload,确认时照它提交 `submit_free_video`,返回同步 JSON。 积分确认卡是一次性的:`submitted` 置位后重复提交返回 409。 但**提交失败要把闸门放回去**(`submitted` 复位),否则积分不足改完也点不了了。 顶栏的模型 label(「Seedance 2.5」)要翻成火山真名才能提交;`duration` 从「15 秒」里 抠数字,「智能时长」回落 15,超过 30 秒夹住(火山单次上限)。 --- ## 5 · 记忆三层(会话内 SQL 即可,不用向量库) 1. **滚动摘要** `memory.summary` —— 消息数超过 `COMPRESS_AFTER_MESSAGES`(24)时, 把最老的一批压成一段,只留最近 `KEEP_RECENT_MESSAGES`(12)条原文喂模型。 **`COMPRESS_MIN_BATCH`(8)是关键**:没有它的话过了阈值以后每一轮都要多花一次 模型调用去重压那么两三句话,长会话成本翻倍。压缩失败静默跳过,摘要是锦上添花。 2. **实体锁定** `pinned_refs` —— 本会话引用过的商品/角色/场景 + 参考图,**每轮无条件带上** 3. **产物索引** `memory.artifacts` —— `[{msg_id, asset_id, prompt, kind}]`,让"背景换成夜景"能定位到上一版 --- ## 6 · 实施顺序 - [x] 阶段 0 · 本契约 - [x] 阶段 1 · `CreationConversation` / `CreationMessage` + 迁移 0034 + 会话 CRUD - [x] 阶段 2 · `/mentions/` 检索 + `resolve_refs()` → `apps/ai/mentions.py` - [x] 阶段 3 · agent 循环 + SSE → `apps/ai/creation_agent.py`,已接 `ask_user` / `search_library` / `generate_image` - [x] 阶段 4 · 视频链路:`write_strategy` / `write_plan` + 确认闸门 + `submit_free_video` - [x] 阶段 5 · 记忆压缩(`compress_memory`)+ 历史页接后端 - [x] 阶段 6 · 预设拍法 → `apps/ai/creation_presets.py`(14 条,与前端卡片逐字对应) 前端 `omni-session.tsx`(对话页)已港,设计稿 `omni-session-page` 逐行照抄。 **港页面时踩到的三个坑(以后港别的页面同样要查):** 1. 设计稿用 `--black` / `--text`,`design-restraint.css` 里没有 → `background: var(--black)` 解析失败变透明,白字白底看不见。已在 design-restraint.css 加别名指向 `--accent-black`。 **首页 omni-create 之前就带着这个 bug。** 2. 设计稿的 `.omni-chat-row.user` 撞上 `design-restraint.css` 里的**裸 `.user` 全局类** (侧栏用户胶囊:999 圆角 + 灰底 + space-between),整套样式被传染。改用 `.is-user`。 3. 设计稿靠 `id="omniSessionPrompt"` 给输入框上样式;React 不写 id 就没样式。已补上 id (与 `omni-create.tsx` 的 `#omniStartPrompt` 一致)。 另:这一版布局是**整页滚 + 输入框 `position: sticky`**,feed 没有自己的滚动条 —— 对 feed 调 `scrollTo` 是空操作,自动滚到底必须滚 window。 --- ## 7 · 前端稳定性(2026-09-02 修) 对话页「条来跳去」的四个根因,改别的页面时同样要查: 1. **`onNotify` 是 App 里的内联箭头,每次 App 重渲染都是新身份。** 放进 `useEffect` / `useCallback` 依赖 → 整条会话被反复重拉、消息数组被反复替换。 **这是抖动的主因。** 解法:存进 ref,回调身份稳定、值永远最新。 2. **逐字 `scrollIntoView({behavior:"smooth"})`** —— 每个流式字符发一次平滑滚动, 动画互相打架。解法:流式期间用 `instant` + rAF 合帧;新消息落地才用 smooth; 且用户手动上滑看历史时**不跟**(距底 <120px 才跟)。 3. **轮询整个替换 messages 数组** → 所有卡片重渲染。解法:按 id + payload 比对, 只替换真变了的那几条,其余保持引用不变让 React 跳过。 4. **流式临时气泡带入场动画** → 逐字重渲染时一直在闪。解法:`.is-live { animation: none }`。 另修:`handleSend` 原来先清输入框再判 `streaming`,流式期间敲回车会把打好的内容 清掉但消息没发出去。顺序反过来。