添加全能创作功能

This commit is contained in:
Azmat@qq.com
2026-09-03 13:11:46 +08:00
parent 22ed2833ad
commit 6a628b0ca7
53 changed files with 9115 additions and 108 deletions
+203
View File
@@ -0,0 +1,203 @@
"""全能创作 · 会话与消息的写入服务(契约 §1/§2)。
只放「怎么把一条消息安全落库」这类底座能力;agent 循环、工具执行、SSE 在
后续的 creation_agent.py 里,别混进来。
"""
from __future__ import annotations
from django.db import transaction
from django.db.models import Max
from django.utils import timezone
from .models import CreationConversation, CreationMessage
@transaction.atomic
def append_message(
conversation: CreationConversation,
*,
role: str,
kind: str = CreationMessage.Kind.TEXT,
text: str = "",
payload: dict | None = None,
refs: list | None = None,
task=None,
) -> CreationMessage:
"""往会话尾部追加一条消息,并刷新 last_active_at。
seq 在事务里 select_for_update 锁住会话行再取 max+1 —— SSE 流式期间可能有
并发写(用户抢答 / 轮询回填),不锁会撞 uniq_creation_message_seq。
"""
locked = CreationConversation.objects.select_for_update().get(pk=conversation.pk)
next_seq = (locked.messages.aggregate(m=Max("seq"))["m"] or 0) + 1
message = CreationMessage.objects.create(
conversation=locked,
role=role,
kind=kind,
text=text,
payload=payload or {},
refs=refs or [],
task=task,
seq=next_seq,
)
locked.last_active_at = timezone.now()
locked.save(update_fields=["last_active_at", "updated_at"])
conversation.last_active_at = locked.last_active_at
return message
def _assets_from_task(task) -> list[dict]:
"""任务落库的资产 → 结果卡要用的 {id,url,cover,type}。
URL 必须走长期直链:预签名链 1 小时过期,写进消息 payload 第二天就打不开。
"""
from apps.ai.services import asset_stable_url
from apps.assets.models import Asset
items = []
assets = Asset.objects.filter(
origin_task=task, is_deleted=False, purged_at__isnull=True,
).prefetch_related("files")
for asset in assets:
url, cover = asset_stable_url(asset)
if not url:
continue
items.append({
"id": str(asset.id),
"url": url,
"cover": cover or url,
"type": "video" if asset.asset_type == Asset.Type.VIDEO else "image",
})
return items
def _meta_from_task(task, message: CreationMessage) -> dict:
payload = message.payload or {}
req = task.request_payload or {}
model = ""
if task.model_config_id:
model = task.model_config.display_name or task.model_config.name
return {
"model": model or payload.get("model") or req.get("model") or "",
"ratio": req.get("ratio") or payload.get("ratio") or "",
"prompt": payload.get("prompt") or req.get("prompt") or "",
}
def _message_task(message: CreationMessage):
"""GENERATING 消息挂的任务:优先 FK,payload.task_id 兜底(旧数据 / 序列化往返)。"""
if message.task_id:
return message.task
task_id = (message.payload or {}).get("task_id")
if not task_id:
return None
from .models import AITask
return AITask.objects.select_related("model_config").filter(id=task_id).first()
@transaction.atomic
def fail_generating_message(message: CreationMessage, error: str) -> CreationMessage:
"""生成失败:GENERATING 原地改成 ERROR,不另开一条,避免中间态刷屏。"""
message.kind = CreationMessage.Kind.ERROR
message.text = (error or "生成失败")[:500]
message.save(update_fields=["kind", "text", "updated_at"])
conversation = message.conversation
conversation.status = CreationConversation.Status.FAILED
conversation.last_active_at = timezone.now()
conversation.save(update_fields=["status", "last_active_at", "updated_at"])
return message
def sync_generating_message(message: CreationMessage) -> bool:
"""看挂着的 AITask 是否已经终态,是就把 GENERATING 改成 RESULT / ERROR。
出图/出片在 worker 里跑,agent 只提交。前端轮询 GET 会话时靠这个回填;
worker 结束时也会调一次,不用干等到下一次轮询。
返回是否改了这条消息。
"""
from .models import AITask
if message.kind != CreationMessage.Kind.GENERATING:
return False
task = _message_task(message)
if task is None:
return False
if task.status == AITask.Status.SUCCEEDED:
assets = _assets_from_task(task)
if not assets:
return False # 状态已成功但资产还没落(极端竞态),下轮再试
finish_generating_message(message, assets=assets, meta=_meta_from_task(task, message))
return True
if task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED):
fail_generating_message(message, task.error_message or "生成失败")
return True
return False
def sync_generating_messages(conversation: CreationConversation) -> int:
"""把一条会话里所有已结束的 GENERATING 回填。返回改了几条。"""
pending = list(
conversation.messages.filter(kind=CreationMessage.Kind.GENERATING)
.select_related("task", "task__model_config")
)
return sum(1 for message in pending if sync_generating_message(message))
def sync_generating_for_task(task) -> int:
"""worker / poll 终态后:只扫挂在这个任务上的 GENERATING。失败不能向外抛。"""
try:
pending = list(
CreationMessage.objects.filter(
task=task, kind=CreationMessage.Kind.GENERATING,
).select_related("conversation", "task", "task__model_config")
)
return sum(1 for message in pending if sync_generating_message(message))
except Exception: # noqa: BLE001 — 回填失败不能把已经成功的出图任务打成失败
import logging
logging.getLogger(__name__).warning(
"omni create: sync generating for task %s failed", getattr(task, "id", "?"),
exc_info=True,
)
return 0
@transaction.atomic
def finish_generating_message(message: CreationMessage, *, assets: list[dict], meta: dict) -> CreationMessage:
"""把「生成中」原地改成「结果」(契约 §2:不新增消息,避免中间态刷屏)。
重生成是**新开一条** GENERATING → RESULT,所以对话流仍然是往下叠加;
这里改的只是同一次生成自己的中间态。
"""
message.kind = CreationMessage.Kind.RESULT
message.payload = {**(message.payload or {}), **meta, "assets": assets}
message.save(update_fields=["kind", "payload", "updated_at"])
conversation = message.conversation
conversation.status = CreationConversation.Status.COMPLETED
conversation.last_active_at = timezone.now()
conversation.save(update_fields=["status", "last_active_at", "updated_at"])
return message
def pin_refs(conversation: CreationConversation, refs: list[dict]) -> list[dict]:
"""把本轮引用的实体并进「实体锁定」。按 (type, id) 去重,保留首次出现的顺序 ——
每轮无条件带上它们,这是多次生成之间锁脸/锁商品的唯一手段(契约 §5)。
"""
merged = list(conversation.pinned_refs or [])
seen = {(item.get("type"), str(item.get("id"))) for item in merged}
changed = False
for ref in refs or []:
ref_type, ref_id = ref.get("type"), ref.get("id")
if not ref_type or not ref_id:
continue # 缺 type/id 的 ref 解析不出实体,直接丢
key = (ref_type, str(ref_id))
if key in seen:
continue
merged.append(ref)
seen.add(key)
changed = True
if changed:
conversation.pinned_refs = merged
conversation.save(update_fields=["pinned_refs", "updated_at"])
return merged
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
"""全能创作 · 预设(契约 §6)。
首页那 8+6 张预设卡不只是个名字 —— 每个预设代表一套**明确的拍法**。
只把「达人口播种草」四个字塞进提示词,模型只能靠猜;这里给它可执行的约束。
新增预设 = 在下面加一条,前端 `omni-create.tsx` 的 PRESET 列表加一张卡。两边的
key 必须是同一个中文名(会话建的时候原样存进 CreationConversation.preset)。
"""
from __future__ import annotations
VIDEO_PRESETS: dict[str, str] = {
"剧情反转带货": (
"轻剧情短片。必须有一个具体的困境场景 → 意外转折 → 商品成为解决问题的关键道具。"
"商品不能在开头硬推,要等冲突立住了再自然介入。禁止把普通不便夸成严重后果。"
),
"商品拟人广告": (
"把商品拟人化成有性格的角色,用它的动作、表情和情绪推进。轻快有趣,不说教。"
"拟人化不能牺牲商品真实外观 —— 材质、颜色、结构必须和参考图一致。"
),
"达人口播种草": (
"真人出镜口播,生活化语气,像朋友分享而不是念广告稿。开头一句话建立停留理由,"
"中段讲清使用场景和一个核心卖点,结尾给明确行动理由。禁止万能主播腔和空卖点。"
),
"商品图一键成片": (
"从商品参考图出发建立镜头语言:主图定调 → 补场景 → 补使用动作 → 收尾。"
"节奏清晰,转场干净。商品外观严格以参考图为准。"
),
"鱼眼换装": (
"鱼眼/广角近距离透视,连续换装节奏。**人物面部和身形必须全程一致**,"
"只有服装在变。每次换装用一个明确动作触发。"
),
"点触换款": (
"统一构图和机位,用点击/触碰动作触发商品款式切换,快速展示多个 SKU。"
"背景、光线、机位全程不变 —— 变化只发生在商品本身。"
),
"探店漫游": (
"以空间动线串联:入口 → 环境 → 关键细节 → 服务/主推项目。"
"镜头连续移动有路线感,不要碎切。"
),
"品牌质感大片": (
"强调光影、材质和镜头节奏,建立品牌识别。慢节奏、精致构图、克制的色彩。"
"少即是多,不要堆信息。"
),
}
IMAGE_PRESETS: dict[str, str] = {
"商品场景套图": (
"一组风格统一的电商图:主图(干净突出商品)、场景图(真实使用环境)、细节图(材质/工艺特写)。"
"三张的光线、色调、质感必须是同一套。"
),
"极简棚拍": "干净背景、柔和投影、主体明确居中。大量留白,不加多余道具。适合主图和详情页头图。",
"清透自然光人像": "自然光,保留真实肤质和毛孔,不磨皮不过曝。氛围清透,人物状态放松自然。",
"生活方式场景": "把商品放进真实生活空间和使用动作里,强调自然可信的生活气息,不要摆拍感。",
"高级奢华质感": "深色环境 + 局部高光 + 材质细节特写,强化品牌高级感。对比强但不失细节。",
"复古胶片风格": "低饱和、细腻颗粒、柔和对比、偏暖或偏青的胶片色调。怀旧情绪但不脏。",
}
ALL_PRESETS: dict[str, str] = {**VIDEO_PRESETS, **IMAGE_PRESETS}
def preset_guidance(name: str) -> str:
"""预设名 → 拍法约束。认不出的名字返回 "" —— 前端加了新卡但这里还没写时,
退回「只有名字」的行为,不要报错。"""
return ALL_PRESETS.get((name or "").strip(), "")
+9
View File
@@ -959,6 +959,9 @@ def finalize_free_video(*, task: AITask) -> AITask:
) )
release_credit(reservation=locked.credit_reservation, reason=raw_message[:200]) release_credit(reservation=locked.credit_reservation, reason=raw_message[:200])
_notify_failure(locked, raw=f"[{code}] {raw_message}", hint=public_error.fallback_message) _notify_failure(locked, raw=f"[{code}] {raw_message}", hint=public_error.fallback_message)
from apps.ai.creation import sync_generating_for_task
sync_generating_for_task(locked)
return locked return locked
# succeeded —— 认领 POSTPROCESSING(并发 finalize 只有一路进入慢活) # succeeded —— 认领 POSTPROCESSING(并发 finalize 只有一路进入慢活)
@@ -1025,6 +1028,9 @@ def finalize_free_video(*, task: AITask) -> AITask:
update_fields=["status", "actual_cost", "base_cost", "request_payload", "response_payload", "completed_at", "updated_at"] update_fields=["status", "actual_cost", "base_cost", "request_payload", "response_payload", "completed_at", "updated_at"]
) )
charge_reserved_credit(reservation=reservation, actual_amount=actual) charge_reserved_credit(reservation=reservation, actual_amount=actual)
from apps.ai.creation import sync_generating_for_task
sync_generating_for_task(locked)
return locked return locked
except Exception as exc: # noqa: BLE001 — 后处理失败:标失败退费(release 幂等,已扣则不动) except Exception as exc: # noqa: BLE001 — 后处理失败:标失败退费(release 幂等,已扣则不动)
logger.exception("free video finalize failed for task %s", locked.id) logger.exception("free video finalize failed for task %s", locked.id)
@@ -1045,6 +1051,9 @@ def finalize_free_video(*, task: AITask) -> AITask:
locked.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"]) locked.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"])
release_credit(reservation=locked.credit_reservation, reason=str(exc)[:200]) release_credit(reservation=locked.credit_reservation, reason=str(exc)[:200])
_notify_failure(locked, raw=str(exc), hint=public_error.fallback_message) _notify_failure(locked, raw=str(exc), hint=public_error.fallback_message)
from apps.ai.creation import sync_generating_for_task
sync_generating_for_task(locked)
return locked return locked
+366
View File
@@ -0,0 +1,366 @@
"""全能创作 · @引用:实体检索 与 Ref 解析(契约 §1/§3)。
两件事:
1. `search_mentions()` —— 输入框打 @ 时的检索,返回 [Ref] 给前端渲染菜单。
2. `resolve_refs()` —— 把消息里的 [Ref] 变成模型真正吃得下的两样东西:
**事实文本**(卖点/规格,进提示词)+ **参考图**(进 content_items,锁脸/锁商品/锁场景)。
铁律:消息里存的是结构化 Ref(type + id),**不是** "@净颜精华" 这串字。
后端必须拿 id 回表取事实与图,靠字符串匹配迟早对不上。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from apps.assets.models import Asset, Model
from apps.products.models import Product
from .services import _asset_preview_url, _product_cover_url
# Ref.type → 前端菜单里的分组名(设计稿 .omni-mention-group 的 small 文案)
TYPE_LABELS = {
"product": "商品库",
"model": "模特库",
"character": "角色",
"scene": "场景库",
"asset": "资产库",
}
VALID_TYPES = tuple(TYPE_LABELS)
DEFAULT_TYPES = VALID_TYPES
# 参考图顺序固定:角色 → 场景 → 商品。这个顺序是出片模型 @图N 的语义依据,别改。
_REF_ORDER = {"model": 0, "character": 0, "scene": 1, "product": 2, "asset": 3}
# 火山单次出片最多 9 张图;留足余量,超出的靠优先级截断而不是报错
MAX_REFERENCE_IMAGES = 6
# 「资产库」是兜底分组,不重复列已经有专属分组的资产 ——
# 否则同一张定妆照会在「角色」和「资产库」各出现一次,菜单里看着像两个素材。
ASSET_EXCLUDED_CATEGORIES = (
Asset.Category.PERSON, # → character
Asset.Category.SCENE, # → scene
Asset.Category.MODEL_PORTRAIT, # → model
Asset.Category.TRI_VIEW, # → model
)
@dataclass
class ResolvedRefs:
"""resolve_refs 的产物。facts 进提示词,references 进 content_items。"""
facts: list[str] = field(default_factory=list)
references: list[dict] = field(default_factory=list)
missing: list[dict] = field(default_factory=list) # 删掉/不属于本团队的引用,要在对话里告诉用户
@property
def facts_text(self) -> str:
return "\n\n".join(self.facts)
def _ref(type_: str, obj_id, name: str, cover: str = "") -> dict:
return {"type": type_, "id": str(obj_id), "name": name, "cover": cover}
def _search_products(team, q: str, limit: int) -> list[dict]:
queryset = Product.objects.filter(team=team, purged_at__isnull=True)
if q:
queryset = queryset.filter(title__icontains=q)
out = []
for product in queryset.order_by("-created_at")[:limit]:
out.append(_ref("product", product.id, product.title, _product_cover_url(product)))
return out
def _search_models(team, q: str, limit: int) -> list[dict]:
queryset = Model.objects.filter(team=team, is_deleted=False, purged_at__isnull=True)
if q:
queryset = queryset.filter(name__icontains=q)
out = []
for model in queryset.select_related("portrait_asset")[:limit]:
out.append(_ref("model", model.id, model.name, _asset_preview_url(model.portrait_asset)))
return out
def _search_assets(team, q: str, limit: int, categories: tuple[str, ...], type_: str) -> list[dict]:
queryset = Asset.objects.filter(
team=team,
is_deleted=False,
purged_at__isnull=True,
asset_type=Asset.Type.IMAGE,
category__in=categories,
)
if type_ == "asset":
# 「资产库」只列用户真正加进库的图,不然工作台的每张试验图都会冒出来
queryset = queryset.filter(in_library=True)
if q:
queryset = queryset.filter(name__icontains=q)
out = []
for asset in queryset.order_by("-created_at")[:limit]:
out.append(_ref(type_, asset.id, asset.name, _asset_preview_url(asset)))
return out
def search_mentions(team, q: str = "", types: list[str] | None = None, limit: int = 8) -> list[dict]:
"""@ 检索。types 不传则全类型各取 limit 条,按 商品 → 模特 → 角色 → 场景 → 资产 排。"""
wanted = [t for t in (types or DEFAULT_TYPES) if t in TYPE_LABELS]
q = (q or "").strip()
results: list[dict] = []
for type_ in wanted:
if type_ == "product":
results.extend(_search_products(team, q, limit))
elif type_ == "model":
results.extend(_search_models(team, q, limit))
elif type_ == "character":
results.extend(_search_assets(team, q, limit, (Asset.Category.PERSON,), "character"))
elif type_ == "scene":
results.extend(_search_assets(team, q, limit, (Asset.Category.SCENE,), "scene"))
elif type_ == "asset":
categories = tuple(
c for c in Asset.Category.values if c not in ASSET_EXCLUDED_CATEGORIES
)
results.extend(_search_assets(team, q, limit, categories, "asset"))
return results
_KEY_TO_TYPES = {
"product": ["product"],
"sku": ["product"],
"goods": ["product"],
"item": ["product"],
"model": ["model"],
"character": ["character"],
"person": ["character"],
"scene": ["scene"],
"asset": ["asset"],
}
def infer_field_types(field: dict) -> list[str]:
"""追问卡字段 → 该去哪类库里解析用户的选择。"""
typed = [t for t in (field.get("asset_types") or []) if t in TYPE_LABELS]
if typed:
return typed
key = str(field.get("key") or "").strip().lower()
if key in _KEY_TO_TYPES:
return _KEY_TO_TYPES[key]
label = str(field.get("label") or "")
if "商品" in label:
return ["product"]
if "模特" in label:
return ["model"]
if "角色" in label or "人物" in label:
return ["character"]
if "场景" in label:
return ["scene"]
return list(DEFAULT_TYPES)
def lookup_mention(team, value: str, types: list[str] | None = None) -> dict | None:
"""把追问卡里的选项值(实体 id 或精确名字)还原成 Ref。对不上就返回 None,绝不瞎配。"""
raw = str(value or "").strip()
if not raw:
return None
wanted = [t for t in (types or DEFAULT_TYPES) if t in TYPE_LABELS] or list(DEFAULT_TYPES)
uid = None
try:
uid = str(uuid.UUID(raw))
except ValueError:
uid = None
if uid:
if "product" in wanted:
product = Product.objects.filter(team=team, id=uid, purged_at__isnull=True).first()
if product:
return _ref("product", product.id, product.title, _product_cover_url(product))
if "model" in wanted:
model = (
Model.objects.filter(team=team, id=uid, is_deleted=False, purged_at__isnull=True)
.select_related("portrait_asset")
.first()
)
if model:
return _ref("model", model.id, model.name, _asset_preview_url(model.portrait_asset))
if any(t in wanted for t in ("character", "scene", "asset")):
asset = Asset.objects.filter(
team=team, id=uid, is_deleted=False, purged_at__isnull=True
).first()
if asset is not None:
if asset.category == Asset.Category.PERSON:
type_ = "character"
elif asset.category == Asset.Category.SCENE:
type_ = "scene"
else:
type_ = "asset"
if type_ in wanted:
return _ref(type_, asset.id, asset.name, _asset_preview_url(asset))
if types:
return lookup_mention(team, raw, None)
return None
hits = search_mentions(team, q=raw, types=wanted, limit=8)
for hit in hits:
if (hit.get("name") or "") == raw:
return hit
return None
def refs_from_elicit_answers(team, fields, answers: dict) -> list[dict]:
"""用户在追问卡里点选的商品/角色等 → 可 pin 的 Ref。
模型常用 type=single + 选项 value=商品id/名字,前端只回 answers 不回 refs,
不在这里补上的话出片参考图里就没有这件商品。"""
refs: list[dict] = []
seen: set[tuple] = set()
for field in fields or []:
if not isinstance(field, dict):
continue
key = str(field.get("key") or "")
if key in {"duration", "ratio", "resolution", "video_model", "count"}:
continue
raw = (answers or {}).get(field.get("key"))
if raw is None:
continue
values = raw if isinstance(raw, list) else [raw]
inferred = infer_field_types(field)
for value in values:
ref = lookup_mention(team, str(value or ""), inferred)
if ref is None:
continue
mark = (ref.get("type"), str(ref.get("id")))
if mark in seen:
continue
seen.add(mark)
refs.append(ref)
return refs
def product_facts_text(product) -> str:
"""商品事实块。全能创作没有 project,所以不能复用 script_agent._product_context()。
这里只给**客观事实**(标题/品牌/品类/规格/卖点),不带人设和口吻 —— 那些由策略卡决定。"""
lines = [f"商品:{product.title}"]
if product.brand:
lines.append(f"品牌:{product.brand}")
if product.category:
lines.append(f"品类:{product.category}")
if product.target_audience:
lines.append(f"目标人群:{product.target_audience}")
description = (product.description or "").strip()
if description:
lines.append(f"商品描述:{description}")
specs = product.specs if isinstance(product.specs, dict) else {}
spec_text = "".join(f"{k}:{v}" for k, v in specs.items() if v)
if spec_text:
lines.append(f"规格:{spec_text}")
points = list(product.selling_points.order_by("sort_order", "created_at"))
if points:
joined = "\n".join(f"- {p.title}:{p.detail or p.title}" for p in points)
lines.append(f"卖点:\n{joined}")
return "\n".join(lines)
def _asset_reference(asset, type_: str, label: str) -> dict | None:
"""Asset → 参考图条目。带上审核态,视频路据此换成火山 asset:// 引用(否则真人图会被判「疑似真人」拒)。"""
url = _asset_preview_url(asset)
if not url:
return None
return {
"url": url,
"type": type_,
"label": label,
"asset_id": str(asset.id),
"review_status": asset.review_status,
"review_remote_id": asset.review_remote_id,
}
def _product_reference(product) -> dict | None:
"""商品参考图:**真实上传图优先,排除 AI 生成图** —— 拿生成图当真相再喂回模型会误差累积。
一张真实图都没有才回落封面(可能是 AI 图,但好过纯文生图)。
这里不复用 services._product_reference_urls():那个只返回 url,而视频路还需要
asset_id 和审核态才能把图换成火山 asset:// 引用(商品图也可能出现真人上身)。
"""
rels = sorted(product.images.select_related("asset").all(), key=lambda im: (not im.is_primary, im.sort_order))
for rel in rels:
asset = rel.asset
if asset is None or asset.source == Asset.Source.AI_GENERATED:
continue
entry = _asset_reference(asset, "product", product.title)
if entry:
return entry
if product.cover_asset_id:
entry = _asset_reference(product.cover_asset, "product", product.title)
if entry:
return entry
cover = _product_cover_url(product)
return (
{"url": cover, "type": "product", "label": product.title,
"asset_id": "", "review_status": "", "review_remote_id": ""}
if cover else None
)
def resolve_refs(team, refs: list[dict]) -> ResolvedRefs:
"""[Ref] → 事实文本 + 参考图。查不到的进 missing,**不抛异常** ——
素材被别人删掉不该让整条对话崩掉,该由 agent 在对话里说明。"""
resolved = ResolvedRefs()
for ref in refs or []:
type_, ref_id = ref.get("type"), ref.get("id")
if type_ not in TYPE_LABELS or not ref_id:
continue
if type_ == "product":
product = Product.objects.filter(team=team, id=ref_id, purged_at__isnull=True).first()
if product is None:
resolved.missing.append(ref)
continue
resolved.facts.append(product_facts_text(product))
entry = _product_reference(product)
if entry:
resolved.references.append(entry)
continue
if type_ == "model":
model = Model.objects.filter(
team=team, id=ref_id, is_deleted=False, purged_at__isnull=True
).select_related("triview_asset", "portrait_asset").first()
if model is None:
resolved.missing.append(ref)
continue
if (model.description or "").strip():
resolved.facts.append(f"模特「{model.name}」:{model.description.strip()}")
# 锁脸优先用三视图(正/侧/背都在一张 16:9 里,信息量最大),没有才回落形象图
entry = _asset_reference(model.triview_asset, "model", model.name) or _asset_reference(
model.portrait_asset, "model", model.name
)
if entry:
resolved.references.append(entry)
else:
resolved.missing.append(ref)
continue
asset = Asset.objects.filter(
team=team, id=ref_id, is_deleted=False, purged_at__isnull=True
).first()
if asset is None:
resolved.missing.append(ref)
continue
if (asset.description or "").strip():
resolved.facts.append(f"{TYPE_LABELS[type_]}{asset.name}」:{asset.description.strip()}")
entry = _asset_reference(asset, type_, asset.name)
if entry:
resolved.references.append(entry)
else:
resolved.missing.append(ref)
# 角色 → 场景 → 商品。同优先级内保持用户 @ 的先后。
resolved.references.sort(key=lambda item: _REF_ORDER.get(item["type"], 9))
resolved.references = _dedupe_references(resolved.references)[:MAX_REFERENCE_IMAGES]
return resolved
def _dedupe_references(references: list[dict]) -> list[dict]:
"""同一张图被 @ 两次(比如商品图同时是资产库图)只留一条,否则 @图N 编号会错位。"""
out: list[dict] = []
seen: set[str] = set()
for item in references:
key = item.get("url") or ""
if not key or key in seen:
continue
seen.add(key)
out.append(item)
return out
@@ -0,0 +1,77 @@
# Generated by Django 5.1.15 on 2026-09-02 09:45
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0009_team_price_multiplier'),
('ai', '0033_seedance_25_capabilities'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='CreationConversation',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('title', models.CharField(default='未命名创作', max_length=120)),
('mode', models.CharField(choices=[('video', '视频创作'), ('image', '图片创作')], default='video', max_length=16)),
('preset', models.CharField(blank=True, default='', max_length=64)),
('params', models.JSONField(blank=True, default=dict)),
('pinned_refs', models.JSONField(blank=True, default=list)),
('memory', models.JSONField(blank=True, default=dict)),
('status', models.CharField(choices=[('running', '进行中'), ('completed', '已完成'), ('failed', '失败')], default='running', max_length=16)),
('last_active_at', models.DateTimeField(auto_now_add=True)),
('is_deleted', models.BooleanField(default=False)),
('purged_at', models.DateTimeField(blank=True, null=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(class)s_set', to=settings.AUTH_USER_MODEL)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='accounts.team')),
],
),
migrations.CreateModel(
name='CreationMessage',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('role', models.CharField(choices=[('user', '用户'), ('assistant', 'AI'), ('system', '系统')], max_length=16)),
('kind', models.CharField(choices=[('text', '文字气泡'), ('elicit', '追问卡'), ('strategy', '创作策略理解卡'), ('plan', '视频最终方案卡'), ('prompt_file', '生成 Prompt 文件卡'), ('confirm', '确认闸门(带预计积分)'), ('generating', '生成中'), ('result', '生成结果'), ('error', '错误')], default='text', max_length=24)),
('text', models.TextField(blank=True, default='')),
('payload', models.JSONField(blank=True, default=dict)),
('refs', models.JSONField(blank=True, default=list)),
('seq', models.PositiveIntegerField(default=0)),
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='ai.creationconversation')),
('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='creation_messages', to='ai.aitask')),
],
options={
'ordering': ['seq', 'created_at'],
},
),
migrations.AddIndex(
model_name='creationconversation',
index=models.Index(fields=['team', '-last_active_at'], name='ai_creation_team_id_82f111_idx'),
),
migrations.AddIndex(
model_name='creationconversation',
index=models.Index(fields=['team', 'status', '-last_active_at'], name='ai_creation_team_id_3aa2e5_idx'),
),
migrations.AddIndex(
model_name='creationconversation',
index=models.Index(fields=['team', 'is_deleted', 'purged_at'], name='ai_creation_team_id_26e56a_idx'),
),
migrations.AddIndex(
model_name='creationmessage',
index=models.Index(fields=['conversation', 'seq'], name='ai_creation_convers_e9c745_idx'),
),
migrations.AddConstraint(
model_name='creationmessage',
constraint=models.UniqueConstraint(fields=('conversation', 'seq'), name='uniq_creation_message_seq'),
),
]
+105
View File
@@ -279,3 +279,108 @@ class PromptTemplate(TimeStampedModel):
def __str__(self) -> str: def __str__(self) -> str:
return f"prompt:{self.key}" return f"prompt:{self.key}"
class CreationConversation(TeamOwnedModel):
"""全能创作的会话。一条会话 = 一次完整创作(可多轮改稿、多次出图/出片)。
和 ImageConversation 的区别:那张表只是「生图线程」,没有消息实体,历史靠 AITask 拼;
这里是真对话,消息落 CreationMessage。两者并存,互不影响(图片工作台仍走旧表)。
mode 发起时定死,会话内不可切(设计稿顶栏的模型/分辨率/比例跟着 mode 固定)。
pinned_refs 是「实体锁定」:本会话引用过的商品/角色/场景,每轮无条件带进上下文 ——
这是多次生成之间锁脸、锁商品的唯一手段,别省。
"""
class Mode(models.TextChoices):
VIDEO = "video", "视频创作"
IMAGE = "image", "图片创作"
class Status(models.TextChoices):
RUNNING = "running", "进行中"
COMPLETED = "completed", "已完成"
FAILED = "failed", "失败"
title = models.CharField(max_length=120, default="未命名创作")
mode = models.CharField(max_length=16, choices=Mode.choices, default=Mode.VIDEO)
preset = models.CharField(max_length=64, blank=True, default="") # "" = 自由创作
# 会话级参数:{model, resolution, ratio, duration} —— 设计稿顶栏 meta 就渲染它
params = models.JSONField(default=dict, blank=True)
# 实体锁定:[Ref],见契约 §1。每轮无条件带上
pinned_refs = models.JSONField(default=list, blank=True)
# 记忆:{summary, artifacts:[{msg_id,asset_id,prompt,kind}], turn_count}
memory = models.JSONField(default=dict, blank=True)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.RUNNING)
last_active_at = models.DateTimeField(auto_now_add=True)
is_deleted = models.BooleanField(default=False)
purged_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [
# 创作历史页:按团队 + 最近活跃倒序
models.Index(fields=["team", "-last_active_at"]),
models.Index(fields=["team", "status", "-last_active_at"]),
models.Index(fields=["team", "is_deleted", "purged_at"]),
]
def __str__(self) -> str:
return f"creation:{self.mode}:{self.title}"
class CreationMessage(TimeStampedModel):
"""全能创作对话流里的一条消息。kind 决定前端渲染成哪种卡片(见契约 §2)。
text 只给 TEXT 用;其余 kind 的内容全在 payload 里 —— 前端按 kind 走不同组件,
别把结构化内容塞进 text 再让前端解析。
生成类消息(GENERATING / RESULT)挂 task:提交时先落一条 GENERATING,
轮询到终态后**原地改成 RESULT**(不新增消息),这样对话流不会被中间态刷屏。
"""
class Role(models.TextChoices):
USER = "user", "用户"
ASSISTANT = "assistant", "AI"
SYSTEM = "system", "系统"
class Kind(models.TextChoices):
TEXT = "text", "文字气泡"
ELICIT = "elicit", "追问卡" # AI 反问用户,带 单选/多选/填空/选素材 控件
STRATEGY = "strategy", "创作策略理解卡"
PLAN = "plan", "视频最终方案卡"
PROMPT_FILE = "prompt_file", "生成 Prompt 文件卡"
CONFIRM = "confirm", "确认闸门(带预计积分)"
GENERATING = "generating", "生成中"
RESULT = "result", "生成结果"
ERROR = "error", "错误"
conversation = models.ForeignKey(
CreationConversation, on_delete=models.CASCADE, related_name="messages"
)
role = models.CharField(max_length=16, choices=Role.choices)
kind = models.CharField(max_length=24, choices=Kind.choices, default=Kind.TEXT)
text = models.TextField(blank=True, default="")
payload = models.JSONField(default=dict, blank=True)
# 本条消息引用的实体:[Ref]。用 id 取事实与参考图,不许只存 "@商品名" 字符串
refs = models.JSONField(default=list, blank=True)
task = models.ForeignKey(
AITask,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="creation_messages",
)
# 会话内自增渲染序;并发插入靠 select_for_update 取 max+1
seq = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["seq", "created_at"]
indexes = [
models.Index(fields=["conversation", "seq"]),
]
constraints = [
models.UniqueConstraint(
fields=["conversation", "seq"], name="uniq_creation_message_seq"
),
]
def __str__(self) -> str:
return f"msg:{self.kind}:{self.seq}"
+76 -1
View File
@@ -1,6 +1,13 @@
from rest_framework import serializers from rest_framework import serializers
from .models import AITask, ImageConversation, ModelConfig, ModelProvider from .models import (
AITask,
CreationConversation,
CreationMessage,
ImageConversation,
ModelConfig,
ModelProvider,
)
class ModelProviderSerializer(serializers.ModelSerializer): class ModelProviderSerializer(serializers.ModelSerializer):
@@ -89,3 +96,71 @@ class AITaskSerializer(serializers.ModelSerializer):
# batch_id / mode 是显式声明的 SerializerMethodField(本就只读),不能再列进 read_only_fields(DRF 会报错) # batch_id / mode 是显式声明的 SerializerMethodField(本就只读),不能再列进 read_only_fields(DRF 会报错)
read_only_fields = [f for f in fields if f not in ("batch_id", "mode")] read_only_fields = [f for f in fields if f not in ("batch_id", "mode")]
class CreationMessageSerializer(serializers.ModelSerializer):
"""全能创作对话流里的一条消息。前端**按 kind 分发到不同卡片组件**,
结构化内容一律在 payload 里(契约 §2),不要从 text 里解析。"""
class Meta:
model = CreationMessage
fields = ["id", "role", "kind", "text", "payload", "refs", "task", "seq", "created_at"]
read_only_fields = fields
class CreationConversationSerializer(serializers.ModelSerializer):
"""会话列表 / 详情。title 可写(重命名);params 创建后也可改(对话页改模型/比例后立刻生效);
mode 创建后不可改。"""
message_count = serializers.SerializerMethodField()
cover_url = serializers.SerializerMethodField()
class Meta:
model = CreationConversation
fields = [
"id", "title", "mode", "preset", "params", "status",
"message_count", "cover_url",
"last_active_at", "created_at", "updated_at",
]
read_only_fields = [
"id", "status", "message_count", "cover_url",
"last_active_at", "created_at", "updated_at",
]
def get_message_count(self, obj) -> int:
cached = getattr(obj, "_message_count", None)
return cached if cached is not None else obj.messages.count()
def get_cover_url(self, obj) -> str:
"""历史页封面 = **最新一版**结果(重生成是往下叠加,所以取最后一条 RESULT)。"""
last = (
obj.messages.filter(kind=CreationMessage.Kind.RESULT)
.order_by("-seq")
.values_list("payload", flat=True)
.first()
)
if not last:
return ""
assets = (last or {}).get("assets") or []
if not assets:
return ""
first = assets[0] or {}
return first.get("cover") or first.get("url") or ""
def update(self, instance, validated_data):
# mode 定死:允许传但忽略,避免前端误改后顶栏参数与已生成内容对不上
validated_data.pop("mode", None)
return super().update(instance, validated_data)
class CreationConversationDetailSerializer(CreationConversationSerializer):
"""详情:带全量消息,进对话页一次性回填。"""
messages = CreationMessageSerializer(many=True, read_only=True)
pinned_refs = serializers.JSONField(read_only=True)
class Meta(CreationConversationSerializer.Meta):
fields = [*CreationConversationSerializer.Meta.fields, "messages", "pinned_refs"]
read_only_fields = [
*CreationConversationSerializer.Meta.read_only_fields, "messages", "pinned_refs",
]
+5
View File
@@ -3883,6 +3883,11 @@ def run_standalone_image_task(*, task_id: str) -> None:
task=task, project=task.project, recipient=user, task=task, project=task.project, recipient=user,
stage_label="图片创作", raw=str(exc), hint=friendly_generation_error(str(exc)), stage_label="图片创作", raw=str(exc), hint=friendly_generation_error(str(exc)),
) )
from apps.ai.creation import sync_generating_for_task
# 全能创作挂在这条任务上的 GENERATING 要立刻改成 RESULT/ERROR,
# 不能干等前端下一次轮询 —— 否则页面会一直停在「正在生成」。
sync_generating_for_task(task)
# ── 旁白配音(TTS):每镜旁白合成一段语音,导出时作为人声轨混在 BGM 之上 ── # ── 旁白配音(TTS):每镜旁白合成一段语音,导出时作为人声轨混在 BGM 之上 ──
+733
View File
@@ -0,0 +1,733 @@
"""全能创作 · Agent 循环与 SSE(契约 §3/§4)。
用假 provider 逐帧回放模型输出,验证的是**编排**而不是模型质量:
工具调用拼装、追问中断、计费闸门、参考图带入、错误不打崩流。
"""
import json
from unittest.mock import patch
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.assets.models import Asset, AssetFile
from apps.products.models import Product, ProductImage
from .creation import append_message
from .creation_agent import (
COMPRESS_MIN_BATCH,
DEFAULT_VIDEO_MODEL,
KEEP_RECENT_MESSAGES,
SMART_DURATION,
_coerce_fields,
_image_count,
_merge_tool_call_deltas,
stream_creation_agent,
submit_confirmed_video,
video_duration,
video_model_name,
wanted_asset_pick,
wanted_param_keys,
)
from .models import AITask, CreationConversation, CreationMessage, ModelConfig, ModelProvider
def _text_chunks(text):
for piece in text:
yield {"type": "delta", "text": piece}
yield {"type": "done"}
def _tool_chunks(name, arguments, *, said=""):
"""模拟 OpenAI 流式:arguments 被拆成多片下发。"""
for piece in said:
yield {"type": "delta", "text": piece}
yield {"type": "tool_call", "tool_calls": [{"index": 0, "function": {"name": name, "arguments": ""}}]}
blob = json.dumps(arguments, ensure_ascii=False)
for i in range(0, len(blob), 7):
yield {"type": "tool_call", "tool_calls": [{"index": 0, "function": {"arguments": blob[i:i + 7]}}]}
yield {"type": "done"}
class FakeProvider:
"""按脚本逐轮回放。每调用一次 chat_completion_stream 消费一个剧本。"""
def __init__(self, scripts):
self.scripts = list(scripts)
self.calls = []
def chat_completion_stream(self, **kwargs):
self.calls.append(kwargs)
if not self.scripts:
return iter([{"type": "done"}])
return self.scripts.pop(0)
def _events(stream):
out = []
for frame in stream:
for line in frame.strip().splitlines():
if line.startswith("data: "):
out.append(json.loads(line[6:]))
return out
class CreationAgentBaseTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="agent-owner", password="p")
self.team = Team.objects.create(name="Agent", owner=self.user)
provider = ModelProvider.objects.create(name="fake", display_name="Fake", base_url="https://x")
self.model = ModelConfig.objects.create(
provider=provider, name="fake-text", display_name="Fake Text",
capability=ModelConfig.Capability.TEXT, endpoint="chat/completions",
)
self.conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="image", title="出图",
params={"ratio": "1:1", "model": "Seedream5.0"},
)
def _run(self, scripts, text="来一张商品图", refs=None):
fake = FakeProvider(scripts)
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation, user=self.user,
text=text, refs=refs or [], model_config=self.model,
))
return events, fake
class ToolCallAssemblyTests(TestCase):
def test_streamed_arguments_are_concatenated_not_overwritten(self):
buffer = {}
_merge_tool_call_deltas(buffer, [{"index": 0, "function": {"name": "ask_user", "arguments": '{"a"'}}])
_merge_tool_call_deltas(buffer, [{"index": 0, "function": {"arguments": ':1}'}}])
# 覆盖式赋值会只剩最后一片,tool call 直接废掉
self.assertEqual(buffer[0], {"name": "ask_user", "arguments": '{"a":1}'})
def test_multiple_parallel_tool_calls_keep_separate_slots(self):
buffer = {}
_merge_tool_call_deltas(buffer, [
{"index": 0, "function": {"name": "search_library", "arguments": "{}"}},
{"index": 1, "function": {"name": "ask_user", "arguments": "{}"}},
])
self.assertEqual({buffer[0]["name"], buffer[1]["name"]}, {"search_library", "ask_user"})
class ImageCountTests(TestCase):
def test_session_sheet_count_wins(self):
self.assertEqual(_image_count({"count": "2 张"}, 4), 2)
self.assertEqual(_image_count({"duration": "4 张"}, None), 4)
def test_tool_count_used_when_user_did_not_pick(self):
self.assertEqual(_image_count({"duration": "智能时长"}, 3), 3)
self.assertEqual(_image_count({}, None), 1)
self.assertEqual(_image_count({}, 9), 8)
class FieldCoercionTests(TestCase):
def test_single_choice_without_options_is_dropped(self):
fields = _coerce_fields([{"key": "tone", "label": "什么调性?", "type": "single"}])
self.assertEqual(fields, []) # 没选项的单选是废卡
def test_text_and_asset_fields_get_their_defaults(self):
fields = _coerce_fields([
{"key": "slogan", "label": "想突出哪句话?", "type": "text"},
{"key": "who", "label": "用哪个模特?", "type": "asset"},
])
self.assertEqual(fields[0]["placeholder"], "")
self.assertIn("product", fields[1]["asset_types"])
def test_unknown_type_and_over_limit_are_trimmed(self):
raw = [{"key": f"k{i}", "label": "x", "type": "text"} for i in range(5)]
raw.append({"key": "bad", "label": "x", "type": "dropdown"})
self.assertEqual(len(_coerce_fields(raw)), 3) # 一次最多问 3 项
def test_product_text_or_single_is_coerced_to_asset_card(self):
fields = _coerce_fields([
{"key": "product", "label": "选择商品", "type": "single",
"options": [{"value": "a", "label": "A"}]},
])
self.assertEqual(fields[0]["type"], "asset")
self.assertEqual(fields[0]["asset_types"], ["product"])
def test_duration_single_stays_single(self):
fields = _coerce_fields([
{"key": "duration", "label": "改成多长?", "type": "single",
"options": [{"value": "10 秒", "label": "10 秒"}]},
])
self.assertEqual(fields[0]["type"], "single")
self.assertEqual(fields[0]["options"][0]["value"], "10 秒")
class AssetPickIntentTests(TestCase):
def test_change_product_without_at_should_open_picker(self):
self.assertEqual(wanted_asset_pick("我想修改商品", []), "product")
self.assertEqual(wanted_asset_pick("换个角色", []), "character")
def test_named_or_already_referenced_does_not_force_picker(self):
self.assertIsNone(wanted_asset_pick("换成净颜精华", []))
self.assertIsNone(wanted_asset_pick("我想修改商品", [{"type": "product", "id": "1"}]))
class ParamPickIntentTests(TestCase):
def test_change_duration_opens_duration_card(self):
self.assertEqual(wanted_param_keys("我想改时长", is_video=True), ["duration"])
self.assertEqual(wanted_param_keys("改成 10 秒", is_video=True), ["duration"])
def test_change_model_does_not_mean_character_model(self):
self.assertEqual(wanted_param_keys("换个模型", is_video=True), ["video_model"])
self.assertEqual(wanted_param_keys("改模特", is_video=True), [])
class AskUserTests(CreationAgentBaseTests):
def test_saying_change_duration_injects_param_card(self):
events, _ = self._run([_text_chunks("你想改成多长?")], text="我想改时长")
elicit = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "elicit"]
self.assertEqual(len(elicit), 1)
self.assertEqual(elicit[0]["message"]["payload"]["fields"][0]["key"], "duration")
self.assertEqual(elicit[0]["message"]["payload"]["fields"][0]["type"], "single")
def test_saying_change_product_injects_asset_card(self):
events, _ = self._run([_text_chunks("换成哪个商品?直接选或填名字都行:")], text="我想修改商品")
elicit = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "elicit"]
self.assertEqual(len(elicit), 1)
self.assertEqual(elicit[0]["message"]["payload"]["fields"][0]["type"], "asset")
self.assertEqual(elicit[0]["message"]["payload"]["fields"][0]["asset_types"], ["product"])
def test_ask_user_emits_elicit_card_and_stops_the_loop(self):
events, fake = self._run([
_tool_chunks("ask_user", {"fields": [
{"key": "tone", "label": "想要什么调性?", "type": "single",
"options": [{"value": "warm", "label": "温暖生活感"}, {"value": "cool", "label": "冷淡高级感"}]},
]}),
_text_chunks("不该跑到这一轮"),
])
elicit = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "elicit"]
self.assertEqual(len(elicit), 1)
self.assertEqual(len(elicit[0]["message"]["payload"]["fields"][0]["options"]), 2)
# 反问必须中断循环,否则模型会自问自答
self.assertEqual(len(fake.calls), 1)
self.assertEqual(events[-1]["type"], "done")
def test_malformed_fields_do_not_stop_the_conversation(self):
events, fake = self._run([
_tool_chunks("ask_user", {"fields": [{"key": "x", "label": "y", "type": "dropdown"}]}),
_text_chunks("那我直接来了"),
])
kinds = [e["message"]["kind"] for e in events if e.get("type") == "message"]
self.assertNotIn("elicit", kinds)
self.assertEqual(len(fake.calls), 2) # 废卡不算反问,循环继续
class GenerateImageTests(CreationAgentBaseTests):
def _fake_task(self, key):
"""真 AITask —— GENERATING 消息要把它挂上 FK,假对象赋不进去。"""
return AITask.objects.create(
team=self.team, created_by=self.user, task_type=AITask.Type.PRODUCT_IMAGE,
model_config=self.model, idempotency_key=key,
)
def setUp(self):
super().setUp()
self.product = Product.objects.create(team=self.team, created_by=self.user, title="净颜精华")
asset = Asset.objects.create(
team=self.team, created_by=self.user, name="商品实拍",
asset_type=Asset.Type.IMAGE, source=Asset.Source.UPLOAD,
category=Asset.Category.PRODUCT_IMAGE,
)
AssetFile.objects.create(asset=asset, object_key="k", bucket="b", is_primary=True,
preview_url="https://cdn/prod.jpg")
ProductImage.objects.create(product=self.product, asset=asset, is_primary=True)
self.product_asset = asset
def test_generate_image_submits_with_pinned_reference_and_emits_task(self):
with patch("apps.ai.services.enqueue_standalone_images") as enqueue:
enqueue.return_value = [self._fake_task("k-ref")]
events, _ = self._run(
[_tool_chunks("generate_image", {"prompt": "干净棚拍,柔光,居中构图"})],
refs=[{"type": "product", "id": str(self.product.id), "name": "净颜精华"}],
)
kwargs = enqueue.call_args.kwargs
# @ 引用的商品图必须作为参考图带上,否则出的图跟商品长得不一样
self.assertEqual(kwargs["reference_image_ids"], [str(self.product_asset.id)])
self.assertEqual(kwargs["ratio"], "1:1") # 会话级参数直接用,不再问用户
self.assertEqual(kwargs["count"], 1)
task_events = [e for e in events if e.get("type") == "task"]
self.assertEqual(len(task_events), 1)
generating = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "generating"]
self.assertEqual(len(generating), 1)
def test_session_image_count_overrides_model_count(self):
self.conversation.params = {"ratio": "1:1", "count": "2 张"}
self.conversation.save(update_fields=["params"])
with patch("apps.ai.services.enqueue_standalone_images") as enqueue:
enqueue.return_value = [self._fake_task("k-count-1"), self._fake_task("k-count-2")]
events, _ = self._run(
[_tool_chunks("generate_image", {"prompt": "白底棚拍", "count": 1})],
)
self.assertEqual(enqueue.call_args.kwargs["count"], 2)
generating = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "generating"]
self.assertEqual(len(generating), 2)
def test_second_generation_in_one_turn_is_blocked(self):
with patch("apps.ai.services.enqueue_standalone_images") as enqueue:
enqueue.return_value = [self._fake_task("k-twice")]
self._run([
_tool_chunks("generate_image", {"prompt": "第一版"}),
_tool_chunks("generate_image", {"prompt": "第二版"}),
])
# 一条用户消息只计费一次:一句「多做几版」不能烧掉一堆积分
self.assertEqual(enqueue.call_count, 1)
def test_prompt_is_remembered_for_the_next_revision(self):
with patch("apps.ai.services.enqueue_standalone_images") as enqueue:
enqueue.return_value = [self._fake_task("k-memory")]
self._run([_tool_chunks("generate_image", {"prompt": "白底棚拍"})])
self.conversation.refresh_from_db()
# 产物索引:下一轮「背景换夜景」要靠它知道在改哪一版
self.assertEqual(self.conversation.memory["artifacts"][-1]["prompt"], "白底棚拍")
def test_video_conversation_is_not_offered_the_image_tool(self):
video = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="video", params={}
)
fake = FakeProvider([_text_chunks("先聊聊")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
list(stream_creation_agent(conversation=video, user=self.user, text="做条视频",
model_config=self.model))
names = {t["function"]["name"] for t in fake.calls[0]["extra_body"]["tools"]}
# 会话 mode 定死,摆出不该用的工具只会诱导模型走错路
self.assertNotIn("generate_image", names)
class MissingRefAndFailureTests(CreationAgentBaseTests):
def test_deleted_ref_is_reported_but_conversation_continues(self):
events, fake = self._run(
[_text_chunks("")],
refs=[{"type": "product", "id": "00000000-0000-0000-0000-000000000000", "name": "已删商品"}],
)
texts = [e["message"]["text"] for e in events if e.get("type") == "message"]
self.assertTrue(any("已删商品" in t for t in texts))
self.assertEqual(len(fake.calls), 1) # 照常继续,不打断
def test_provider_blowup_yields_error_event_not_a_hang(self):
def explode(**kwargs):
raise RuntimeError("provider down")
fake = FakeProvider([])
fake.chat_completion_stream = explode
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation, user=self.user, text="来一张", model_config=self.model,
))
# 未捕获异常会让前端白屏卡死,必须收成 error 事件
self.assertEqual(events[-1]["type"], "error")
def test_no_text_model_configured_fails_fast(self):
ModelConfig.objects.update(status=ModelConfig.Status.DISABLED)
events = _events(stream_creation_agent(
conversation=self.conversation, user=self.user, text="来一张", model_config=None,
))
self.assertEqual(events[0]["type"], "error")
class SseFramingTests(CreationAgentBaseTests):
def test_messages_carrying_uuid_and_datetime_are_serializable(self):
"""GENERATING 消息带 task 外键(UUID)和 created_at(datetime)。
用标准 json.dumps 会当场 TypeError 把整条流打断 —— 必须走 DjangoJSONEncoder。"""
task = AITask.objects.create(
team=self.team, created_by=self.user, task_type=AITask.Type.PRODUCT_IMAGE,
model_config=self.model, idempotency_key="k-sse",
)
with patch("apps.ai.services.enqueue_standalone_images", return_value=[task]):
events, _ = self._run([_tool_chunks("generate_image", {"prompt": "白底棚拍"})])
self.assertNotIn("error", [e.get("type") for e in events])
generating = next(e for e in events if e.get("type") == "message"
and e["message"]["kind"] == "generating")
self.assertEqual(generating["message"]["task"], str(task.id))
class SendEndpointTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="send-owner", password="p")
self.team = Team.objects.create(name="Send", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role="owner")
self.conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="image"
)
self.client = APIClient()
self.client.force_authenticate(self.user)
def test_empty_message_is_rejected(self):
response = self.client.post(f"/api/ai/creations/{self.conversation.id}/send/", {}, format="json")
self.assertEqual(response.status_code, 400)
def test_answering_an_elicit_card_marks_it_submitted(self):
card = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.ELICIT,
payload={"fields": [{"key": "tone", "label": "什么调性?", "type": "single",
"options": [{"value": "warm", "label": "温暖"}]}],
"submitted": False, "answers": {}},
)
fake = FakeProvider([_text_chunks("收到")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{"kind": "elicit_answer", "reply_to": str(card.id), "answers": {"tone": "warm"}},
format="json",
)
list(response.streaming_content)
card.refresh_from_db()
self.assertTrue(card.payload["submitted"])
self.assertEqual(card.payload["answers"], {"tone": "warm"})
def test_elicit_product_choice_pins_the_product(self):
"""对话里点选商品只回 answers 时,也必须钉进 pinned_refs,否则出片带不上商品图。"""
product = Product.objects.create(team=self.team, created_by=self.user, title="净颜精华")
card = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.ELICIT,
payload={"fields": [{"key": "product", "label": "选择商品", "type": "single",
"options": [{"value": str(product.id), "label": product.title}]}],
"submitted": False, "answers": {}},
)
fake = FakeProvider([_text_chunks("收到")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{"kind": "elicit_answer", "reply_to": str(card.id),
"answers": {"product": str(product.id)}},
format="json",
)
list(response.streaming_content)
self.conversation.refresh_from_db()
pinned = self.conversation.pinned_refs or []
self.assertTrue(
any(r.get("type") == "product" and str(r.get("id")) == str(product.id) for r in pinned)
)
def test_elicit_product_choice_by_name_pins_the_product(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="控油洁面")
card = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.ELICIT,
payload={"fields": [{"key": "product", "label": "选择商品", "type": "single",
"options": [{"value": product.title, "label": product.title}]}],
"submitted": False, "answers": {}},
)
fake = FakeProvider([_text_chunks("收到")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{"kind": "elicit_answer", "reply_to": str(card.id),
"answers": {"product": product.title}},
format="json",
)
list(response.streaming_content)
self.conversation.refresh_from_db()
pinned = self.conversation.pinned_refs or []
self.assertTrue(
any(r.get("type") == "product" and str(r.get("id")) == str(product.id) for r in pinned)
)
def test_answering_the_same_card_twice_is_refused(self):
card = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.ELICIT,
payload={"fields": [], "submitted": True, "answers": {"tone": "warm"}},
)
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{"kind": "elicit_answer", "reply_to": str(card.id), "answers": {"tone": "cool"}},
format="json",
)
# 重复提交会让同一个问题在上下文里出现两次答案
self.assertEqual(response.status_code, 409)
class VideoPlanAndConfirmTests(CreationAgentBaseTests):
"""视频链路:策略卡 → 方案卡 → 确认闸门 → 出片(契约 §0)。"""
def setUp(self):
super().setUp()
self.conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="video", title="口播",
params={"model": "Seedance 2.5", "ratio": "9:16", "resolution": "720p", "duration": "15 秒"},
)
def _plan_args(self, **overrides):
args = {
"usp": "核心效果:一整天不泛油光",
"points": ["质地轻薄"],
"timeline": [{"start": 0, "end": 2.7, "stage": "Hook"}],
"matrix": {"shots": 4, "rows": [{"point": "USP", "hits": [1, 3]}]},
"voice_chars": [51, 60],
"video_prompt": "0-3秒 近景手持商品…",
}
args.update(overrides)
return args
def test_image_tool_is_hidden_and_video_tools_offered(self):
fake = FakeProvider([_text_chunks("先聊聊")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
list(stream_creation_agent(conversation=self.conversation, user=self.user,
text="做条视频", model_config=self.model))
names = {t["function"]["name"] for t in fake.calls[0]["extra_body"]["tools"]}
self.assertIn("write_strategy", names)
self.assertIn("write_plan", names)
self.assertNotIn("generate_image", names)
def test_strategy_card_does_not_stop_the_loop(self):
fake = FakeProvider([
_tool_chunks("write_strategy", {"target": "油皮通勤人群", "trust": "真实使用反馈",
"belief": "值得一试", "direction": "达人 UGC 口播"}),
_text_chunks("方案我这就写"),
])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(conversation=self.conversation, user=self.user,
text="做条视频", model_config=self.model))
strategy = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "strategy"]
self.assertEqual(strategy[0]["message"]["payload"]["target"], "油皮通勤人群")
# 策略卡只是「我理解对了吗」,不该停下来
self.assertEqual(len(fake.calls), 2)
def test_plan_emits_three_cards_and_stops_for_confirmation(self):
fake = FakeProvider([
_tool_chunks("write_plan", self._plan_args()),
_text_chunks("不该跑到这一轮"),
])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(conversation=self.conversation, user=self.user,
text="做条视频", model_config=self.model))
kinds = [e["message"]["kind"] for e in events if e.get("type") == "message"]
self.assertEqual(kinds[-3:], ["plan", "prompt_file", "confirm"])
self.assertTrue(any(e.get("type") == "credits" for e in events))
# 「仅需确认一次」—— 必须停下等人点,不能自己往下烧钱出片
self.assertEqual(len(fake.calls), 1)
def test_plan_without_video_prompt_is_rejected_without_emitting_cards(self):
fake = FakeProvider([
_tool_chunks("write_plan", self._plan_args(video_prompt="")),
_text_chunks("我重写一版"),
])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(conversation=self.conversation, user=self.user,
text="做条视频", model_config=self.model))
kinds = [e["message"]["kind"] for e in events if e.get("type") == "message"]
self.assertNotIn("confirm", kinds) # 没有出片指令的方案不能放行
self.assertEqual(len(fake.calls), 2)
def test_confirm_submits_video_with_prompt_and_session_params(self):
card = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM,
payload={"video_prompt": "0-3秒 近景手持商品…", "submitted": False, "estimated_credits": 120},
)
task = AITask.objects.create(
team=self.team, created_by=self.user, task_type=AITask.Type.FREE_VIDEO,
model_config=self.model, idempotency_key="k-video",
)
with patch("apps.ai.free_video.submit_free_video", return_value=task) as submit:
message, error = submit_confirmed_video(
conversation=self.conversation, user=self.user, confirm_message=card
)
params = submit.call_args.kwargs["params"]
self.assertEqual(error, "")
self.assertEqual(message.kind, CreationMessage.Kind.GENERATING)
self.assertEqual(params["prompt"], "0-3秒 近景手持商品…")
# 顶栏参数直接用,label 要翻成火山真名
self.assertEqual(params["model"], "doubao-seedance-2-5-260628")
self.assertEqual(params["duration"], 15)
self.assertEqual(params["aspect_ratio"], "9:16")
self.assertTrue(params["generate_audio"])
def test_confirm_without_stored_prompt_reports_instead_of_submitting(self):
card = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM,
payload={"submitted": False},
)
with patch("apps.ai.free_video.submit_free_video") as submit:
message, error = submit_confirmed_video(
conversation=self.conversation, user=self.user, confirm_message=card
)
self.assertIsNone(message)
self.assertIn("出片指令", error)
submit.assert_not_called()
class VideoParamParsingTests(TestCase):
def test_duration_label_and_smart_fallback(self):
self.assertEqual(video_duration({"duration": "15 秒"}), 15)
self.assertEqual(video_duration({"duration": "智能时长"}), SMART_DURATION)
self.assertEqual(video_duration({}), SMART_DURATION)
# 火山单次最长 30 秒,超了要夹住而不是让 submit 报错
self.assertEqual(video_duration({"duration": "99 秒"}), 30)
def test_model_label_maps_to_volcano_name(self):
self.assertEqual(video_model_name({"model": "Seedance 2.0 Fast"}), "doubao-seedance-2-0-fast-260128")
self.assertEqual(video_model_name({"model": "没见过的模型"}), DEFAULT_VIDEO_MODEL)
class ConfirmEndpointTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="confirm-owner", password="p")
self.team = Team.objects.create(name="Confirm", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role="owner")
self.conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="video", params={}
)
self.card = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM,
payload={"video_prompt": "出片指令", "submitted": False},
)
self.client = APIClient()
self.client.force_authenticate(self.user)
def _post(self):
return self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{"kind": "confirm", "reply_to": str(self.card.id)}, format="json",
)
def test_confirm_twice_is_refused(self):
provider = ModelProvider.objects.create(name="fk2", display_name="F", base_url="https://x")
model = ModelConfig.objects.create(
provider=provider, name="fk-video", display_name="V",
capability=ModelConfig.Capability.VIDEO,
)
task = AITask.objects.create(
team=self.team, created_by=self.user, task_type=AITask.Type.FREE_VIDEO,
model_config=model, idempotency_key="k-confirm",
)
with patch("apps.ai.free_video.submit_free_video", return_value=task):
self.assertEqual(self._post().status_code, 201)
# 连点两下会出两条片、扣两次积分
self.assertEqual(self._post().status_code, 409)
def test_failed_submit_reopens_the_gate(self):
with patch("apps.ai.free_video.submit_free_video", side_effect=ValueError("积分不足")):
response = self._post()
self.card.refresh_from_db()
self.assertEqual(response.status_code, 400)
# 出片没提交成功,闸门要放回去让用户改完再确认
self.assertFalse(self.card.payload["submitted"])
class MemoryCompressionTests(CreationAgentBaseTests):
"""长会话记忆压缩(契约 §5)。"""
def _fill(self, count):
for i in range(count):
append_message(self.conversation, role="user" if i % 2 == 0 else "assistant", text=f"{i}")
def test_short_conversation_is_not_compressed(self):
self._fill(6)
fake = FakeProvider([_text_chunks("")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
list(stream_creation_agent(conversation=self.conversation, user=self.user,
text="继续", model_config=self.model))
self.conversation.refresh_from_db()
self.assertNotIn("summary", self.conversation.memory)
self.assertEqual(len(fake.calls), 1) # 没有多花一次压缩调用
def test_long_conversation_compresses_and_feeds_summary_into_system_prompt(self):
self._fill(30)
fake = FakeProvider([_text_chunks("这是摘要正文"), _text_chunks("")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
list(stream_creation_agent(conversation=self.conversation, user=self.user,
text="继续", model_config=self.model))
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.memory["summary"], "这是摘要正文")
# 第二次调用才是真正的对话,system 里要带上刚压出来的摘要
system = fake.calls[1]["messages"][0]["content"]
self.assertIn("前情提要", system)
self.assertIn("这是摘要正文", system)
# 且只喂最近 KEEP_RECENT_MESSAGES 条原文,不是全量
self.assertLessEqual(len(fake.calls[1]["messages"]), KEEP_RECENT_MESSAGES + 2)
def test_next_turn_does_not_pay_for_another_compression(self):
self._fill(30)
fake = FakeProvider([_text_chunks("摘要"), _text_chunks(""), _text_chunks("")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
list(stream_creation_agent(conversation=self.conversation, user=self.user,
text="", model_config=self.model))
calls_after_first = len(fake.calls)
list(stream_creation_agent(conversation=self.conversation, user=self.user,
text="", model_config=self.model))
# 第二轮只多了 1 次(对话本身)。不设最小批量的话每轮都要重压,长会话成本翻倍。
self.assertEqual(len(fake.calls) - calls_after_first, 1)
def test_compression_resumes_once_enough_new_messages_pile_up(self):
self._fill(30)
fake = FakeProvider([_text_chunks("摘要一")] + [_text_chunks("") for _ in range(20)])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
list(stream_creation_agent(conversation=self.conversation, user=self.user,
text="", model_config=self.model))
self.conversation.refresh_from_db()
first_upto = self.conversation.memory["summarized_upto"]
self._fill(COMPRESS_MIN_BATCH * 2)
list(stream_creation_agent(conversation=self.conversation, user=self.user,
text="", model_config=self.model))
self.conversation.refresh_from_db()
# 攒够一批之后要接着压,否则早期内容永远进不了摘要
self.assertGreater(self.conversation.memory["summarized_upto"], first_upto)
def test_compression_failure_does_not_break_the_conversation(self):
self._fill(30)
class Flaky(FakeProvider):
def chat_completion_stream(self, **kwargs):
if len(self.calls) == 0:
self.calls.append(kwargs)
raise RuntimeError("summary model down")
return super().chat_completion_stream(**kwargs)
fake = Flaky([_text_chunks("照常回复")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(conversation=self.conversation, user=self.user,
text="继续", model_config=self.model))
# 摘要是锦上添花,压缩挂了不该把整条对话打断
self.assertEqual(events[-1]["type"], "done")
self.conversation.refresh_from_db()
self.assertNotIn("summary", self.conversation.memory)
class PresetGuidanceTests(CreationAgentBaseTests):
"""预设不只是个名字,要把拍法约束一起给模型(契约 §6)。"""
def test_preset_guidance_reaches_the_system_prompt(self):
conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="video", preset="鱼眼换装", params={},
)
fake = FakeProvider([_text_chunks("")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
list(stream_creation_agent(conversation=conversation, user=self.user,
text="开始", model_config=self.model))
system = fake.calls[0]["messages"][0]["content"]
self.assertIn("鱼眼换装", system)
# 光有名字模型只能靠猜,拍法约束必须一起给
self.assertIn("人物面部和身形必须全程一致", system)
def test_unknown_preset_degrades_to_name_only(self):
conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="video", preset="前端新加的卡", params={},
)
fake = FakeProvider([_text_chunks("")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
list(stream_creation_agent(conversation=conversation, user=self.user,
text="开始", model_config=self.model))
system = fake.calls[0]["messages"][0]["content"]
# 前端加了新卡但后端还没写拍法时,退回「只有名字」而不是报错
self.assertIn("前端新加的卡", system)
def test_every_frontend_preset_has_guidance(self):
"""前端 8 个视频 + 6 个图片预设都要有拍法,漏一个就等于那张卡是摆设。"""
from .creation_presets import IMAGE_PRESETS, VIDEO_PRESETS
self.assertEqual(len(VIDEO_PRESETS), 8)
self.assertEqual(len(IMAGE_PRESETS), 6)
self.assertTrue(all(text.strip() for text in {**VIDEO_PRESETS, **IMAGE_PRESETS}.values()))
@@ -0,0 +1,238 @@
"""全能创作 · 会话与消息底座(契约 §1/§3)。"""
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.assets.models import Asset, AssetFile
from .creation import append_message, finish_generating_message, pin_refs, sync_generating_messages
from .models import AITask, CreationConversation, CreationMessage, ModelConfig, ModelProvider
class CreationMessageServiceTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="omni-svc", password="p")
self.team = Team.objects.create(name="Omni SVC", owner=self.user)
self.conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, title="净颜精华口播", mode="video"
)
def test_seq_is_monotonic_per_conversation(self):
first = append_message(self.conversation, role="user", text="做一条口播")
second = append_message(self.conversation, role="assistant", text="好的")
other = CreationConversation.objects.create(team=self.team, created_by=self.user, mode="image")
other_first = append_message(other, role="user", text="来张主图")
self.assertEqual([first.seq, second.seq], [1, 2])
# seq 是会话内自增,不是全局 —— 换一条会话要从 1 重新开始
self.assertEqual(other_first.seq, 1)
def test_append_refreshes_last_active_at(self):
before = self.conversation.last_active_at
append_message(self.conversation, role="user", text="改一下背景")
self.conversation.refresh_from_db()
self.assertGreater(self.conversation.last_active_at, before)
def test_generating_message_is_replaced_in_place_not_appended(self):
placeholder = append_message(
self.conversation,
role="assistant",
kind=CreationMessage.Kind.GENERATING,
payload={"task_id": "t-1", "kind": "video"},
)
finish_generating_message(
placeholder,
assets=[{"id": "a-1", "url": "https://x/v.mp4", "cover": "https://x/c.jpg", "type": "video"}],
meta={"model": "Seedance 2.5", "resolution": "1080p", "ratio": "9:16"},
)
placeholder.refresh_from_db()
self.conversation.refresh_from_db()
self.assertEqual(placeholder.kind, CreationMessage.Kind.RESULT)
self.assertEqual(placeholder.payload["assets"][0]["id"], "a-1")
self.assertEqual(placeholder.payload["task_id"], "t-1") # 原 payload 不能被覆盖掉
self.assertEqual(self.conversation.messages.count(), 1) # 中间态不刷屏
self.assertEqual(self.conversation.status, CreationConversation.Status.COMPLETED)
def test_pin_refs_dedupes_and_keeps_first_seen_order(self):
pin_refs(self.conversation, [
{"type": "product", "id": "p1", "name": "净颜精华"},
{"type": "character", "id": "c1", "name": "白领女性"},
])
pin_refs(self.conversation, [
{"type": "product", "id": "p1", "name": "净颜精华"}, # 重复,不入
{"type": "scene", "id": "s1", "name": "居家早餐台"},
{"type": "scene"}, # 缺 id,丢弃
])
self.conversation.refresh_from_db()
self.assertEqual(
[(r["type"], r["id"]) for r in self.conversation.pinned_refs],
[("product", "p1"), ("character", "c1"), ("scene", "s1")],
)
class GenerationBackfillTests(TestCase):
"""出图在 worker 里跑完后,GENERATING 必须被回填,否则对话会一直转圈。"""
def setUp(self):
self.user = User.objects.create_user(username="omni-backfill", password="p")
self.team = Team.objects.create(name="Omni Backfill", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role="owner")
provider = ModelProvider.objects.create(name="img", display_name="Img")
self.model = ModelConfig.objects.create(
provider=provider, name="gpt-image-2", display_name="YQ image2",
capability=ModelConfig.Capability.IMAGE,
)
self.conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, title="回填", mode="image"
)
self.client = APIClient()
self.client.force_authenticate(self.user)
def _task(self, status, key="k-backfill"):
return AITask.objects.create(
team=self.team, created_by=self.user, task_type=AITask.Type.PRODUCT_IMAGE,
model_config=self.model, status=status, idempotency_key=key,
error_message="额度不足" if status == AITask.Status.FAILED else "",
)
def _asset(self, task, url="https://cdn.example/done.png"):
asset = Asset.objects.create(
team=self.team, created_by=self.user, name="成图",
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED,
category=Asset.Category.PRODUCT_IMAGE, origin_task=task,
)
AssetFile.objects.create(
asset=asset, object_key="k", bucket="b", is_primary=True, preview_url=url,
)
return asset
def test_succeeded_task_turns_generating_into_result(self):
task = self._task(AITask.Status.SUCCEEDED)
self._asset(task)
message = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.GENERATING,
payload={"task_id": str(task.id), "kind": "image", "prompt": "白底"},
task=task,
)
self.assertEqual(sync_generating_messages(self.conversation), 1)
message.refresh_from_db()
self.conversation.refresh_from_db()
self.assertEqual(message.kind, CreationMessage.Kind.RESULT)
self.assertEqual(message.payload["assets"][0]["url"], "https://cdn.example/done.png")
self.assertEqual(message.payload["task_id"], str(task.id))
self.assertEqual(self.conversation.status, CreationConversation.Status.COMPLETED)
def test_failed_task_turns_generating_into_error(self):
task = self._task(AITask.Status.FAILED, key="k-fail")
message = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.GENERATING,
payload={"task_id": str(task.id), "kind": "image"},
task=task,
)
self.assertEqual(sync_generating_messages(self.conversation), 1)
message.refresh_from_db()
self.assertEqual(message.kind, CreationMessage.Kind.ERROR)
self.assertIn("额度不足", message.text)
def test_running_task_stays_generating(self):
task = self._task(AITask.Status.RESERVED, key="k-run")
message = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.GENERATING,
payload={"task_id": str(task.id), "kind": "image"},
task=task,
)
self.assertEqual(sync_generating_messages(self.conversation), 0)
message.refresh_from_db()
self.assertEqual(message.kind, CreationMessage.Kind.GENERATING)
def test_retrieve_backfills_before_returning_messages(self):
task = self._task(AITask.Status.SUCCEEDED, key="k-api")
self._asset(task, url="https://cdn.example/api.png")
append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.GENERATING,
payload={"task_id": str(task.id), "kind": "image"},
task=task,
)
detail = self.client.get(f"/api/ai/creations/{self.conversation.id}/")
self.assertEqual(detail.status_code, 200)
self.assertEqual(detail.data["messages"][0]["kind"], "result")
self.assertEqual(detail.data["messages"][0]["payload"]["assets"][0]["url"], "https://cdn.example/api.png")
class CreationConversationAPITests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="omni-api", password="p")
self.team = Team.objects.create(name="Omni API", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role="owner")
self.client = APIClient()
self.client.force_authenticate(self.user)
def test_create_list_and_rename(self):
created = self.client.post(
"/api/ai/creations/",
{"title": "净颜精华口播", "mode": "video", "preset": "达人口播种草",
"params": {"model": "Seedance 2.5", "ratio": "9:16", "resolution": "1080p", "duration": "智能时长"}},
format="json",
)
self.assertEqual(created.status_code, 201, created.data)
conv_id = created.data["id"]
listed = self.client.get("/api/ai/creations/?mode=video")
self.assertEqual(listed.status_code, 200)
self.assertEqual(len(listed.data["results"] if "results" in listed.data else listed.data), 1)
renamed = self.client.patch(f"/api/ai/creations/{conv_id}/", {"title": "改个名", "mode": "image"}, format="json")
self.assertEqual(renamed.status_code, 200)
self.assertEqual(renamed.data["title"], "改个名")
# mode 定死:传了也不生效,否则顶栏参数会和已生成内容对不上
self.assertEqual(renamed.data["mode"], "video")
def test_retrieve_returns_full_thread_and_cover_is_latest_result(self):
conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, title="叠加测试", mode="image"
)
append_message(conversation, role="user", text="来一张")
append_message(conversation, role="assistant", kind=CreationMessage.Kind.RESULT,
payload={"assets": [{"id": "a1", "url": "u1", "cover": "c1"}]})
append_message(conversation, role="user", text="背景换夜景")
append_message(conversation, role="assistant", kind=CreationMessage.Kind.RESULT,
payload={"assets": [{"id": "a2", "url": "u2", "cover": "c2"}]})
detail = self.client.get(f"/api/ai/creations/{conversation.id}/")
self.assertEqual(detail.status_code, 200)
self.assertEqual(len(detail.data["messages"]), 4)
# 重生成往下叠加,旧的留着;封面取最新一版
self.assertEqual(detail.data["cover_url"], "c2")
def test_messages_endpoint_supports_incremental_pull(self):
conversation = CreationConversation.objects.create(team=self.team, created_by=self.user, mode="video")
append_message(conversation, role="user", text="")
append_message(conversation, role="assistant", text="")
incremental = self.client.get(f"/api/ai/creations/{conversation.id}/messages/?after_seq=1")
self.assertEqual(incremental.status_code, 200)
self.assertEqual([m["text"] for m in incremental.data], [""])
def test_other_team_cannot_read_conversation(self):
conversation = CreationConversation.objects.create(team=self.team, created_by=self.user, mode="video")
stranger = User.objects.create_user(username="omni-stranger", password="p")
other_team = Team.objects.create(name="Other", owner=stranger)
TeamMember.objects.create(team=other_team, user=stranger, role="owner")
other_client = APIClient()
other_client.force_authenticate(stranger)
self.assertEqual(other_client.get(f"/api/ai/creations/{conversation.id}/").status_code, 404)
def test_destroy_is_soft_delete(self):
conversation = CreationConversation.objects.create(team=self.team, created_by=self.user, mode="video")
self.assertEqual(self.client.delete(f"/api/ai/creations/{conversation.id}/").status_code, 204)
conversation.refresh_from_db()
self.assertTrue(conversation.is_deleted)
self.assertEqual(self.client.get(f"/api/ai/creations/{conversation.id}/").status_code, 404)
@@ -0,0 +1,179 @@
"""全能创作 · @引用检索与 Ref 解析(契约 §1/§3)。"""
from unittest.mock import patch
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.assets.models import Asset, AssetFile, Model
from apps.products.models import Product, ProductImage, ProductSellingPoint
from .mentions import product_facts_text, resolve_refs, search_mentions
def _image_asset(team, user, name, category, *, source=Asset.Source.UPLOAD, url="", **kwargs):
asset = Asset.objects.create(
team=team, created_by=user, name=name, asset_type=Asset.Type.IMAGE,
source=source, category=category, **kwargs,
)
AssetFile.objects.create(
asset=asset, object_key=f"k/{name}", bucket="b", is_primary=True,
preview_url=url or f"https://cdn/{name}.jpg",
)
return asset
class MentionSearchTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="mention-owner", password="p")
self.team = Team.objects.create(name="Mention", owner=self.user)
self.product = Product.objects.create(team=self.team, created_by=self.user, title="净颜精华")
self.person = _image_asset(self.team, self.user, "白领女性", Asset.Category.PERSON)
self.scene = _image_asset(self.team, self.user, "居家早餐台", Asset.Category.SCENE)
def test_search_filters_by_keyword_and_type(self):
Product.objects.create(team=self.team, created_by=self.user, title="控油洁面")
hits = search_mentions(self.team, q="净颜", types=["product"])
self.assertEqual([h["name"] for h in hits], ["净颜精华"])
self.assertEqual(hits[0]["type"], "product")
def test_search_returns_all_types_when_unspecified(self):
found = {(h["type"], h["name"]) for h in search_mentions(self.team, q="")}
self.assertIn(("product", "净颜精华"), found)
self.assertIn(("character", "白领女性"), found)
self.assertIn(("scene", "居家早餐台"), found)
def test_asset_type_only_lists_items_added_to_library(self):
_image_asset(self.team, self.user, "工作台试验图", Asset.Category.FREE_CREATE, in_library=False)
_image_asset(self.team, self.user, "入库图", Asset.Category.FREE_CREATE)
names = [h["name"] for h in search_mentions(self.team, types=["asset"])]
self.assertNotIn("工作台试验图", names) # 工作台的试验图不该冒进 @ 菜单
self.assertIn("入库图", names)
def test_asset_group_does_not_duplicate_entries_with_their_own_group(self):
found = [(h["type"], h["name"]) for h in search_mentions(self.team)]
# 定妆照只该出现在「角色」里;再在「资产库」列一遍,菜单里看着像两个素材
self.assertEqual(found.count(("character", "白领女性")), 1)
self.assertNotIn(("asset", "白领女性"), found)
self.assertNotIn(("asset", "居家早餐台"), found)
def test_other_team_entities_are_invisible(self):
stranger = User.objects.create_user(username="mention-stranger", password="p")
other_team = Team.objects.create(name="Other", owner=stranger)
Product.objects.create(team=other_team, created_by=stranger, title="别家的商品")
names = [h["name"] for h in search_mentions(self.team)]
self.assertNotIn("别家的商品", names)
class ResolveRefsTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="resolve-owner", password="p")
self.team = Team.objects.create(name="Resolve", owner=self.user)
self.product = Product.objects.create(
team=self.team, created_by=self.user, title="净颜精华", brand="影擎",
category="护肤", description="早晚各一次", specs={"容量": "30ml"},
)
ProductSellingPoint.objects.create(product=self.product, title="控油", detail="12 小时不脱妆")
product_asset = _image_asset(
self.team, self.user, "商品实拍", Asset.Category.PRODUCT_IMAGE, url="https://cdn/prod.jpg"
)
ProductImage.objects.create(product=self.product, asset=product_asset, is_primary=True)
self.person = _image_asset(
self.team, self.user, "白领女性", Asset.Category.PERSON,
url="https://cdn/person.jpg", review_status="active", review_remote_id="R-1",
)
self.scene = _image_asset(
self.team, self.user, "居家早餐台", Asset.Category.SCENE, url="https://cdn/scene.jpg"
)
def test_product_ref_yields_selling_points_and_reference_image(self):
resolved = resolve_refs(self.team, [{"type": "product", "id": str(self.product.id)}])
self.assertIn("控油", resolved.facts_text)
self.assertIn("12 小时不脱妆", resolved.facts_text)
self.assertIn("30ml", resolved.facts_text)
self.assertEqual([r["url"] for r in resolved.references], ["https://cdn/prod.jpg"])
def test_reference_order_is_character_then_scene_then_product(self):
resolved = resolve_refs(self.team, [
{"type": "product", "id": str(self.product.id)},
{"type": "scene", "id": str(self.scene.id)},
{"type": "character", "id": str(self.person.id)},
])
# 顺序是 @图N 的语义依据:角色 → 场景 → 商品,不能跟着用户 @ 的先后走
self.assertEqual([r["type"] for r in resolved.references], ["character", "scene", "product"])
def test_character_ref_carries_review_status_for_asset_scheme_swap(self):
resolved = resolve_refs(self.team, [{"type": "character", "id": str(self.person.id)}])
entry = resolved.references[0]
# 视频路要靠这两个字段把真人图换成火山 asset:// 引用,否则会被判「疑似真人」拒
self.assertEqual(entry["review_status"], "active")
self.assertEqual(entry["review_remote_id"], "R-1")
def test_model_ref_prefers_triview_over_portrait(self):
portrait = _image_asset(self.team, self.user, "形象图", Asset.Category.MODEL_PORTRAIT, url="https://cdn/p.jpg")
triview = _image_asset(self.team, self.user, "三视图", Asset.Category.TRI_VIEW, url="https://cdn/t.jpg")
model = Model.objects.create(
team=self.team, created_by=self.user, name="小夏",
portrait_asset=portrait, triview_asset=triview,
)
resolved = resolve_refs(self.team, [{"type": "model", "id": str(model.id)}])
# 三视图信息量最大,锁脸优先用它
self.assertEqual(resolved.references[0]["url"], "https://cdn/t.jpg")
def test_model_falls_back_to_portrait_when_no_triview(self):
portrait = _image_asset(self.team, self.user, "形象图2", Asset.Category.MODEL_PORTRAIT, url="https://cdn/p2.jpg")
model = Model.objects.create(team=self.team, created_by=self.user, name="阿岚", portrait_asset=portrait)
resolved = resolve_refs(self.team, [{"type": "model", "id": str(model.id)}])
self.assertEqual(resolved.references[0]["url"], "https://cdn/p2.jpg")
def test_deleted_or_foreign_refs_go_to_missing_and_never_raise(self):
stranger = User.objects.create_user(username="resolve-stranger", password="p")
other_team = Team.objects.create(name="Other", owner=stranger)
foreign = Product.objects.create(team=other_team, created_by=stranger, title="别家的")
resolved = resolve_refs(self.team, [
{"type": "product", "id": str(foreign.id)},
{"type": "character", "id": "00000000-0000-0000-0000-000000000000"},
{"type": "product", "id": str(self.product.id)},
])
# 素材被删/跨团队不该炸掉整条对话,交给 agent 在对话里说明
self.assertEqual(len(resolved.missing), 2)
self.assertEqual(len(resolved.references), 1)
def test_same_image_referenced_twice_is_deduped(self):
resolved = resolve_refs(self.team, [
{"type": "character", "id": str(self.person.id)},
{"type": "asset", "id": str(self.person.id)},
])
# 编号错位会让 @图N 指错,必须去重
self.assertEqual(len(resolved.references), 1)
def test_product_facts_text_without_selling_points_still_has_title(self):
bare = Product.objects.create(team=self.team, created_by=self.user, title="裸商品")
self.assertIn("裸商品", product_facts_text(bare))
class MentionAPITests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="mention-api", password="p")
self.team = Team.objects.create(name="Mention API", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role="owner")
Product.objects.create(team=self.team, created_by=self.user, title="净颜精华")
self.client = APIClient()
self.client.force_authenticate(self.user)
def test_endpoint_returns_refs_with_group_labels(self):
response = self.client.get("/api/ai/mentions/?q=净颜&types=product")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["results"][0]["name"], "净颜精华")
self.assertEqual(response.data["type_labels"]["product"], "商品库")
def test_unknown_type_is_rejected(self):
response = self.client.get("/api/ai/mentions/?types=product,ghost")
self.assertEqual(response.status_code, 400)
self.assertIn("ghost", response.data["detail"])
+4
View File
@@ -3,6 +3,7 @@ from rest_framework.routers import DefaultRouter
from .views import ( from .views import (
AITaskViewSet, AITaskViewSet,
CreationConversationViewSet,
VideoDigestDetailView, VideoDigestDetailView,
VideoDigestView, VideoDigestView,
FreeVideoDetailView, FreeVideoDetailView,
@@ -15,6 +16,7 @@ from .views import (
FreeVideoView, FreeVideoView,
GenerateImageView, GenerateImageView,
ImageConversationViewSet, ImageConversationViewSet,
MentionSearchView,
ModelConfigViewSet, ModelConfigViewSet,
VideoReplacePollView, VideoReplacePollView,
VideoReplaceView, VideoReplaceView,
@@ -24,8 +26,10 @@ router = DefaultRouter()
router.register("tasks", AITaskViewSet, basename="ai-task") router.register("tasks", AITaskViewSet, basename="ai-task")
router.register("models", ModelConfigViewSet, basename="model-config") router.register("models", ModelConfigViewSet, basename="model-config")
router.register("image-conversations", ImageConversationViewSet, basename="image-conversation") router.register("image-conversations", ImageConversationViewSet, basename="image-conversation")
router.register("creations", CreationConversationViewSet, basename="creation-conversation")
urlpatterns = [ urlpatterns = [
path("mentions/", MentionSearchView.as_view(), name="ai-mentions"),
path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"), path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"),
path("video-digest/", VideoDigestView.as_view(), name="ai-video-digest"), path("video-digest/", VideoDigestView.as_view(), name="ai-video-digest"),
path("video-digest/<uuid:task_id>/", VideoDigestDetailView.as_view(), name="ai-video-digest-detail"), path("video-digest/<uuid:task_id>/", VideoDigestDetailView.as_view(), name="ai-video-digest-detail"),
+224 -2
View File
@@ -2,6 +2,7 @@ import logging
import uuid import uuid
from django.db import transaction from django.db import transaction
from django.http import JsonResponse, StreamingHttpResponse
from django.db.models import Count, Exists, OuterRef, Q from django.db.models import Count, Exists, OuterRef, Q
from django.utils import timezone from django.utils import timezone
from rest_framework import status from rest_framework import status
@@ -14,14 +15,20 @@ from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from apps.assets.models import Asset from apps.assets.models import Asset
from apps.assets.serializers import AssetFileSerializer, AssetSerializer from apps.assets.serializers import AssetFileSerializer, AssetSerializer
from apps.common.api import TeamScopedViewSetMixin, get_current_team from apps.common.api import ServerSentEventRenderer, TeamScopedViewSetMixin, get_current_team
from apps.common.celery_health import require_worker, require_worker_task from apps.common.celery_health import require_worker, require_worker_task
from apps.products.models import Product from apps.products.models import Product
from .generation_errors import classify_generation_error, public_error_for_task from .generation_errors import classify_generation_error, public_error_for_task
from .models import AITask, ImageConversation, ModelConfig from .creation import append_message, sync_generating_messages
from .creation_agent import apply_session_params, stream_creation_agent, submit_confirmed_video
from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions
from .models import AITask, CreationConversation, CreationMessage, ImageConversation, ModelConfig
from .serializers import ( from .serializers import (
AITaskSerializer, AITaskSerializer,
CreationConversationDetailSerializer,
CreationConversationSerializer,
CreationMessageSerializer,
ImageConversationSerializer, ImageConversationSerializer,
ImageConversationTrashSerializer, ImageConversationTrashSerializer,
ModelConfigSerializer, ModelConfigSerializer,
@@ -1244,3 +1251,218 @@ class ModelConfigViewSet(ReadOnlyModelViewSet):
search_fields = ["name", "display_name", "capability"] search_fields = ["name", "display_name", "capability"]
ordering_fields = ["created_at", "display_name"] ordering_fields = ["created_at", "display_name"]
class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
"""全能创作会话 CRUD(契约 §3)。
list 创作历史页,按 ?status=running|completed 过滤,-last_active_at 倒序
retrieve 进对话页,一次性带回全部消息
create 从首页「开始创作」发起,带 mode/preset/params;首条用户消息由 messages 接口发
partial_update 只允许改 title(重命名)
destroy 软删(不连带删已生成的资产 —— 图还在资产库里)
发消息走 POST {id}/messages/(SSE),不在这里。
"""
serializer_class = CreationConversationSerializer
queryset = CreationConversation.objects.order_by("-last_active_at")
def get_serializer_class(self):
if self.action == "retrieve":
return CreationConversationDetailSerializer
return super().get_serializer_class()
def get_queryset(self):
queryset = super().get_queryset().filter(is_deleted=False, purged_at__isnull=True)
if self.action == "retrieve":
queryset = queryset.prefetch_related("messages")
else:
queryset = queryset.annotate(_message_count=Count("messages"))
mode = self.request.query_params.get("mode", "").strip()
if mode:
if mode not in CreationConversation.Mode.values:
raise ValidationError({"detail": "mode 仅支持 video / image"})
queryset = queryset.filter(mode=mode)
conv_status = self.request.query_params.get("status", "").strip()
if conv_status:
if conv_status not in CreationConversation.Status.values:
raise ValidationError({"detail": "status 仅支持 running / completed / failed"})
queryset = queryset.filter(status=conv_status)
return queryset
def perform_destroy(self, instance):
# 只软删会话本身。已生成的图/视频留在资产库 —— 用户删对话不等于要删素材。
instance.is_deleted = True
instance.save(update_fields=["is_deleted", "updated_at"])
def _synced_conversation(self):
"""拉会话前先把已结束的 GENERATING 回填成 RESULT/ERROR。
出图在 worker 里跑,agent 只提交。前端轮询 GET 本接口拿结果。
prefetch 缓存里是回填前的旧对象,改过必须丢掉再读。
"""
conversation = self.get_object()
if sync_generating_messages(conversation):
conversation.refresh_from_db()
cache = getattr(conversation, "_prefetched_objects_cache", None)
if cache is not None:
cache.pop("messages", None)
return conversation
def retrieve(self, request, *args, **kwargs):
conversation = self._synced_conversation()
serializer = self.get_serializer(conversation)
return Response(serializer.data)
@action(detail=True, methods=["get"], url_path="messages")
def messages(self, request, pk=None):
"""按 ?after_seq= 增量拉消息。轮询视频结果时前端只补新的,不重拉整条会话。"""
conversation = self._synced_conversation()
queryset = conversation.messages.all()
after_seq = request.query_params.get("after_seq", "").strip()
if after_seq:
try:
queryset = queryset.filter(seq__gt=int(after_seq))
except (TypeError, ValueError) as exc:
raise ValidationError({"detail": "after_seq 必须是整数"}) from exc
return Response(CreationMessageSerializer(queryset, many=True).data)
@action(
detail=True, methods=["post"], url_path="send",
renderer_classes=[ServerSentEventRenderer],
)
def send(self, request, pk=None):
"""发一条消息 → SSE 流(契约 §3)。
kind=text 普通发言,text + refs
kind=elicit_answer 回答追问卡,reply_to + answers
kind=confirm 点确认闸门 → **不跑模型**,直接按方案卡存的 video_prompt 出片
响应 text/event-stream。**必须挂 ServerSentEventRenderer,否则 DRF 内容协商直接 406。**
"""
conversation = self.get_object()
# 模型/比例/分辨率/时长在新建会话时锁定,发送和确认出片都按当时那套,
# 否则 5 秒方案被改成 10 秒再出片会对不上。
kind = str(request.data.get("kind") or "text")
text = str(request.data.get("text") or "").strip()
refs = request.data.get("refs") or []
if not isinstance(refs, list):
return JsonResponse({"detail": "refs 必须是数组"}, status=400)
if kind == "confirm":
reply_to = str(request.data.get("reply_to") or "").strip()
card = conversation.messages.filter(
id=reply_to, kind=CreationMessage.Kind.CONFIRM
).first() if reply_to else None
if card is None:
return JsonResponse({"detail": "确认卡不存在"}, status=404)
if (card.payload or {}).get("submitted"):
# 确认闸门是一次性的:连点两下会出两条片、扣两次积分
return JsonResponse({"detail": "这条方案已经确认过了"}, status=409)
card.payload = {**(card.payload or {}), "submitted": True}
card.save(update_fields=["payload", "updated_at"])
message, error = submit_confirmed_video(
conversation=conversation, user=request.user, confirm_message=card
)
if error:
# 出片没提交成功 → 把闸门放回去,用户可以改完再确认
card.payload = {**(card.payload or {}), "submitted": False}
card.save(update_fields=["payload", "updated_at"])
failure = append_message(
conversation, role="assistant",
kind=CreationMessage.Kind.ERROR, text=error,
)
return JsonResponse(
{"detail": error, "message": CreationMessageSerializer(failure).data}, status=400
)
# 纯 Django 响应:这个 action 只挂了 SSE renderer,走 DRF Response 会渲染失败
return JsonResponse(
{"message": CreationMessageSerializer(message).data}, status=201
)
if kind == "elicit_answer":
reply_to = str(request.data.get("reply_to") or "").strip()
answers = request.data.get("answers")
if not reply_to or not isinstance(answers, dict):
return JsonResponse({"detail": "回答追问需要 reply_to 与 answers"}, status=400)
card = conversation.messages.filter(
id=reply_to, kind=CreationMessage.Kind.ELICIT
).first()
if card is None:
return JsonResponse({"detail": "追问卡不存在"}, status=404)
if (card.payload or {}).get("submitted"):
# 追问卡是一次性的:重复提交会让同一个问题在上下文里出现两次答案
return JsonResponse({"detail": "这个问题已经回答过了"}, status=409)
payload = dict(card.payload or {})
payload["answers"] = answers
payload["submitted"] = True
card.payload = payload
card.save(update_fields=["payload", "updated_at"])
labels = {f["key"]: f["label"] for f in payload.get("fields", [])}
text = "".join(
f"{labels.get(k, k)}{''.join(v) if isinstance(v, list) else v}"
for k, v in answers.items()
)
if apply_session_params(conversation, payload.get("fields") or [], answers):
text = f"{text}。请按新的会话参数重新写方案,旧方案作废"
# 点选商品/角色必须钉成 Ref:模型常把选项做成单选文字,前端只回 answers。
refs = list(refs)
existing = {(item.get("type"), str(item.get("id"))) for item in refs if isinstance(item, dict)}
for extra in refs_from_elicit_answers(conversation.team, payload.get("fields") or [], answers):
mark = (extra.get("type"), str(extra.get("id")))
if mark in existing:
continue
refs.append(extra)
existing.add(mark)
elif not text and not refs:
return JsonResponse({"detail": "消息不能为空"}, status=400)
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()
)
stream = stream_creation_agent(
conversation=conversation,
user=request.user,
text=text,
refs=refs,
model_config=model_config,
)
response = StreamingHttpResponse(stream, content_type="text/event-stream")
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no" # 关 nginx 缓冲,保证逐帧下发
return response
class MentionSearchView(APIView):
"""@ 引用检索(契约 §3)。
GET /api/ai/mentions/?q=净颜&types=product,character&limit=8
返回 [Ref] —— 前端把它按 type 分组渲染成 @ 菜单(设计稿 .omni-mention-group)。
**前端拿到后必须整条 Ref 存进消息的 refs 字段**,不能只把 name 拼进文本,
否则后端取不到卖点和参考图(契约 §1)。
"""
def get(self, request):
team = get_current_team(request.user)
q = str(request.query_params.get("q") or "").strip()
raw_types = str(request.query_params.get("types") or "").strip()
types = [t.strip() for t in raw_types.split(",") if t.strip()] if raw_types else None
if types:
unknown = [t for t in types if t not in VALID_TYPES]
if unknown:
raise ValidationError({"detail": f"未知引用类型:{''.join(unknown)}"})
try:
limit = int(request.query_params.get("limit") or 8)
except (TypeError, ValueError) as exc:
raise ValidationError({"detail": "limit 必须是整数"}) from exc
limit = max(1, min(limit, 20))
results = search_mentions(team, q=q, types=types, limit=limit)
return Response({"results": results, "type_labels": TYPE_LABELS})
+13
View File
@@ -1,4 +1,5 @@
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.renderers import BaseRenderer
def get_current_team(user): def get_current_team(user):
@@ -27,3 +28,15 @@ class TeamScopedViewSetMixin:
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(team=self.get_team(), created_by=self.request.user) serializer.save(team=self.get_team(), created_by=self.request.user)
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
@@ -1358,3 +1358,7 @@ class QuickCreateCoordinatorTests(TestCase):
+1 -14
View File
@@ -11,7 +11,6 @@ from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import APIException, ValidationError from rest_framework.exceptions import APIException, ValidationError
from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.renderers import BaseRenderer
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
@@ -42,7 +41,7 @@ from apps.ai.services import (
from apps.assets.models import Asset, AssetFile from apps.assets.models import Asset, AssetFile
from apps.assets.serializers import AssetFileSerializer from apps.assets.serializers import AssetFileSerializer
from apps.assets.storage import TosStorage from apps.assets.storage import TosStorage
from apps.common.api import TeamScopedViewSetMixin from apps.common.api import ServerSentEventRenderer, TeamScopedViewSetMixin
from apps.common.celery_health import require_worker, require_worker_task from apps.common.celery_health import require_worker, require_worker_task
from apps.ai.generation_errors import classify_generation_error, public_error_for_task from apps.ai.generation_errors import classify_generation_error, public_error_for_task
from apps.ai.video_digest import VideoDigestError, digest_project_video from apps.ai.video_digest import VideoDigestError, digest_project_video
@@ -94,18 +93,6 @@ class QuickCreateInProgress(APIException):
default_code = "quick_create_running" default_code = "quick_create_running"
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: def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str, name: str) -> Asset:
"""把上传的文件落到 TOS,建 Asset+AssetFile(主文件)。供上传视频段 / 上传 BGM 复用。""" """把上传的文件落到 TOS,建 Asset+AssetFile(主文件)。供上传视频段 / 上传 BGM 复用。"""
fallback_suffix = { fallback_suffix = {
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+31 -2
View File
@@ -31,6 +31,9 @@ import {
Dashboard, Dashboard,
FreeCreatePage, FreeCreatePage,
QuickCreatePage, QuickCreatePage,
OmniCreatePage,
OmniHistoryPage,
OmniSessionPage,
VideoRemixPage, VideoRemixPage,
VideoReplacePage, VideoReplacePage,
ImageWorkbenchPage, ImageWorkbenchPage,
@@ -544,7 +547,8 @@ export function App() {
if (options.projectId !== undefined) setActiveProjectId(options.projectId); if (options.projectId !== undefined) setActiveProjectId(options.projectId);
const hash = options.hash?.replace(/^#/, ""); const hash = options.hash?.replace(/^#/, "");
const currentPath = `${window.location.pathname}${window.location.hash}`; const currentPath = `${window.location.pathname}${window.location.hash}`;
const path = `${pathForPage(next, { productId, projectId })}${hash ? `#${hash}` : ""}`; const conversationId = options.conversationId ?? route.conversationId;
const path = `${pathForPage(next, { productId, projectId, conversationId })}${hash ? `#${hash}` : ""}`;
const prevState = readNavState(window.history.state); const prevState = readNavState(window.history.state);
const leaving: NavHistoryState = { const leaving: NavHistoryState = {
airshelf: 1, airshelf: 1,
@@ -555,7 +559,10 @@ export function App() {
if (!options.replace) { if (!options.replace) {
window.history.replaceState(leaving, "", currentPath); window.history.replaceState(leaving, "", currentPath);
} }
setRoute({ page: next, authMode, productId, projectId, hash, tab: options.tab }); setRoute({
page: next, authMode, productId, projectId, conversationId, hash,
tab: options.tab, firstMessage: options.firstMessage, firstRefs: options.firstRefs,
});
const arriving: NavHistoryState = { const arriving: NavHistoryState = {
airshelf: 1, airshelf: 1,
scrollY: 0, scrollY: 0,
@@ -1065,6 +1072,22 @@ export function App() {
navigate={navigate} navigate={navigate}
/> />
); );
case "omniCreate":
return <OmniCreatePage navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
case "omniHistory":
return <OmniHistoryPage navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
case "omniSession":
return route.conversationId ? (
<OmniSessionPage
conversationId={route.conversationId}
firstMessage={route.firstMessage}
firstRefs={route.firstRefs}
navigate={navigate}
onNotify={(type, text) => setNotice({ type, text })}
/>
) : (
<OmniHistoryPage navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />
);
case "assetFactory": case "assetFactory":
return <AssetFactoryPage navigate={navigate} />; return <AssetFactoryPage navigate={navigate} />;
case "freeCreate": case "freeCreate":
@@ -1252,13 +1275,19 @@ export function App() {
<div className="app"> <div className="app">
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} canManageBilling={isOwner} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} aiUnread={aiUnread} onOpenAdmin={() => navigateAdmin("")} accountOpen={accountAnchor !== null} onOpenAccount={(rect) => setAccountAnchor((prev) => (prev ? null : rect))} /> <Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} canManageBilling={isOwner} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} aiUnread={aiUnread} onOpenAdmin={() => navigateAdmin("")} accountOpen={accountAnchor !== null} onOpenAccount={(rect) => setAccountAnchor((prev) => (prev ? null : rect))} />
<header className="topbar"> <header className="topbar">
{page === "omniSession" ? (
<div id="omni-session-topbar-slot" className="omni-session-topbar-slot" />
) : (
<ModeTabs active={topModule} navigate={navigate} /> <ModeTabs active={topModule} navigate={navigate} />
)}
<div className="right"> <div className="right">
{page !== "omniSession" && (
<button className="top-search" type="button" onClick={() => openCommandPalette()} aria-label="搜索"> <button className="top-search" type="button" onClick={() => openCommandPalette()} aria-label="搜索">
<IconKitSvg name="search" /> <IconKitSvg name="search" />
<span></span> <span></span>
<span className="kbd">{searchKbd}</span> <span className="kbd">{searchKbd}</span>
</button> </button>
)}
<span className="balance-chip" onClick={() => navigate("account")}> <span className="balance-chip" onClick={() => navigate("account")}>
<IconKitSvg name="creditCard" /> <IconKitSvg name="creditCard" />
<strong>{money(billing?.account.balance)}</strong> <strong>{money(billing?.account.balance)}</strong>
+127
View File
@@ -29,6 +29,10 @@ import type {
ImageConversation, ImageConversation,
ImageConversationTrash, ImageConversationTrash,
ImageConversationTask, ImageConversationTask,
CreationConversation,
CreationConversationDetail,
CreationMessage,
CreationRef,
ModelConfig, ModelConfig,
ModelEntity, ModelEntity,
Notification, Notification,
@@ -530,6 +534,129 @@ export const api = {
} }
} }
}, },
// ── 全能创作(契约见仓库根 `全能创作-契约-2026-09-02.md`)
/** @ 检索:输入框打 @ 或用户说了名字时用。返回的整条 Ref 要原样带进 send 的 refs。 */
searchMentions(params: { q?: string; types?: CreationRef["type"][]; limit?: number } = {}) {
const query = new URLSearchParams();
if (params.q) query.set("q", params.q);
if (params.types?.length) query.set("types", params.types.join(","));
if (params.limit) query.set("limit", String(params.limit));
const suffix = query.toString();
return request<{ results: CreationRef[]; type_labels: Record<string, string> }>(
`/api/ai/mentions/${suffix ? `?${suffix}` : ""}`
);
},
listCreations(params: { mode?: "video" | "image"; status?: string } = {}) {
const query = new URLSearchParams();
if (params.mode) query.set("mode", params.mode);
if (params.status) query.set("status", params.status);
const suffix = query.toString();
return request<Paginated<CreationConversation>>(`/api/ai/creations/${suffix ? `?${suffix}` : ""}`);
},
createCreation(payload: {
title?: string;
mode: "video" | "image";
preset?: string;
params?: Record<string, string>;
}) {
return request<CreationConversation>("/api/ai/creations/", {
method: "POST",
body: JSON.stringify(payload)
});
},
getCreation(id: string) {
return request<CreationConversationDetail>(`/api/ai/creations/${id}/`);
},
renameCreation(id: string, title: string) {
return request<CreationConversation>(`/api/ai/creations/${id}/`, {
method: "PATCH",
body: JSON.stringify({ title })
});
},
updateCreation(id: string, payload: { title?: string; params?: Record<string, string> }) {
return request<CreationConversation>(`/api/ai/creations/${id}/`, {
method: "PATCH",
body: JSON.stringify(payload)
});
},
deleteCreation(id: string) {
return request<void>(`/api/ai/creations/${id}/`, { method: "DELETE" });
},
/** 增量拉消息:轮询生成结果时只补 after_seq 之后的,不重拉整条会话。 */
creationMessages(id: string, afterSeq?: number) {
const suffix = afterSeq === undefined ? "" : `?after_seq=${afterSeq}`;
return request<CreationMessage[]>(`/api/ai/creations/${id}/messages/${suffix}`);
},
/**
* ** SSE**:,
* video_prompt ,,
*/
confirmCreationPlan(id: string, replyTo: string, params?: Record<string, string>) {
return request<{ message: CreationMessage }>(`/api/ai/creations/${id}/send/`, {
method: "POST",
body: JSON.stringify({ kind: "confirm", reply_to: replyTo, params })
});
},
/**
* SSE 事件:tool / reasoning / delta / message / task / credits / done / error
* agentScriptStream fetch + ReadableStream(EventSource GET, POST body)
*/
async creationSendStream(
id: string,
payload: {
kind?: "text" | "elicit_answer";
text?: string;
refs?: CreationRef[];
reply_to?: string;
answers?: Record<string, string | string[]>;
model_config_id?: string;
params?: Record<string, string>;
},
onEvent: (evt: { type: string; [k: string]: unknown }) => void,
signal?: AbortSignal
): 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/ai/creations/${id}/send/`, {
method: "POST",
headers,
body: JSON.stringify(payload),
signal
});
if (!response.ok || !response.body) {
const text = await response.text().catch(() => "");
let message = text || "发送失败";
try {
const data = JSON.parse(text) as Record<string, unknown>;
if (typeof data.detail === "string") message = data.detail;
} catch {
/* 非 JSON 错误体,用原文 */
}
throw new ApiError(response.status, message);
}
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;
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) { adoptScript(projectId: string, script_version_id: string) {
return request<ScriptVersion>(`/api/projects/${projectId}/adopt-script/`, { return request<ScriptVersion>(`/api/projects/${projectId}/adopt-script/`, {
method: "POST", method: "POST",
+11 -4
View File
@@ -12,9 +12,11 @@ const SIDEBAR_COLLAPSED_KEY = "airshelf:sidebar-collapsed";
type Command = { id: string; group: string; label: string; sub: string; page: Page; icon: string; key?: string }; type Command = { id: string; group: string; label: string; sub: string; page: Page; icon: string; key?: string };
const SHELL_COMMANDS: Command[] = [ const SHELL_COMMANDS: Command[] = [
{ id: "dashboard", group: "导航", label: "工作台", sub: "任务队列、今日消耗、项目进度", page: "dashboard", icon: "dashboard", key: "D" }, { id: "dashboard", group: "导航", label: "工作台", sub: "任务队列、今日消耗、项目进度", page: "dashboard", icon: "dashboard", key: "D" },
{ id: "omni-create", group: "导航", label: "全能创作", sub: "预设工作流 + 对话式 Agent", page: "omniCreate", icon: "sparkles", key: "O" },
{ id: "omni-history", group: "导航", label: "创作历史", sub: "查看与继续独立会话", page: "omniHistory", icon: "history", key: "H" },
{ id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" }, { id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" },
{ id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" }, { id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" },
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" }, { id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、自由创作", page: "assetFactory", icon: "sparkles", key: "I" },
{ id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" }, { id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" },
{ id: "quick-create", group: "导航", label: "一键成片", sub: "上传商品图片,自动完成整条视频", page: "quickCreate", icon: "wand", key: "Q" }, { id: "quick-create", group: "导航", label: "一键成片", sub: "上传商品图片,自动完成整条视频", page: "quickCreate", icon: "wand", key: "Q" },
{ id: "video-remix", group: "导航", label: "提炼提示词", sub: "上传参考视频,提炼可编辑提示词", page: "videoRemix", icon: "scan", key: "R" }, { id: "video-remix", group: "导航", label: "提炼提示词", sub: "上传参考视频,提炼可编辑提示词", page: "videoRemix", icon: "scan", key: "R" },
@@ -29,7 +31,7 @@ const SHELL_COMMANDS: Command[] = [
{ id: "quick-create-action", group: "常用动作", label: "一键成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" }, { id: "quick-create-action", group: "常用动作", label: "一键成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" },
{ id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" }, { id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" },
{ id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "适配电商平台封面与详情图", page: "platformCover", icon: "images" }, { id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "适配电商平台封面与详情图", page: "platformCover", icon: "images" },
{ id: "image-optimize", group: "常用动作", label: "图片创作", sub: "对话式生成、编辑", page: "imageOptimize", icon: "images" } { id: "image-optimize", group: "常用动作", label: "自由创作", sub: "对话式生成、编辑", page: "imageOptimize", icon: "images" }
]; ];
function CommandPalette({ open, onClose, navigate, canManageBilling = true }: { open: boolean; onClose: () => void; navigate: Navigate; canManageBilling?: boolean }) { function CommandPalette({ open, onClose, navigate, canManageBilling = true }: { open: boolean; onClose: () => void; navigate: Navigate; canManageBilling?: boolean }) {
@@ -256,7 +258,7 @@ function LiquidIcon({ name }: { name: "boxes" | "users-round" | "settings" }) {
return <Settings size={20} strokeWidth={1.9} />; return <Settings size={20} strokeWidth={1.9} />;
} }
export type TopModule = "workbench" | "image" | "video"; export type TopModule = "workbench" | "omni" | "image" | "video";
const OPEN_PALETTE_EVENT = "airshelf:open-palette"; const OPEN_PALETTE_EVENT = "airshelf:open-palette";
export function openCommandPalette() { export function openCommandPalette() {
@@ -265,6 +267,7 @@ export function openCommandPalette() {
export function topModuleForPage(page: Page): TopModule | null { export function topModuleForPage(page: Page): TopModule | null {
if (page === "dashboard") return "workbench"; if (page === "dashboard") return "workbench";
if (page === "omniCreate" || page === "omniHistory" || page === "omniSession") return "omni";
if ( if (
page === "assetFactory" page === "assetFactory"
|| page === "imageOptimize" || page === "imageOptimize"
@@ -279,6 +282,7 @@ export function topModuleForPage(page: Page): TopModule | null {
const MODE_TABS: { id: TopModule; label: string; page: Page }[] = [ const MODE_TABS: { id: TopModule; label: string; page: Page }[] = [
{ id: "workbench", label: "工作台", page: "dashboard" }, { id: "workbench", label: "工作台", page: "dashboard" },
{ id: "omni", label: "全能创作", page: "omniCreate" },
{ id: "image", label: "图片创作", page: "assetFactory" }, { id: "image", label: "图片创作", page: "assetFactory" },
{ id: "video", label: "视频创作", page: "projects" }, { id: "video", label: "视频创作", page: "projects" },
]; ];
@@ -364,6 +368,7 @@ export function ModeTabs({ active, navigate }: { active: TopModule | null; navig
<rect rx="22" ry="22" x="0" y="0" width="104" height="44" /> <rect rx="22" ry="22" x="0" y="0" width="104" height="44" />
<rect rx="22" ry="22" x="120" y="0" width="110" height="44" /> <rect rx="22" ry="22" x="120" y="0" width="110" height="44" />
<rect rx="22" ry="22" x="246" y="0" width="110" height="44" /> <rect rx="22" ry="22" x="246" y="0" width="110" height="44" />
<rect rx="22" ry="22" x="372" y="0" width="110" height="44" />
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>
@@ -411,7 +416,9 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
account: "account", account: "account",
trash: "trash", trash: "trash",
settings: "settings", settings: "settings",
settingsNotify: "settings" settingsNotify: "settings",
omniCreate: "omniCreate",
omniHistory: "omniHistory"
}; };
export function Sidebar({ page, navigate, user, team, canManageBilling = true, products, projects, productTotal, projectTotal, aiUnread, onOpenAdmin, onOpenAccount, accountOpen = false }: { export function Sidebar({ page, navigate, user, team, canManageBilling = true, products, projects, productTotal, projectTotal, aiUnread, onOpenAdmin, onOpenAccount, accountOpen = false }: {
+12 -33
View File
@@ -90,6 +90,12 @@
--klein-hover: #002680; --klein-hover: #002680;
--heat: var(--klein); --heat: var(--klein);
--heat-hover: var(--klein-hover); --heat-hover: var(--klein-hover);
/* 影擎设计稿(omni-* 页面)沿用的两个前景名设计稿里是 --black:#101012 / --text:#17181a,
与上面的 --accent-black 是同一个近黑 这里做别名而不是新造色值(design.md §8)
缺了它们,omni 页面的 background:var(--black) 会解析失败变透明(白字白底看不见) */
--black: var(--accent-black);
--text: var(--accent-black);
--heat-90: rgba(0, 47, 167, .90); --heat-90: rgba(0, 47, 167, .90);
--heat-40: rgba(0, 47, 167, .40); --heat-40: rgba(0, 47, 167, .40);
--heat-20: rgba(0, 47, 167, .20); --heat-20: rgba(0, 47, 167, .20);
@@ -792,40 +798,12 @@ body.sidebar-collapsed .user::after { display: none; }
z-index: 0; z-index: 0;
pointer-events: none; pointer-events: none;
background-image: background-image:
linear-gradient(rgba(0, 47, 167, 0.16) 1px, transparent 1px), linear-gradient(rgba(24, 31, 42, 0.032) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 47, 167, 0.16) 1px, transparent 1px); linear-gradient(90deg, rgba(24, 31, 42, 0.032) 1px, transparent 1px);
background-size: 48px 48px; background-size: 48px 48px;
background-position: -1px -1px; background-position: -1px -1px;
-webkit-mask-image: linear-gradient( -webkit-mask-image: linear-gradient(90deg, #000 0%, rgba(0, 0, 0, 0.78) 17%, transparent 40%, transparent 60%, rgba(0, 0, 0, 0.78) 83%, #000 100%);
90deg, mask-image: linear-gradient(90deg, #000 0%, rgba(0, 0, 0, 0.78) 17%, transparent 40%, transparent 60%, rgba(0, 0, 0, 0.78) 83%, #000 100%);
#000 0%,
rgba(0, 0, 0, 0.58) 2%,
rgba(0, 0, 0, 0.3) 16%,
rgba(0, 0, 0, 0.12) 30%,
rgba(0, 0, 0, 0.03) 40%,
transparent 46%,
transparent 54%,
rgba(0, 0, 0, 0.03) 60%,
rgba(0, 0, 0, 0.12) 70%,
rgba(0, 0, 0, 0.3) 84%,
rgba(0, 0, 0, 0.58) 98%,
#000 100%
);
mask-image: linear-gradient(
90deg,
#000 0%,
rgba(0, 0, 0, 0.58) 2%,
rgba(0, 0, 0, 0.3) 16%,
rgba(0, 0, 0, 0.12) 30%,
rgba(0, 0, 0, 0.03) 40%,
transparent 46%,
transparent 54%,
rgba(0, 0, 0, 0.03) 60%,
rgba(0, 0, 0, 0.12) 70%,
rgba(0, 0, 0, 0.3) 84%,
rgba(0, 0, 0, 0.58) 98%,
#000 100%
);
} }
.scatter { .scatter {
position: absolute; position: absolute;
@@ -2298,6 +2276,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
/* ─── Toast ─── */ /* ─── Toast ─── */
.toast { .toast {
position: fixed; bottom: 24px; right: 24px; position: fixed; bottom: 24px; right: 24px;
max-width: min(360px, calc(100vw - 32px));
background: var(--surface); background: var(--surface);
border: 1px solid var(--border-faint); border: 1px solid var(--border-faint);
border-radius: var(--r-md); border-radius: var(--r-md);
@@ -2319,7 +2298,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
flex-shrink: 0; flex-shrink: 0;
} }
.toast .ic-t svg { width: 13px; height: 13px; } .toast .ic-t svg { width: 13px; height: 13px; }
.toast .txt { font-size: 13px; color: var(--accent-black); font-weight: 500; } .toast .txt { font-size: 13px; color: var(--accent-black); font-weight: 500; overflow-wrap: anywhere; }
.toast .txt .mono { .toast .txt .mono {
font-family: var(--font-mono); font-size: 12px; font-family: var(--font-mono); font-size: 12px;
color: var(--black-alpha-48); display: block; margin-top: 2px; color: var(--black-alpha-48); display: block; margin-top: 2px;
+2
View File
@@ -20,5 +20,7 @@ import "./product-create-page.css";
import "./project-wizard-page.css"; import "./project-wizard-page.css";
import "./quick-create-page.css"; import "./quick-create-page.css";
import "./admin-page.css"; import "./admin-page.css";
import "./omni-create-page.css";
import "./omni-session-page.css";
createRoot(document.getElementById("root")!).render(<App />); createRoot(document.getElementById("root")!).render(<App />);
+4 -3
View File
@@ -2,8 +2,9 @@ import type { ModelConfig } from "./types";
/** 普通用户界面的临时品牌映射。后续接入模型自动反馈/配置化前,只在此处维护。 */ /** 普通用户界面的临时品牌映射。后续接入模型自动反馈/配置化前,只在此处维护。 */
const PUBLIC_MODEL_NAME: Record<string, string> = { const PUBLIC_MODEL_NAME: Record<string, string> = {
"gpt-image": "AirShelf Image", volcano: "Seedream-5.0-pro",
"gpt-image-2": "AirShelf Image", "gpt-image": "影擎-Image2",
"gpt-image-2": "影擎-Image2",
"gemini-3.1-pro-preview": "AirShelf Script" "gemini-3.1-pro-preview": "AirShelf Script"
}; };
@@ -12,7 +13,7 @@ export function publicModelRouteName(routeKey: string, fallback = routeKey) {
} }
export const imageModelPickerOptions = [ export const imageModelPickerOptions = [
{ id: "volcano", label: publicModelRouteName("volcano", "火山 Seedream") }, { id: "volcano", label: publicModelRouteName("volcano", "Seedream-5.0-pro") },
{ id: "gpt-image", label: publicModelRouteName("gpt-image") } { id: "gpt-image", label: publicModelRouteName("gpt-image") }
]; ];
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -42,12 +42,12 @@ import { productMockCoverUrl } from "./products";
import { isLocalLife } from "../product-business"; import { isLocalLife } from "../product-business";
import "../ai-tools-page.css"; import "../ai-tools-page.css";
// 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/图片创作, // 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/自由创作,
// 而 task_type 区分不了——cover 与 image 都是 product_image) // 而 task_type 区分不了——cover 与 image 都是 product_image)
const MODE_LABEL: Record<string, string> = { const MODE_LABEL: Record<string, string> = {
model: "模特上身图", model: "模特上身图",
cover: "平台套图", cover: "平台套图",
image: "图片创作" image: "自由创作"
}; };
const MODE_TAG: Record<string, string> = { const MODE_TAG: Record<string, string> = {
model: "模特上身", model: "模特上身",
@@ -142,19 +142,19 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
page: "modelPhoto" as Page, page: "modelPhoto" as Page,
title: "模特上身", title: "模特上身",
desc: "上传商品和模特参考图,生成自然统一的服装、饰品上身效果。", desc: "上传商品和模特参考图,生成自然统一的服装、饰品上身效果。",
image: "/assets/yz/tool-model.jpg", image: "/assets/prototype/photo-1524504388940-b1c1722653e1.jpg",
}, },
{ {
page: "platformCover" as Page, page: "platformCover" as Page,
title: "平台套图", title: "平台套图",
desc: "基于商品主图一次生成主图、卖点图、细节图与场景图。", desc: "基于商品主图一次生成主图、卖点图、细节图与场景图。",
image: "/assets/yz/tool-cover.jpg", image: "/assets/prototype/unbranded-running-shoe-remix.png",
}, },
{ {
page: "imageOptimize" as Page, page: "imageOptimize" as Page,
title: "图片创作", title: "自由创作",
desc: "使用提示词、参考图和画布比例自由生成或修改视觉素材。", desc: "使用提示词、参考图和画布比例自由生成或修改视觉素材。",
image: "/assets/yz/tool-studio.jpg", image: "/assets/prototype/photo-1557682250-33bd709cbe85.jpg",
} }
]; ];
@@ -441,7 +441,7 @@ const MODE_META: Record<
} }
> = { > = {
image: { image: {
title: "图片创作", title: "自由创作",
tag: "[ IMAGE · STUDIO ]", tag: "[ IMAGE · STUDIO ]",
desc: "使用提示词与参考图,自由生成或修改电商视觉素材", desc: "使用提示词与参考图,自由生成或修改电商视觉素材",
ratio: "1:1", ratio: "1:1",
+2
View File
@@ -15,3 +15,5 @@ export { QuickCreatePage } from "./quick-create";
export { VideoRemixPage } from "./video-remix"; export { VideoRemixPage } from "./video-remix";
export { VideoReplacePage } from "./video-replace"; export { VideoReplacePage } from "./video-replace";
export { SettingsPage } from "./settings"; export { SettingsPage } from "./settings";
export { OmniCreatePage, OmniHistoryPage } from "./omni-create";
export { OmniSessionPage } from "./omni-session";
+766
View File
@@ -0,0 +1,766 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
Box,
ChevronDown,
ChevronRight,
FolderOpen,
History,
Image as ImageIcon,
Play,
Plus,
SlidersHorizontal,
Sparkles,
Trash2,
Upload,
UserRound,
Users,
Video,
WandSparkles,
X,
} from "lucide-react";
import { api } from "../api";
import { CustomSelect } from "../components/custom-select";
import { ConfirmModal } from "../components/overlays";
import type { CreationConversation, CreationRef } from "../types";
import type { NavigateFn } from "./route-config";
type OutputMode = "video" | "image";
type PresetItem = {
name: string;
category: string;
mode: OutputMode;
title: string;
desc: string;
starter: string;
cover: string;
};
type Attachment = { name: string; type: string; url?: string; source: "local" | "library" };
const VIDEO_PRESETS: PresetItem[] = [
{ name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/prototype/photo-1529139574466-a303027c1d8b.jpg" },
{ name: "商品拟人广告", category: "commerce", mode: "video", title: "商品拟人广告", desc: "让商品成为故事主角,通过个性、动作和情绪完成趣味表达。", starter: "把商品塑造成有性格的拟人角色,创作一条轻快、有趣的短广告。", cover: "/assets/prototype/photo-1608248543803-ba4f8c70ae0b.jpg" },
{ name: "达人口播种草", category: "speaker", mode: "video", title: "达人口播种草", desc: "用真实体验与生活化表达建立信任,适合电商和本地生活内容。", starter: "创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。", cover: "/assets/prototype/photo-1494790108377-be9c29b29330.jpg" },
{ name: "商品图一键成片", category: "commerce", mode: "video", title: "商品图一键成片", desc: "从商品参考图建立镜头语言,自动补齐场景、动作与转场。", starter: "根据我上传的商品图片生成一条节奏清晰的电商短视频。", cover: "/assets/prototype/photo-1556228578-8c89e6adf883.jpg" },
{ name: "鱼眼换装", category: "visual", mode: "video", title: "鱼眼换装", desc: "强调近距离透视与连续变装节奏,适合服饰和人物视觉内容。", starter: "创作一条鱼眼镜头风格的连续换装视频,人物和服装需要保持稳定。", cover: "/assets/prototype/photo-1483985988355-763728e1935b.jpg" },
{ name: "点触换款", category: "visual", mode: "video", title: "点触换款", desc: "用统一构图和动作触发商品变化,快速展示多个款式或 SKU。", starter: "创作一条通过点击动作连续切换不同商品款式的短视频。", cover: "/assets/prototype/unbranded-running-shoe-remix.png" },
{ name: "探店漫游", category: "story", mode: "video", title: "探店漫游", desc: "以空间动线串联门店环境、服务细节与主推项目。", starter: "根据门店素材创作一条有路线感和空间氛围的探店漫游视频。", cover: "/assets/prototype/photo-1497366754035-f200968a6e72.jpg" },
{ name: "品牌质感大片", category: "visual", mode: "video", title: "品牌质感大片", desc: "通过统一的光影、材质与镜头节奏建立更强的品牌识别。", starter: "创作一条强调光影、材质和品牌气质的高级感商品短片。", cover: "/assets/prototype/video-free-cinematic.png" },
];
const IMAGE_PRESETS: PresetItem[] = [
{ name: "商品场景套图", category: "product", mode: "image", title: "商品场景套图", desc: "一次生成多张统一视觉的电商图片,覆盖主图、场景和细节表达。", starter: "根据商品参考图生成一组统一风格的主图、场景图和细节图。", cover: "/assets/prototype/photo-1556229010-6c3f2c9ca5f8.jpg" },
{ name: "极简棚拍", category: "product", mode: "image", title: "极简棚拍", desc: "干净背景、柔和投影与明确主体,适合商品主图和详情页头图。", starter: "生成一组留白克制、光线干净的极简棚拍商品图。", cover: "/assets/prototype/photo-1523275335684-37898b6baf30.jpg" },
{ name: "清透自然光人像", category: "portrait", mode: "image", title: "清透自然光人像", desc: "保留真实肤质与自然光影,适合人物种草和生活方式内容。", starter: "生成一组清透自然光、肤色真实的人物氛围图。", cover: "/assets/prototype/photo-1524504388940-b1c1722653e1.jpg" },
{ name: "生活方式场景", category: "scene", mode: "image", title: "生活方式场景", desc: "将商品融入真实空间和使用动作,强调自然、可信的生活气息。", starter: "把商品放入真实、舒适的生活方式场景中,生成自然使用感的图片。", cover: "/assets/prototype/photo-1600210492486-724fe5c67fb0.jpg" },
{ name: "高级奢华质感", category: "style", mode: "image", title: "高级奢华质感", desc: "通过深色环境、局部高光和材质细节强化品牌高级感。", starter: "生成一组深色光影、精致材质与高级氛围的品牌视觉图片。", cover: "/assets/prototype/video-quick-cinematic-v2.png" },
{ name: "复古胶片风格", category: "style", mode: "image", title: "复古胶片风格", desc: "使用低饱和色彩、胶片颗粒和柔和对比形成怀旧情绪。", starter: "生成一组低饱和、细腻颗粒与复古色调的胶片感图片。", cover: "/assets/prototype/photo-1485846234645-a62644f84728.jpg" },
];
const VIDEO_FILTERS = [
{ key: "all", label: "全部" },
{ key: "story", label: "剧情" },
{ key: "commerce", label: "电商" },
{ key: "speaker", label: "口播" },
{ key: "visual", label: "视觉" },
];
const IMAGE_FILTERS = [
{ key: "all", label: "全部" },
{ key: "product", label: "商品" },
{ key: "portrait", label: "人像" },
{ key: "scene", label: "场景" },
{ key: "style", label: "风格" },
];
const VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0 Fast", "Seedance 2.0 Mini"];
const IMAGE_MODELS = ["Seedream5.0", "YQ image2"];
const VIDEO_DURATIONS = ["4 秒", "5 秒", "6 秒", "7 秒", "8 秒", "9 秒", "10 秒", "11 秒", "12 秒", "13 秒", "14 秒", "15 秒", "30 秒"];
const IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"];
const RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"];
const MENTION_REF_LIMIT = 5;
const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: typeof ImageIcon }> = [
{ type: "asset", label: "素材", Icon: ImageIcon },
{ type: "character", label: "角色", Icon: UserRound },
{ type: "model", label: "模特", Icon: Users },
{ type: "product", label: "商品", Icon: Box },
{ type: "scene", label: "场景", Icon: FolderOpen },
];
function formatRelativeTime(iso: string) {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "";
const minutes = Math.floor((Date.now() - then) / 60000);
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
return days < 30 ? `${days} 天前` : new Date(then).toLocaleDateString("zh-CN");
}
function toOptions(values: string[]) {
return values.map((value) => ({ value, label: value }));
}
export function OmniCreatePage({
navigate,
onNotify,
}: {
navigate: NavigateFn;
onNotify?: (type: "success" | "error" | "info", text: string) => void;
}) {
const [outputMode, setOutputMode] = useState<OutputMode>("video");
const [selectedCase, setSelectedCase] = useState<PresetItem | null>(null);
const [prompt, setPrompt] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [model, setModel] = useState("Seedance 2.5");
const [resolution, setResolution] = useState("1080p");
const [ratio, setRatio] = useState("9:16");
const [duration, setDuration] = useState("智能时长");
const [customDurationOn, setCustomDurationOn] = useState(false);
const [durationMenuOpen, setDurationMenuOpen] = useState(false);
const [uploadMenuOpen, setUploadMenuOpen] = useState(false);
const [mentionMenuOpen, setMentionMenuOpen] = useState(false);
const [mentionTab, setMentionTab] = useState<CreationRef["type"]>("asset");
const [mentionLoading, setMentionLoading] = useState(false);
const [mentionResults, setMentionResults] = useState<CreationRef[]>([]);
const [typeLabels, setTypeLabels] = useState<Record<string, string>>({});
const [pendingRefs, setPendingRefs] = useState<CreationRef[]>([]);
const [previewCase, setPreviewCase] = useState<PresetItem | null>(null);
const [activeCategory, setActiveCategory] = useState("all");
const fileInputRef = useRef<HTMLInputElement>(null);
const toolsRef = useRef<HTMLDivElement>(null);
const [starting, setStarting] = useState(false);
useEffect(() => {
if (outputMode === "image") {
setModel("Seedream5.0");
setResolution("模型默认");
setRatio("1:1");
setDuration("1 张");
setCustomDurationOn(true);
} else {
setModel("Seedance 2.5");
setResolution("1080p");
setRatio("9:16");
setDuration("智能时长");
setCustomDurationOn(false);
}
setActiveCategory("all");
setDurationMenuOpen(false);
setSelectedCase((prev) => (prev && prev.mode !== outputMode ? null : prev));
}, [outputMode]);
useEffect(() => {
const onDown = (event: MouseEvent) => {
const target = event.target as Node;
if (toolsRef.current?.contains(target)) return;
setUploadMenuOpen(false);
setMentionMenuOpen(false);
setDurationMenuOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, []);
const filters = outputMode === "video" ? VIDEO_FILTERS : IMAGE_FILTERS;
const presets = useMemo(() => {
const list = outputMode === "video" ? VIDEO_PRESETS : IMAGE_PRESETS;
if (activeCategory === "all") return list;
return list.filter((item) => item.category === activeCategory);
}, [outputMode, activeCategory]);
const applyPreset = (preset: PresetItem) => {
setSelectedCase(preset);
setPrompt(preset.starter);
setPreviewCase(null);
onNotify?.("info", `已选择预设:${preset.name}`);
};
const openMentions = async (query = "", type: CreationRef["type"] = mentionTab) => {
setMentionTab(type);
setMentionMenuOpen(true);
setUploadMenuOpen(false);
setDurationMenuOpen(false);
setMentionLoading(true);
setMentionResults([]);
try {
const res = await api.searchMentions({ q: query, types: [type], limit: 12 });
setMentionResults(res.results);
setTypeLabels(res.type_labels);
} catch (error) {
onNotify?.("error", (error as Error).message);
} finally {
setMentionLoading(false);
}
};
const insertMention = (ref: CreationRef) => {
if (pendingRefs.some((r) => r.id === ref.id)) {
setMentionMenuOpen(false);
return;
}
if (pendingRefs.length >= MENTION_REF_LIMIT) {
onNotify?.("info", `最多引用 ${MENTION_REF_LIMIT}`);
setMentionMenuOpen(false);
return;
}
setPendingRefs((prev) => [...prev, ref]);
setPrompt((prev) => prev.replace(/@\S*$/, "").replace(/\s+$/, " "));
setMentionMenuOpen(false);
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (!files.length) return;
setAttachments((prev) => [
...prev,
...files.map((file) => ({
name: file.name,
type: file.type,
url: URL.createObjectURL(file),
source: "local" as const,
})),
]);
setUploadMenuOpen(false);
event.target.value = "";
};
return (
<section className="page-view omni-create-page omni-home-page">
<div className="omni-home-shell">
<header className="omni-home-hero">
<button type="button" className="omni-home-history" onClick={() => navigate("omniHistory")}>
<History />
</button>
<span className="omni-home-kicker">
<Sparkles /> YINGQING CREATIVE AGENT
</span>
<h1></h1>
<p></p>
<div className="omni-output-switch" aria-label="输出类型">
<button type="button" className={outputMode === "video" ? "active" : ""} onClick={() => setOutputMode("video")}></button>
<button type="button" className={outputMode === "image" ? "active" : ""} onClick={() => setOutputMode("image")}></button>
</div>
</header>
<section className="omni-start-composer" aria-label="全能创作输入区">
<div className="omni-selected-case" hidden={!selectedCase}>
<span>
<WandSparkles />
<strong>{selectedCase?.name}</strong>
</span>
<button type="button" aria-label="取消预设" onClick={() => setSelectedCase(null)}>
<X />
</button>
</div>
<div className="omni-start-attachments" aria-live="polite">{pendingRefs.map((ref) => (
<span className="omni-attachment-chip" key={ref.id}>
{ref.cover ? <img src={ref.cover} alt="" /> : <ImageIcon />}
<span>{ref.name.split(" · ")[0]}</span>
<button
type="button"
className="omni-attachment-remove"
aria-label={`删除 ${ref.name}`}
onClick={() => setPendingRefs((prev) => prev.filter((item) => item.id !== ref.id))}
>
<X />
</button>
</span>
))}{attachments.map((file, index) => (
<span className="omni-attachment-chip" key={`${file.name}-${index}`}>
{file.type.startsWith("video") ? <Video /> : <ImageIcon />}
<span>{file.name}</span>
<button
type="button"
className="omni-attachment-remove"
aria-label={`删除 ${file.name}`}
onClick={() => setAttachments((prev) => prev.filter((_, i) => i !== index))}
>
<X />
</button>
</span>
))}</div>
<textarea
id="omniStartPrompt"
rows={3}
placeholder="描述你想制作的内容,@ 可引用商品、模特、场景或已有素材……"
value={prompt}
onChange={(event) => {
const value = event.target.value;
setPrompt(value);
const caret = event.target.selectionStart ?? 0;
if (caret > 0 && value.charAt(caret - 1) === "@") void openMentions();
}}
/>
<div className="omni-start-toolbar">
<div className="omni-start-tools" ref={toolsRef}>
<div className="omni-upload-wrap">
<button
type="button"
className="omni-icon-tool"
aria-label="添加参考素材"
onClick={() => {
setUploadMenuOpen((open) => !open);
setMentionMenuOpen(false);
setDurationMenuOpen(false);
}}
>
<Plus />
</button>
<div className="omni-upload-menu" hidden={!uploadMenuOpen}>
<strong></strong>
<button
type="button"
onClick={() => {
setUploadMenuOpen(false);
void openMentions();
}}
>
<FolderOpen />
<span><small>使</small></span>
</button>
<button type="button" onClick={() => fileInputRef.current?.click()}>
<Upload />
<span><small></small></span>
</button>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*"
multiple
hidden
onChange={handleFileChange}
/>
<div className="omni-mention-wrap">
<button
type="button"
className="omni-icon-tool"
aria-label="引用素材"
onClick={() => {
if (mentionMenuOpen) setMentionMenuOpen(false);
else void openMentions("", mentionTab);
setUploadMenuOpen(false);
setDurationMenuOpen(false);
}}
>
@
</button>
<div className="omni-session-mention-menu" hidden={!mentionMenuOpen} role="dialog" aria-label="引用素材">
<div className="omni-at-cats" role="tablist">
{MENTION_TABS.map((tab) => (
<button
type="button"
key={tab.type}
role="tab"
aria-selected={mentionTab === tab.type}
className={mentionTab === tab.type ? "is-on" : ""}
onClick={() => { if (mentionTab !== tab.type) void openMentions("", tab.type); }}
>
<tab.Icon size={14} />
{tab.label}
</button>
))}
</div>
<div className="omni-at-list">
{mentionLoading ? (
<div className="omni-at-loading" aria-live="polite">
<span className="omni-send-spinner" />
</div>
) : mentionResults.length === 0 ? (
<strong></strong>
) : (
mentionResults.map((ref) => (
<button type="button" key={ref.id} onClick={() => insertMention(ref)}>
{ref.cover ? <img src={ref.cover} alt="" /> : <Play />}
<span>
{ref.name.split(" · ")[0]}
<small>{typeLabels[ref.type] || MENTION_TABS.find((tab) => tab.type === ref.type)?.label}</small>
</span>
</button>
))
)}
</div>
</div>
</div>
<label className="omni-parameter omni-parameter-model">
<CustomSelect
fill
size="sm"
aria-label={outputMode === "video" ? "视频模型" : "图片模型"}
value={model}
onChange={setModel}
options={toOptions(outputMode === "video" ? VIDEO_MODELS : IMAGE_MODELS)}
/>
</label>
<label className={`omni-parameter${outputMode === "image" ? " is-hidden" : ""}`}>
<CustomSelect
fill
size="sm"
aria-label="分辨率"
value={resolution}
onChange={setResolution}
options={toOptions(["1080p", "720p", "480p"])}
/>
</label>
<label className="omni-parameter">
<CustomSelect
fill
size="sm"
aria-label="画面比例"
value={ratio}
onChange={setRatio}
options={toOptions(RATIOS)}
/>
</label>
<div className={`omni-duration-control${outputMode === "image" ? " is-image-count" : ""}`}>
<button
type="button"
className="omni-duration-trigger"
aria-expanded={durationMenuOpen}
onClick={() => {
setDurationMenuOpen((open) => !open);
setUploadMenuOpen(false);
setMentionMenuOpen(false);
}}
>
<span>{duration}</span>
<ChevronDown />
</button>
<div className="omni-duration-menu" hidden={!durationMenuOpen}>
<span className="omni-duration-title">{outputMode === "image" ? "生成张数" : "时长"}</span>
<div className="omni-duration-modes">
<button
type="button"
className={!customDurationOn ? "active" : ""}
onClick={() => {
setCustomDurationOn(false);
setDuration("智能时长");
setDurationMenuOpen(false);
}}
>
<Sparkles />
<span></span>
</button>
<button
type="button"
className={customDurationOn ? "active" : ""}
onClick={() => setCustomDurationOn(true)}
>
<SlidersHorizontal />
<span></span>
</button>
</div>
<div className="omni-duration-values" hidden={outputMode === "video" && !customDurationOn}>
{(outputMode === "image" ? IMAGE_COUNTS : VIDEO_DURATIONS).map((value) => (
<button
type="button"
key={value}
className={duration === value ? "active" : ""}
onClick={() => {
setDuration(value);
setCustomDurationOn(true);
setDurationMenuOpen(false);
}}
>
{value}
</button>
))}
</div>
</div>
</div>
</div>
<button
type="button"
className="omni-start-generate"
disabled={starting}
onClick={() => {
const text = prompt.trim();
if (!text && !selectedCase && attachments.length === 0) {
onNotify?.("info", "请先输入你想制作的内容,或选择一个创作预设");
return;
}
if (starting) return;
setStarting(true);
// 会话 mode 与顶栏参数在这里定死,进对话页后不再改(契约 §0)
void api
.createCreation({
title: (text || selectedCase?.title || "未命名创作").slice(0, 20),
mode: outputMode,
preset: selectedCase?.name || "",
params: {
model,
ratio,
...(outputMode === "video"
? { resolution, duration }
: { count: duration }),
},
})
.then((conversation) => {
// 首条消息交给对话页发,避免这里再复制一份 SSE 消费逻辑
navigate("omniSession", { conversationId: conversation.id, firstMessage: text, firstRefs: pendingRefs });
})
.catch((error) => {
onNotify?.("error", (error as Error).message);
setStarting(false);
});
}}
>
{starting ? "正在创建…" : "开始创作"}
</button>
</div>
</section>
<section className="omni-case-library" aria-labelledby="omniCaseTitle">
<div className="omni-case-head">
<strong className="omni-case-head-label" id="omniCaseTitle"></strong>
<div className="omni-case-filters" role="tablist" aria-label="预设筛选">
{filters.map((filter) => (
<button
type="button"
key={filter.key}
className={activeCategory === filter.key ? "active" : ""}
onClick={() => setActiveCategory(filter.key)}
>
{filter.label}
</button>
))}
</div>
</div>
<div className="omni-case-grid">
{presets.map((card) => (
<button
type="button"
className={`omni-case-card${selectedCase?.name === card.name ? " active" : ""}`}
data-mode={card.mode}
key={card.name}
onClick={() => setPreviewCase(card)}
>
<span className="omni-case-visual">
<img src={card.cover} alt="" />
<span className="omni-case-play">
{card.mode === "video" ? <Play className="lucide-play" /> : <ImageIcon />}
</span>
</span>
<span className="omni-case-copy">
<strong>{card.title}</strong>
<small>{card.desc}</small>
</span>
<span
className="omni-card-use"
onClick={(event) => {
event.stopPropagation();
applyPreset(card);
}}
>
使
</span>
</button>
))}
</div>
</section>
<div className="omni-preset-modal" hidden={!previewCase}>
<button type="button" className="omni-preset-scrim" aria-label="关闭预设详情" onClick={() => setPreviewCase(null)} />
{previewCase && (
<section className="omni-preset-dialog" role="dialog" aria-modal="true" aria-labelledby="omniPresetDialogTitle">
<button type="button" className="omni-preset-close" aria-label="关闭" onClick={() => setPreviewCase(null)}>
<X />
</button>
<div className="omni-preset-media-frame">
<img src={previewCase.cover} alt="预设示例" />
{previewCase.mode === "video" && (
<span className="omni-preset-video-mark">
<Play />
</span>
)}
</div>
<div className="omni-preset-detail">
<span className="omni-preset-kind">{previewCase.mode === "video" ? "视频预设" : "图片预设"}</span>
<h2 id="omniPresetDialogTitle">{previewCase.title}</h2>
<p>{previewCase.desc}</p>
<div className="omni-preset-default">
<span></span>
<p>{previewCase.starter}</p>
</div>
<div className="omni-preset-actions">
<button type="button" onClick={() => setPreviewCase(null)}></button>
<button type="button" className="primary" onClick={() => applyPreset(previewCase)}>使</button>
</div>
</div>
</section>
)}
</div>
</div>
</section>
);
}
export function OmniHistoryPage({
navigate,
onNotify,
}: {
navigate: NavigateFn;
onNotify?: (type: "success" | "error" | "info", text: string) => void;
}) {
const [filter, setFilter] = useState<"all" | "running" | "completed">("all");
const [items, setItems] = useState<CreationConversation[] | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CreationConversation | null>(null);
// 同会话页:onNotify 是内联箭头,进依赖会让列表每次 App 重渲染都重拉一遍
const notifyRef = useRef(onNotify);
notifyRef.current = onNotify;
useEffect(() => {
let cancelled = false;
setItems(null);
api
.listCreations(filter === "all" ? {} : { status: filter })
.then((page) => {
if (!cancelled) setItems(page.results || []);
})
.catch((error) => {
if (cancelled) return;
setItems([]);
notifyRef.current?.("error", (error as Error).message);
});
return () => {
cancelled = true;
};
}, [filter]);
const remove = async (id: string) => {
// 软删:会话没了,已生成的图和视频仍留在资产库里
setItems((prev) => (prev || []).filter((item) => item.id !== id));
try {
await api.deleteCreation(id);
} catch (error) {
notifyRef.current?.("error", (error as Error).message);
}
};
return (
<section className="page-view omni-history-page">
<header className="omni-history-header">
<div className="omni-history-heading">
<span>ALL-IN-ONE CREATION</span>
<div className="omni-history-title">
<button type="button" className="omni-history-back" aria-label="返回" onClick={() => navigate("omniCreate")}>
<ArrowLeft />
</button>
<h1></h1>
</div>
<p></p>
</div>
<div className="omni-history-tools">
<div className="omni-history-toolbar">
{([
["all", "全部"],
["running", "进行中"],
["completed", "已完成"],
] as const).map(([key, label]) => (
<button
type="button"
key={key}
className={filter === key ? "active" : ""}
onClick={() => setFilter(key)}
>
{label}
</button>
))}
</div>
<button type="button" className="omni-history-new" onClick={() => navigate("omniCreate")}>
<Plus />
</button>
</div>
</header>
<div className="omni-history-list">
{items === null ? (
<div className="omni-history-empty">
<p></p>
</div>
) : items.length === 0 ? (
<div className="omni-history-empty">
<p></p>
<button type="button" onClick={() => navigate("omniCreate")}>
<ChevronRight size={14} />
</button>
</div>
) : (
items.map((item) => (
<article
className="omni-history-item"
key={item.id}
data-status={item.status}
onClick={() => navigate("omniSession", { conversationId: item.id })}
>
{item.cover_url ? (
<img src={item.cover_url} alt={item.title} />
) : (
<span className="omni-history-placeholder">
{item.mode === "video" ? <Video /> : <ImageIcon />}
</span>
)}
<div>
<span
className={`omni-history-status${item.status === "completed" ? " completed" : ""}`}
>
{item.status === "completed" ? "已完成" : "进行中"}
</span>
<h2>{item.title}</h2>
<p>
{[
item.preset || "自由创作",
item.mode === "video" ? "视频" : "图片",
item.params?.model,
item.params?.ratio,
]
.filter(Boolean)
.join(" · ")}
</p>
<small>{formatRelativeTime(item.last_active_at)}</small>
</div>
<div className="omni-history-actions">
<button
type="button"
aria-label="删除项目"
onClick={(event) => {
event.stopPropagation();
setDeleteTarget(item);
}}
>
<Trash2 />
</button>
<ChevronRight />
</div>
</article>
))
)}
</div>
<ConfirmModal
open={Boolean(deleteTarget)}
title="删除会话"
icon={<Trash2 size={16} />}
detail={`确定删除「${deleteTarget?.title || "未命名创作"}」?会话会从创作历史中移除,已生成的图和视频仍留在资产库里。`}
confirmText="删除"
onCancel={() => setDeleteTarget(null)}
onConfirm={() => {
const id = deleteTarget?.id;
setDeleteTarget(null);
if (id) void remove(id);
}}
/>
</section>
);
}
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -444,13 +444,13 @@ const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; labe
type ProjTab = (typeof PROJ_TABS)[number]["filter"]; type ProjTab = (typeof PROJ_TABS)[number]["filter"];
const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string; primary?: boolean }> = [ const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string; primary?: boolean }> = [
{ title: "一键成片", desc: "输入商品名称并上传图片,自动完成脚本、场景与视频生成。", page: "quickCreate", image: "/assets/yz/video-quick.jpg", primary: true }, { title: "一键成片", desc: "输入商品名称并上传图片,自动完成脚本、场景与视频生成。", page: "quickCreate", image: "/assets/prototype/video-oneclick-film-v3.png", primary: true },
{ title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产和最终视频。", page: "projectWizard", image: "/assets/yz/video-pro.jpg", primary: true }, { title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产和最终视频。", page: "projectWizard", image: "/assets/prototype/photo-1485846234645-a62644f84728.jpg", primary: true },
]; ];
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [ const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string; style?: React.CSSProperties }> = [
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/yz/video-free.jpg" }, { title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/prototype/video-free-film-v3.png" },
{ title: "提炼提示词", desc: "从参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/yz/video-remix.jpg" }, { title: "提炼提示词", desc: "从参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/prototype/video-prompt-extract-film-v3.png" },
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "videoReplace", image: "/assets/yz/video-replace.jpg" }, { title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "videoReplace", image: "/assets/prototype/video-remix-film-v3.png", style: { objectPosition: "center 58%" } },
]; ];
function isQuickCreateProject(project: Project) { function isQuickCreateProject(project: Project) {
@@ -622,7 +622,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
<div className="vc-tools three"> <div className="vc-tools three">
{VIDEO_TOOLS.map((card) => ( {VIDEO_TOOLS.map((card) => (
<button className="vc-tool" type="button" key={card.title} onClick={() => navigate(card.page)}> <button className="vc-tool" type="button" key={card.title} onClick={() => navigate(card.page)}>
<div className="vc-tool-cover"><img src={card.image} alt="" /></div> <div className="vc-tool-cover"><img src={card.image} alt="" style={card.style} /></div>
<div className="vc-tool-body"> <div className="vc-tool-body">
<div className="vc-tool-title"> <div className="vc-tool-title">
<h3>{card.title}</h3> <h3>{card.title}</h3>
+29 -1
View File
@@ -29,6 +29,9 @@ export type Page =
| "assetFactory" | "assetFactory"
| "freeCreate" | "freeCreate"
| "quickCreate" | "quickCreate"
| "omniCreate"
| "omniHistory"
| "omniSession"
| "videoRemix" | "videoRemix"
| "videoReplace" | "videoReplace"
| "imageOptimize" | "imageOptimize"
@@ -46,6 +49,12 @@ export type ResolvedRoute = {
authMode: AuthMode; authMode: AuthMode;
productId?: string; productId?: string;
projectId?: string; projectId?: string;
// 全能创作对话页的会话 id(/omni-session/<id>)
conversationId?: string;
// 从首页「开始创作」带过来的首条消息。**只经 route state 透传,不进 URL** ——
// 刷新后它已经在库里了,再发一遍会重复。
firstMessage?: string;
firstRefs?: import("../types").CreationRef[];
hash?: string; hash?: string;
// 视频项目页初始 tab(all/wip/done/fail):由 navigate 透传,刷新/前进后退不持久(回默认 all)。 // 视频项目页初始 tab(all/wip/done/fail):由 navigate 透传,刷新/前进后退不持久(回默认 all)。
tab?: string; tab?: string;
@@ -55,6 +64,9 @@ export type ResolvedRoute = {
export type NavigateOptions = { export type NavigateOptions = {
productId?: string; productId?: string;
projectId?: string; projectId?: string;
conversationId?: string;
firstMessage?: string;
firstRefs?: import("../types").CreationRef[];
replace?: boolean; replace?: boolean;
hash?: string; hash?: string;
// 视频项目页初始 tab(all/wip/done/fail):仅经 route state 透传给 ProjectsPage,不进 URL path。 // 视频项目页初始 tab(all/wip/done/fail):仅经 route state 透传给 ProjectsPage,不进 URL path。
@@ -107,11 +119,14 @@ export const routeLabels: Record<Page, string> = {
team: "团队", team: "团队",
messages: "消息", messages: "消息",
assetFactory: "图片工具", assetFactory: "图片工具",
omniCreate: "全能创作",
omniHistory: "创作历史",
omniSession: "全能创作",
freeCreate: "自由创作", freeCreate: "自由创作",
quickCreate: "一键成片", quickCreate: "一键成片",
videoRemix: "提炼提示词", videoRemix: "提炼提示词",
videoReplace: "视频复刻", videoReplace: "视频复刻",
imageOptimize: "图片创作", imageOptimize: "自由创作",
modelPhoto: "模特上身图", modelPhoto: "模特上身图",
modelPhotoDemoA: "模特图方案 A", modelPhotoDemoA: "模特图方案 A",
modelPhotoDemoB: "模特图方案 B", modelPhotoDemoB: "模特图方案 B",
@@ -128,6 +143,8 @@ export function isPage(value: string): value is Page {
export function parentPage(page: Page): Page { export function parentPage(page: Page): Page {
if (["productDetail", "productCreateUpload"].includes(page)) return "products"; if (["productDetail", "productCreateUpload"].includes(page)) return "products";
if (page === "omniHistory") return "omniCreate";
if (page === "omniSession") return "omniHistory";
if (["projectWizard", "freeCreate", "quickCreate", "videoRemix", "videoReplace", "pipeline"].includes(page)) return "projects"; if (["projectWizard", "freeCreate", "quickCreate", "videoRemix", "videoReplace", "pipeline"].includes(page)) return "projects";
if (["imageOptimize", "modelPhoto", "modelPhotoDemoA", "modelPhotoDemoB", "platformCover"].includes(page)) { if (["imageOptimize", "modelPhoto", "modelPhotoDemoA", "modelPhotoDemoB", "platformCover"].includes(page)) {
return "assetFactory"; return "assetFactory";
@@ -170,6 +187,11 @@ export function resolveRoute(): ResolvedRoute {
if (path === "/team") return { page: "team", authMode: "login", hash }; if (path === "/team") return { page: "team", authMode: "login", hash };
if (path === "/messages") return { page: "messages", authMode: "login", hash }; if (path === "/messages") return { page: "messages", authMode: "login", hash };
if (path === "/asset-factory") return { page: "assetFactory", authMode: "login", hash }; if (path === "/asset-factory") return { page: "assetFactory", authMode: "login", hash };
if (path === "/omni-create") return { page: "omniCreate", authMode: "login", hash };
if (path === "/omni-history") return { page: "omniHistory", authMode: "login", hash };
if (path.startsWith("/omni-session/")) {
return { page: "omniSession", authMode: "login", conversationId: decodeURIComponent(path.slice("/omni-session/".length)), hash };
}
if (path === "/free-create") return { page: "freeCreate", authMode: "login", hash }; if (path === "/free-create") return { page: "freeCreate", authMode: "login", hash };
if (path === "/quick-create") { if (path === "/quick-create") {
return { page: "quickCreate", authMode: "login", productId: search.get("product_id") || undefined, hash }; return { page: "quickCreate", authMode: "login", productId: search.get("product_id") || undefined, hash };
@@ -217,6 +239,12 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
return "/messages"; return "/messages";
case "assetFactory": case "assetFactory":
return "/asset-factory"; return "/asset-factory";
case "omniCreate":
return "/omni-create";
case "omniHistory":
return "/omni-history";
case "omniSession":
return options.conversationId ? `/omni-session/${encodeURIComponent(options.conversationId)}` : "/omni-history";
case "freeCreate": case "freeCreate":
return "/free-create"; return "/free-create";
case "quickCreate": case "quickCreate":
+6 -33
View File
@@ -183,40 +183,12 @@ main { position: relative; background: #fff; }
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
background-image: background-image:
linear-gradient(rgba(0, 47, 167, 0.16) 1px, transparent 1px), linear-gradient(rgba(24, 31, 42, 0.032) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 47, 167, 0.16) 1px, transparent 1px); linear-gradient(90deg, rgba(24, 31, 42, 0.032) 1px, transparent 1px);
background-size: 48px 48px; background-size: 48px 48px;
background-position: -1px -1px; background-position: -1px -1px;
-webkit-mask-image: linear-gradient( -webkit-mask-image: linear-gradient(90deg, #000 0%, rgba(0, 0, 0, 0.78) 17%, transparent 40%, transparent 60%, rgba(0, 0, 0, 0.78) 83%, #000 100%);
90deg, mask-image: linear-gradient(90deg, #000 0%, rgba(0, 0, 0, 0.78) 17%, transparent 40%, transparent 60%, rgba(0, 0, 0, 0.78) 83%, #000 100%);
#000 0%,
rgba(0, 0, 0, 0.58) 2%,
rgba(0, 0, 0, 0.3) 16%,
rgba(0, 0, 0, 0.12) 30%,
rgba(0, 0, 0, 0.03) 40%,
transparent 46%,
transparent 54%,
rgba(0, 0, 0, 0.03) 60%,
rgba(0, 0, 0, 0.12) 70%,
rgba(0, 0, 0, 0.3) 84%,
rgba(0, 0, 0, 0.58) 98%,
#000 100%
);
mask-image: linear-gradient(
90deg,
#000 0%,
rgba(0, 0, 0, 0.58) 2%,
rgba(0, 0, 0, 0.3) 16%,
rgba(0, 0, 0, 0.12) 30%,
rgba(0, 0, 0, 0.03) 40%,
transparent 46%,
transparent 54%,
rgba(0, 0, 0, 0.03) 60%,
rgba(0, 0, 0, 0.12) 70%,
rgba(0, 0, 0, 0.3) 84%,
rgba(0, 0, 0, 0.58) 98%,
#000 100%
);
} }
.scatter { position: absolute; font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.05; color: var(--ink-4); white-space: pre; pointer-events: none; opacity: .8; letter-spacing: .04em; } .scatter { position: absolute; font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.05; color: var(--ink-4); white-space: pre; pointer-events: none; opacity: .8; letter-spacing: .04em; }
.tag-corner { position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; letter-spacing: .06em; pointer-events: none; opacity: .85; z-index: 1; } .tag-corner { position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; letter-spacing: .06em; pointer-events: none; opacity: .85; z-index: 1; }
@@ -494,6 +466,7 @@ table.t tbody tr:hover { background: var(--bg-soft); }
/* ─── Toast ─── */ /* ─── Toast ─── */
.toast { .toast {
position: fixed; bottom: 24px; right: 24px; position: fixed; bottom: 24px; right: 24px;
max-width: min(360px, calc(100vw - 32px));
background: var(--card); background: var(--card);
border: 1px solid var(--border); border: 1px solid var(--border);
padding: 12px 16px; padding: 12px 16px;
@@ -514,7 +487,7 @@ table.t tbody tr:hover { background: var(--bg-soft); }
flex-shrink: 0; flex-shrink: 0;
} }
.toast .ic-t svg { width: 12px; height: 12px; } .toast .ic-t svg { width: 12px; height: 12px; }
.toast .txt { font-size: 13px; color: var(--ink); } .toast .txt { font-size: 13px; color: var(--ink); overflow-wrap: anywhere; }
.toast .txt .mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--ink-3); display: block; margin-top: 2px; letter-spacing: .02em; } .toast .txt .mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--ink-3); display: block; margin-top: 2px; letter-spacing: .02em; }
/* ─── Modal ─── */ /* ─── Modal ─── */
+64
View File
@@ -867,3 +867,67 @@ export type BillingConfigInfo = {
// 当前请求者团队的价格系数(差异化调价,默认 "1"):预估所见即所扣 // 当前请求者团队的价格系数(差异化调价,默认 "1"):预估所见即所扣
team_price_multiplier?: string; team_price_multiplier?: string;
}; };
// ── 全能创作(契约见仓库根 `全能创作-契约-2026-09-02.md`)
/** @引用的实体。**必须整条存进消息的 refs**,只把 name 拼进文本后端就取不到卖点和参考图。 */
export type CreationRef = {
type: "product" | "model" | "character" | "scene" | "asset";
id: string;
name: string;
cover?: string;
};
/** 追问卡里的一个控件(后端 ask_user 工具产出)。 */
export type CreationField = {
key: string;
label: string;
type: "single" | "multi" | "text" | "asset";
required?: boolean;
options?: Array<{ value: string; label: string }>;
asset_types?: CreationRef["type"][];
placeholder?: string;
};
export type CreationMessageKind =
| "text"
| "elicit"
| "strategy"
| "plan"
| "prompt_file"
| "confirm"
| "generating"
| "result"
| "error";
/** 对话流里的一条消息。**按 kind 分发到不同卡片组件**,结构化内容全在 payload 里。 */
export type CreationMessage = {
id: string;
role: "user" | "assistant" | "system";
kind: CreationMessageKind;
text: string;
payload: Record<string, unknown>;
refs: CreationRef[];
task: string | null;
seq: number;
created_at: string;
};
export type CreationConversation = {
id: string;
title: string;
mode: "video" | "image";
preset: string;
params: Record<string, string>;
status: "running" | "completed" | "failed";
message_count: number;
cover_url: string;
last_active_at: string;
created_at: string;
updated_at: string;
};
export type CreationConversationDetail = CreationConversation & {
messages: CreationMessage[];
pinned_refs: CreationRef[];
};
+255
View File
@@ -0,0 +1,255 @@
# 全能创作 · 前后端契约(V1)
> 2026-09-02 定案。改这份文件 = 改前后端两侧,必须同步。
> 设计稿:`影擎 - 欢迎回来.html``omni-create-page` / `omni-session-page` / `omni-history-page`
## 0 · 定案(不再讨论)
| 项 | 定案 |
| --- | --- |
| 视频链路 | **单次出片**:AI 把方案编译成一个长 prompt,一次 Seedance 2.5 调用(≤30 秒)。方案卡里的「镜头 1-4 矩阵」只是给用户看的说明,不拆成 4 个任务 |
| 会话模式 | 发起时选定 `video` / `image`,**会话内不可切换**。顶栏模型/分辨率/比例/时长跟着 mode 固定 |
| 追问形态 | **对话流里的卡片**(进消息历史、可回看),支持 单选 / 多选 / 填空 / 选素材 |
| 策略卡 · 方案卡 | **AI 真生成**(新 skill `omni-creative-strategy`),不是模板 |
| @ 引用范围 | 商品(卖点+主图) · 角色/模特(锁脸) · 场景/资产库任意图。**不含**历史项目;**不含**本会话产物(产物靠记忆层自动带) |
| 图片会话 | 无确认闸门,聊完直接生成 |
| 视频会话 | 策略卡 → 方案卡 → **确认(按钮旁显示预计积分)** → 生成 |
| 重新生成 | 对话流**往下叠加**新结果,旧的留在上面。历史页封面取最新一版 |
| 编排模型 | `doubao-seed-2-1-pro-260628`(火山直连),`gpt-5.5`(tokenssr)兜底 |
| 生成模型 | 图 `doubao-seedream-5-0-260128` / `gpt-image-2`;视频 `doubao-seedance-2-5-260628` |
**改图 = 重新生成**,不做局部编辑。带上一版当参考图重跑一次。
---
## 1 · 数据模型
### CreationConversation(会话)
```
id uuid
team FK accounts.Team (TeamOwnedModel)
created_by FK accounts.User
title str(120) 首条用户消息前 20 字,可改名
mode "video" | "image" 发起时定死
preset str(64) 预设名,"" = 自由创作
params json {model, resolution, ratio, duration} 会话级参数
pinned_refs json [Ref] 本会话锁定的实体(每轮无条件带上 → 锁脸锁商品)
memory json {summary: str, artifacts: [ArtifactRef], turn_count: int}
status "running" | "completed" | "failed"
last_active_at / is_deleted / purged_at
```
### CreationMessage(消息)
```
id uuid
conversation FK CreationConversation
role "user" | "assistant" | "system"
kind 见 §2 消息类型
text str 纯文字内容(bubble 用)
payload json 按 kind 而定,见 §2
refs json [Ref] 本条消息引用的实体
task FK ai.AITask null 生成类消息挂的任务
seq int 会话内自增,渲染顺序
created_at
```
### Ref(引用,结构化 —— 不许只存 "@净颜精华" 字符串)
```json
{ "type": "product|model|character|scene|asset",
"id": "uuid",
"name": "净颜精华",
"cover": "https://..." }
```
后端 `resolve_refs()` 把 Ref → (文字事实, 参考图 URL 列表)。
参考图顺序固定:**角色/模特 → 场景 → 商品**(这是 @图N 的语义依据,不跟用户 @ 的先后),
最多 6 张、同图去重;真人图带 `review_status`/`review_remote_id`,视频路据此换成火山 `asset://`
| type | 落到哪张表 | 参考图取哪张 |
| --- | --- | --- |
| `product` | products.Product | 真实上传主图(排除 AI 生成图) |
| `model` | assets.Model(模特库) | **三视图优先**,无则形象图 |
| `character` | Asset category=person | 角色定妆照 |
| `scene` | Asset category=scene | 场景图 |
| `asset` | Asset in_library=True | 该图本身 |
查不到的引用(被删/跨团队)进 `missing`,**不抛异常** —— 由 agent 在对话里说明。
---
## 2 · 消息类型(kind)与 payload
| kind | 谁产 | payload | 对应设计稿 |
| --- | --- | --- | --- |
| `text` | 双方 | — (用 text 字段) | `.omni-chat-bubble` |
| `elicit` | AI | `{fields: [Field], submitted: bool, answers: {}}` | **新增卡片** |
| `strategy` | AI | `{target, trust, belief, direction}` | `.omni-strategy-card` |
| `plan` | AI | 见下 | `.omni-video-plan-card` |
| `prompt_file` | AI | `{title, body, ref_count}` | `.omni-prompt-file-card` |
| `confirm` | AI | `{estimated_credits: int, label: "开始生成"}` | 方案卡下的确认条 |
| `generating` | AI | `{task_id, kind: "video"\|"image"}` | `.omni-process-card` |
| `result` | AI | `{task_id, assets: [{id,url,cover,type}], model, resolution, ratio}` | `.omni-result-card` |
| `error` | AI | `{code, message, refunded: bool}` | 气泡红态 |
### Field(追问控件)
```json
{ "key": "product",
"label": "这条视频推哪个商品?",
"type": "single|multi|text|asset",
"required": true,
"options": [{"value":"uuid","label":"净颜精华","cover":"https://..."}],
"asset_types": ["product","character"], // type=asset 时,决定弹哪个选择器
"placeholder": "比如:通勤补妆" // type=text 时
}
```
用户提交 → `POST .../messages/``{kind:"elicit_answer", reply_to: <msg_id>, answers:{...}}`
后端把 answers 塞回上下文,继续 agent 循环。
### plan payload
```json
{ "usp": "核心效果:一句话讲清为什么值得选",
"points": ["使用感受:质地与触感", "转化理由:限时优惠"],
"timeline": [{"start":0,"end":2.7,"stage":"Hook","desc":"..."}, ...],
"matrix": {"shots": 4, "rows": [{"point":"主打卖点 USP","hits":[1,3]}, ...]},
"voice_chars": [51, 60],
"ref_count": 3 }
```
---
## 3 · 接口
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/ai/mentions/?q=&types=product,model,character,scene,asset&limit=` | @ 检索,返回 `{results:[Ref], type_labels:{}}`。**新增** |
| GET/POST | `/api/ai/creations/` | 会话列表 / 新建(带首条消息即开聊) |
| GET/PATCH/DELETE | `/api/ai/creations/{id}/` | 详情(含全部消息) / 改名 / 软删 |
| GET | `/api/ai/creations/{id}/messages/?after_seq=` | 增量拉消息(轮询时只补新的) |
| POST | `/api/ai/creations/{id}/send/` | 发消息 → **SSE 流**;`kind=confirm` 例外,返回 JSON |
| GET | `/api/ai/creations/{id}/tasks/{task_id}/poll/` | 轮询生成任务(视频用) |
`POST send/` 请求体:
```json
{ "text": "帮我做一条净颜精华的口播种草",
"refs": [Ref],
"attachments": [{"asset_id":"uuid"}],
"kind": "text" | "elicit_answer" | "confirm",
"reply_to": "msg_uuid", // elicit_answer / confirm 时必填
"answers": {} // elicit_answer 时必填
}
```
### SSE 事件(`text/event-stream`,每帧 `data: {json}\n\n`)
沿用 script_agent 的事件名,新增 3 个。**DRF 必须挂 `ServerSentEventRenderer`,否则 406。**
| event type | 载荷 | 前端动作 |
| --- | --- | --- |
| `tool` | `{id,label,status:running\|done\|error}` | 工具卡(理解需求/查商品/写方案…) |
| `reasoning` | `{text}` | 思考流,纯展示 |
| `delta` | `{text}` | 逐字追加到当前气泡 |
| `message` | `{message: CreationMessage}` | **新增**。一条完整消息落库了,整块渲染(策略卡/方案卡/追问卡/结果卡都走它) |
| `task` | `{task_id, kind}` | **新增**。异步生成已提交,前端开始轮询 |
| `credits` | `{estimated, balance}` | **新增**。确认按钮旁的积分 |
| `done` | `{}` | 收流 |
| `error` | `{detail, error}` | 已回滚积分 |
**铁律:视频 5–10 分钟,绝不在 SSE 里等。** 工具立刻返回 task_id → 落一条 `generating` 消息 → 发 `task` 事件 → 收流。前端轮询完成后把该消息替换成 `result`
---
## 4 · Agent 工具清单
| 工具 | 参数 | 复用 |
| --- | --- | --- |
| `ask_user` | `{fields: [Field]}` | 新写。**这是「小云雀式追问」的唯一入口** |
| `search_library` | `{query, types}` | 新写,复用 mentions 检索 |
| `write_strategy` | `{}` → strategy payload | 新 skill |
| `write_plan` | `{}` → plan payload | 新 skill |
| `generate_image` | `{prompt, ref_asset_ids, count, ratio}` | 现有 `GenerateImageView` 服务层 |
| `write_strategy` | strategy payload | 出策略卡,**不打断循环** |
| `write_plan` | plan payload + `video_prompt` | 出 方案卡+Prompt卡+确认卡,**打断循环等确认** |
循环上限:单条用户消息最多 **8 轮**工具调用、最多 **1 次**计费生成(防「多做几版」烧积分)。
**三条已固化进代码的铁律:**
1. `ask_user` 一被调用就**中断循环**等人回答 —— 继续跑等于自问自答。
2. 生成工具**只提交不等待**,立刻发 `task` 事件收流,前端轮询回填。
3. SSE 帧必须用 `DjangoJSONEncoder` —— 消息里带 UUID 和 datetime,
标准 `json.dumps` 会 TypeError 把整条流当场打断。
会话 mode 决定暴露哪些工具:图片会话只有 `generate_image`,视频会话只有
`write_strategy` / `write_plan`,互相看不见。
### 视频确认闸门(阶段 4)
```
聊 → [ask_user…] → write_strategy(策略卡,不停)
→ write_plan(方案卡 + Prompt卡 + 确认卡,**停**)
→ 用户点确认 → 直接出片
```
**点确认后不再跑模型。** 方案已经确认过了,再让模型决定一次既费钱、又可能它根本不调
生成工具。`video_prompt``write_plan` 时就存进确认卡的 payload,确认时照它提交
`submit_free_video`,返回同步 JSON(不是 SSE)。
确认卡是一次性的:`submitted` 置位后重复提交返回 409 —— 连点两下会出两条片、扣两次积分。
但**提交失败要把闸门放回去**(`submitted` 复位),否则积分不足改完也点不了了。
顶栏的模型 label(「Seedance 2.5」)要翻成火山真名才能提交;`duration` 从「15 秒」里
抠数字,「智能时长」回落 15,超过 30 秒夹住(火山单次上限)。
---
## 5 · 记忆三层(会话内 SQL 即可,不用向量库)
1. **滚动摘要** `memory.summary` —— 消息数超过 `COMPRESS_AFTER_MESSAGES`(24)时,
把最老的一批压成一段,只留最近 `KEEP_RECENT_MESSAGES`(12)条原文喂模型。
**`COMPRESS_MIN_BATCH`(8)是关键**:没有它的话过了阈值以后每一轮都要多花一次
模型调用去重压那么两三句话,长会话成本翻倍。压缩失败静默跳过,摘要是锦上添花。
2. **实体锁定** `pinned_refs` —— 本会话引用过的商品/角色/场景 + 参考图,**每轮无条件带上**
3. **产物索引** `memory.artifacts` —— `[{msg_id, asset_id, prompt, kind}]`,让"背景换成夜景"能定位到上一版
---
## 6 · 实施顺序
- [x] 阶段 0 · 本契约
- [x] 阶段 1 · `CreationConversation` / `CreationMessage` + 迁移 0034 + 会话 CRUD
- [x] 阶段 2 · `/mentions/` 检索 + `resolve_refs()``apps/ai/mentions.py`
- [x] 阶段 3 · agent 循环 + SSE → `apps/ai/creation_agent.py`,已接 `ask_user` / `search_library` / `generate_image`
- [x] 阶段 4 · 视频链路:`write_strategy` / `write_plan` + 确认闸门 + `submit_free_video`
- [x] 阶段 5 · 记忆压缩(`compress_memory`)+ 历史页接后端
- [x] 阶段 6 · 预设拍法 → `apps/ai/creation_presets.py`(14 条,与前端卡片逐字对应)
前端 `omni-session.tsx`(对话页)已港,设计稿 `omni-session-page` 逐行照抄。
**港页面时踩到的三个坑(以后港别的页面同样要查):**
1. 设计稿用 `--black` / `--text`,`design-restraint.css` 里没有 → `background: var(--black)`
解析失败变透明,白字白底看不见。已在 design-restraint.css 加别名指向 `--accent-black`
**首页 omni-create 之前就带着这个 bug。**
2. 设计稿的 `.omni-chat-row.user` 撞上 `design-restraint.css` 里的**裸 `.user` 全局类**
(侧栏用户胶囊:999 圆角 + 灰底 + space-between),整套样式被传染。改用 `.is-user`
3. 设计稿靠 `id="omniSessionPrompt"` 给输入框上样式;React 不写 id 就没样式。已补上 id
(与 `omni-create.tsx``#omniStartPrompt` 一致)。
另:这一版布局是**整页滚 + 输入框 `position: sticky`**,feed 没有自己的滚动条 ——
对 feed 调 `scrollTo` 是空操作,自动滚到底必须滚 window。
---
## 7 · 前端稳定性(2026-09-02 修)
对话页「条来跳去」的四个根因,改别的页面时同样要查:
1. **`onNotify` 是 App 里的内联箭头,每次 App 重渲染都是新身份。**
放进 `useEffect` / `useCallback` 依赖 → 整条会话被反复重拉、消息数组被反复替换。
**这是抖动的主因。** 解法:存进 ref,回调身份稳定、值永远最新。
2. **逐字 `scrollIntoView({behavior:"smooth"})`** —— 每个流式字符发一次平滑滚动,
动画互相打架。解法:流式期间用 `instant` + rAF 合帧;新消息落地才用 smooth;
且用户手动上滑看历史时**不跟**(距底 <120px 才跟)。
3. **轮询整个替换 messages 数组** → 所有卡片重渲染。解法:按 id + payload 比对,
只替换真变了的那几条,其余保持引用不变让 React 跳过。
4. **流式临时气泡带入场动画** → 逐字重渲染时一直在闪。解法:`.is-live { animation: none }`
另修:`handleSend` 原来先清输入框再判 `streaming`,流式期间敲回车会把打好的内容
清掉但消息没发出去。顺序反过来。