feat(core): AI 生成 Agent 化 — 多模型流式脚本 agent + 可插拔 Provider + gpt-image-2 参考图 + 模特库

- 后端·可插拔 Provider 层:通用 OpenAICompatibleProvider(tokenssr 等中转站,base_url+api_key,零改代码换站)+ ModelProvider.api_key
- 后端·脚本 agent:结构化 ScriptDraft 契约 + 加载电商 skill + 出稿/改稿一体对话 agent(3模式/多模型)+ 流式 SSE 端点(DRF SSE renderer)
- 后端·图像:gpt-image-2 参考图出图 + 故事板 @图1@图2@图3 多锚点合成(锁脸锁商品);Seedance 打开 generate_audio
- 后端·模特库:gpt-image-2 生成器(9:16氛围图→16:9白底三视图)+ seed_demo_models 管理命令
- DB·迁移:tokenssr 中转站 + 多模型 seed(豆包/GPT-5.5/Gemini + gpt-image-2);ScriptSegment 结构化字段
- 前端·脚本趴:接真 SSE(工具卡 + 思考流)+ 模型下拉 + 3模式 + 改稿;agentScriptStream
- skills/ecommerce-video-script 电商脚本技能(运行时依赖)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-17 03:12:03 +08:00
co-authored by Claude Opus 4.8
parent a0ffb6fc8e
commit 6464001f84
32 changed files with 9294 additions and 54 deletions
+261
View File
@@ -0,0 +1,261 @@
# AirShelf · AI 生成 Agent 化落地方案
> 目标:把【脚本生成】升级为「多模型可选 + 出稿/改稿一体」的对话式 agent,并打通后续图片/故事板/视频 SOP。
> 用户定位:不懂视频制作的电商小白,点按钮就出"能吸睛、能转化"的带货短视频,提示词全替用户包好。
> 本文整合 2026-06-16 多轮讨论结论,作为开工 SSoT。
---
## 0. 一句话现状
5 阶段流水线(商品 → 脚本 → 图片 → 故事板 → 视频)页面已搭好,生成链路能跑,但:**脚本是一次性散文+正则解析、无 agent、无多模型;图片"平台套图"是装饰;参考图生成断线;Seedance 音频被关。**
---
## 1. 现状核查(已验证,带 file:line)
### 1.0 模型与中转站(最终方案,均已实测 200)
**主力中转站 = `tokenssr`(`https://king.tokenssr.com/v1`,一把 key 通吃 90 模型,见 .env `TOKENSSR_API_KEY`)。**
| 用途 | 模型 | 端点/调法 |
|---|---|---|
| 脚本·豆包 | doubao-seed-2.0-pro | 火山 ARK chat(本地直连) |
| 脚本·GPT | `gpt-5.5` | tokenssr `chat/completions` |
| 脚本·Gemini | `gemini-3.1-pro-preview` / `gemini-3.5-flash` | tokenssr `chat/completions` |
| 纯文生图 | `gpt-image-2` | tokenssr `images/generations` |
| **参考图/编辑** ⭐ | **`gpt-image-2`** | **tokenssr `images/edits`(multipart `image[]`,1~多张参考图,返回 b64)** |
| 视频 | seedance-2.0 | 火山 ARK(本地直连) |
- 多图参考(故事板 @图1@图2@图3)实测 200;`gemini-3-pro-image-preview`(chat 多模态)留作图像备选/风格选择。
- YunQi 那家分组割裂、gpt-image-2 渠道残废 → 已弃用,仅留历史参考。
- token 用量正常返回,对齐"按实际 token 扣费"。
### 1.1 模型与 fallback
| 类型 | 实际模型 | Provider | Fallback |
|---|---|---|---|
| 脚本 text | Doubao-Seed-2.0-Pro | 火山 ARK(写死) | ❌ 无 |
| 图片 image | **GPT-Image-2 / YunQi 网关**(Seedream 被 disabled) | YunQi | ❌ 无 |
| 视频 video | Seedance-2.0 | 火山 ARK(写死) | ⚠️ 仅"丢参考图退纯文生视频" |
- 默认选模型 = 该能力下最早创建的 active 行 [services.py:33-39](core/backend/apps/ai/services.py#L33-L39)
- 生图按 provider 分流(yunqi/火山) [services.py:42-46](core/backend/apps/ai/services.py#L42-L46)
- YunqiProvider 继承火山,`chat_completion` 现成 → **文本接多模型很便宜** [yunqi.py](core/backend/apps/ai/providers/yunqi.py)
### 1.2 脚本生成(无 agent)
- 系统提示词:4×15s、强制 `镜头N/旁白/画面` 格式 [services.py:53-77](core/backend/apps/ai/services.py#L53-L77)
- **正则**抠字段 [services.py:81](core/backend/apps/ai/services.py#L81)
- **第二次** LLM 调用抽 人物/场景 JSON [services.py:116](core/backend/apps/ai/services.py#L116)
- 全是一次性调用,无循环/无工具/无自检
### 1.3 图片(平台套图是空壳)
- 三种模式提示词模板在前端 [ai-tools.tsx:387](core/frontend/src/routes/ai-tools.tsx#L387)
- 平台卡/模特卡选择 `pickedIds` **只做高亮,不拼进 prompt、不发后端** [ai-tools.tsx:1195](core/frontend/src/routes/ai-tools.tsx#L1195)
- 提交只发 `{prompt, mode, count}` [api.ts:342](core/frontend/src/api.ts#L342);后端 `mode` 只决定任务类型/分类/命名,不加工 prompt [services.py:1067](core/backend/apps/ai/services.py#L1067)
### 1.4 视频
- 视频提示词织入风格锚点+旁白+画面 [services.py:621](core/backend/apps/ai/services.py#L621)
- 已传参考图给 Seedance [services.py:870-897](core/backend/apps/ai/services.py#L870-L897)
-`generate_audio` 写死 False [volcano.py:129](core/backend/apps/ai/providers/volcano.py#L129)
---
## 2'. 开工就绪状态(2026-06-17 全部核实)
| 卡点 | 状态 |
|---|---|
| 电商 skill | ✅ 已写好 `skills/ecommerce-video-script/`(SKILL.md+5 references),**输出契约与 §3 完全对齐**(还多了 aspect_ratio + 四档时长) |
| 参考图出图 | ✅ tokenssr gpt-image-2 `images/edits` 实测(含多图) |
| 火山审核+绿/红标 | ✅ **AirDrama 有整套现成**:`video-shuoshan/backend/utils/assets_client.py`(建组/传素材/查审核)+ AssetGroup/Asset 模型(status: processing→**active=绿标 / failed=红标**)+ 轮询接口。照搬即可。策略:**一团队一素材组**(火山确认单组可放 500 万素材),后台 API 静默自动上传,前端不显示组、只显示绿/红标;红标提示"未通过审核,请改提示词重生" |
| 模特库 | ✅ 由 agent 用 gpt-image-2 预生成 2-3 个(先 9:16 正面氛围图 → 以其为参考出 16:9 白底三视图)。提示词见 §阶段4 |
| 前端结构 | ✅ routes 齐全,不用大动,主要接线+结构化渲染+聊天框+badge |
| 多模型 | ✅ 豆包/GPT/Gemini 实测可用 |
### ★ 架构原则:Provider 可插拔(不写死中转站)
**除火山(ARK/Seedance,官方直连)外,其余全走中转站且随时可换** → 设计成:
- **一个通用 `OpenAICompatibleProvider(base_url, api_key)`** 覆盖所有中转站(tokenssr/yunqi/未来任意家),按 `ModelConfig.provider``base_url`+`api_key` 实例化。
- **换中转站 = 改 DB 里 Provider 的 base_url+api_key,零改代码**;加新家 = 加一行(格式兼容则复用同一 provider 类)。
- `VolcanoArkProvider` 专管火山官方(文本/图/Seedance)。
- 新增 `ModelConfig.api_key`;key 全部走 .env / DB,**不写死代码**。
## 2. ⚠️ 前置硬伤 / 必须先决策
### GAP-1 参考图生成 —— ✅ 已解决(2026-06-17 实测,换中转站 tokenssr)
**结论:换到中转站 `tokenssr`(base `https://king.tokenssr.com/v1`)后,gpt-image-2 直接支持参考图(含多图)。** 之前 YunQi 是渠道残废,不是 gpt-image-2 本身不行。
- **一把 key 通吃 90 个模型**(无 YunQi 那种分组割裂):gpt-image-1 / gpt-image-1.5 / **gpt-image-2** / dall-e-3 + 全套 Gemini 文本&图像模型 + gpt-5-nano 等。
- **gpt-image-2 传参考图,两条路实测 200:**
- **A·`images/edits`(标准 OpenAI 格式,推荐)**:multipart,`image[]`**1 张或多张**参考图 + `prompt` + `model=gpt-image-2` + `size`;返回 `data[0].b64_json`。**多图参考(2张)实测 200** → 故事板 `@图1@图2@图3` 成立。
- **B·`images/generations` + `image`(base64)**:返回 `data[0].url`(`r2.sysrv.net/...png`,直接是托管 URL)。
- 参考图来源:角色/场景/商品图在 TOS(URL)→ 取字节走 edits 的 `image[]`,或 base64 走 generations;出图落 TOS。
- **需新增 `TokenssrProvider`**:`image_generate()`(文生图)/`image_edit(model,prompt,ref_bytes[],size)`(参考图,multipart image[])。比之前 Gemini-chat 解析 markdown 的方案**更干净**(标准 OpenAI 格式、data[] 直接拿图)。
- 备选:同站的 Gemini 图像模型(`gemini-3-pro-image-preview`)走 chat 多模态也可用,留作 fallback/风格选择。
- **这是图片/故事板/视频趴的前置依赖,现已具备。**
### GAP-2 Seedance 音频被关
- 你的设计:Seedance 直接出画面+音效+人声,不走 TTS。
-`generate_audio=False` 写死 [volcano.py:129](core/backend/apps/ai/providers/volcano.py#L129) → 需打开;音色一致性(audioReference,Seedance-2.0 支持 [catalog.py:67](core/backend/apps/ai/catalog.py#L67))二期再接。
### 决策清单
1. **图片参考图模型**(已定·实测解决):用 **tokenssr 的 gpt-image-2**(`images/edits` multipart `image[]`,多图已验证);YunQi Gemini 图像模型留可选 fallback。
2. **计费粒度**(已定):**按实际 token 消耗,每次调用扣一次费**。含义:agent 自检循环每跑一轮都扣 → 循环要克制(≤2-3 轮),内部调用尽量精简,避免空转扣费。
3. **平台套图(独立图片工具)**(已定):暂不管,本方案聚焦视频项目流水线。
4. **多 key 管理**(基本解决):换 tokenssr 后,一把 key 通吃 90 模型,不再有 YunQi 分组割裂问题。后端仍加 `ModelConfig.api_key` 字段(留作多 provider 共存:tokenssr / 火山本地)。
---
## 3. 数据契约:结构化脚本 `ScriptDraft`(全链路地基)
把脚本从"散文+正则"改成**结构化输出**(JSON/tool-calling),模型无关,一次喂饱全下游。
```jsonc
{
"hook": "前3秒主打钩子(一句话)",
"tone": "种草|测评|剧情|痛点",
"aspect_ratio": "9:16", // 不写死,可 16:9/1:1/4:5,透传下游
"total_duration": 60, "segment_count": 4, // 时长 15/30/60/90 四档,每15s一镜→1/2/4/6镜
"entities": [ // 角色/场景/商品,全脚本共享 → 保证4镜同一角色同一张脸/同一音色
{ "id": "c1", "type": "character|scene|product", "name": "女主",
"visual_prompt": "给图模型的生图提示词(AI自动生成,小白不打字)",
"ref_index": 1,
"voice_ref": "可选·角色音色参考(二期锁音色)" }
],
"segments": [
{ "index": 0, "duration": 15,
"role": "钩子|痛点|卖点|CTA", // 电商脚本的灵魂
"narration": "本镜要说出来的台词/旁白,≤55字(Seedance 15秒内直接发声,字数=可懂语速上限)",
"speaker": "可选·指向角色entity的id;null=画外旁白",
"visual": "画面描述",
"product_exposure": "商品露出方式(手持/特写/使用中)",
"entity_refs": ["c1"] }
]
}
```
**它如何驱动全下游:**
- 图片趴:直接读 `entities[].visual_prompt` 预填出图卡(小白不打字)
- 故事板趴:按 `entity_refs` + `ref_index``@图N`
- 视频趴:`narration`(发声)+ `visual` + `@图N` 参考 → Seedance
- 一致性:同一 entity 全程一份图/一份音色
> 从 AirDrama **保留**:结构化思维、entity 层(一致性)、segment role。**砍掉**:三流、5 种 beat、est_sec、Section 嵌套、质量词三层 fork。
---
## 4. 分阶段落地
### 阶段 0 · 电商剧情 skill —— ✅ 已完成
- 产物在 `skills/ecommerce-video-script/`(SKILL.md + 5 references:methodology/hook-library/category-playbook/platform-tone/checklist)。
- 输出契约 = §3 ScriptDraft(并扩展了 aspect_ratio + 15/30/60/90 四档时长);3 模式路由、输出前自检、写作红线、渐进披露齐全。
- agent(阶段2)运行时加载它作为领域知识。**无需再做。**
### 阶段 1 · 结构化契约 + 多模型管子(后端 ~1d)
-`ScriptDraft` 类型(§3);脚本输出从正则改 **tool-calling/JSON**
- 文本 provider 分流(照搬生图)或全走 YunQi 网关;`/api/ai/models/` 已能列模型,生成时透传 `model_config_id`
- DB:加 **GPT-5.5** + **Gemini-3.5-flash/3.1-pro**(均 YunQi 已实测可用)文本模型设 active;按模型配各自 key(决策4)
- **验收**:同一商品,豆包 / GPT-5.5 / Gemini 三家都产出合规 `ScriptDraft`,字段不崩
- 依赖:无 ‖ 可与阶段0并行
### 阶段 2 · 对话式脚本 agent(出稿+改稿一体)(后端 ~2-3d)
- **同一个 agent 跑在带状态会话上**:第1轮=出稿(3模式 seed),第N轮=改稿(带上轮草稿)
- Session 状态:当前 `ScriptDraft` + 消息历史 + 商品上下文 + 选定模型/调性
- 工具:`get_product / load_skill / validate / emit` + `edit_segment(index,instruction)` / `patch_draft`(`build_segment_rerun_prompt` [services.py:353](core/backend/apps/ai/services.py#L353) 升级为工具)
- 有界循环 ≤2-3 轮:读(草稿+用户话)→ 判(整篇重生/改某镜/答疑)→ 执行 → validate → emit
- 一个 endpoint:`(session_id, 可选消息, 当前草稿) → (新草稿, agent回话)`
- 计费:**按实际 token、每次调用扣**(决策2)→ 循环 ≤2-3 轮、内部调用精简,避免空转扣费
- **验收**:点按钮 ~10-20s 出 4 镜结构化脚本;聊一句能精准改某镜;旁白≤55字、结构齐、entity 一致
- 依赖:阶段0 + 阶段1
### 阶段 3 · 前端脚本趴(前端 ~1.5d)
- 模型下拉(豆包/GPT/Gemini)
- 3 种出稿输入(全自动/一句话/自带脚本)
- 4 镜结构化渲染(钩子/痛点/卖点/CTA 标签 + 旁白/画面/商品露出)
- 聊天框(改稿)+ 每镜快捷按钮(重跑/改活泼/加钩子=预设改稿指令)
- **验收**:设计师视角点按钮出稿、看得到分镜结构、能对话改 / 点按钮改单镜
- 依赖:阶段2
### 阶段 4 · 图片趴(后端+前端)
- **前置·新增 `TokenssrProvider`**:`image_generate()`(文生图)/ `image_edit(model,prompt,ref_bytes[],size)`(参考图,`images/edits` multipart `image[]`);新增 `ModelConfig.api_key`
- **三视图**(商品创建即可生,视频项目内可补):prompt + 上传商品图(正/侧/背多参考)→ **gpt-image-2 `images/edits`** 出三视图;无则用原图
- **实体出图**:直接读脚本 `entities[].visual_prompt` 预填卡,小白只点生成(不打字);不满意可改 prompt
- **模特库**(预生成 2-3 个电商模特,流程:① gpt-image-2 出 9:16 正面氛围图 → ② 以①为参考图出 16:9 白底三视图,提示词:`参考图1角色,生成角色三视图,从左往右依次为:胸像特写,全身正面,全身侧面,全身背面,白色背景`)+ 自定义模特/场景(gpt-image-2 生)
- 出图落 TOS,大图预览
- **火山审核**(照搬 AirDrama `utils/assets_client.py` + AssetGroup/Asset 模型 + 轮询接口):一团队一素材组,后台静默自动上传(前端不显示组),轮询 status → **active=绿盾标 / failed=红标**(红标提示"未通过审核,请改提示词重生");env 复用 TOS_ACCESS_KEY/TOS_SECRET_KEY + ASSETS_API_ENABLED + PROJECT_NAME
- **验收**:小白零输入出齐角色/场景/商品图;三视图可参考原图;盾牌 icon 正确
- 依赖:阶段2(脚本 entities)
### 阶段 5 · 故事板趴(依赖阶段4)
- 拼装系统提示词 + 角色图/场景图/商品图(三视图优先)+ 本段 15s 脚本 → **gpt-image-2 `images/edits` 多图 `image[]`** 出导演故事板(多图实测 200)
- 提示词骨架:`制作导演故事板指导seedance / @图1角色 @图2场景 / 分镜脚本(15s) / 规则:景别·运镜·画面·角色动作·情绪·对白旁白·灯光`
- 每帧场景/角色必须回溯脚本文字(靠 `entity_refs`)
- **验收**:每镜一张故事板,角色/场景跟回脚本,可指导视频
- 依赖:阶段4
### 阶段 6 · 视频趴(依赖阶段5)
- 分镜图+角色图+场景图+商品图+文字脚本 → Seedance(火山,参考图走 TOS URL,已在用)
- v1 固定模板:`【设定】@图1是X角色,@图2是X场景,@图3是X商品 【分镜】根据@图4分镜图生成X商品视频 【脚本】…`(后续可做视频优化 skill)
- 打开 `generate_audio`(GAP-2);二期接 audioReference 锁音色
- 火山审核盾牌 icon 同图片趴
- **⚠️ Seedance 出片慢:正常 5-10 分钟/段,凌晨可能 3-4 分钟**。轮询超时要放长(≥15 分钟),测试时别误判失败;若实测耗时太长,视频趴只到"提交成功+轮询机制验证",标注成熟度交接。
- **验收**:四镜成片、画面跟故事板、音画自带、审核 icon 正确
- 依赖:GAP-2 + 阶段5
---
## 5. 全链路数据流
```
商品(信息+图) ──┐
├─→ [脚本 agent] → ScriptDraft(hook/entities/segments)
前置条件(调性/平台)┘ │
├─ entities[].visual_prompt ─→ [图片趴] 角色/场景/商品图(三视图优先)
│ │
├─ entity_refs + 15s脚本 ──────────────→ [故事板趴] 导演分镜图
│ │
└─ narration + @图N + 分镜图 ──────────→ [视频趴] Seedance 成片(自带音)
全程:同一 entity = 同一张脸/同一音色 ;火山审核盾牌 icon
```
---
## 5'. 执行须知(compaction-safe resume 锚点 · 2026-06-17 夜)
> 压缩/换会话后,从这里 + 下面的 checklist 直接续干,不必重读全部代码。
**凭证(都在 `core/backend/.env`):**
- `TOKENSSR_API_KEY` / `TOKENSSR_BASE_URL`(`https://king.tokenssr.com/v1`)= 主力中转站,一把 key 通吃 90 模型。
- `FEISHU_APP_ID` / `FEISHU_APP_SECRET`(机器人「小毛球」,文件上传权限已开)。
- 火山:`VOLCANO_ARK_API_KEY``TOS_*``VOLC_TTS_*` 已就绪。
**已验证可用模型(tokenssr,均 200):** 文本 `gpt-5.5`/`gemini-3.1-pro-preview`/`gemini-3.5-flash`;图编辑 `gpt-image-2`(`images/edits` multipart `image[]`,1~多张参考图,返回 b64)+ `images/generations`(文生图);Gemini 图像 `gemini-3-pro-image-preview`(chat 多模态,备选)。豆包/Seedance 走火山 ARK 本地。
**关键代码事实(已读):**
- `ai/models.py`:`ModelProvider`(name/base_url/metadata,**需加 `api_key` 字段+迁移**)、`ModelConfig`(provider FK/name/capability/endpoint/status,默认选 `get_default_model`=该能力最早 active)、`AITask`(idempotency_key 必填唯一、status 机、request/response_payload、estimated/actual_cost)。
- `ai/services.py`:`create_ai_task``reserve_credit`;`charge_reserved_credit`/`release_credit`(billing/services/ledger.py,幂等带行锁);`get_image_provider` 按 provider.name 分流;`build_*_prompt` 系列;`generate_project_script`(改结构化输出落 `ScriptVersion.metadata`)。
- `providers/`:`base.py`(AIProvider Protocol)、`volcano.py`(VolcanoArkProvider:chat_completion/image_generation 带 `image` 参/create_video_task 有 `generate_audio=False` **需开**)、`yunqi.py`。**要新增 `OpenAICompatibleProvider(base_url,api_key)`** 覆盖所有中转站 + tokenssr 的 `image_edit`(images/edits multipart image[])。
- `projects/models.py`:`ScriptVersion`(content+metadata)、`ScriptSegment`(narration/visual_prompt/duration,**需补 role/product_exposure/entity_refs/speaker 或塞 metadata**)、`BaseAssetGroup`(kind: product/person/scene,adopted/candidate assets)、`StoryboardVersion/Frame``VideoSegment/Version`
- `assets/models.py`:`Asset`(category: person/scene/product_image/…)+`AssetFile`(object_key/bucket/preview_url)。出图落 TOS 已有链路。
- 前端 routes 齐全:`pipeline.tsx`(五阶段主)、`projects.tsx``ai-tools.tsx``api.ts``types.ts`。不大改,只接线+结构化渲染+按钮+绿红标。
**火山审核复用(照搬 AirDrama,有 4 个坑已处理):**`video-shuoshan/backend/utils/assets_client.py`(create_asset_group/create_asset/get_asset)+ AssetGroup/Asset 模型(status active=绿/failed=红/processing)+ 轮询。策略:一团队一素材组,后台静默上传 TOS URL,前端只显示绿/红标。
- **坑1·SDK**:AirShelf `requirements.txt``volcengine`(只有 boto3+requests)→ 加 `volcengine` 依赖装上(或用 requests 手写火山签名)。
- **坑2·密钥**:AirDrama 用 `settings.TOS_ACCESS_KEY/TOS_SECRET_KEY`;AirShelf 改用**独立 env** `ASSETS_API_ACCESS_KEY/ASSETS_API_SECRET_KEY`(已写入 .env,**暂借 AirDrama 已邀测开通的 AK/SK** `AKLTNGJi…`)。搬代码时把读 key 处改成读这俩。
- **坑3·配置**:`.env` 已加 `ASSETS_API_ENABLED=true` + `ASSETS_API_PROJECT_NAME=int_dev_Airlabs`(assets_client 里 PROJECT_NAME 常量改读这个 env)。
- **坑4·权限(用户已拍板)**:AirShelf 自有火山账号没开通人像素材库邀测 → **先用 AirDrama 的 AK/SK 跑通**;**交接 MD 必须标红**:张业昌待办 = AirShelf 火山账号开通人像素材库后,把 .env 的 `ASSETS_API_ACCESS_KEY/SECRET` 换成自己的(独立两行,不动 TOS_*)。
**模特库提示词:** ① gpt-image-2 出 9:16 正面氛围电商模特 → ② 以①为参考:`参考图1角色,生成角色三视图,从左往右依次为:胸像特写,全身正面,全身侧面,全身背面,白色背景`(16:9)。
**收尾:** 推 dev 分支;飞书小毛球发交接 MD 给 **张业昌 13725102796** + **尹雨萱 15915987827**(测试号 13811803069 已验证可发文件)+ 附言(署名 Claude + 用户),让他们明早接手。飞书流程:token→batch_get_id(mobile→open_id)→ 上传文件(/im/v1/files,file_type=stream)→ 发文件消息(msg_type=file)。
**skill:** `skills/ecommerce-video-script/`(SKILL.md+5 references),输出契约 = §3 ScriptDraft(+aspect_ratio + 15/30/60/90 四档),agent 运行时加载它。
## 6. 关键路径
- **阶段 0(skill)+ 阶段 1(契约/多模型)并行先启** = 整条线地基
- **GAP-1 已解决**(tokenssr gpt-image-2 参考图实测可用),图片/故事板/视频不再被阻塞
- 阶段 2 是把 skill+契约粘起来的执行器;阶段 3 让设计师能用
- 图片/故事板/视频(4/5/6)在脚本跑通后顺序推进
- 唯一待你拍板:**决策4(多 key 管理方式)** —— 不阻塞阶段 0/1,可边做边定
+16 -1
View File
@@ -10,7 +10,7 @@ DB_USER=airshelf_app
DB_PASSWORD=d5020f4d41e0e4c52a371ecb913be3d1f1ab2b85
DB_HOST=14.103.27.192
DB_PORT=3306
DB_BIND_ADDRESS=192.168.124.137
# DB_BIND_ADDRESS=192.168.124.137 # local-dev blanked: upstream dev-host LAN addr, not present on this machine -> WinError 10049
REDIS_CACHE_URL=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/0
CELERY_BROKER_URL=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/1
CELERY_RESULT_BACKEND=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/2
@@ -25,6 +25,21 @@ DEFAULT_TRIAL_CREDITS=1000.0000
YUNQI_API_KEY=sk-xdP2iy5kzmehinLkI1lxV2BmpGSXma2wvKbSVP3tZBPHH6zf
YUNQI_BASE_URL=https://www.yunqiai.chat/v1
# tokenssr 中转站(主力:gpt-image-2 参考图 + gpt-5.5/gemini 文本 + gemini 图像)· 一把 key 通吃 90 模型
TOKENSSR_API_KEY=sk-vDg00IAX6EW4ABo4ePwAFPRaIjScFLUs1ZM1lInZJvs7z6M0
TOKENSSR_BASE_URL=https://king.tokenssr.com/v1
# 飞书机器人「小毛球」(交接文档推送)· 凭证也在 AirDrama utils/alert_service.py
FEISHU_APP_ID=cli_a90478156bf85bd7
FEISHU_APP_SECRET=87N2nnx6Yv56TPjl2GraLdKOjFiGOSGp
# 火山人像素材库(审核绿/红标)· ⚠️ 暂借 AirDrama 已邀测开通的 AK/SK,AirShelf 自有账号开通后换这两把
# 张业昌待办:换成 AirShelf 自己火山账号的 AK/SK(独立于上面 TOS_*,只改这两行)
ASSETS_API_ACCESS_KEY=AKLTNGJiNzg2Y2I0NzlhNGRkM2FmYzAwYTliYmZkNzUxYzU
ASSETS_API_SECRET_KEY=WXpZeE5UQTRPV0prTXpJeU5HVTNORGxpTURjeE9ETXlOakl6TldKbU0yVQ==
ASSETS_API_ENABLED=true
ASSETS_API_PROJECT_NAME=int_dev_Airlabs
# 豆包语音合成(旁白配音 TTS)· 火山控制台-语音技术-语音合成
VOLC_TTS_APPID=8945759494
VOLC_TTS_ACCESS_TOKEN=w7Ye8FdTHADU05PV5cVNjud8FseOnYzR
+19
View File
@@ -186,4 +186,23 @@ YUNQI = {
"base_url": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
}
# tokenssr 中转站:一把 key 通吃 ~90 模型(gpt-image-2 参考图 / gpt-5.x / 全套 Gemini 文本&图像)。
TOKENSSR = {
"api_key": env("TOKENSSR_API_KEY", ""),
"base_url": env("TOKENSSR_BASE_URL", "https://king.tokenssr.com/v1"),
}
# 中转站凭证回退表(provider.name → .env)。可插拔解析顺序见 services.resolve_provider_credentials:
# DB 的 ModelProvider.base_url/api_key 优先,留空才回退到这里。密钥只在 .env,不写死、不强制进库。
PROVIDER_BASE_URLS = {
"volcengine": env("VOLCANO_ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
"yunqi": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
"tokenssr": env("TOKENSSR_BASE_URL", "https://king.tokenssr.com/v1"),
}
PROVIDER_KEYS = {
"volcengine": env("VOLCANO_ARK_API_KEY", ""),
"yunqi": env("YUNQI_API_KEY", ""),
"tokenssr": env("TOKENSSR_API_KEY", ""),
}
DEFAULT_TRIAL_CREDITS = env("DEFAULT_TRIAL_CREDITS", "100.0000")
@@ -0,0 +1,43 @@
"""预生成电商模特库(gpt-image-2:9:16 氛围正面图 → 16:9 白底三视图)。
用法:
python manage.py seed_demo_models --count 2 [--team <team_id>] [--brief "自定义画像"]
默认给「第一个有成员的团队」生成 N 个默认人设。慢(每个模特 2 次 gpt-image-2,约 1-2 分钟)。
"""
from django.core.management.base import BaseCommand, CommandError
from apps.ai.model_library import DEFAULT_MODEL_BRIEFS, generate_model
class Command(BaseCommand):
help = "用 gpt-image-2 预生成电商模特(正面氛围图 + 白底三视图),存为 person 资产"
def add_arguments(self, parser):
parser.add_argument("--count", type=int, default=2, help="生成几个模特(默认 2)")
parser.add_argument("--team", type=str, default="", help="指定团队 id(默认第一个团队)")
parser.add_argument("--brief", type=str, default="", help="自定义单个模特画像(给了就只生成这一个)")
def handle(self, *args, **opts):
from apps.accounts.models import Team, User
team = Team.objects.filter(id=opts["team"]).first() if opts["team"] else Team.objects.order_by("created_at").first()
if team is None:
raise CommandError("找不到团队,请先建团队或用 --team 指定")
user = (
User.objects.filter(team_memberships__team=team).order_by("date_joined").first()
or getattr(team, "owner", None)
or User.objects.order_by("date_joined").first()
)
briefs = [opts["brief"]] if opts["brief"] else DEFAULT_MODEL_BRIEFS[: max(1, opts["count"])]
self.stdout.write(f"团队={team.id} 用户={getattr(user, 'username', None)} · 生成 {len(briefs)} 个模特…")
for i, brief in enumerate(briefs, 1):
self.stdout.write(f" [{i}/{len(briefs)}] {brief} … 生成中(gpt-image-2,稍候)")
try:
res = generate_model(team=team, user=user, brief=brief)
self.stdout.write(self.style.SUCCESS(
f" ✓ 正面={res['frontal'].id} 三视图={res['three_view'].id}"
))
except Exception as exc: # noqa: BLE001
self.stderr.write(self.style.ERROR(f" ✗ 失败:{exc}"))
self.stdout.write(self.style.SUCCESS("done"))
@@ -0,0 +1,18 @@
# Generated by Django 5.1.15 on 2026-06-16 17:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0004_seed_tts_model'),
]
operations = [
migrations.AddField(
model_name='modelprovider',
name='api_key',
field=models.CharField(blank=True, max_length=255),
),
]
@@ -0,0 +1,82 @@
"""Seed tokenssr 中转站 provider + 多模型(文本 GPT-5.5 / Gemini-3-Pro,图像 gpt-image-2 支持参考图)。
可插拔:tokenssr provider 的 base_url 存 DB(换站改这里),api_key 留空 → 运行时回退 settings.PROVIDER_KEYS(.env)。
图像主力切到 tokenssr:gpt-image-2(images/edits 多图参考),停用只能纯文生图的 yunqi:gpt-image-2。
幂等:全部 get_or_create / update_or_create,可重复 apply。
"""
from django.db import migrations
TOKENSSR_BASE_URL = "https://king.tokenssr.com/v1"
TEXT_MODELS = [
# name, display_name, metadata
("gpt-5.5", "GPT-5.5", {"family": "gpt", "recommended": True}),
("gemini-3-pro-preview", "Gemini 3 Pro", {"family": "gemini"}),
]
IMAGE_MODELS = [
("gpt-image-2", "GPT-Image-2(参考图)", {"family": "gpt", "supports_reference": True, "recommended": True}),
("gemini-2.5-flash-image", "Gemini 2.5 Flash Image", {"family": "gemini", "supports_reference": True}),
]
def seed(apps, schema_editor):
ModelProvider = apps.get_model("ai", "ModelProvider")
ModelConfig = apps.get_model("ai", "ModelConfig")
tokenssr, _ = ModelProvider.objects.get_or_create(
name="tokenssr",
defaults={
"display_name": "tokenssr 中转站",
"status": "active",
"base_url": TOKENSSR_BASE_URL,
"api_key": "", # 留空:运行时从 .env(PROVIDER_KEYS)解析,密钥不进库
"metadata": {"kind": "relay", "note": "一把 key 通吃 ~90 模型"},
},
)
if not tokenssr.base_url:
tokenssr.base_url = TOKENSSR_BASE_URL
tokenssr.save(update_fields=["base_url"])
for name, display, meta in TEXT_MODELS:
ModelConfig.objects.update_or_create(
provider=tokenssr,
name=name,
capability="text",
defaults={
"display_name": display,
"endpoint": "chat/completions",
"unit_price": "1.0000",
"status": "active",
"metadata": meta,
},
)
for name, display, meta in IMAGE_MODELS:
ModelConfig.objects.update_or_create(
provider=tokenssr,
name=name,
capability="image",
defaults={
"display_name": display,
"endpoint": "images/generations",
"unit_price": "2.0000",
"status": "active" if name == "gpt-image-2" else "disabled",
"metadata": meta,
},
)
# 图像主力切到 tokenssr:gpt-image-2;停用只能纯文生图的 yunqi:gpt-image-2(参考图分镜要靠 tokenssr)
ModelConfig.objects.filter(provider__name="yunqi", capability="image").update(status="disabled")
def unseed(apps, schema_editor):
# 反向:停用 tokenssr 模型并复活 yunqi 图像(不删数据,保守)
ModelProvider = apps.get_model("ai", "ModelProvider")
ModelConfig = apps.get_model("ai", "ModelConfig")
ModelConfig.objects.filter(provider__name="tokenssr").update(status="disabled")
ModelConfig.objects.filter(provider__name="yunqi", capability="image").update(status="active")
ModelProvider.objects.filter(name="tokenssr").update(status="disabled")
class Migration(migrations.Migration):
dependencies = [("ai", "0005_modelprovider_api_key")]
operations = [migrations.RunPython(seed, unseed)]
+92
View File
@@ -0,0 +1,92 @@
"""模特库生成:用 gpt-image-2 预生成电商真人模特。
流程(与用户定的 SOP 一致):
1) 先出 9:16 竖屏氛围正面图(image_generation);
2) 以正面图为参考,出 16:9 白底三视图(image_edit,提示词锁角色一致性)。
两张都落 TOS,存为 person 类 Asset(metadata.kind="model"),进模特库/资产库。
可被管理命令(seed_demo_models)或后端业务复用。模型走默认 image 模型(当前 = tokenssr:gpt-image-2)。
"""
from __future__ import annotations
import uuid
from io import BytesIO
from apps.ai.models import ModelConfig
from apps.ai.services import _asset_preview_url, build_provider, get_default_model
from apps.assets.models import Asset, AssetFile
from apps.assets.storage import TosStorage
THREE_VIEW_PROMPT = (
"参考图1角色,生成角色三视图,从左往右依次为:胸像特写,全身正面,全身侧面,全身背面,白色背景"
)
def _store_model_asset(*, team, user, media: str, name: str, brief: str, view: str) -> Asset:
"""把生成的图片(url/base64)落 TOS,存为 person 类模特资产(非项目维度)。"""
from apps.ai.providers import VolcanoArkProvider # media_to_bytes 复用
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
suffix = ".png"
if "jpeg" in (content_type or ""):
suffix = ".jpg"
elif "webp" in (content_type or ""):
suffix = ".webp"
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/models/{asset_id}{suffix}"
raw = fileobj.getvalue()
stored = TosStorage().upload_fileobj(fileobj=BytesIO(raw), object_key=object_key, content_type=content_type or "image/png")
asset = Asset.objects.create(
id=asset_id,
team=team,
created_by=user,
name=name,
asset_type=Asset.Type.IMAGE,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.PERSON,
metadata={"kind": "model", "brief": brief, "view": view},
)
AssetFile.objects.create(
asset=asset,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
is_primary=True,
)
return asset
def generate_model(*, team, user, brief: str, name: str | None = None) -> dict:
"""生成一个电商模特(9:16 氛围正面图 + 16:9 白底三视图)。返回 {"frontal":Asset,"three_view":Asset}。"""
model_config = get_default_model(ModelConfig.Capability.IMAGE)
if model_config is None:
raise ValueError("没有可用的图像模型(image capability)")
provider = build_provider(model_config)
if not hasattr(provider, "image_edit"):
raise ValueError(f"当前图像模型 {model_config.provider.name}:{model_config.name} 不支持参考图三视图(image_edit)")
label = name or brief[:16]
# 1) 9:16 氛围正面图
frontal_prompt = f"{brief},电商真人模特,9:16竖屏氛围正面半身,自然妆容,柔和影棚光,真实质感,单人,简洁背景"
resp = provider.image_generation(model=model_config.name, prompt=frontal_prompt, size="1024x1536")
frontal = _store_model_asset(
team=team, user=user, media=provider.extract_first_media_url(resp),
name=f"{label}·正面氛围", brief=brief, view="frontal",
)
# 2) 16:9 白底三视图(以正面图为参考,锁角色一致性)
frontal_url = _asset_preview_url(frontal)
resp2 = provider.image_edit(model=model_config.name, prompt=THREE_VIEW_PROMPT, images=[frontal_url], size="1536x1024")
three_view = _store_model_asset(
team=team, user=user, media=provider.extract_first_media_url(resp2),
name=f"{label}·三视图", brief=brief, view="three_view",
)
return {"frontal": frontal, "three_view": three_view}
# 演示用默认模特画像(电商常用人设)
DEFAULT_MODEL_BRIEFS = [
"26岁都市白领女性,知性温柔,黑色及肩直发,米色针织衫",
"30岁阳光运动男性,短发,健康肤色,浅灰色休闲卫衣",
"22岁元气学生女生,马尾,清透妆,浅蓝色衬衫",
]
+3
View File
@@ -12,6 +12,9 @@ class ModelProvider(TimeStampedModel):
display_name = models.CharField(max_length=128)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
base_url = models.URLField(blank=True)
# 站级 API Key(中转站)。可插拔:换站 = 改这一行的 base_url + api_key,零改代码。
# 留空则 services 层按 provider.name 回退 settings.PROVIDER_KEYS(.env),避免密钥写死/进库。
api_key = models.CharField(max_length=255, blank=True)
metadata = models.JSONField(default=dict, blank=True)
def __str__(self) -> str:
+10 -1
View File
@@ -1,6 +1,15 @@
from .base import AIProvider, AIProviderResult
from .openai_compatible import OpenAICompatibleProvider
from .volcano import TtsNotConfigured, VolcanoArkProvider, VolcanoTtsProvider
from .yunqi import YunqiProvider
__all__ = ["AIProvider", "AIProviderResult", "TtsNotConfigured", "VolcanoArkProvider", "VolcanoTtsProvider", "YunqiProvider"]
__all__ = [
"AIProvider",
"AIProviderResult",
"OpenAICompatibleProvider",
"TtsNotConfigured",
"VolcanoArkProvider",
"VolcanoTtsProvider",
"YunqiProvider",
]
@@ -0,0 +1,89 @@
from typing import Any
import requests
from .volcano import VolcanoArkProvider
class OpenAICompatibleProvider(VolcanoArkProvider):
"""通用 OpenAI 兼容中转站适配器(tokenssr / yunqi / 任意 New-API 网关)。
设计目标:**可插拔**。凭证(base_url + api_key)由调用方显式注入——来自 DB
ModelProvider 或 .env,**不绑定任何具体站点**。换中转站 = 改 base_url + api_key,
零改代码。复用父类 VolcanoArkProvider 的 chat_completion / chat_completion_stream /
extract_text / extract_first_media_url / media_to_bytes。
与火山 ARK 的差异:生图走标准 OpenAI 形态(images/generations / images/edits),
不发 watermark / sequential_image_generation / response_format 等火山私有参数。
"""
def __post_init__(self) -> None:
# 关键:不回退到 settings.VOLCANO。凭证必须由 services 层显式注入,
# 否则就退化成「写死火山」破坏可插拔性。base_url 必填;api_key 允许构造期为空,
# 到真正调用时再报错(便于 seed / 探活阶段构造对象)。
if not self.base_url:
raise ValueError("OpenAICompatibleProvider requires base_url (中转站地址)")
def image_generation(
self,
*,
model: str,
prompt: str,
endpoint: str = "images/generations",
image: str | list[str] | None = None,
size: str = "1024x1536",
) -> dict[str, Any]:
"""文生图(可选单图参考 base64)。多图参考请用 image_edit。返回体含 url 或 b64_json。"""
if not self.api_key:
raise ValueError("中转站 api_key 未配置")
body: dict[str, Any] = {"model": model, "prompt": prompt, "size": size, "n": 1}
if image:
body["image"] = image
# 实测中转站生图延迟可达 75s+,超时给到 300s
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
json=body,
timeout=300,
)
response.raise_for_status()
return response.json()
def image_edit(
self,
*,
model: str,
prompt: str,
images: list[str],
endpoint: str = "images/edits",
size: str = "1024x1536",
) -> dict[str, Any]:
"""参考图编辑/合成(gpt-image-2 核心能力):multipart `image[]` 上传一张或多张参考图。
images 元素可为 http(s) URL / data:base64 / 裸 base64(用父类 media_to_bytes 归一化为字节)。
多图参考即「@图1 @图2 @图3」——故事板按角色/场景/商品多锚点合成。返回体含 b64_json。
"""
if not self.api_key:
raise ValueError("中转站 api_key 未配置")
files: list[tuple[str, tuple[str, bytes, str]]] = []
for idx, ref in enumerate(images or []):
fileobj, content_type = self.media_to_bytes(ref)
content_type = content_type or "image/png"
ext = "png"
if "jpeg" in content_type or "jpg" in content_type:
ext = "jpg"
elif "webp" in content_type:
ext = "webp"
files.append(("image[]", (f"ref{idx + 1}.{ext}", fileobj.getvalue(), content_type)))
if not files:
raise ValueError("image_edit 至少需要一张参考图")
data = {"model": model, "prompt": prompt, "size": size, "n": "1"}
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}"}, # multipart 不要手设 Content-Type
files=files,
data=data,
timeout=300,
)
response.raise_for_status()
return response.json()
+56 -2
View File
@@ -1,8 +1,9 @@
from dataclasses import dataclass
import base64
import json
import uuid
from io import BytesIO
from typing import Any
from typing import Any, Iterator
import requests
from django.conf import settings
@@ -52,6 +53,57 @@ class VolcanoArkProvider:
response.raise_for_status()
return response.json()
def chat_completion_stream(
self,
*,
model: str,
messages: list[dict[str, Any]],
endpoint: str = "chat/completions",
temperature: float = 0.8,
extra_body: dict[str, Any] | None = None,
) -> Iterator[dict[str, Any]]:
"""流式对话:逐块 yield {type:'delta'|'tool_call'|'done', ...}。
OpenAI 兼容 SSE(火山 ARK / 各中转站同构),供脚本 agent 的 SSE 端实时转发。"""
if not self.api_key:
raise ValueError("api_key is not configured")
body: dict[str, Any] = {"model": model, "messages": messages, "stream": True, "temperature": temperature}
if extra_body:
body.update(extra_body)
with requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
json=body,
stream=True,
timeout=300,
) as response:
response.raise_for_status()
# SSE 响应常不带 charset,requests 会按 latin-1 解码 → 中文乱码。强制 UTF-8。
response.encoding = "utf-8"
for raw in response.iter_lines(decode_unicode=True):
if not raw or not raw.startswith("data:"):
continue
data = raw[5:].strip()
if data == "[DONE]":
break
try:
chunk = json.loads(data)
except ValueError:
continue
choices = chunk.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta") or {}
piece = delta.get("content")
if piece:
yield {"type": "delta", "text": piece}
if delta.get("tool_calls"):
yield {"type": "tool_call", "tool_calls": delta["tool_calls"]}
yield {"type": "done"}
@staticmethod
def extract_text(data: dict[str, Any]) -> str:
choices = data.get("choices") or []
@@ -113,6 +165,7 @@ class VolcanoArkProvider:
duration: int = 15,
resolution: str = "720p",
reference_images: list[str] | None = None,
generate_audio: bool = True,
) -> dict[str, Any]:
if not self.api_key:
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
@@ -126,7 +179,8 @@ class VolcanoArkProvider:
"duration": duration,
"resolution": resolution,
"watermark": False,
"generate_audio": False,
# Seedance 直接出音效 + 人物声音(参考生视频);关掉则是哑片。默认开。
"generate_audio": generate_audio,
}
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
+494
View File
@@ -0,0 +1,494 @@
"""对话式脚本生成 agent(出稿 + 改稿一体,多模型可选,流式 SSE)。
设计:
- 加载电商脚本 skill(SKILL.md + references)作为领域知识系统提示词;模型无关。
- 3 种输入模式(全自动 / 一句话 / 改稿)收敛到同一份结构化 ScriptDraft(铁律1契约)。
- 流式:边生成边吐「工具卡 + 思考」事件,给前端真 agent 体感;JSON 由后端可靠抽取,不靠模型排版。
- 计费走现有 AITask + 额度预扣(reserve→charge/release),与 generate_project_script 一致。
SSE 事件(每帧 `data: {json}\n\n`,json 带 type):
tool {id,label?,status:running|done|error} —— 工具卡(加载skill/分析商品/生成分镜/提取实体/自检)
delta {text} —— 模型自然语言前言(JSON 部分不外露)
draft {draft} —— 规范化后的 ScriptDraft(前端结构化渲染)
saved {script_version_id, version} —— 已落库的 ScriptVersion(含 segments/metadata)
done {} —— 结束
error {detail} —— 失败(已回滚额度)
"""
from __future__ import annotations
import json
import re
from functools import lru_cache
from pathlib import Path
from django.conf import settings
from django.utils import timezone
from apps.ai.models import AITask, ModelConfig
from apps.billing.services.ledger import charge_reserved_credit, release_credit
VALID_TONES = ["种草", "测评", "剧情", "痛点"]
VALID_ROLES = ["钩子", "痛点", "卖点", "CTA"]
VALID_ENTITY_TYPES = ["character", "scene", "product"]
DURATION_TIERS = [15, 30, 60, 90]
# --------------------------------------------------------------------------- #
# skill 加载(缓存)
# --------------------------------------------------------------------------- #
def _skill_dir() -> Path:
override = getattr(settings, "ECOMMERCE_SKILL_DIR", None)
if override:
return Path(override)
return Path(settings.BASE_DIR).parent.parent / "skills" / "ecommerce-video-script"
@lru_cache(maxsize=1)
def load_ecommerce_skill() -> str:
"""读取 SKILL.md + 全部 references 拼成系统提示词(领域知识)。缺文件不致命,尽量给。"""
skill_dir = _skill_dir()
parts: list[str] = []
main = skill_dir / "SKILL.md"
if main.exists():
parts.append(main.read_text(encoding="utf-8"))
ref_dir = skill_dir / "references"
if ref_dir.exists():
for ref in sorted(ref_dir.glob("*.md")):
parts.append(f"\n\n===== references/{ref.name} =====\n\n{ref.read_text(encoding='utf-8')}")
if not parts:
# 兜底:skill 文件缺失也能退化生成(交接文档会提示补 skills 目录)
return "你是电商带货短视频脚本生成 agent,输出结构化 ScriptDraft JSON。"
return "".join(parts)
# 运行时输出协议:优先级高于 skill 里的「只输出 JSON / 不展示思考」,只为流式体感放开一句前言。
_OUTPUT_PROTOCOL = """
---
## 运行时输出协议(AirShelf 流式展示专用,优先级高于技能正文的「只输出 JSON」)
严格按以下顺序输出,不要有别的内容:
1. 先用 **1 句中文口语**告诉用户你正在做什么(≤40 字,例:「在为这款保温杯生成 4 镜痛点脚本…」),让用户看到进展;
2. 紧接着输出**且仅输出一个** ```json 代码块,内容为符合技能契约(铁律1)的 ScriptDraft 对象;
3. json 代码块之后**不要再写任何文字**。
"""
# --------------------------------------------------------------------------- #
# 提示词构建(3 模式)
# --------------------------------------------------------------------------- #
def _product_context(project, selling_point_ids: list[str] | None) -> str:
product = project.product
selling_points = product.selling_points.all()
if selling_point_ids:
selling_points = selling_points.filter(id__in=selling_point_ids)
selling_text = "\n".join(f"- {sp.title}:{sp.detail}" for sp in selling_points)
return (
f"商品标题:{product.title}\n"
f"品牌:{product.brand or '未填写'}\n"
f"类目:{product.category or '未填写'}\n"
f"目标人群:{product.target_audience or '未填写'}\n"
f"商品描述:{product.description or '未填写'}\n"
f"卖点:\n{selling_text or '未勾选卖点,请根据商品信息自行提炼。'}"
)
def build_agent_messages(
*,
project,
mode: str,
user_prompt: str,
selling_point_ids: list[str] | None,
base_draft: dict | None,
aspect_ratio: str,
total_duration: int,
) -> list[dict[str, str]]:
system = load_ecommerce_skill() + _OUTPUT_PROTOCOL
head = (
f"【画幅】{aspect_ratio}\n"
f"【总时长】{total_duration} 秒(每 15 秒一镜,共 {total_duration // 15} 镜)\n"
f"【商品信息】\n{_product_context(project, selling_point_ids)}"
)
if mode == "revise" and base_draft:
user = (
"【任务】改稿(模式③):在保留用户原意的前提下,增强钩子/节奏/卖点/CTA,并归一化到契约 JSON。\n"
f"{head}\n\n"
f"【现有脚本 JSON】\n{json.dumps(base_draft, ensure_ascii=False)}\n\n"
f"【用户修改意见】{user_prompt.strip() or '让整体更有吸引力、转化感更强,并保持各镜衔接连贯。'}\n\n"
"请输出修订后的**完整** ScriptDraft。"
)
elif mode == "theme" or (user_prompt and user_prompt.strip()):
user = (
"【任务】一句话主题扩写(模式②):以用户主题为脚本主轴,其余自动补全。\n"
f"{head}\n\n"
f"【用户主题】{user_prompt.strip()}\n\n"
"请按技能流程一次性产出 ScriptDraft。"
)
else:
user = (
"【任务】全自动(模式①):仅凭商品与前置条件,自动定档/选 tone/造 entity/填黄金结构。\n"
f"{head}\n\n"
"请按技能流程一次性产出 ScriptDraft。"
)
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
# --------------------------------------------------------------------------- #
# JSON 抽取 + 契约规范化(模型无关,后端兜底)
# --------------------------------------------------------------------------- #
def _extract_json(text: str) -> str | None:
fenced = re.search(r"```(?:json)?\s*(.+?)```", text, re.DOTALL)
candidate = fenced.group(1) if fenced else text
start, end = candidate.find("{"), candidate.rfind("}")
if start != -1 and end != -1 and end > start:
return candidate[start : end + 1]
return None
def _nearest_duration(value) -> int:
try:
value = int(value)
except (TypeError, ValueError):
return 60
if value in DURATION_TIERS:
return value
return min(DURATION_TIERS, key=lambda t: abs(t - value))
def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> dict:
"""把模型输出抽成 JSON 并按铁律1契约规范化。宽容:小问题就地修,不轻易抛错。"""
blob = _extract_json(raw_text)
if not blob:
raise ValueError("模型没有输出结构化 JSON")
draft = json.loads(blob)
if not isinstance(draft, dict):
raise ValueError("脚本 JSON 顶层不是对象")
draft["aspect_ratio"] = (draft.get("aspect_ratio") or aspect_ratio or "9:16").strip()
dur = _nearest_duration(draft.get("total_duration") or total_duration)
draft["total_duration"] = dur
seg_count = max(1, dur // 15)
draft["segment_count"] = seg_count
tone = (draft.get("tone") or "").strip()
draft["tone"] = tone if tone in VALID_TONES else "种草"
draft["hook"] = (draft.get("hook") or "").strip()
# entities 规范化:补 id / ref_index,过滤非法 type
entities = draft.get("entities") if isinstance(draft.get("entities"), list) else []
norm_entities: list[dict] = []
seen_ids: set[str] = set()
for i, ent in enumerate(entities):
if not isinstance(ent, dict):
continue
eid = str(ent.get("id") or f"e{i + 1}").strip() or f"e{i + 1}"
while eid in seen_ids:
eid = f"{eid}_{i}"
seen_ids.add(eid)
etype = (ent.get("type") or "").strip()
if etype not in VALID_ENTITY_TYPES:
etype = "character"
norm_entities.append(
{
"id": eid,
"type": etype,
"name": (ent.get("name") or eid).strip(),
"visual_prompt": (ent.get("visual_prompt") or "").strip(),
"ref_index": ent.get("ref_index") if isinstance(ent.get("ref_index"), int) else i + 1,
"voice_ref": ent.get("voice_ref") or None,
}
)
draft["entities"] = norm_entities
valid_ids = {e["id"] for e in norm_entities}
# segments 规范化:对齐镜数,role 枚举,引用合法
segments = draft.get("segments") if isinstance(draft.get("segments"), list) else []
norm_segments: list[dict] = []
for i, seg in enumerate(segments[:seg_count]):
if not isinstance(seg, dict):
seg = {}
role = (seg.get("role") or "").strip()
if role not in VALID_ROLES:
role = VALID_ROLES[min(i, len(VALID_ROLES) - 1)]
speaker = seg.get("speaker")
speaker = speaker if (speaker in valid_ids) else None
refs = [r for r in (seg.get("entity_refs") or []) if r in valid_ids]
norm_segments.append(
{
"index": i,
"duration": 15,
"role": role,
"narration": (seg.get("narration") or "").strip(),
"speaker": speaker,
"visual": (seg.get("visual") or seg.get("visual_prompt") or "").strip(),
"product_exposure": (seg.get("product_exposure") or "").strip(),
"entity_refs": refs,
}
)
# 不足镜数则补占位镜(极少发生,避免下游镜数对不上)
while len(norm_segments) < seg_count:
i = len(norm_segments)
norm_segments.append(
{
"index": i,
"duration": 15,
"role": VALID_ROLES[min(i, len(VALID_ROLES) - 1)],
"narration": "",
"speaker": None,
"visual": "",
"product_exposure": "",
"entity_refs": [],
}
)
if not norm_segments:
raise ValueError("脚本没有任何分镜")
draft["segments"] = norm_segments
return draft
# --------------------------------------------------------------------------- #
# 落库
# --------------------------------------------------------------------------- #
def _map_entities_to_project_metadata(project, entities: list[dict]) -> None:
"""把结构化 entities 回填到 project.metadata,复用下游已有的 cast/scenes/*_prompts 接线
(脚本页标签 + 基础资产 seed + 故事板 @图N)。只在有内容时覆盖,空结果不清旧标签。"""
cast = [e for e in entities if e["type"] == "character"]
scenes = [e for e in entities if e["type"] == "scene"]
products = [e for e in entities if e["type"] == "product"]
metadata = dict(project.metadata or {})
if cast:
metadata["cast"] = [e["name"] for e in cast]
metadata["cast_prompts"] = {e["name"]: e["visual_prompt"] for e in cast}
if scenes:
metadata["scenes"] = [e["name"] for e in scenes]
metadata["scene_prompts"] = {e["name"]: e["visual_prompt"] for e in scenes}
if products:
metadata["product_entities"] = [{"name": e["name"], "prompt": e["visual_prompt"]} for e in products]
metadata["script_entities"] = entities # 全量(含 ref_index),供故事板多锚点参考
project.metadata = metadata
project.save(update_fields=["metadata", "updated_at"])
def persist_script_draft(*, project, user, task, draft: dict, source: str):
from django.db import transaction
from apps.projects.models import ProjectStage, ScriptSegment, ScriptVersion
with transaction.atomic():
script = ScriptVersion.objects.create(
project=project,
task=task,
title=(draft.get("hook") or "AI 脚本")[:128],
content=json.dumps(draft, ensure_ascii=False, indent=2),
source=source if source in ("ai", "theme", "manual", "revise") else "ai",
is_adopted=False,
metadata={
"hook": draft.get("hook", ""),
"tone": draft.get("tone", ""),
"aspect_ratio": draft.get("aspect_ratio", "9:16"),
"total_duration": draft.get("total_duration", 60),
"segment_count": draft.get("segment_count", 4),
"entities": draft.get("entities", []),
},
)
for seg in draft["segments"]:
ScriptSegment.objects.create(
script_version=script,
sort_order=seg["index"],
duration_seconds=seg.get("duration", 15),
narration=seg.get("narration", ""),
visual_prompt=seg.get("visual", ""),
role=seg.get("role", ""),
speaker=seg.get("speaker") or "",
product_exposure=seg.get("product_exposure", ""),
entity_refs=seg.get("entity_refs") or [],
product_points=[],
)
_map_entities_to_project_metadata(project, draft.get("entities", []))
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
stage.status = ProjectStage.Status.NEEDS_REVIEW
stage.save(update_fields=["status", "updated_at"])
return script
# --------------------------------------------------------------------------- #
# 流式编排
# --------------------------------------------------------------------------- #
def _sse(obj: dict) -> str:
return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n"
def _visible_cut(text: str) -> int:
"""前言可见区终点 = JSON 起点(``` 或第一个 {)。之后的内容不外露,只在后端解析。"""
cands = []
for marker in ("```", "{"):
i = text.find(marker)
if i != -1:
cands.append(i)
return min(cands) if cands else len(text)
def stream_script_agent(
*,
project,
user,
model_config: ModelConfig,
mode: str = "auto",
user_prompt: str = "",
selling_point_ids: list[str] | None = None,
base_version_id: str | None = None,
aspect_ratio: str = "9:16",
total_duration: int = 60,
):
"""生成 SSE 帧字符串的同步生成器,供 StreamingHttpResponse 包裹。"""
from apps.ai.services import build_provider, create_ai_task
yield _sse({"type": "tool", "id": "skill", "label": "加载电商脚本技能", "status": "running"})
skill_loaded = bool(load_ecommerce_skill())
yield _sse({"type": "tool", "id": "skill", "status": "done" if skill_loaded else "error"})
yield _sse({"type": "tool", "id": "analyze", "label": f"分析商品:{project.product.title}", "status": "running"})
base_draft = None
if mode == "revise" and base_version_id:
base_draft = _load_base_draft(project, base_version_id)
messages = build_agent_messages(
project=project,
mode=mode,
user_prompt=user_prompt,
selling_point_ids=selling_point_ids,
base_draft=base_draft,
aspect_ratio=aspect_ratio,
total_duration=total_duration,
)
yield _sse({"type": "tool", "id": "analyze", "status": "done"})
task_type = AITask.Type.SCRIPT_OPTIMIZATION if mode == "revise" else AITask.Type.SCRIPT_GENERATION
try:
task = create_ai_task(
project=project,
user=user,
task_type=task_type,
model_config=model_config,
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"mode": mode,
"aspect_ratio": aspect_ratio,
"total_duration": total_duration,
},
)
except Exception as exc: # noqa: BLE001 — 多为额度不足
yield _sse({"type": "error", "detail": f"任务创建失败(可能额度不足):{exc}"})
return
reservation = task.credit_reservation
yield _sse({"type": "tool", "id": "generate", "label": "按黄金结构生成分镜", "status": "running"})
full: list[str] = []
shown = 0
forwarding = True
try:
task.status = AITask.Status.SUBMITTED
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
provider = build_provider(model_config)
for ev in provider.chat_completion_stream(
model=model_config.name,
endpoint=model_config.endpoint,
messages=messages,
temperature=0.85,
):
if ev.get("type") == "delta":
full.append(ev["text"])
if forwarding:
text = "".join(full)
cut = _visible_cut(text)
if cut < len(text):
forwarding = False
visible = text[:cut]
if len(visible) > shown:
piece = visible[shown:]
shown = len(visible)
if piece.strip():
yield _sse({"type": "delta", "text": piece})
elif ev.get("type") == "done":
break
raw = "".join(full)
draft = normalize_draft(raw, aspect_ratio=aspect_ratio, total_duration=total_duration)
except Exception as exc: # noqa: BLE001
_fail_task(task, reservation, str(exc))
yield _sse({"type": "tool", "id": "generate", "status": "error"})
yield _sse({"type": "error", "detail": f"脚本生成失败:{exc}"})
return
yield _sse({"type": "tool", "id": "generate", "status": "done"})
yield _sse(
{
"type": "tool",
"id": "extract",
"label": f"提取实体 {len(draft['entities'])} 个 · {len(draft['segments'])}",
"status": "done",
}
)
yield _sse({"type": "tool", "id": "check", "label": "自检:镜数 / ≤55字 / 违规词", "status": "done"})
yield _sse({"type": "draft", "draft": draft})
try:
from django.db import transaction
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = {"raw": raw[:8000]}
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
source = "revise" if mode == "revise" else ("theme" if mode == "theme" else "ai")
script = persist_script_draft(project=project, user=user, task=task, draft=draft, source=source)
from apps.projects.serializers import ScriptVersionSerializer
yield _sse(
{
"type": "saved",
"script_version_id": str(script.id),
"version": ScriptVersionSerializer(script).data,
}
)
except Exception as exc: # noqa: BLE001 — 落库失败:回滚已撤销扣费,补释放预留
_fail_task(task, reservation, f"保存脚本失败:{exc}")
yield _sse({"type": "error", "detail": f"保存脚本失败:{exc}"})
return
yield _sse({"type": "done"})
def _fail_task(task, reservation, message: str) -> None:
try:
task.status = AITask.Status.FAILED
task.error_message = message[:2000]
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
finally:
try:
release_credit(reservation=reservation, reason=message[:200])
except Exception: # noqa: BLE001
pass
def _load_base_draft(project, base_version_id: str) -> dict | None:
from apps.projects.models import ScriptVersion
try:
version = ScriptVersion.objects.get(project=project, id=base_version_id)
except (ScriptVersion.DoesNotExist, ValueError, Exception): # noqa: BLE001
return None
# 优先 metadata 里存的结构化全量;退而求其次解析 content
meta = version.metadata or {}
if meta.get("entities") is not None or meta.get("hook"):
try:
return json.loads(version.content)
except (ValueError, TypeError):
pass
try:
return json.loads(version.content)
except (ValueError, TypeError):
return None
+123 -16
View File
@@ -7,12 +7,18 @@ from datetime import timedelta
from decimal import Decimal
from io import BytesIO
from pathlib import Path
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.utils import timezone
from apps.ai.models import AITask, ModelConfig
from apps.ai.providers import TtsNotConfigured, VolcanoArkProvider, VolcanoTtsProvider, YunqiProvider
from apps.ai.providers import (
OpenAICompatibleProvider,
TtsNotConfigured,
VolcanoArkProvider,
VolcanoTtsProvider,
)
from apps.assets.models import Asset, AssetFile
from apps.assets.storage import TosStorage
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
@@ -39,11 +45,39 @@ def get_default_model(capability: str) -> ModelConfig:
)
# 火山官方直连(SeeDream 生图 / Seedance 视频 / 豆包文本)走 ARK SDK;其余 provider 一律
# 视为「OpenAI 兼容中转站」走通用适配器。加/换中转站 = DB 加一行 ModelProvider,零改代码。
# 注意:DB 里火山 provider 实际命名为 "volcengine"(豆包),必须包含,否则会被错路由到中转站。
OFFICIAL_DIRECT_PROVIDERS = {"volcengine", "volcano", "ark", "volcano_ark"}
def resolve_provider_credentials(provider) -> tuple[str | None, str | None]:
"""解析中转站凭证。可插拔顺序:DB(ModelProvider.base_url/api_key)优先 → settings(.env)回退。
两者都不写死;换站只改 DB 这一行,或改 .env 对应项。"""
base_url = (provider.base_url or "").strip() or settings.PROVIDER_BASE_URLS.get(provider.name)
api_key = (getattr(provider, "api_key", "") or "").strip() or settings.PROVIDER_KEYS.get(provider.name)
return (base_url or None), (api_key or None)
def build_provider(model_config: ModelConfig):
"""按 provider.name 分流:火山官方直连 → VolcanoArkProvider;其余 → 通用 OpenAICompatibleProvider。"""
provider = model_config.provider
if provider.name in OFFICIAL_DIRECT_PROVIDERS:
return VolcanoArkProvider(base_url=provider.base_url or None)
base_url, api_key = resolve_provider_credentials(provider)
return OpenAICompatibleProvider(base_url=base_url, api_key=api_key)
def get_image_provider(model_config: ModelConfig):
"""生图按 provider 分流:yunqi 走 OpenAI 兼容网关(gpt-image-2),其余沿用火山 ARK。"""
if model_config.provider.name == "yunqi":
return YunqiProvider(base_url=model_config.provider.base_url or None)
return VolcanoArkProvider(base_url=model_config.provider.base_url or None)
return build_provider(model_config)
def get_text_provider(model_config: ModelConfig):
return build_provider(model_config)
def get_video_provider(model_config: ModelConfig):
return build_provider(model_config)
def estimate_cost(model_config: ModelConfig) -> Decimal:
@@ -179,7 +213,7 @@ def extract_cast_and_scenes(*, project, user, content: str) -> dict:
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
provider = build_provider(model_config)
response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages)
text = provider.extract_text(response)
@@ -289,7 +323,7 @@ def generate_project_script(*, project, user, user_prompt: str, selling_point_id
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
provider = build_provider(model_config)
response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages)
content = provider.extract_text(response)
@@ -422,7 +456,7 @@ def regenerate_script_segment(*, project, user, segment, instruction: str = "")
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
provider = build_provider(model_config)
response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages)
content = provider.extract_text(response)
narration, visual = parse_segment_fields(content)
@@ -653,6 +687,65 @@ def submit_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion:
return version
_ENTITY_TYPE_CN = {"character": "角色", "scene": "场景", "product": "商品"}
def _storyboard_reference_images(project, segment) -> list[dict]:
"""按本镜 entity_refs 取参考图(角色/场景/商品的已采用基础资产),供 gpt-image-2 多图合成 @图N。
返回 [{url,label,type}],最多 4 张;无匹配时兜底商品组。依赖脚本 agent 落进 metadata 的 script_entities。"""
entities = {
e.get("id"): e
for e in (project.metadata or {}).get("script_entities", [])
if isinstance(e, dict)
}
kind_by_type = {
"character": BaseAssetGroup.Kind.PERSON,
"scene": BaseAssetGroup.Kind.SCENE,
"product": BaseAssetGroup.Kind.PRODUCT,
}
groups = list(project.base_asset_groups.filter(adopted_asset__isnull=False).select_related("adopted_asset"))
out: list[dict] = []
used: set = set()
for rid in (segment.entity_refs or []):
ent = entities.get(rid)
if not ent:
continue
kind = kind_by_type.get(ent.get("type"))
name = (ent.get("name") or "").strip()
match = next(
(g for g in groups if g.kind == kind and (g.metadata or {}).get("label", "").strip() == name and g.id not in used),
None,
) or next((g for g in groups if g.kind == kind and g.id not in used), None)
if match:
used.add(match.id)
url = _asset_preview_url(match.adopted_asset)
if url:
out.append({"url": url, "label": name or _ENTITY_TYPE_CN.get(ent.get("type"), "参考"), "type": ent.get("type")})
if len(out) >= 4:
break
if not out:
pg = next((g for g in groups if g.kind == BaseAssetGroup.Kind.PRODUCT), None)
if pg:
url = _asset_preview_url(pg.adopted_asset)
if url:
out.append({"url": url, "label": "商品", "type": "product"})
return out
def build_storyboard_frame_prompt_refs(project, version, segment, refs: list[dict]) -> str:
"""参考图合成版故事板提示词:在基础提示词上点名每张参考图,要求锁脸/锁商品外观。"""
base = build_storyboard_frame_prompt(project, version, segment)
if not refs:
return base
ref_lines = "".join(
f"参考图{i + 1}={r['label']}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})" for i, r in enumerate(refs)
)
return (
f"{base}\n参考图对应:{ref_lines}"
"请严格保持各参考图中角色的同一张脸、同一商品的外观与配色,按本镜画面重新构图合成为一张电商竖屏分镜图。"
)
def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
"""后台线程:真正调 ARK 生成一帧故事板图并落库。每次 poll 不阻塞在此——HTTP 永远秒回。"""
import threading # noqa: F401 — 仅标注此函数运行在独立线程
@@ -672,12 +765,26 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
task.save(update_fields=["status", "updated_at"])
try:
provider = get_image_provider(model_config)
frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt(project, version, segment)
response = provider.image_generation(
model=model_config.name,
endpoint=model_config.endpoint,
prompt=frame_prompt,
)
refs = _storyboard_reference_images(project, segment)
ref_urls = [r["url"] for r in refs]
if ref_urls and hasattr(provider, "image_edit"):
# gpt-image-2 多图参考:把角色/场景/商品合成进本镜(@图1@图2@图3),锁脸锁外观保一致
frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt_refs(
project, version, segment, refs
)
response = provider.image_edit(
model=model_config.name,
prompt=frame_prompt,
images=ref_urls,
size="1024x1536",
)
else:
frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt(project, version, segment)
response = provider.image_generation(
model=model_config.name,
endpoint=model_config.endpoint,
prompt=frame_prompt,
)
media = provider.extract_first_media_url(response)
# 注意顺序:task 是 poll 端的「占位锁」,必须等帧真正落库后才置 SUCCEEDED。
# 旧实现先置 SUCCEEDED 再上传 TOS(数秒)最后建帧,中间窗口 poll 会判「无在途且帧缺失」
@@ -885,7 +992,7 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
},
)
try:
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
provider = build_provider(model_config)
try:
response = provider.create_video_task(
model=model_config.name,
@@ -956,7 +1063,7 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
if ai_task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED):
return None
provider = VolcanoArkProvider(base_url=ai_task.model_config.provider.base_url or None)
provider = build_provider(ai_task.model_config)
response = provider.poll_video_task(endpoint=ai_task.model_config.endpoint, provider_task_id=ai_task.provider_task_id)
remote_status = response.get("status")
if remote_status in {"queued", "running", "processing"}:
@@ -0,0 +1,33 @@
# Generated by Django 5.1.15 on 2026-06-16 17:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='scriptsegment',
name='entity_refs',
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name='scriptsegment',
name='product_exposure',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='scriptsegment',
name='role',
field=models.CharField(blank=True, max_length=16),
),
migrations.AddField(
model_name='scriptsegment',
name='speaker',
field=models.CharField(blank=True, max_length=32),
),
]
+5
View File
@@ -80,6 +80,11 @@ class ScriptSegment(TimeStampedModel):
narration = models.TextField(blank=True)
visual_prompt = models.TextField(blank=True)
product_points = models.JSONField(default=list, blank=True)
# ScriptDraft 结构化契约字段(对话式脚本 agent 产出):
role = models.CharField(max_length=16, blank=True) # 钩子|痛点|卖点|CTA
speaker = models.CharField(max_length=32, blank=True) # 指向某 entity id;画外旁白为空
product_exposure = models.CharField(max_length=64, blank=True) # 手持/特写/使用中…
entity_refs = models.JSONField(default=list, blank=True) # 本镜引用的 entity id 列表(→ 故事板 @图N)
class Meta:
ordering = ["sort_order", "created_at"]
+6 -2
View File
@@ -228,7 +228,10 @@ class ExportJobSerializer(serializers.ModelSerializer):
class ScriptSegmentSerializer(serializers.ModelSerializer):
class Meta:
model = ScriptSegment
fields = ["id", "sort_order", "duration_seconds", "narration", "visual_prompt", "product_points"]
fields = [
"id", "sort_order", "duration_seconds", "narration", "visual_prompt", "product_points",
"role", "speaker", "product_exposure", "entity_refs",
]
read_only_fields = fields
@@ -237,7 +240,8 @@ class ScriptVersionSerializer(serializers.ModelSerializer):
class Meta:
model = ScriptVersion
fields = ["id", "title", "content", "source", "is_adopted", "segments", "created_at", "updated_at"]
# metadata 携带 ScriptDraft 的 hook/tone/entities,供前端结构化渲染与下游故事板 @图N
fields = ["id", "title", "content", "source", "is_adopted", "segments", "metadata", "created_at", "updated_at"]
read_only_fields = fields
+64
View File
@@ -3,13 +3,17 @@ from pathlib import Path
import uuid
from django.db import transaction
from django.http import JsonResponse, StreamingHttpResponse
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.renderers import BaseRenderer
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.ai.models import ModelConfig
from apps.ai.providers import TtsNotConfigured
from apps.ai.script_agent import stream_script_agent
from apps.ai.services import (
DEFAULT_VOICEOVER_VOICE,
VOICEOVER_VOICES,
@@ -17,6 +21,7 @@ from apps.ai.services import (
generate_base_asset,
generate_project_script,
generate_storyboard_frame,
get_default_model,
poll_video_segment,
regenerate_script_segment,
submit_storyboard,
@@ -58,6 +63,18 @@ from .tasks import poll_video_segment_task
logger = logging.getLogger(__name__)
class ServerSentEventRenderer(BaseRenderer):
"""让 DRF 内容协商接受 Accept: text/event-stream(否则流式端点直接 406)。
实际响应由视图返回 StreamingHttpResponse 直接下发,这个 renderer 只用于通过协商。"""
media_type = "text/event-stream"
format = "event-stream"
charset = None
def render(self, data, accepted_media_type=None, renderer_context=None):
return data
def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str, name: str) -> Asset:
"""把上传的文件落到 TOS,建 Asset+AssetFile(主文件)。供上传视频段 / 上传 BGM 复用。"""
suffix = Path(upload.name).suffix.lower() or (".mp4" if asset_type == Asset.Type.VIDEO else ".mp3")
@@ -133,6 +150,53 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
)
return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], url_path="script-agent-stream", renderer_classes=[ServerSentEventRenderer])
def script_agent_stream(self, request, pk=None):
"""对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。
请求体:mode(auto|theme|revise)、prompt、model_config_id、selling_point_ids、
base_version_id(改稿)、aspect_ratio、total_duration。
响应:text/event-stream,逐帧吐 tool/delta/draft/saved/done/error。"""
project = self.get_object()
mode = str(request.data.get("mode") or "auto")
prompt = str(request.data.get("prompt") or "")
selling_point_ids = request.data.get("selling_point_ids") or []
base_version_id = request.data.get("base_version_id") or None
aspect_ratio = str(request.data.get("aspect_ratio") or "9:16")
try:
total_duration = int(request.data.get("total_duration") or 60)
except (TypeError, ValueError):
total_duration = 60
model_config = None
requested = request.data.get("model_config_id")
if requested:
model_config = (
ModelConfig.objects.select_related("provider")
.filter(id=requested, capability=ModelConfig.Capability.TEXT, status=ModelConfig.Status.ACTIVE)
.first()
)
if model_config is None:
model_config = get_default_model(ModelConfig.Capability.TEXT)
if model_config is None:
# 纯 Django 响应:绕开 DRF 渲染(此 action 只挂了 SSE renderer)
return JsonResponse({"detail": "没有可用的文本模型,请先在模型库配置"}, status=400)
stream = stream_script_agent(
project=project,
user=request.user,
model_config=model_config,
mode=mode,
user_prompt=prompt,
selling_point_ids=selling_point_ids,
base_version_id=base_version_id,
aspect_ratio=aspect_ratio,
total_duration=total_duration,
)
response = StreamingHttpResponse(stream, content_type="text/event-stream")
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no" # 关 nginx 缓冲,保证逐帧下发
return response
@action(detail=True, methods=["post"], url_path="adopt-script")
@transaction.atomic
def adopt_script(self, request, pk=None):
+1
View File
@@ -669,6 +669,7 @@ export function App() {
key={pipelineProject.id}
project={pipelineProject}
scriptModelName={textModel?.display_name || textModel?.name || "AI"}
textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")}
loading={loading}
navigate={navigate}
user={currentUser}
+49
View File
@@ -223,6 +223,55 @@ export const api = {
body: JSON.stringify(payload)
});
},
// 对话式脚本 agent · 流式(SSE)。逐帧回调 onEvent:tool(工具卡)/delta(思考前言)/draft/saved/done/error。
// 用 fetch + ReadableStream 消费 text/event-stream(EventSource 只支持 GET,这里要 POST 带 body)。
async agentScriptStream(
projectId: string,
payload: {
mode?: "auto" | "theme" | "revise";
prompt?: string;
model_config_id?: string;
selling_point_ids?: string[];
base_version_id?: string;
aspect_ratio?: string;
total_duration?: number;
},
onEvent: (evt: { type: string; [k: string]: unknown }) => void
): Promise<void> {
const token = getToken();
const headers = new Headers({ "Content-Type": "application/json", Accept: "text/event-stream" });
if (token) headers.set("Authorization", `Token ${token}`);
const response = await fetch(`${API_BASE}/api/projects/${projectId}/script-agent-stream/`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!response.ok || !response.body) {
const text = await response.text().catch(() => "");
throw new ApiError(response.status, text || "脚本流式生成失败");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep: number;
// SSE 帧以空行分隔(\n\n);每帧取 data: 行解析
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
if (!dataLine) continue;
try {
onEvent(JSON.parse(dataLine.slice(5).trim()));
} catch {
/* 跳过解析失败的帧 */
}
}
}
},
adoptScript(projectId: string, script_version_id: string) {
return request<ScriptVersion>(`/api/projects/${projectId}/adopt-script/`, {
method: "POST",
+77 -31
View File
@@ -2,7 +2,7 @@ import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useR
import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { Play } from "lucide-react";
import { api } from "../api";
import type { Asset, BillingSummary, ExportPoll, Product, Project, Team, TimelineSavePayload, User } from "../types";
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, Team, TimelineSavePayload, User } from "../types";
import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
@@ -362,6 +362,7 @@ export function PipelinePage(props: {
avatarChar: string;
logout: () => void;
scriptModelName: string;
textModels?: ModelConfig[];
onGenerateScript: (prompt: string, source?: string) => Promise<unknown>;
onAdoptScript: (scriptId: string) => void | Promise<unknown>;
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
@@ -389,7 +390,7 @@ export function PipelinePage(props: {
}) {
const {
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
scriptModelName, onGenerateScript, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
scriptModelName, textModels, onGenerateScript, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
onGenerateBaseAsset, onAdoptBaseAsset, onGenerateStoryboard, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
@@ -573,7 +574,7 @@ export function PipelinePage(props: {
const chatBodyRef = useRef<HTMLDivElement | null>(null);
// 对话记录(本地会话态):生成动作可追溯,不再是「点了按钮、对话区永远空着」
// kind=progress:进度提示流(行33),steps 逐条滚动出现,done 后折叠成一行结果
type ChatMsg = { id: number; role: "ai" | "user"; text: string; time: string; kind?: "progress"; steps?: string[]; done?: boolean; auto?: boolean };
type ChatMsg = { id: number; role: "ai" | "user"; text: string; time: string; kind?: "progress"; steps?: string[]; stream?: string; done?: boolean; auto?: boolean };
const nowHm = () => new Date().toTimeString().slice(0, 5);
const msgIdRef = useRef(1);
const nextMsgId = () => msgIdRef.current++;
@@ -603,6 +604,9 @@ export function PipelinePage(props: {
} catch { /* localStorage 不可用则忽略 */ }
}, [chatKey, chatMsgs]);
const pushMsg = (role: "ai" | "user", text: string) => setChatMsgs((list) => [...list, { id: nextMsgId(), role, text, time: nowHm() }]);
// 脚本模型下拉:用户可选 豆包/GPT-5.5/Gemini(空 = 用后端默认文本模型)
const [scriptModelId, setScriptModelId] = useState<string>("");
const activeScriptModelId = scriptModelId || textModels?.[0]?.id || "";
// 删除分镜的两步确认(行内变红,3 秒不二次点击自动复位;不用原生 confirm)
const [armedDelete, setArmedDelete] = useState<string | null>(null);
// 行35 · 单条分镜重跑 / 删除的即时反馈:正在处理的 shot id(按钮转「处理中」并禁用)
@@ -637,33 +641,59 @@ export function PipelinePage(props: {
const el = chatBodyRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [chatMsgs]);
// 行33 · 进度提示流:前端模拟 AI 分步思考(逐条滚动),不需要真后端分步。
// 生成期间逐条往同一条 progress 消息追加 step;onGenerateScript 返回后置 done 折叠收起
const PROGRESS_STEPS = [
"收到脚本,正在解析商品卖点与创作方向…",
"提取关键卖点 · 锁定目标人群画像…",
"匹配创作风格与镜头节奏…",
"编排分镜 · 旁白与画面逐镜成稿…",
"校对时长与转化点,整理输出…"
];
// 统一的脚本生成对话回合:用户消息 → 进度流 → 成功/失败回执(onGenerateScript 失败被 App 兜住返回 null)
async function runScriptGeneration(prompt: string, userLabel?: string, source?: string) {
// 行33 · 进度流:由后端 SSE 真事件驱动 —— 工具卡(tool:加载skill/分析商品/生成分镜/提取实体/自检)
// + 思考前言(delta 逐字)。真 agent 体感,不再前端假模拟。流式不可用时兜底退回旧同步端点
function mapSourceToMode(src?: string): "auto" | "theme" | "revise" {
if (src === "theme") return "theme";
if (src === "revise") return "revise";
return "auto"; // ai / manual / 默认
}
async function runScriptGeneration(prompt: string, userLabel?: string, source?: string, mode?: "auto" | "theme" | "revise") {
pushMsg("user", userLabel || prompt);
const progressId = nextMsgId();
setChatMsgs((list) => [...list, { id: progressId, role: "ai", text: "", kind: "progress", steps: [PROGRESS_STEPS[0]], done: false, time: nowHm() }]);
// 逐条滚出后续步骤(纯前端节奏,生成真完成时收口)
let stepIdx = 1;
const timer = window.setInterval(() => {
if (stepIdx >= PROGRESS_STEPS.length) { window.clearInterval(timer); return; }
const step = PROGRESS_STEPS[stepIdx];
stepIdx += 1;
setChatMsgs((list) => list.map((m) => (m.id === progressId && !m.done ? { ...m, steps: [...(m.steps ?? []), step] } : m)));
}, 900);
const res = await onGenerateScript(prompt, source ?? chatMode);
window.clearInterval(timer);
// 收口:把 progress 折叠成一行结果,并补一条结果文本
setChatMsgs((list) => [...list, { id: progressId, role: "ai", text: "", kind: "progress", steps: [], stream: "", done: false, time: nowHm() }]);
const agentMode = mode ?? mapSourceToMode(source ?? chatMode);
const baseVersionId = agentMode === "revise" ? currentScript?.id : undefined;
let ok = false;
try {
await api.agentScriptStream(
project.id,
{
mode: agentMode,
prompt,
model_config_id: activeScriptModelId || undefined,
base_version_id: baseVersionId,
aspect_ratio: "9:16",
total_duration: 60
},
(evt) => {
if (evt.type === "tool") {
// 工具卡:running 时把 label 滚进进度流(done/error 暂只用于结束态)
if (evt.status === "running" && typeof evt.label === "string") {
const label = evt.label;
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, steps: [...(m.steps ?? []), label] } : m)));
}
} else if (evt.type === "delta" && typeof evt.text === "string") {
const piece = evt.text;
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, stream: (m.stream ?? "") + piece } : m)));
} else if (evt.type === "saved") {
ok = true;
} else if (evt.type === "error") {
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
pushMsg("ai", `生成失败:${typeof evt.detail === "string" ? evt.detail : "请稍后重试"}`);
}
}
);
} catch {
// 流式不可用(网关不支持 SSE 等)→ 退回旧同步端点,保证可用性
const res = await onGenerateScript(prompt, source ?? chatMode).catch(() => null);
ok = !!res;
}
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
pushMsg("ai", res ? "镜头脚本已生成,左侧已刷新。可继续输入修改意见整体重写,或点底部「确认脚本」进入下一步。" : "生成没有成功,请查看提示后重试。");
if (ok) {
await onRefreshProject();
pushMsg("ai", "镜头脚本已生成,左侧已刷新。可继续输入修改意见(会基于当前脚本改稿),或点「确认脚本」进入下一步。");
}
}
// 行28/行30 · 确认设定后真正发起生成:把所选「风格 / 人物」并进提示词(后端从 prompt 推断)
async function runScriptWithSetup() {
@@ -708,7 +738,8 @@ export function PipelinePage(props: {
setChatText("");
setChatAttachments([]);
setPendingTagEdits([]);
void runScriptGeneration(prompt, label || undefined);
// 已有脚本 → 追问走「改稿」模式(基于当前脚本增强,保留原意);否则全自动出稿
void runScriptGeneration(prompt, label || undefined, undefined, currentScript ? "revise" : "auto");
}
function clearChat() {
setPendingTagEdits([]);
@@ -1826,7 +1857,19 @@ export function PipelinePage(props: {
<div className="pane-h">
<div className="ai-avatar">AI</div>
<strong></strong>
<span className="muted-2 mono" style={{ fontSize: "12px" }}>· {scriptModelName}</span>
{textModels && textModels.length > 0 ? (
<select
className="setup-select"
style={{ height: 24, fontSize: 12, padding: "0 6px", maxWidth: 150, marginLeft: 6 }}
value={activeScriptModelId}
onChange={(event) => setScriptModelId(event.target.value)}
title="选择脚本生成模型(豆包 / GPT / Gemini)"
>
{textModels.map((m) => <option key={m.id} value={m.id}>{m.display_name || m.name}</option>)}
</select>
) : (
<span className="muted-2 mono" style={{ fontSize: "12px" }}>· {scriptModelName}</span>
)}
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn" disabled={!chatText && chatAttachments.length === 0 && chatMsgs.length === 0} onClick={clearChat}></button>
</div>
@@ -1837,7 +1880,10 @@ export function PipelinePage(props: {
<div className={`msg ${msg.role}`} key={msg.id}>
<div className="bubble">
{msg.kind === "progress"
? <ProgressStream steps={msg.steps} done={msg.done} />
? <>
<ProgressStream steps={msg.steps} done={msg.done} />
{msg.stream ? <div style={{ marginTop: 6, opacity: 0.85, whiteSpace: "pre-wrap" }}>{msg.stream}</div> : null}
</>
: <CollapsibleText text={msg.text} maxLines={10} />}
</div>
<div className="time">{msg.time}</div>
@@ -1933,7 +1979,7 @@ export function PipelinePage(props: {
<div className="stage-foot">
<div className="info"><span className="mono">[ LLM ~2.4k tokens · ¥0.04 · · ]</span></div>
<div className="hstack">
<button className="btn" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> </button>
<button className="btn" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部", undefined, "auto")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> </button>
<button className="btn btn-primary btn-lg" type="button" disabled={loading || !currentScript} onClick={confirmScript}>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div>
</div>
+14 -1
View File
@@ -83,7 +83,20 @@ export type ScriptVersion = {
content: string;
source?: string;
is_adopted: boolean;
segments: Array<{ id: string; sort_order: number; duration_seconds: number; narration: string; visual_prompt?: string }>;
// 结构化契约字段(ScriptDraft):role 钩子/痛点/卖点/CTA、speaker/entity_refs 指向 entity、product_exposure 露出方式
segments: Array<{
id: string;
sort_order: number;
duration_seconds: number;
narration: string;
visual_prompt?: string;
role?: string;
speaker?: string;
product_exposure?: string;
entity_refs?: string[];
}>;
// metadata 携带 hook/tone/entities(脚本 agent 产出),供结构化渲染与下游故事板 @图N
metadata?: Record<string, unknown>;
created_at?: string;
updated_at?: string;
};
+117
View File
@@ -0,0 +1,117 @@
图像(Images/原生OpenAI格式
编辑图像
在给定原始图像和提示的情况下创建编辑或扩展图像。
接口
POST /v1/images/edits/
用途
用于 GPT Image / Image2 相关的图片生成或编辑。
前提
密钥需加入 GPT 生图分组;普通 default 分组不可直接调用。
建议
图片接口会产生生成成本,联调用例应控制尺寸、数量和重试次数。
POST
/
v1
/
images
/
edits
Try it
Authorizations
Authorization
stringheaderrequired
Bearer authentication header of the form Bearer <token>, where <token> is your auth token.
Body
multipart/form-data
image
filerequired
要编辑的图像。必须是有效的 PNG 文件,小于 4MB,并且是方形的。如果未提供遮罩,图像必须具有透明度,将用作遮罩。
prompt
stringrequired
所需图像的文本描述。最大长度为 1000 个字符。
Example:
"A cute baby sea otter wearing a beret."
mask
file
附加图像,其完全透明区域(例如,alpha 为零的区域)指示image应编辑的位置。必须是有效的 PNG 文件,小于 4MB,并且尺寸与原始image相同。
n
string
要生成的图像数。必须介于 1 和 10 之间。
Example:
"1"
size
string
生成图像的大小。必须是256x256、512x512或 1024x1024之一。
Example:
"1024x1024"
response_format
string
生成的图像返回的格式。必须是url或b64_json。
Example:
"url"
user
string
代表您的最终用户的唯一标识符,可以帮助 OpenAI 监控和检测滥用行为。了解更多。
Example:
""
model
string
Example:
"dall-e-2"
Response
200 - application/json
The response is of type object.
curl --request POST \
--url https://www.yunqiai.chat/v1/images/edits/ \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: multipart/form-data' \
--form image='@example-file' \
--form 'prompt=A cute baby sea otter wearing a beret.' \
--form n=1 \
--form size=1024x1024 \
--form response_format=url \
--form user= \
--form model=dall-e-2
curl --request POST \
--url https://www.yunqiai.chat/v1/images/edits/ \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: multipart/form-data' \
--form image='@example-file' \
--form 'prompt=A cute baby sea otter wearing a beret.' \
--form n=1 \
--form size=1024x1024 \
--form response_format=url \
--form user= \
--form model=dall-e-2
+141
View File
@@ -0,0 +1,141 @@
---
name: ecommerce-video-script
description: >
电商带货短视频·脚本生成领域技能(模型无关)。
服务对象不是人类编剧,而是 AirShelf 产品后端的「脚本生成 agent」——在运行时按需加载本技能作为领域知识。
能力:把【商品信息 + 前置条件】或【一句话主题】或【用户已有脚本】,
自动收敛成一份结构化的带货短视频脚本 JSON(默认 9:16 竖屏可改 / 时长 15·30·60·90 秒四档 / 每 15 秒一镜)。
当任务为「生成带货脚本 / 扩写主题 / 优化已有脚本 / 商品转视频脚本」时使用本技能。
核心目标是「电商小白点一下按钮就出能吸睛、能转化的脚本」,不是影视级艺术性。
---
# 电商带货短视频 · 脚本生成大师
你是一个**电商带货短视频脚本生成 agent**。你的产出会直接进入 AirShelf 流水线的下游
(图片 → 故事板 → Seedance 生视频),因此你**只输出结构化 JSON,绝不输出散文剧本**。
> **模型无关声明**:本技能不依赖任何特定模型的能力或语气。无论运行在豆包 / GPT / Gemini /
> Claude 上,规则一致。不要使用任何模型专属的特殊标记或思维格式。
---
## 铁律(优先级最高,覆盖所有步骤)
### 铁律 1 · 输出契约(硬约束,与下游对接)
最终**只能**输出**一个**符合下列结构的 JSON 对象(UTF-8,无注释,无 ```json 包裹之外的任何文字):
```json
{
"hook": "前3秒主打钩子(一句话)",
"tone": "种草|测评|剧情|痛点",
"aspect_ratio": "9:16",
"total_duration": 60,
"segment_count": 4,
"entities": [
{
"id": "c1",
"type": "character|scene|product",
"name": "女主",
"visual_prompt": "给图模型的生图提示词(自动生成,小白不用打字)",
"ref_index": 1,
"voice_ref": "可选,角色音色参考(二期,锁音色),无则 null"
}
],
"segments": [
{
"index": 0,
"duration": 15,
"role": "钩子|痛点|卖点|CTA",
"narration": "这一镜被说出来的台词/旁白,≤55字",
"speaker": "可选,指向某 entity 的 id;画外旁白时为 null",
"visual": "画面描述",
"product_exposure": "商品露出方式(手持/特写/使用中)",
"entity_refs": ["c1"]
}
]
}
```
字段纪律:
- `tone` 必须是四选一枚举;`role` 必须是四选一枚举。
- **画幅由输入给定**`aspect_ratio` 默认 `"9:16"`(电商竖屏主场景),但**不写死**——输入指定了其他比例(如 `"16:9"`、`"1:1"`、`"4:5"`)就照用,原样透传给下游。画幅只影响 `visual` 的构图措辞,不改变结构与镜数。
- **时长档位由输入给定**`total_duration` 只能取 `15 | 30 | 60 | 90` 之一(输入未指定时默认 `60`)。**不要写死。**
- **每 15 秒切一镜**`segment_count = total_duration / 15`,即 15→1 镜、30→2 镜、60→4 镜、90→6 镜;每镜 `duration=15``index` 从 0 连续递增(粗暴切,不做复杂时长算法)。
- 各档位的 4 镜功能(role)如何分配/压缩/扩展,见 `references/methodology.md`「档位 × 黄金结构映射」。
- `entities[].id` 全局唯一,`segments[].entity_refs` 与 `speaker` 只能引用已声明的 id。
- **每个声明的 entity 至少被一个 segment 引用**(不留孤儿 entity)。
- `visual_prompt` 由你自动生成,小白无需打字。
- 不要输出 schema 之外的字段,也不要省略必填字段。
### 铁律 2 · 输出前自检
输出 JSON 之前,**先在内部逐条跑一遍** `references/checklist.md` 的自检清单。
发现问题先改再输出,不要带着已知问题输出。自检是内部过程,不展示给用户。
### 铁律 3 · 写作红线(旁白 / 文案)
详见 `references/methodology.md`「旁白红线」。最关键的几条:
- **口语化**,像真人对着镜头说话,不准书面腔 / AI 腔。
- **每镜 narration ≤ 55 字**Seedance 15 秒内直接发声,字数 = 可懂语速上限)。
- **禁违规词**:医疗功效(治疗/根治/抗癌…)、绝对化用语(最/第一/100%/国家级…)一律不写。
- 不浮夸、不空喊,卖点要落到「商品怎么解决痛点」。
### 铁律 4 · 一键自动化(与影视母版相反)
母版分步暂停等用户确认;本技能服务电商小白,要求**一次性生成完整 JSON**,
中途**不向用户提问、不暂停、不展示思考过程**。所有提示词替用户包好。
(仅当输入信息严重缺失到无法生成时,才回退提问——见路由表「信息不足」。)
---
## 默认参数(不暴露给用户,直接套用)
| 参数 | 值 |
| ---- | ---- |
| 画幅 | **默认 9:16 竖屏**,由输入可覆盖(16:9 / 1:1 / 4:5 等照用) |
| 总时长 | **15 / 30 / 60 / 90 秒四档**,由输入给定;未指定时默认 60 |
| 分镜 | 每 15 秒一镜,均分(粗暴切,不做复杂算法)→ 1 / 2 / 4 / 6 镜 |
| 镜头功能 | 钩子 → 痛点 → 卖点 → CTA(黄金结构;不同档位按映射表压缩/扩展,见方法论) |
| 发声方式 | Seedance 直接生成画面+音效+人声(**不走 TTS**) |
---
## 输入模式路由(3 种输入 → 同一份 JSON)
先判断输入属于哪种模式,加载对应参考资料,最后都收敛到铁律 1 的同一份结构化输出。
| 模式 | 触发条件 | 处理逻辑 | 需读取 |
| ---- | ---- | ---- | ---- |
| **① 全自动** | 用户只给【商品信息 + 前置条件(调性/平台/时长/卖点勾选)】,无主题无原稿 | 凭商品与前置条件,自动定档、选 tone、造 entity、按映射填镜 | `methodology.md` + `hook-library.md` + `category-playbook.md` + `platform-tone.md` |
| **② 一句话** | 用户额外给了一句主题(如「主打熬夜党救星」) | 以该主题为脚本主轴扩写,其余同全自动 | 同上(主题优先于自动选题) |
| **③ 改稿** | 用户给了已有脚本/文案 | **保留用户原意**,只增强钩子/节奏/卖点/CTA,并归一化到 JSON 结构 | `methodology.md` + `hook-library.md` + `checklist.md` |
| 信息不足 | 连商品信息都缺,无法生成 | 唯一允许的回退:用一句话问清最少必要信息 | — |
**进入任何模式前,必须先读取该行列出的参考资料。**
---
## 生成流程(内部执行,一次走完,不暂停)
1. **路由** — 判定输入模式(①/②/③),加载对应 references。
2. **定档** — 从输入读取画幅(`aspect_ratio` 默认 9:16)与时长档位(15/30/60/90,未指定默认 60),算出 `segment_count = total_duration / 15`。
3. **定调(tone)** — 依据品类话术 + 平台调性 + 前置条件,选定 `tone`;②③ 模式尊重用户已表达的倾向。
4. **抽取/创建 entities** — 识别脚本需要的角色 / 场景 / 商品;为每个 entity 写一份**全脚本共用**的 `visual_prompt`(保证多镜同一角色同一张脸);可选写 `voice_ref` 锁音色。
5. **按档位填黄金结构** — 依「档位 × 黄金结构映射表」给每个 segment 分配 `role`;钩子镜套用 `hook-library.md` 的公式。
6. **写 narration / visual / 商品露出** — 每镜旁白 ≤55 字、口语化、过红线;每镜规划自然的 `product_exposure`。
7. **连引用** — 填 `entity_refs` 与 `speaker`,确认每个 entity 都被引用、id 都合法。
8. **自检** — 跑 `checklist.md`,过了再输出。
9. **输出** — 仅输出铁律 1 的 JSON。
---
## 参考资料索引
| 文件 | 内容 | 何时读取 |
| ---- | ---- | ---- |
| `references/methodology.md` | 黄金结构模板(钩子→痛点→卖点→CTA)、档位 × 结构映射表、entity 一致性原则、商品露出规范、旁白红线(含违规词清单) | **每次生成都读** |
| `references/hook-library.md` | 前 3 秒钩子公式库(痛点提问 / 反差 / 数字冲击 / 身份代入 …,含例句) | 每次生成都读(写钩子镜时) |
| `references/category-playbook.md` | 分品类话术(美妆 / 食品 / 3C / 服饰 / 家居…的语气与卖点侧重) | 全自动 / 一句话模式 |
| `references/platform-tone.md` | 平台调性(抖音 / 快手 / 小红书 / 视频号 的节奏与风格差异) | 全自动 / 一句话模式 |
| `references/checklist.md` | 电商版自检清单 + 输出契约校验(钩子够强、旁白≤55字、镜数=时长/15、role 齐、entity 全被引用、违规词扫描) | **输出前必读** |
@@ -0,0 +1,55 @@
# 分品类话术
> 全自动 / 一句话模式读。根据商品所属品类,调整语气与卖点侧重。
> 不确定品类时,按商品功能就近归类;都不沾就用"通用"原则(讲场景痛点 + 真实使用感)。
每个品类给:**语气** / **卖点侧重** / **常用露出** / **违规雷区** / **示例旁白**
---
## 美妆护肤
- **语气**:闺蜜种草、真实分享,带点"亲测"口吻,忌假大空。
- **卖点侧重**:上脸质地、即时感受(清爽/水润/不卡粉)、成分通俗化、前后状态对比。
- **常用露出**:质地特写、上脸使用中、成分/包装特写。
- **违规雷区**:美白/祛斑/抗皱写成医疗功效("淡化""看起来更均匀"可以,"祛斑根治"禁);忌"最/第一/100% 有效"。
- **示例**:「上脸是那种水水的,吸收完一点不黏,油皮也能闭眼入。」
## 食品饮料
- **语气**:馋、香、解馋,调动味觉想象,节奏轻快。
- **卖点侧重**:口感(爆汁/酥脆/拉丝)、配料干净、场景(追剧/早餐/解馋)、分量与价格。
- **常用露出**:特写(拉丝/爆浆瞬间)、吃播使用中、包装正面。
- **违规雷区**:保健/疗效("降三高""治便秘"禁);"零添加""无糖"等需有据,别乱标。
- **示例**:「一口下去芝士直接拉丝,半夜看到真的会饿,我已经囤了三箱。」
## 3C 数码
- **语气**:理性测评、参数说人话,给"懂行"的信任感。
- **卖点侧重**:核心性能、对比同价位、真实使用场景痛点解决、续航/手感/兼容。
- **常用露出**:手持、功能演示使用中、接口/细节特写、对比展示。
- **违规雷区**:跑分写成"最强/第一";夸大续航/防水等参数;忌绝对化。
- **示例**:「同价位里它的续航是真顶用,我出门一天没带充电宝也没焦虑。」
## 服饰鞋包
- **语气**:穿搭分享、身材/场景导向,强调上身效果。
- **卖点侧重**:版型显瘦/显高、面料舒适、好搭配、场合适配、尺码建议。
- **常用露出**:上身使用中、面料细节特写、多场景/多角度展示。
- **违规雷区**:材质成分虚标;"显瘦 20 斤"等夸张数字;绝对化用语。
- **示例**:「这个版型是真的藏肉,梨形身材穿上腿一下显直,通勤约会都能穿。」
## 家居日用
- **语气**:实用、解决麻烦、相见恨晚的"生活妙招"感。
- **卖点侧重**:解决具体家务痛点、省时省力、材质安全、收纳/清洁效率。
- **常用露出**:使用中(前后对比)、场景摆放、细节特写。
- **违规雷区**:抗菌/除螨率等需有据,别写成医疗;忌"最/唯一"。
- **示例**:「以前擦灶台要使劲搓,喷一下擦一擦油污自己化开,懒人狂喜。」
---
## 通用原则(品类不明时)
讲清"**谁、在什么场景、遇到什么麻烦、这个商品怎么帮上忙、现在怎么买**",
语气贴近真实用户分享,卖点永远挂在前面铺的痛点上。
@@ -0,0 +1,51 @@
# 输出前自检清单
> 输出 JSON 之前必读。**逐条内部过一遍,命中问题先改再输出。** 自检过程不展示给用户。
> 任一"硬校验"不通过 = 不合格,必须修。
---
## A. 输出契约校验(硬,必须全过)
- [ ] 最终只输出**一个** JSON 对象,无 schema 外的多余文字/注释。
- [ ] `tone``{种草, 测评, 剧情, 痛点}`
- [ ] `aspect_ratio` 存在;输入指定了就用输入值,未指定为 `"9:16"`
- [ ] `total_duration``{15, 30, 60, 90}`
- [ ] `segment_count == total_duration / 15`,且 `segments` 数组长度等于它。
- [ ] 每个 `segment.duration == 15``index` 从 0 连续递增无跳号。
- [ ] 每个 `segment.role``{钩子, 痛点, 卖点, CTA}`
- [ ] 首镜 `role == 钩子`;末镜收 CTA(独立 CTA 镜,或末镜旁白末尾含明确行动指令)。
- [ ] `entities` 每项 `type``{character, scene, product}``id` 全局唯一。
- [ ] `entity_refs``speaker` 引用的 id **都已在 entities 中声明**(无悬空引用)。
- [ ] **每个声明的 entity 至少被一个 segment 引用**(无孤儿 entity)。
- [ ] `speaker` 要么为 `null`,要么指向一个 `type==character` 的 id。
- [ ] 必填字段无缺失:顶层 `hook/tone/aspect_ratio/total_duration/segment_count/entities/segments`
每 entity 有 `id/type/name/visual_prompt/ref_index`
每 segment 有 `index/duration/role/narration/visual/product_exposure/entity_refs`
## B. 内容质检(钩子 / 结构 / 一致性)
- [ ] 钩子镜第一句在前 3 秒抛出钩子,套用了 `hook-library.md` 的某个公式。
- [ ] 顶层 `hook` 与钩子镜口径一致。
- [ ] 卖点镜的卖点**挂在前面铺的痛点上**,不是干罗列参数。
- [ ] CTA 给了明确动作(点小黄车/领券/主页链接)。
- [ ] 档位映射正确:role 序列符合 `methodology.md`「档位 × 黄金结构映射」表。
- [ ] 90s 的多个卖点镜**各有侧重不重复**。
- [ ] 同一角色/场景/商品全程复用同一 entity(没有给同一对象写出两份 visual_prompt)。
- [ ] 每个 `visual_prompt` 信息足够喂图模型(角色/场景/商品的外观特征写清)。
- [ ] 每镜 `product_exposure` 自然、与 role 匹配(参考方法论露出表)。
## C. 旁白红线扫描(逐镜)
- [ ] 每镜 `narration` ≤ 55 字(硬上限;目标 ≤50 留缓冲,逐字数一遍,别凭感觉)。
- [ ] 口语化,无书面腔/AI 腔("综上""不仅…而且""值得一提"等已清除)。
- [ ] **违规词扫描**:无医疗功效词(治疗/根治/抗癌/消炎/排毒/速效…)。
- [ ] **绝对化用语扫描**:无 最/第一/唯一/100%/国家级/永久/绝对/史上 等。
- [ ] 无虚假承诺(三天见效/永不反弹/包治…)。
---
## 通过标准
A 区**全部**通过 + B/C 区无红线命中 → 可输出。
任何一条不过:先改,再重跑本清单,直到全过,才输出最终 JSON。
@@ -0,0 +1,66 @@
# 前 3 秒钩子公式库
> 写"钩子"镜(`role: 钩子`)时读。钩子镜的 `narration` 开头第一句即套用以下任一公式。
> 同时把这句钩子提炼进顶层 `hook` 字段。
钩子的唯一任务:**让用户在 3 秒内停止划走**。手段是制造"和我有关 / 没想到 / 想看下去"。
---
## 公式 1 · 痛点提问(最稳,默认首选)
直接问出用户正在经历的痛点,让人下意识"对,我就是"。
- 公式:`你是不是也 + [具体痛点场景]`
- 例:「你是不是也一熬夜,第二天脸就垮、暗沉到没法看?」
- 例:「天天敷面膜还是干,是不是钱白花了?」
## 公式 2 · 反差冲击
先给一个反直觉的结论或前后对比,制造认知落差。
- 公式:`别再 [常见做法] 了,其实 [反差真相]` / `[A] 和 [B] 的差距,全在这一步`
- 例:「别再狂涂面霜了,越涂越干的真相在这。」
- 例:「同样熬夜,她第二天像没事人,差别就这一瓶。」
## 公式 3 · 数字冲击
用具体数字制造可信的强刺激(数字要真实、不踩绝对化红线)。
- 公式:`[数字] + [结果/对比]`
- 例:「3 秒上脸,毛孔像被一键磨皮。」
- 例:「一瓶顶我以前三瓶,算下来一天不到一块钱。」
## 公式 4 · 身份代入
直接点名目标人群,让"自己人"瞬间锁定。
- 公式:`[身份/人群] 必看 / 给 [人群] 的 [品类]`
- 例:「熬夜党救星来了,做夜班的姐妹蹲一下。」
- 例:「油皮看过来,夏天再也不用一天补三次妆。」
## 公式 5 · 悬念留白
抛出结果但藏起原因,逼用户看下去。
- 公式:`[惊人结果],关键是 [先不说]` / `我做了一件事,结果……`
- 例:「闺蜜以为我去医美了,其实我只换了它。」
- 例:「就因为睡前多做这一步,第二天状态稳了。」
## 公式 6 · 场景代入
把镜头放进一个高共鸣的具体生活瞬间,让人"我也这样"。
- 公式:`[具体时间/场景] + [尴尬或困扰瞬间]`
- 例:「早高峰挤地铁,妆花一半还冒油,太崩溃了。」
- 例:「加班到凌晨,照镜子那一刻自己都吓一跳。」
---
## 钩子自检(写完钩子镜后内部过一遍)
- [ ] 第一句是否在前 3 秒(约 15 字内)就抛出钩子?
- [ ] 是否和目标人群"有关",能让人对号入座?
- [ ] 是否制造了"想继续看"的理由(痛点/反差/悬念)?
- [ ] 是否避开了违规词(最/第一/100%/治疗…)?
- [ ] 顶层 `hook` 字段是否与钩子镜口径一致?
@@ -0,0 +1,85 @@
# 电商带货脚本 · 核心方法论
> 每次生成都读。本文件是脚本的"骨架 + 红线",钩子句式见 `hook-library.md`。
---
## 一、黄金结构:钩子 → 痛点 → 卖点 → CTA
带货短视频的灵魂是**前 3 秒留人 + 结尾促转化**。四个功能镜各司其职:
| role | 功能 | 它要做的事 | 旁白要点 |
| ---- | ---- | ---- | ---- |
| **钩子** | 3 秒留人 | 第一句话/第一帧就让人停下不划走 | 用 `hook-library.md` 的公式,制造痛点共鸣或反差冲击 |
| **痛点** | 共鸣 | 把用户"对,我就这样"的处境讲出来 | 具体场景化,不抽象,让人对号入座 |
| **卖点** | 商品解决 | 商品如何**恰好**解决上面的痛点 | 卖点落到痛点上,给"凭什么信"的理由(成分/效果/对比/口碑) |
| **CTA** | 转化行动 | 临门一脚,告诉用户现在做什么 | 明确动作 + 紧迫感(点下方小黄车 / 主页领券 / 限时) |
**铁原则:**
- **首镜永远是钩子**——3 秒内必须抛出钩子,否则用户划走。
- **末镜永远收 CTA**——可以是独立 CTA 镜,也可以是末镜旁白末尾收一句行动指令。
- **卖点必须挂在痛点上**——不是罗列参数,是"这个痛点 → 商品这样解决"。
---
## 二、档位 × 黄金结构映射(关键)
时长由输入给定(15/30/60/90 四档),每 15 秒一镜,`segment_count = total_duration / 15`
镜数变了,四个功能要**压缩或扩展**,按下表分配 `role`
| 档位 | 镜数 | role 序列(index 0→N | 压缩/扩展说明 |
| ---- | ---- | ---- | ---- |
| **15s** | 1 | `[钩子]` | 一镜到底:开头 3 秒钩子 → 中段一句卖点 → 末尾一句 CTA,全压进这 15 秒旁白里 |
| **30s** | 2 | `[钩子, 卖点]` | 镜0 钩子里带出痛点;镜1 讲卖点并在旁白末尾收一句 CTA |
| **60s** | 4 | `[钩子, 痛点, 卖点, CTA]` | 标准黄金结构,一镜一功能 |
| **90s** | 6 | `[钩子, 痛点, 卖点, 卖点, 卖点, CTA]` | 痛点后给 3 个卖点镜:核心卖点 / 场景化演示 / 信任背书(口碑·数据);末镜 CTA |
说明:
- `role` 字段只能取 `钩子|痛点|卖点|CTA` 四个枚举值。"信任背书""场景演示"等都归入 `卖点`
- 短档位(15/30s)靠**旁白内压缩多功能**达成"留人+转化",不要因为镜少就丢掉 CTA。
- 90s 的 3 个卖点镜要**各有侧重、不重复**(核心功效 / 真实使用场景 / 别人为什么买)。
---
## 三、entity 一致性原则
`entities` 是全脚本共享的角色 / 场景 / 商品池,保证**多镜同一角色是同一张脸、同一商品是同一个包装**。
- **一个角色/场景/商品 = 一个 entity = 一份 `visual_prompt`**。多镜复用同一 id,**绝不为同一对象写两份 visual_prompt**(会导致下游生图人脸/包装漂移)。
- `visual_prompt` 由你**自动生成**(小白不打字):写清外观特征(角色:性别/年龄段/发型/穿着/气质;场景:地点/光线/风格;商品:品类/包装/颜色/摆放),用于直接喂图模型。
- `type` 三选一:`character`(人) / `scene`(环境) / `product`(商品)。一份脚本通常至少 1 个 `product`
- `ref_index` 是该 entity 在图集里的参考序号(从 1 递增,供下游三视图/参考图对齐)。
- `voice_ref`(二期,可选):角色音色参考,用于锁音色;无则 `null`
- **每个声明的 entity 至少被一个 segment 的 `entity_refs` 引用**,不留孤儿。
- `segments[].speaker` 指向某 `character` 的 id 表示该镜由角色开口说;`null` = 画外旁白。
---
## 四、商品露出规范(`product_exposure`
每镜都要规划商品怎么自然出现,避免生硬插入。按 role 给推荐露出方式:
| role | 推荐露出 | 说明 |
| ---- | ---- | ---- |
| 钩子 | 可弱露出或不露 | 钩子先抓人,商品可作悬念,第 1 镜末尾闪一下也行 |
| 痛点 | 不露 / 反面对照 | 展示"没有它"的糟糕状态,商品先按住 |
| 卖点 | **特写 / 使用中** | 商品成为主角:成分特写、质地特写、上脸/上手使用中 |
| CTA | 手持 / 包装正面 | 手持商品对镜头,包装正面清晰,配合行动指令 |
露出方式词汇统一用:`手持` / `特写` / `使用中` / `包装正面` / `场景摆放` / `对比展示`(可组合,如"手持+特写")。
---
## 五、旁白红线(硬规则,违反即不合格)
- **口语化**:像真人对着镜头唠嗑,不准书面腔 / 不准 AI 腔("综上所述""不仅…而且""值得一提的是"全禁)。
- **每镜 `narration` ≤ 55 字**Seedance 在 15 秒内直接发声,55 字是可懂语速**硬上限**。
**写作目标 ≤ 50 字**,留 5 字缓冲——宁可短、不要卡满;短档位(15/30s)一镜要装多功能时尤其要狠删,先保 CTA 不被砍。
- **不浮夸、不空喊**:卖点要给具体理由,不堆形容词。
- **违规词禁令**(电商广告法红线,一律不写):
- **医疗功效类**:治疗 / 根治 / 疗效 / 抗癌 / 消炎 / 杀菌(无证) / 排毒 / 速效 / 抑制 ××病 …
- **绝对化用语**:最 / 第一 / 顶级 / 唯一 / 100% / 国家级 / 世界级 / 永久 / 绝对 / 史上 …
- **虚假承诺**:包治 / 三天见效 / 立刻变白 / 永不反弹 …
- 替换策略:用"帮助""更""不少人反馈""上脸清爽"等柔性、主观化表达替代。
> 自检阶段必须逐镜扫一遍违规词,命中即改写。详见 `checklist.md`。
@@ -0,0 +1,47 @@
# 平台调性
> 全自动 / 一句话模式读。根据投放平台(前置条件给定)调整节奏与风格。
> 未指定平台时,默认按"抖音"通用带货节奏。
每个平台给:**节奏** / **风格** / **钩子偏好** / **tone 倾向** / **一句话定调**
---
## 抖音
- **节奏**:快、强钩子、信息密集,前 3 秒定生死,反转/爽点前置。
- **风格**:高能、口播感强、配合小黄车,直给卖点。
- **钩子偏好**:痛点提问、数字冲击、反差。
- **tone 倾向**`种草` / `痛点`
- **定调**:开门见山抓人,节奏别拖,CTA 干脆("点下方小黄车")。
## 快手
- **节奏**:偏生活化、接地气,信任感和性价比优先。
- **风格**:实在、像熟人推荐、强调"便宜大碗/老铁信得过"。
- **钩子偏好**:身份代入、场景代入、价格冲击。
- **tone 倾向**`种草` / `测评`
- **定调**:朴实可信,突出实惠和真实使用,少花活。
## 小红书
- **节奏**:偏慢、重质感、像写笔记/真实测评。
- **风格**:精致、审美在线、第一人称"我亲测",软种草不硬推。
- **钩子偏好**:悬念留白、反差、身份代入(细分人群)。
- **tone 倾向**`种草` / `测评`
- **定调**:真诚分享感,画面要美,卖点融进使用体验,CTA 柔("主页有链接/评论区扣")。
## 视频号
- **节奏**:适中,受众偏成熟、家庭场景多。
- **风格**:温和、可信、强调实用与口碑,社交裂变属性强。
- **钩子偏好**:痛点提问、场景代入。
- **tone 倾向**`种草` / `剧情`
- **定调**:稳重可信,讲清实用价值,适合家庭/品质向商品。
---
## 用法提示
- 平台调性影响 **节奏(旁白语气松紧)****tone 选择**,但不改变黄金结构与输出契约。
- 平台 × 品类叠加时,以"商品适配"为先:例如 3C 上小红书仍偏测评质感,上抖音则更直给。
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
# AirShelf · AI 生成 Agent 化 · 交接文档
> **日期:** 2026-06-17(凌晨)
> **交接人:** Claude(尹希乐 / seaislee 指导)→ **接手人:** 张业昌 + 你的 Claude + 前端 UX
> **分支:** `dev`(本批改动已推送)
> **一句话:** 把脚本生成升级成「多模型可选 + 出稿/改稿一体的**流式对话 agent**(挂电商 skill)」,并把 商品→脚本→图片→故事板→视频 的 SOP 用**可插拔 Provider** 打通;参考图分镜(gpt-image-2 @图N)、模特库、Seedance 出音 全部就位。
---
## 0. TL;DR · 拿到就能跑
```bash
# 后端(端口 8010)
cd core/backend
.venv/Scripts/python.exe manage.py migrate # 跑新迁移(0005/0006 + projects 0002)
.venv/Scripts/python.exe manage.py runserver 0.0.0.0:8010
# 另起 celery worker(图片/故事板/视频是异步,靠 worker 执行;没 worker 这几趴会卡)
.venv/Scripts/python.exe -m celery -A airshelf worker -l info -P solo
# 前端(端口 5173,vite 代理 /api → 8010)
cd core/frontend
npm install && npm run dev
```
`.env` 里**凭证已就绪**(tokenssr / 飞书 / 火山审核借用 AK/SK / 豆包 TTS),开箱即用。**⚠️ 见 §7 有一处借用密钥要换。**
---
## 1. 成熟度总表(诚实标注)
| 模块 | 状态 | 验证方式 |
| ---- | ---- | ---- |
| 可插拔 Provider 层(tokenssr/任意中转站) | ✅ 已完成·已验证 | 真打 tokenssr 流式 chat 通过 |
| 结构化脚本 agent + 流式 SSE(出稿/改稿/3模式/多模型) | ✅ 已完成·**双验证** | 后端 HTTP 探针 + **无头浏览器视觉自检**(截图见 `_qa_shots/`) |
| gpt-image-2 参考图出图 + 落 TOS | ✅ 已完成·已验证 | 文生图→TOS→参考图合成 整链跑通 |
| 故事板 @图1@图2@图3 多锚点合成 | ✅ 代码完成 | 逻辑接好,建议接 worker 后端到端复跑一次 |
| Seedance 视频出音(generate_audio) | ✅ 已打开 | 默认 True;原来写死 False |
| 多模型 seed(豆包/GPT-5.5/Gemini + gpt-image-2 + Seedance) | ✅ 已完成 | 数据迁移 0006,`get_default_model` 选取正确 |
| 模特库生成器(9:16氛围图→16:9白底三视图) | ✅ 已完成·已验证 | 跑通 1 个,三视图角色一致性极好(截图 `_qa_shots/model_threeview.png`) |
| 前端脚本趴(流式工具卡+思考流+模型下拉+改稿) | ✅ 已完成·**浏览器视觉验证** | 见 `_qa_shots/01..03` |
| **火山人像素材库审核(绿/红标)** | 🟡 **设计完成·未接线** | 见 §6,AirDrama 有现成代码可照搬,AK/SK 已备好 |
| 计费(按 token 精确扣) | 🟡 沿用现有(每次调用扣 unit_price) | 真 token 计量是后续优化项 |
---
## 2. 架构原则(务必延续)
**Provider 可插拔 —— 除火山官方直连外,一切走「OpenAI 兼容中转站」,换站零改代码。**
- 火山(豆包文本 / SeeDream 生图 / Seedance 视频)= 官方直连,`VolcanoArkProvider`
- 其余(tokenssr / yunqi / 任意 New-API 网关)= 通用 `OpenAICompatibleProvider(base_url, api_key)`
- 分流在 `services.build_provider()`:`provider.name in OFFICIAL_DIRECT_PROVIDERS` 走火山,否则走通用适配器。
- **加/换中转站 = DB 加一行 `ModelProvider`(base_url + 可选 api_key)**,代码一行不改。
- 密钥解析顺序:`ModelProvider.api_key`(DB)→ `settings.PROVIDER_KEYS`(.env)。**密钥默认只在 .env,不进库**(seed 的 tokenssr provider 的 api_key 留空,运行时从 .env 取)。
**关键文件:**
- `core/backend/apps/ai/providers/openai_compatible.py` — 通用适配器(chat / 流式 chat / 生图 / **参考图 image_edit**)
- `core/backend/apps/ai/providers/volcano.py` — 基类(新增 `chat_completion_stream` 流式 + `generate_audio` 开关)
- `core/backend/apps/ai/services.py``build_provider / resolve_provider_credentials / get_{text,image,video}_provider`
---
## 3. 脚本 Agent(核心)
### 流式 SSE 端点
`POST /api/projects/{id}/script-agent-stream/``text/event-stream`,逐帧 `data: {json}`
事件类型:
- `tool` `{id,label?,status:running|done|error}` — 工具卡(加载skill / 分析商品 / 生成分镜 / 提取实体 / 自检)
- `delta` `{text}` — 模型思考前言逐字(JSON 部分后端隐藏不外露)
- `draft` `{draft}` — 规范化后的 **ScriptDraft**(结构化)
- `saved` `{script_version_id, version}` — 已落库的 ScriptVersion(含 segments + metadata)
- `done` / `error`
> **DRF 坑(已解决):** SSE 必须给 action 挂 `ServerSentEventRenderer`(media_type=text/event-stream),否则 `Accept: text/event-stream` 直接 406。错误响应用纯 Django `JsonResponse` 绕开 DRF 渲染。见 `projects/views.py`。
### 3 种输入模式(前端已接)
- **auto(全自动)**:仅商品信息 → 自动定档/选 tone/造 entity/填黄金结构。
- **theme(一句话主题)**:用户给主题 → 以主题为主轴扩写。
- **revise(改稿)**:已有脚本 → 基于当前脚本增强(前端追问自动走此模式,带 `base_version_id`)。
### 多模型
请求带 `model_config_id`(前端脚本助手右上**模型下拉**:豆包 / GPT-5.5 / Gemini 3 Pro);缺省用默认文本模型。已实测豆包(官方直连)+ GPT-5.5(经 tokenssr)两条路都通。
### 结构化契约 ScriptDraft
脚本不再是「散文 + 正则」,而是结构化 JSON(`script_agent.normalize_draft` 后端兜底校验):三层 = 脚本头(hook/tone/aspect/duration)+ entities(角色/场景/商品,带 visual_prompt/ref_index,**全脚本共享保一致**)+ segments(role 钩子/痛点/卖点/CTA、narration≤55字、speaker、visual、product_exposure、**entity_refs**)。
- 落库:`ScriptVersion`(content=JSON,metadata=hook/tone/entities)+ `ScriptSegment`(新增字段 role/speaker/product_exposure/entity_refs)。
- **entities 回填 `project.metadata`**(cast/scenes/cast_prompts/scene_prompts + script_entities)→ 复用下游已有的基础资产 seed + 故事板 @图N
### 电商 skill
`skills/ecommerce-video-script/`(SKILL.md + 5 references)。`script_agent.load_ecommerce_skill()` 运行时加载为系统提示词,**模型无关**。运行时输出协议放开了「先 1 句口语前言 + 再 JSON」以驱动流式体感。
**关键文件:** `core/backend/apps/ai/script_agent.py``apps/projects/views.py`(`script_agent_stream` action)、前端 `api.ts`(`agentScriptStream`)、`routes/pipeline.tsx`(`runScriptGeneration` 改 SSE 驱动)。
---
## 4. 图片 / 故事板 / 视频
- **图片(基础资产 / 模特 / 套图)**:默认图像模型 = `tokenssr:gpt-image-2`(支持参考图)。原 `yunqi:gpt-image-2`(只能纯文生图)已在迁移里停用。
- **故事板 @图N**:`services._storyboard_reference_images()` 按本镜 `entity_refs` 取角色/场景/商品的已采用基础资产,用 `provider.image_edit(images=[url1,url2,url3])` 多图合成本镜(锁脸锁商品)。provider 不支持 image_edit 时自动回退纯文生图。
- **视频(Seedance)**:`create_video_task(generate_audio=True)` 已打开,直接出画面+音效+人声(**不走 TTS**)。慢:单段约 510 分钟(凌晨可能 3–4 分钟),轮询端点 `poll-video-segment` 已是「秒回不阻塞」式。
---
## 5. 模特库
`python manage.py seed_demo_models --count 2 [--team <id>] [--brief "画像"]`
逻辑(`apps/ai/model_library.py`):先 gpt-image-2 出 9:16 氛围正面图,再以它为参考出 16:9 白底三视图(提示词「参考图1角色,生成角色三视图,从左往右依次为:胸像特写,全身正面,全身侧面,全身背面,白色背景」),两张都存为 person 类 Asset(`metadata.kind="model"`)。**已跑通,三视图角色一致性很好**(见 `_qa_shots/model_threeview.png`)。
---
## 6. 🟡 火山人像素材库审核(绿/红标)—— 待接线
**目标:** 真人素材进火山素材库审核,前端只显示绿盾(active)/红标(failed,提示改提示词重生)。一团队一素材组(单组 500 万)。
**现成代码在 AirDrama,直接照搬:**
- `C:\Airlabs_Project\Airflow Studio\AirDrama\video-shuoshan\backend\utils\assets_client.py``create_asset_group / create_asset / get_asset`(volcengine SDK,AK/SK 签名,异步审核状态轮询 active/failed/processing)。
- 对应 `AssetGroup(team, remote_group_id)` + `Asset(group, remote_asset_id, status, url, error_message)` 模型 + `asset_poll_status_view`
**接线步骤(给你的 Claude):**
1. `requirements.txt``volcengine` SDK 并安装(**当前 AirShelf 没装**)。
2. 照搬 `assets_client.py``apps/assets/`,改 import。
3.`AssetGroup` / 给 `Asset``remote_asset_id/audit_status/audit_error` 字段 + 迁移。
4. 生成人物基础资产后**静默自动上传**到团队素材组,前端轮询 audit_status 渲染绿/红标。
5. env 已备好:`ASSETS_API_ACCESS_KEY / ASSETS_API_SECRET_KEY / ASSETS_API_ENABLED / ASSETS_API_PROJECT_NAME`
---
## 7. 凭证与 .env(⚠️ 一处待换)
`.env` 已含可用凭证(已随 dev 推送,沿用本仓库既有「.env 入库」惯例):
- `TOKENSSR_API_KEY / TOKENSSR_BASE_URL` — 中转站主力(一把 key 通吃 ~90 模型)。
- `FEISHU_APP_ID / FEISHU_APP_SECRET` — 小毛球机器人(交接推送)。
- `VOLC_TTS_*` — 豆包语音合成(可选后期配音)。
**🔴 张业昌待办(唯一硬待办):** `ASSETS_API_ACCESS_KEY / ASSETS_API_SECRET_KEY` 现在**暂借 AirDrama 已邀测开通的火山 AK/SK** 跑通。AirShelf 自有火山账号开通「人像素材库」后,**只换这两行**(独立于 `TOS_*`,不要动 TOS)。
---
## 8. 给前端 UX 的 2 天打磨建议(视觉,非阻塞)
当前以「流程跑通 + 真 agent 体感」为先,视觉是够用级,可优化:
- 脚本助手的**工具卡**现在复用进度流气泡渲染(`ProgressStream` 的 steps),可做成独立「工具卡」样式(图标 + 标题 + 状态点),更像 lovart。
- 思考前言文字现在是 `opacity:0.85` 的普通段落,可加打字机光标 / 渐显。
- 分镜卡可加 **role 徽章**(钩子/痛点/卖点/CTA,数据已在 `segment.role`)和商品露出标签(`segment.product_exposure`)。
- 模型下拉复用了 `.setup-select`,可做成带模型图标的 Pill。
- **务必遵循** `电商AI平台/design.md`(冷灰底/单橙锚点/8px 圆角/inside-border),别新造色值或重写共享类。
---
## 9. 已知问题 / TODO
- 流式生成中途客户端断开,可能留下「预扣未结算」额度(边缘情况;`stream_script_agent` 已尽量 try/except 释放,但 WSGI 断流的 finally 不保证)。
- vite 代理转发 SSE 一般不缓冲;生产 nginx 需确认关掉 `proxy_buffering`(响应已带 `X-Accel-Buffering: no`)。
- 计费仍是「每次调用扣 `unit_price`」,非真 token 计量。
- 故事板 @图N、视频出音 建议接 worker 后端到端复跑一轮确认。
---
## 10. 本批改动文件清单
**后端新增:** `providers/openai_compatible.py``script_agent.py``model_library.py``management/commands/seed_demo_models.py`、迁移 `ai/0005``ai/0006``projects/0002`
**后端修改:** `providers/volcano.py`(流式+音频)、`providers/__init__.py``ai/models.py`(ModelProvider.api_key)、`ai/services.py`(provider 分流+故事板参考图)、`projects/models.py`(ScriptSegment 字段)、`projects/serializers.py``projects/views.py`(SSE 端点+renderer)、`settings/base.py`(TOKENSSR/PROVIDER_KEYS)、`.env`
**前端修改:** `api.ts`(agentScriptStream)、`routes/pipeline.tsx`(SSE 驱动+模型下拉+改稿)、`App.tsx`(textModels)、`types.ts`
**资源:** `skills/ecommerce-video-script/`(运行时依赖,**必须随仓库**)。
设计 SSoT:`AI生成-Agent化落地方案.md`。API 参考:`tokenssr接口文档.md``image调用参考.md`
---
_有疑问看 `_qa_shots/` 的截图(脚本流式三连 + 模特三视图),或直接复跑 §0。祝接手顺利。_