feat(video): DashScope video specialist with correct i2v request bodies

- Add video_generation_client: async video-synthesis, t2v vs i2v (Wan 2.7 input.media first_frame vs legacy img_url).

- Coerce *-t2v* to *-i2v* when first frame present; AIA_VIDEO_I2V_INPUT_STYLE / I2V_MODEL overrides.

- Gateway/direct_loop early return for video specialist; workspace video + factory allowlist.

- Normalize WS and admin chat attachments (image_ref parity); mp4 attachment store roundtrip tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-10 16:20:43 +08:00
parent 398d85ba06
commit 4335040a27
21 changed files with 1152 additions and 56 deletions

View file

@ -2,7 +2,6 @@
from __future__ import annotations
import base64
import json
import queue
import threading
@ -28,8 +27,8 @@ from oclaw.platform.files.file_attachments import (
DEFAULT_MAX_EXCEL_SHEETS,
DEFAULT_TABULAR_ROWS_READ,
clear_attachment_limits_cache,
process_file_data,
)
from oclaw.interfaces.ws.common import normalize_ws_attachments
from oclaw.platform.files.session_export import export_session_json, export_session_markdown
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.gateway import OclawGateway
@ -270,45 +269,9 @@ def _clear_stop(session_id: str) -> None:
_CHAT_STOP_EVENTS.pop(str(session_id), None)
def _decode_base64_payload(s: str) -> bytes | None:
"""Decode base64 from browser (may lack padding; URL-safe variants)."""
raw = (s or "").strip()
if not raw:
return None
if raw.startswith("data:") and "," in raw:
raw = raw.split(",", 1)[1].strip()
raw = raw.replace("-", "+").replace("_", "/")
pad = (-len(raw)) % 4
if pad:
raw += "=" * pad
try:
return base64.b64decode(raw, validate=False)
except Exception:
try:
return base64.standard_b64decode(raw)
except Exception:
return None
def _parse_attachments_payload(raw: Any) -> list[dict[str, Any]] | None:
if not raw:
return None
if not isinstance(raw, list):
return None
out: list[dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "file").strip() or "file"
b64 = item.get("data_base64") if "data_base64" in item else item.get("data")
if not isinstance(b64, str) or not b64.strip():
continue
data = _decode_base64_payload(b64)
if not data:
continue
got = process_file_data(name, data)
if got:
out.extend(got)
"""Match WebSocket :func:`normalize_ws_attachments`: base64 uploads plus pass-through typed refs."""
out = normalize_ws_attachments(raw)
return out if out else None

View file

@ -168,6 +168,7 @@ const I18N = {
"chat.specialistGeneralistShort": "通用",
"chat.specialistOpsShort": "运维",
"chat.specialistImageShort": "图像",
"chat.specialistVideoShort": "视频",
"chat.specialistMemoryShort": "记忆",
"chat.specialistManagerSelfShort": "全能者",
"chat.attachment.download": "下载",
@ -344,6 +345,7 @@ const I18N = {
"chat.specialistGeneralistShort": "Generalist",
"chat.specialistOpsShort": "Ops",
"chat.specialistImageShort": "Image",
"chat.specialistVideoShort": "Video",
"chat.specialistMemoryShort": "Memory",
"chat.specialistManagerSelfShort": "Manager",
"chat.attachment.download": "Download",
@ -649,6 +651,7 @@ function specialistLabel(specialist) {
if (s === "manager_self") return t("chat.specialistManagerSelfShort");
if (s === "ops") return t("chat.specialistOpsShort");
if (s === "image") return t("chat.specialistImageShort");
if (s === "video") return t("chat.specialistVideoShort");
if (s === "memory") return t("chat.specialistMemoryShort");
return t("chat.specialistGeneralistShort");
}
@ -2190,6 +2193,39 @@ async function renderAttachmentsEl(raw) {
text: t("chat.attachment.download"),
}),
);
if (String(typ || "") === "video_ref" && mime.toLowerCase().startsWith("video/")) {
card.appendChild(
el("video", {
class: "chat-att-video",
controls: true,
preload: "metadata",
style: "max-width:100%;max-height:420px;margin-top:8px;border-radius:8px;",
src: url,
}),
);
}
}
} else {
const remote = String(att.url || "").trim();
if (remote && String(typ || "") === "video_ref" && mime.toLowerCase().startsWith("video/")) {
card.appendChild(
el("video", {
class: "chat-att-video",
controls: true,
preload: "metadata",
style: "max-width:100%;max-height:420px;margin-top:8px;border-radius:8px;",
src: remote,
}),
);
card.appendChild(
el("a", {
class: "chat-att-ref__link",
href: remote,
target: "_blank",
rel: "noopener noreferrer",
text: t("chat.attachment.download"),
}),
);
}
}
const canPreviewText = !!aid && (String(typ || "") === "text_ref" || isTextLikeMime(mime));