Files
yingqing/全能创作-契约-2026-09-02.md
T
2026-09-03 13:11:46 +08:00

256 lines
13 KiB
Markdown

# 全能创作 · 前后端契约(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`),不是模板 |
| @ 引用范围 | 商品(卖点+主图) · 角色/模特(锁脸) · 场景/资产库任意图。**不含**历史项目;**不含**本会话产物(产物靠记忆层自动带) |
| 图片会话 | 无确认闸门,聊完直接生成 |
| 视频会话 | 策略卡 → 方案卡 → **确认(按钮旁显示预计积分)** → 生成 |
| 重新生成 | 对话流**往下叠加**新结果,旧的留在上面。历史页封面取最新一版 |
| 编排模型 | `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}
status "running" | "completed" | "failed"
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: {}}` | **新增卡片** |
| `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: <msg_id>, 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/` | 发消息 → **SSE 流**;`kind=confirm` 例外,返回 JSON |
| 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}` | 已回滚积分 |
**铁律:视频 5–10 分钟,绝不在 SSE 里等。** 工具立刻返回 task_id → 落一条 `generating` 消息 → 发 `task` 事件 → 收流。前端轮询完成后把该消息替换成 `result`
---
## 4 · Agent 工具清单
| 工具 | 参数 | 复用 |
| --- | --- | --- |
| `ask_user` | `{fields: [Field]}` | 新写。**这是「小云雀式追问」的唯一入口** |
| `search_library` | `{query, types}` | 新写,复用 mentions 检索 |
| `write_strategy` | `{}` → strategy payload | 新 skill |
| `write_plan` | `{}` → plan payload | 新 skill |
| `generate_image` | `{prompt, ref_asset_ids, count, ratio}` | 现有 `GenerateImageView` 服务层 |
| `write_strategy` | strategy payload | 出策略卡,**不打断循环** |
| `write_plan` | plan payload + `video_prompt` | 出 方案卡+Prompt卡+确认卡,**打断循环等确认** |
循环上限:单条用户消息最多 **8 轮**工具调用、最多 **1 次**计费生成(防「多做几版」烧积分)。
**三条已固化进代码的铁律:**
1. `ask_user` 一被调用就**中断循环**等人回答 —— 继续跑等于自问自答。
2. 生成工具**只提交不等待**,立刻发 `task` 事件收流,前端轮询回填。
3. SSE 帧必须用 `DjangoJSONEncoder` —— 消息里带 UUID 和 datetime,
标准 `json.dumps` 会 TypeError 把整条流当场打断。
会话 mode 决定暴露哪些工具:图片会话只有 `generate_image`,视频会话只有
`write_strategy` / `write_plan`,互相看不见。
### 视频确认闸门(阶段 4)
```
聊 → [ask_user…] → write_strategy(策略卡,不停)
→ write_plan(方案卡 + Prompt卡 + 确认卡,**停**)
→ 用户点确认 → 直接出片
```
**点确认后不再跑模型。** 方案已经确认过了,再让模型决定一次既费钱、又可能它根本不调
生成工具。`video_prompt``write_plan` 时就存进确认卡的 payload,确认时照它提交
`submit_free_video`,返回同步 JSON(不是 SSE)。
确认卡是一次性的:`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`,流式期间敲回车会把打好的内容
清掉但消息没发出去。顺序反过来。