添加全能创作功能
@@ -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
|
||||
@@ -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(), "")
|
||||
@@ -959,6 +959,9 @@ def finalize_free_video(*, task: AITask) -> AITask:
|
||||
)
|
||||
release_credit(reservation=locked.credit_reservation, reason=raw_message[:200])
|
||||
_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
|
||||
|
||||
# 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"]
|
||||
)
|
||||
charge_reserved_credit(reservation=reservation, actual_amount=actual)
|
||||
from apps.ai.creation import sync_generating_for_task
|
||||
|
||||
sync_generating_for_task(locked)
|
||||
return locked
|
||||
except Exception as exc: # noqa: BLE001 — 后处理失败:标失败退费(release 幂等,已扣则不动)
|
||||
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"])
|
||||
release_credit(reservation=locked.credit_reservation, reason=str(exc)[:200])
|
||||
_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
|
||||
|
||||
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -279,3 +279,108 @@ class PromptTemplate(TimeStampedModel):
|
||||
def __str__(self) -> str:
|
||||
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}"
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
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):
|
||||
@@ -89,3 +96,71 @@ class AITaskSerializer(serializers.ModelSerializer):
|
||||
# batch_id / mode 是显式声明的 SerializerMethodField(本就只读),不能再列进 read_only_fields(DRF 会报错)
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -3883,6 +3883,11 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
||||
task=task, project=task.project, recipient=user,
|
||||
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 之上 ──
|
||||
|
||||
@@ -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"])
|
||||
@@ -3,6 +3,7 @@ from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
AITaskViewSet,
|
||||
CreationConversationViewSet,
|
||||
VideoDigestDetailView,
|
||||
VideoDigestView,
|
||||
FreeVideoDetailView,
|
||||
@@ -15,6 +16,7 @@ from .views import (
|
||||
FreeVideoView,
|
||||
GenerateImageView,
|
||||
ImageConversationViewSet,
|
||||
MentionSearchView,
|
||||
ModelConfigViewSet,
|
||||
VideoReplacePollView,
|
||||
VideoReplaceView,
|
||||
@@ -24,8 +26,10 @@ router = DefaultRouter()
|
||||
router.register("tasks", AITaskViewSet, basename="ai-task")
|
||||
router.register("models", ModelConfigViewSet, basename="model-config")
|
||||
router.register("image-conversations", ImageConversationViewSet, basename="image-conversation")
|
||||
router.register("creations", CreationConversationViewSet, basename="creation-conversation")
|
||||
|
||||
urlpatterns = [
|
||||
path("mentions/", MentionSearchView.as_view(), name="ai-mentions"),
|
||||
path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"),
|
||||
path("video-digest/", VideoDigestView.as_view(), name="ai-video-digest"),
|
||||
path("video-digest/<uuid:task_id>/", VideoDigestDetailView.as_view(), name="ai-video-digest-detail"),
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import uuid
|
||||
|
||||
from django.db import transaction
|
||||
from django.http import JsonResponse, StreamingHttpResponse
|
||||
from django.db.models import Count, Exists, OuterRef, Q
|
||||
from django.utils import timezone
|
||||
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.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.products.models import Product
|
||||
|
||||
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 (
|
||||
AITaskSerializer,
|
||||
CreationConversationDetailSerializer,
|
||||
CreationConversationSerializer,
|
||||
CreationMessageSerializer,
|
||||
ImageConversationSerializer,
|
||||
ImageConversationTrashSerializer,
|
||||
ModelConfigSerializer,
|
||||
@@ -1244,3 +1251,218 @@ class ModelConfigViewSet(ReadOnlyModelViewSet):
|
||||
search_fields = ["name", "display_name", "capability"]
|
||||
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})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.renderers import BaseRenderer
|
||||
|
||||
|
||||
def get_current_team(user):
|
||||
@@ -27,3 +28,15 @@ class TeamScopedViewSetMixin:
|
||||
def perform_create(self, serializer):
|
||||
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):
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import APIException, ValidationError
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.renderers import BaseRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
@@ -42,7 +41,7 @@ from apps.ai.services import (
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
from apps.assets.serializers import AssetFileSerializer
|
||||
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.ai.generation_errors import classify_generation_error, public_error_for_task
|
||||
from apps.ai.video_digest import VideoDigestError, digest_project_video
|
||||
@@ -94,18 +93,6 @@ class QuickCreateInProgress(APIException):
|
||||
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:
|
||||
"""把上传的文件落到 TOS,建 Asset+AssetFile(主文件)。供上传视频段 / 上传 BGM 复用。"""
|
||||
fallback_suffix = {
|
||||
|
||||
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
@@ -31,6 +31,9 @@ import {
|
||||
Dashboard,
|
||||
FreeCreatePage,
|
||||
QuickCreatePage,
|
||||
OmniCreatePage,
|
||||
OmniHistoryPage,
|
||||
OmniSessionPage,
|
||||
VideoRemixPage,
|
||||
VideoReplacePage,
|
||||
ImageWorkbenchPage,
|
||||
@@ -544,7 +547,8 @@ export function App() {
|
||||
if (options.projectId !== undefined) setActiveProjectId(options.projectId);
|
||||
const hash = options.hash?.replace(/^#/, "");
|
||||
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 leaving: NavHistoryState = {
|
||||
airshelf: 1,
|
||||
@@ -555,7 +559,10 @@ export function App() {
|
||||
if (!options.replace) {
|
||||
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 = {
|
||||
airshelf: 1,
|
||||
scrollY: 0,
|
||||
@@ -1065,6 +1072,22 @@ export function App() {
|
||||
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":
|
||||
return <AssetFactoryPage navigate={navigate} />;
|
||||
case "freeCreate":
|
||||
@@ -1252,13 +1275,19 @@ export function 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))} />
|
||||
<header className="topbar">
|
||||
<ModeTabs active={topModule} navigate={navigate} />
|
||||
{page === "omniSession" ? (
|
||||
<div id="omni-session-topbar-slot" className="omni-session-topbar-slot" />
|
||||
) : (
|
||||
<ModeTabs active={topModule} navigate={navigate} />
|
||||
)}
|
||||
<div className="right">
|
||||
{page !== "omniSession" && (
|
||||
<button className="top-search" type="button" onClick={() => openCommandPalette()} aria-label="搜索">
|
||||
<IconKitSvg name="search" />
|
||||
<span>搜索</span>
|
||||
<span className="kbd">{searchKbd}</span>
|
||||
</button>
|
||||
)}
|
||||
<span className="balance-chip" onClick={() => navigate("account")}>
|
||||
<IconKitSvg name="creditCard" />
|
||||
余额 <strong>{money(billing?.account.balance)}</strong>
|
||||
|
||||
@@ -29,6 +29,10 @@ import type {
|
||||
ImageConversation,
|
||||
ImageConversationTrash,
|
||||
ImageConversationTask,
|
||||
CreationConversation,
|
||||
CreationConversationDetail,
|
||||
CreationMessage,
|
||||
CreationRef,
|
||||
ModelConfig,
|
||||
ModelEntity,
|
||||
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) {
|
||||
return request<ScriptVersion>(`/api/projects/${projectId}/adopt-script/`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -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 };
|
||||
const SHELL_COMMANDS: Command[] = [
|
||||
{ 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: "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: "quick-create", group: "导航", label: "一键成片", sub: "上传商品图片,自动完成整条视频", page: "quickCreate", icon: "wand", key: "Q" },
|
||||
{ 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: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" },
|
||||
{ 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 }) {
|
||||
@@ -256,7 +258,7 @@ function LiquidIcon({ name }: { name: "boxes" | "users-round" | "settings" }) {
|
||||
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";
|
||||
|
||||
export function openCommandPalette() {
|
||||
@@ -265,6 +267,7 @@ export function openCommandPalette() {
|
||||
|
||||
export function topModuleForPage(page: Page): TopModule | null {
|
||||
if (page === "dashboard") return "workbench";
|
||||
if (page === "omniCreate" || page === "omniHistory" || page === "omniSession") return "omni";
|
||||
if (
|
||||
page === "assetFactory"
|
||||
|| page === "imageOptimize"
|
||||
@@ -279,6 +282,7 @@ export function topModuleForPage(page: Page): TopModule | null {
|
||||
|
||||
const MODE_TABS: { id: TopModule; label: string; page: Page }[] = [
|
||||
{ id: "workbench", label: "工作台", page: "dashboard" },
|
||||
{ id: "omni", label: "全能创作", page: "omniCreate" },
|
||||
{ id: "image", label: "图片创作", page: "assetFactory" },
|
||||
{ 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="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="372" y="0" width="110" height="44" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
@@ -411,7 +416,9 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
|
||||
account: "account",
|
||||
trash: "trash",
|
||||
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 }: {
|
||||
|
||||
@@ -90,6 +90,12 @@
|
||||
--klein-hover: #002680;
|
||||
--heat: var(--klein);
|
||||
--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-40: rgba(0, 47, 167, .40);
|
||||
--heat-20: rgba(0, 47, 167, .20);
|
||||
@@ -792,40 +798,12 @@ body.sidebar-collapsed .user::after { display: none; }
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 47, 167, 0.16) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 47, 167, 0.16) 1px, transparent 1px);
|
||||
linear-gradient(rgba(24, 31, 42, 0.032) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(24, 31, 42, 0.032) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
background-position: -1px -1px;
|
||||
-webkit-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%
|
||||
);
|
||||
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%
|
||||
);
|
||||
-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%);
|
||||
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%);
|
||||
}
|
||||
.scatter {
|
||||
position: absolute;
|
||||
@@ -2298,6 +2276,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
/* ─── Toast ─── */
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; right: 24px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
@@ -2319,7 +2298,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.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 {
|
||||
font-family: var(--font-mono); font-size: 12px;
|
||||
color: var(--black-alpha-48); display: block; margin-top: 2px;
|
||||
|
||||
@@ -20,5 +20,7 @@ import "./product-create-page.css";
|
||||
import "./project-wizard-page.css";
|
||||
import "./quick-create-page.css";
|
||||
import "./admin-page.css";
|
||||
import "./omni-create-page.css";
|
||||
import "./omni-session-page.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -2,8 +2,9 @@ import type { ModelConfig } from "./types";
|
||||
|
||||
/** 普通用户界面的临时品牌映射。后续接入模型自动反馈/配置化前,只在此处维护。 */
|
||||
const PUBLIC_MODEL_NAME: Record<string, string> = {
|
||||
"gpt-image": "AirShelf Image",
|
||||
"gpt-image-2": "AirShelf Image",
|
||||
volcano: "Seedream-5.0-pro",
|
||||
"gpt-image": "影擎-Image2",
|
||||
"gpt-image-2": "影擎-Image2",
|
||||
"gemini-3.1-pro-preview": "AirShelf Script"
|
||||
};
|
||||
|
||||
@@ -12,7 +13,7 @@ export function publicModelRouteName(routeKey: string, fallback = routeKey) {
|
||||
}
|
||||
|
||||
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") }
|
||||
];
|
||||
|
||||
|
||||
@@ -42,12 +42,12 @@ import { productMockCoverUrl } from "./products";
|
||||
import { isLocalLife } from "../product-business";
|
||||
import "../ai-tools-page.css";
|
||||
|
||||
// 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/图片创作,
|
||||
// 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/自由创作,
|
||||
// 而 task_type 区分不了——cover 与 image 都是 product_image)
|
||||
const MODE_LABEL: Record<string, string> = {
|
||||
model: "模特上身图",
|
||||
cover: "平台套图",
|
||||
image: "图片创作"
|
||||
image: "自由创作"
|
||||
};
|
||||
const MODE_TAG: Record<string, string> = {
|
||||
model: "模特上身",
|
||||
@@ -142,19 +142,19 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
||||
page: "modelPhoto" as Page,
|
||||
title: "模特上身",
|
||||
desc: "上传商品和模特参考图,生成自然统一的服装、饰品上身效果。",
|
||||
image: "/assets/yz/tool-model.jpg",
|
||||
image: "/assets/prototype/photo-1524504388940-b1c1722653e1.jpg",
|
||||
},
|
||||
{
|
||||
page: "platformCover" as Page,
|
||||
title: "平台套图",
|
||||
desc: "基于商品主图一次生成主图、卖点图、细节图与场景图。",
|
||||
image: "/assets/yz/tool-cover.jpg",
|
||||
image: "/assets/prototype/unbranded-running-shoe-remix.png",
|
||||
},
|
||||
{
|
||||
page: "imageOptimize" as Page,
|
||||
title: "图片创作",
|
||||
title: "自由创作",
|
||||
desc: "使用提示词、参考图和画布比例自由生成或修改视觉素材。",
|
||||
image: "/assets/yz/tool-studio.jpg",
|
||||
image: "/assets/prototype/photo-1557682250-33bd709cbe85.jpg",
|
||||
}
|
||||
];
|
||||
|
||||
@@ -441,7 +441,7 @@ const MODE_META: Record<
|
||||
}
|
||||
> = {
|
||||
image: {
|
||||
title: "图片创作",
|
||||
title: "自由创作",
|
||||
tag: "[ IMAGE · STUDIO ]",
|
||||
desc: "使用提示词与参考图,自由生成或修改电商视觉素材",
|
||||
ratio: "1:1",
|
||||
|
||||
@@ -15,3 +15,5 @@ export { QuickCreatePage } from "./quick-create";
|
||||
export { VideoRemixPage } from "./video-remix";
|
||||
export { VideoReplacePage } from "./video-replace";
|
||||
export { SettingsPage } from "./settings";
|
||||
export { OmniCreatePage, OmniHistoryPage } from "./omni-create";
|
||||
export { OmniSessionPage } from "./omni-session";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -444,13 +444,13 @@ const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; labe
|
||||
type ProjTab = (typeof PROJ_TABS)[number]["filter"];
|
||||
|
||||
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: "projectWizard", image: "/assets/yz/video-pro.jpg", primary: true },
|
||||
{ title: "一键成片", desc: "输入商品名称并上传图片,自动完成脚本、场景与视频生成。", page: "quickCreate", image: "/assets/prototype/video-oneclick-film-v3.png", 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 }> = [
|
||||
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/yz/video-free.jpg" },
|
||||
{ title: "提炼提示词", desc: "从参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/yz/video-remix.jpg" },
|
||||
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "videoReplace", image: "/assets/yz/video-replace.jpg" },
|
||||
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string; style?: React.CSSProperties }> = [
|
||||
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/prototype/video-free-film-v3.png" },
|
||||
{ title: "提炼提示词", desc: "从参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/prototype/video-prompt-extract-film-v3.png" },
|
||||
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "videoReplace", image: "/assets/prototype/video-remix-film-v3.png", style: { objectPosition: "center 58%" } },
|
||||
];
|
||||
|
||||
function isQuickCreateProject(project: Project) {
|
||||
@@ -622,7 +622,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
||||
<div className="vc-tools three">
|
||||
{VIDEO_TOOLS.map((card) => (
|
||||
<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-title">
|
||||
<h3>{card.title}</h3>
|
||||
|
||||
@@ -29,6 +29,9 @@ export type Page =
|
||||
| "assetFactory"
|
||||
| "freeCreate"
|
||||
| "quickCreate"
|
||||
| "omniCreate"
|
||||
| "omniHistory"
|
||||
| "omniSession"
|
||||
| "videoRemix"
|
||||
| "videoReplace"
|
||||
| "imageOptimize"
|
||||
@@ -46,6 +49,12 @@ export type ResolvedRoute = {
|
||||
authMode: AuthMode;
|
||||
productId?: string;
|
||||
projectId?: string;
|
||||
// 全能创作对话页的会话 id(/omni-session/<id>)
|
||||
conversationId?: string;
|
||||
// 从首页「开始创作」带过来的首条消息。**只经 route state 透传,不进 URL** ——
|
||||
// 刷新后它已经在库里了,再发一遍会重复。
|
||||
firstMessage?: string;
|
||||
firstRefs?: import("../types").CreationRef[];
|
||||
hash?: string;
|
||||
// 视频项目页初始 tab(all/wip/done/fail):由 navigate 透传,刷新/前进后退不持久(回默认 all)。
|
||||
tab?: string;
|
||||
@@ -55,6 +64,9 @@ export type ResolvedRoute = {
|
||||
export type NavigateOptions = {
|
||||
productId?: string;
|
||||
projectId?: string;
|
||||
conversationId?: string;
|
||||
firstMessage?: string;
|
||||
firstRefs?: import("../types").CreationRef[];
|
||||
replace?: boolean;
|
||||
hash?: string;
|
||||
// 视频项目页初始 tab(all/wip/done/fail):仅经 route state 透传给 ProjectsPage,不进 URL path。
|
||||
@@ -107,11 +119,14 @@ export const routeLabels: Record<Page, string> = {
|
||||
team: "团队",
|
||||
messages: "消息",
|
||||
assetFactory: "图片工具",
|
||||
omniCreate: "全能创作",
|
||||
omniHistory: "创作历史",
|
||||
omniSession: "全能创作",
|
||||
freeCreate: "自由创作",
|
||||
quickCreate: "一键成片",
|
||||
videoRemix: "提炼提示词",
|
||||
videoReplace: "视频复刻",
|
||||
imageOptimize: "图片创作",
|
||||
imageOptimize: "自由创作",
|
||||
modelPhoto: "模特上身图",
|
||||
modelPhotoDemoA: "模特图方案 A",
|
||||
modelPhotoDemoB: "模特图方案 B",
|
||||
@@ -128,6 +143,8 @@ export function isPage(value: string): value is Page {
|
||||
|
||||
export function parentPage(page: Page): Page {
|
||||
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 (["imageOptimize", "modelPhoto", "modelPhotoDemoA", "modelPhotoDemoB", "platformCover"].includes(page)) {
|
||||
return "assetFactory";
|
||||
@@ -170,6 +187,11 @@ export function resolveRoute(): ResolvedRoute {
|
||||
if (path === "/team") return { page: "team", authMode: "login", hash };
|
||||
if (path === "/messages") return { page: "messages", 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 === "/quick-create") {
|
||||
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";
|
||||
case "assetFactory":
|
||||
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":
|
||||
return "/free-create";
|
||||
case "quickCreate":
|
||||
|
||||
@@ -183,40 +183,12 @@ main { position: relative; background: #fff; }
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 47, 167, 0.16) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 47, 167, 0.16) 1px, transparent 1px);
|
||||
linear-gradient(rgba(24, 31, 42, 0.032) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(24, 31, 42, 0.032) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
background-position: -1px -1px;
|
||||
-webkit-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%
|
||||
);
|
||||
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%
|
||||
);
|
||||
-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%);
|
||||
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%);
|
||||
}
|
||||
.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; }
|
||||
@@ -494,6 +466,7 @@ table.t tbody tr:hover { background: var(--bg-soft); }
|
||||
/* ─── Toast ─── */
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; right: 24px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px 16px;
|
||||
@@ -514,7 +487,7 @@ table.t tbody tr:hover { background: var(--bg-soft); }
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.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; }
|
||||
|
||||
/* ─── Modal ─── */
|
||||
|
||||
@@ -867,3 +867,67 @@ export type BillingConfigInfo = {
|
||||
// 当前请求者团队的价格系数(差异化调价,默认 "1"):预估所见即所扣
|
||||
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[];
|
||||
};
|
||||
|
||||
@@ -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`,流式期间敲回车会把打好的内容
|
||||
清掉但消息没发出去。顺序反过来。
|
||||