auto-added claus
btnSend.disabled = true;
statusBar.textContent = t("chat.sending");
let attPayload = null;
- if (filesSnapshot.length) {
- statusBar.textContent = currentLang === "zh" ? "准备附件中…" : "Preparing attachments…";
- attPayload = await Promise.all(filesSnapshot.map((f) => fileToPayloadEntry(f)));
- pendingFiles = [];
- syncPendingUi();
- }
const userText =
textRaw ||
(hasFiles ? (currentLang === "zh" ? "(已上传附件)" : "(attachment uploaded)") : "");
- /** 发送过程中用本地 blob 预览,避免只显示文件名直到回合结束再拉历史 */
+ /** 发送过程中用本地 blob 预览;先插入气泡再编码,避免大图 base64 阻塞主线程时长时间只有「准备附件」无预览 */
const previewBlobUrls = [];
try {
textarea.value = "";
@@ -4974,6 +4975,13 @@ ${autoLimit ? `auto-added claus
messagesEl.appendChild(userWrap);
scrollMessagesToBottom(true);
fitComposerTextarea();
+ if (filesSnapshot.length) {
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
+ statusBar.textContent = t("chat.encodingAttachments");
+ attPayload = await Promise.all(filesSnapshot.map((f) => fileToPayloadEntry(f)));
+ pendingFiles = [];
+ syncPendingUi();
+ }
const turnId = `idem_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
statusBar.textContent = currentLang === "zh" ? "连接中…" : "Connecting…";
const doneMeta = await sendMessageStream(userText, attPayload, turnId);
diff --git a/interfaces/http/fastapi_app.py b/interfaces/http/fastapi_app.py
index fb4f2081..bd59c3f4 100644
--- a/interfaces/http/fastapi_app.py
+++ b/interfaces/http/fastapi_app.py
@@ -20,6 +20,7 @@ from fastapi.staticfiles import StaticFiles
from oclaw.runtime.application.gateway import process_inbound_payload_usecase
from oclaw.interfaces.gateway.http_adapter import dispatch_gateway_http_method
from oclaw.interfaces.ws import ws_gateway_loop
+from oclaw.interfaces.ws.common import MAX_PAYLOAD_BYTES
from oclaw.interfaces.admin.routes import admin_static_dir, build_admin_router
from oclaw.runtime.agents.agent_scope import list_agent_ids, resolve_agent_workspace_dir, resolve_default_agent_id
from oclaw.interfaces.gateway.server_startup_plugins import prepare_gateway_plugin_bootstrap
@@ -305,7 +306,21 @@ def main() -> int:
import uvicorn
except Exception as exc:
raise RuntimeError("missing dependency: uvicorn (pip install -r requirements.txt)") from exc
- uvicorn.run("oclaw.interfaces.http.fastapi_app:create_app", host=host, port=port, reload=False, factory=True)
+ # Uvicorn default ws_max_size is 16MB; two photos as base64 in one chat.send often exceed that and the
+ # server drops the socket (client sees Error: ws_closed). Default aligns with WS hello maxPayload.
+ try:
+ ws_max_size = int(os.getenv("OCLAW_UVICORN_WS_MAX_SIZE") or str(MAX_PAYLOAD_BYTES))
+ except Exception:
+ ws_max_size = int(MAX_PAYLOAD_BYTES)
+ ws_max_size = max(1024, min(int(ws_max_size), 200_000_000))
+ uvicorn.run(
+ "oclaw.interfaces.http.fastapi_app:create_app",
+ host=host,
+ port=port,
+ reload=False,
+ factory=True,
+ ws_max_size=ws_max_size,
+ )
return 0
diff --git a/platform/llm/image_legacy_client.py b/platform/llm/image_legacy_client.py
index cfa9bd64..6127af48 100644
--- a/platform/llm/image_legacy_client.py
+++ b/platform/llm/image_legacy_client.py
@@ -21,7 +21,7 @@ SDK-style extras:
use ``DASHSCOPE_IMAGE_*`` env vars or JSON in ``AIA_IMAGE_EXPERT_REQUEST_EXTRA`` (alias: ``AIA_LEGACY_IMAGE_REQUEST_EXTRA``).
Compatibility roots:
-- For OpenAI-compat multimodal, use ``AIA_IMAGE_EXPERT_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1`` etc.
+- ``compatible-mode/v1`` **默认**使用 OpenAI Chat Completions 视觉块(每条含 ``type``:``image_url`` / ``text``);百炼兼容网关否则会报 ``missing_required_parameter … content[n].type``。若网关只吃 DashScope 文档形态(无 ``type`` 的 ``{"image"}``/``{"text"}``),设置 ``AIA_IMAGE_EXPERT_COMPAT_USE_DASHSCOPE_NATIVE_BLOCKS=1``。
For OpenAI-style ``image_url`` chat payloads (tool OCR / multimodal downgrade), use :mod:`oclaw.platform.llm.image_ocr_client`.
@@ -33,6 +33,7 @@ from __future__ import annotations
import base64
import json
import os
+import re
import sys
from typing import Any
@@ -63,15 +64,46 @@ IMAGE_SPECIALIST_DEFAULT_PROMPT_ZH = (
)
+def _max_legacy_input_images() -> int:
+ try:
+ v = int(os.getenv("AIA_IMAGE_EXPERT_MAX_INPUT_IMAGES") or "8")
+ except Exception:
+ v = 8
+ return max(1, min(int(v), 12))
+
+
+def parse_message_attachments_json(raw: Any) -> list[dict[str, Any]]:
+ """Decode ``chat_message.attachments`` (JSON string, list, or dict) into attachment dict rows."""
+ if raw is None:
+ return []
+ if isinstance(raw, list):
+ return [x for x in raw if isinstance(x, dict)]
+ if isinstance(raw, dict):
+ return [raw]
+ if isinstance(raw, str):
+ s = raw.strip()
+ if not s or s.lower() == "null":
+ return []
+ try:
+ v = json.loads(s)
+ if isinstance(v, list):
+ return [x for x in v if isinstance(x, dict)]
+ if isinstance(v, dict):
+ return [v]
+ except Exception:
+ pass
+ return []
+
+
def collect_legacy_lane_images_from_attachments(
attachments: list[dict[str, Any]] | None,
*,
- max_images: int = 3,
+ max_images: int | None = None,
) -> list[str]:
"""Normalize incoming UI/store attachments to URLs/data URLs for :func:`send_legacy_image_messages`."""
from oclaw.platform.files.attachment_assets import attachment_id_to_data_url
- cap = max(1, min(int(max_images), 12))
+ cap = max(1, min(int(max_images if max_images is not None else _max_legacy_input_images()), 12))
out: list[str] = []
for att in attachments or []:
if not isinstance(att, dict):
@@ -96,11 +128,91 @@ def collect_legacy_lane_images_from_attachments(
u = str(att.get("url") or "").strip()
if u:
out.append(u)
+ elif t == "relay_pointer":
+ mime = str(att.get("mime") or "").strip().lower()
+ if mime and not mime.startswith("image/"):
+ continue
+ uri = str(att.get("pointer_uri") or "").strip()
+ aid = str(att.get("attachment_id") or "").strip()
+ if not aid and uri:
+ m = re.match(r"^relay://attachments/[^/]+/([a-f0-9]{8,128})$", uri, re.I)
+ if m:
+ aid = str(m.group(1)).lower()
+ if aid:
+ data_url = attachment_id_to_data_url(aid, mime=str(att.get("mime") or ""))
+ if data_url:
+ out.append(data_url)
if len(out) >= cap:
break
return out
+def collect_legacy_lane_images_with_session_fallback(
+ *,
+ store: Any,
+ session_id: str,
+ attachments: list[dict[str, Any]] | None,
+ max_images: int | None = None,
+) -> tuple[list[str], str]:
+ """Resolve legacy multimodal image URLs for the current turn, then session history.
+
+ When the current request has no embedded/URL images, scan persisted messages (newest→oldest):
+ 1) assistant rows with usable image attachments (e.g. last model output);
+ 2) user rows with images.
+
+ The trailing user bubble for this turn (text-only) is skipped when scanning.
+
+ Returns ``(images, source)`` where ``source`` is ``current``, ``assistant_history``,
+ ``user_history``, or empty when nothing was found.
+
+ Disable with ``AIA_IMAGE_SPECIALIST_SESSION_IMAGE_FALLBACK=0`` (default: on).
+ """
+ cap = max_images if max_images is not None else _max_legacy_input_images()
+ primary = collect_legacy_lane_images_from_attachments(attachments, max_images=cap)
+ if primary:
+ return primary, "current"
+ if str(os.getenv("AIA_IMAGE_SPECIALIST_SESSION_IMAGE_FALLBACK") or "").strip().lower() in (
+ "0",
+ "false",
+ "no",
+ "off",
+ ):
+ return [], ""
+
+ try:
+ scan_n = int(os.getenv("AIA_IMAGE_SPECIALIST_FALLBACK_SCAN_MESSAGES") or "400")
+ except Exception:
+ scan_n = 400
+ scan_n = max(1, min(scan_n, 2000))
+
+ try:
+ msgs = store.get_messages(str(session_id or "").strip(), limit=scan_n)
+ except Exception:
+ msgs = []
+
+ if not msgs:
+ return [], ""
+
+ hi = len(msgs) - 1
+ if hi >= 0:
+ last = msgs[hi]
+ if str(getattr(last, "role", "") or "").strip().lower() == "user":
+ la = parse_message_attachments_json(getattr(last, "attachments", None))
+ if not collect_legacy_lane_images_from_attachments(la, max_images=1):
+ hi -= 1
+
+ for phase in ("assistant", "user"):
+ for i in range(hi, -1, -1):
+ m = msgs[i]
+ if str(getattr(m, "role", "") or "").strip().lower() != phase:
+ continue
+ la = parse_message_attachments_json(getattr(m, "attachments", None))
+ got = collect_legacy_lane_images_from_attachments(la, max_images=cap)
+ if got:
+ return got, f"{phase}_history"
+ return [], ""
+
+
def normalize_legacy_output_image_urls(resp_images: Any, *, max_items: int = 12) -> list[str]:
"""Flatten provider ``images`` / content parts to HTTP/data URLs (strings only)."""
cap = max(1, min(int(max_items), 24))
@@ -340,7 +452,11 @@ def _dashscope_image_env_kw() -> dict[str, Any]:
def _openai_compatible_vision_content(images: list[str], prompt_text: str) -> list[dict[str, Any]]:
- """DashScope *compatible-mode* / OpenAI Chat Completions vision shape (NOT ``{"image":..., "text":...}``)."""
+ """OpenAI Chat Completions vision shape (``type`` + ``image_url`` / ``text``).
+
+ Required for typical ``compatible-mode/v1`` ``/chat/completions`` (each part must have ``type``).
+ Image URLs may be ``https://…`` or ``data:image/…;base64,…``.
+ """
blocks: list[dict[str, Any]] = []
for img in images:
blocks.append({"type": "image_url", "image_url": {"url": img}})
@@ -348,6 +464,16 @@ def _openai_compatible_vision_content(images: list[str], prompt_text: str) -> li
return blocks
+def _compat_use_dashscope_native_blocks() -> bool:
+ """When ``1``, ``compatible-mode`` URLs use DashScope doc blocks without ``type`` (see :func:`_http_content_blocks`)."""
+ return str(os.getenv("AIA_IMAGE_EXPERT_COMPAT_USE_DASHSCOPE_NATIVE_BLOCKS") or "").strip().lower() in (
+ "1",
+ "true",
+ "yes",
+ "on",
+ )
+
+
def _model_triggers_dashscope_native_fallback(model_name: str) -> bool:
"""``qwen-image`` on OpenAI-compat ``/chat/completions`` often returns ``message.content=null``."""
if str(os.getenv("AIA_IMAGE_EXPERT_DISABLE_DASHSCOPE_NATIVE_FALLBACK") or "").strip().lower() in (
@@ -408,7 +534,7 @@ def send_legacy_image_messages(
if not images:
return {"ok": False, "error": "at least one image input is required"}
- raw_selected = [str(x).strip() for x in images if str(x).strip()][:3]
+ raw_selected = [str(x).strip() for x in images if str(x).strip()][: _max_legacy_input_images()]
selected: list[str] = []
input_kind: list[str] = []
for img in raw_selected:
@@ -423,10 +549,11 @@ def send_legacy_image_messages(
if not selected:
return {"ok": False, "error": "no usable image input (expected URL or data URL)"}
- # Compatible-mode expects OpenAI-style ``image_url`` + ``text`` parts; DashScope-native HTTP uses plain ``{"image"}`` blocks.
- use_openai_blocks = "compatible-mode" in resolved_base_url.lower()
+ # compatible-mode /chat/completions is OpenAI-shaped: every content part needs ``type`` (else 400 missing content[n].type).
+ # Native DashScope HTTP (non-compatible base) uses ``{"image"}`` / ``{"text"}`` without ``type``.
+ use_compatible_base = "compatible-mode" in resolved_base_url.lower()
prompt_plain = str(prompt or "").strip() or render_prompt("image/default_edit_prompt.zh.md", strict=True)
- if use_openai_blocks:
+ if use_compatible_base and not _compat_use_dashscope_native_blocks():
content_multi = _openai_compatible_vision_content(selected, prompt_plain)
else:
content_multi = _http_content_blocks(selected, prompt, typed=False)
@@ -482,7 +609,7 @@ def send_legacy_image_messages(
if (
not str(text or "").strip()
and not out_images
- and use_openai_blocks
+ and use_compatible_base
and _model_triggers_dashscope_native_fallback(model_name)
):
compat_extract_diag = build_extract_diag_empty(body)
@@ -596,6 +723,8 @@ def send_legacy_image_messages(
__all__ = [
"IMAGE_SPECIALIST_DEFAULT_PROMPT_ZH",
"collect_legacy_lane_images_from_attachments",
+ "collect_legacy_lane_images_with_session_fallback",
+ "parse_message_attachments_json",
"legacy_image_assistant_body_with_placeholder",
"legacy_image_turn_bundle",
"materialize_legacy_response_output_attachments",
diff --git a/runtime/direct_loop.py b/runtime/direct_loop.py
index dd4adbca..46b3fb22 100644
--- a/runtime/direct_loop.py
+++ b/runtime/direct_loop.py
@@ -1134,13 +1134,17 @@ def _maybe_image_specialist_legacy_gateway_turn(
from oclaw.platform.llm.image_legacy_client import (
IMAGE_SPECIALIST_DEFAULT_PROMPT_ZH,
- collect_legacy_lane_images_from_attachments,
+ collect_legacy_lane_images_with_session_fallback,
legacy_image_assistant_body_with_placeholder,
legacy_image_turn_bundle,
send_legacy_image_messages,
)
- imgs = collect_legacy_lane_images_from_attachments(attachments)
+ imgs, legacy_img_src = collect_legacy_lane_images_with_session_fallback(
+ store=store,
+ session_id=session_id,
+ attachments=attachments,
+ )
if not imgs:
hint_en = "Image specialist received no image input. Attach an image and try again."
hint_zh = "图片专家未收到可用的图片输入;请先上传或附上图片后再试。"
@@ -1160,6 +1164,11 @@ def _maybe_image_specialist_legacy_gateway_turn(
)
if on_progress:
+ if legacy_img_src.endswith("_history"):
+ if str(lang or "").startswith("en"):
+ on_progress("oclaw: reusing earlier session images (no new upload this turn)…")
+ else:
+ on_progress("oclaw: 本轮未上传新图,使用会话中较早的图片作为输入…")
on_progress("oclaw: image specialist (legacy multimodal HTTP)…")
prompt_plain = str(user_text or "").strip()
diff --git a/tests/test_image_legacy_gateway_lane.py b/tests/test_image_legacy_gateway_lane.py
index e9dc72c1..5e7efc8f 100644
--- a/tests/test_image_legacy_gateway_lane.py
+++ b/tests/test_image_legacy_gateway_lane.py
@@ -1,5 +1,9 @@
from __future__ import annotations
+import json
+
+import pytest
+
from oclaw.platform.llm.image_http_common import (
dashscope_multimodal_http_ok,
dashscope_native_multimodal_url_from_compatible_base,
@@ -8,10 +12,43 @@ from oclaw.platform.llm.image_http_common import (
from oclaw.platform.llm.image_http_common import build_extract_diag_empty
from oclaw.platform.llm.image_legacy_client import (
collect_legacy_lane_images_from_attachments,
+ collect_legacy_lane_images_with_session_fallback,
legacy_image_assistant_body_with_placeholder,
legacy_image_turn_bundle,
normalize_legacy_output_image_urls,
+ parse_message_attachments_json,
)
+from oclaw.platform.llm.image_legacy_client import _http_content_blocks
+from oclaw.platform.llm.image_legacy_client import _openai_compatible_vision_content
+
+
+def test_openai_compatible_vision_has_type_per_part() -> None:
+ """compatible-mode /chat/completions expects type on each content element."""
+ b = _openai_compatible_vision_content(
+ ["https://example.invalid/a.png", "data:image/png;base64,abcd"],
+ "prompt",
+ )
+ assert len(b) == 3
+ assert b[0]["type"] == "image_url" and "url" in b[0]["image_url"]
+ assert b[1]["type"] == "image_url"
+ assert b[2]["type"] == "text" and b[2]["text"] == "prompt"
+
+
+def test_http_content_blocks_multi_image_then_text() -> None:
+ """DashScope samples: one user message, content = N × {"image": url} then {"text": ...}."""
+ b = _http_content_blocks(
+ [
+ "https://example.invalid/a.png",
+ "https://example.invalid/b.png",
+ ],
+ "合成说明",
+ typed=False,
+ )
+ assert b == [
+ {"image": "https://example.invalid/a.png"},
+ {"image": "https://example.invalid/b.png"},
+ {"text": "合成说明"},
+ ]
def test_normalize_legacy_output_dict_image_parts() -> None:
@@ -246,3 +283,106 @@ def test_extract_text_and_images_openai_top_level_unchanged() -> None:
)
assert "hi" in text
assert images == ["https://example.invalid/a.jpg"]
+
+
+def test_parse_message_attachments_json_string_list() -> None:
+ raw = json.dumps([{"type": "image_url", "url": "https://example.invalid/z.png"}])
+ got = parse_message_attachments_json(raw)
+ assert len(got) == 1 and got[0]["url"] == "https://example.invalid/z.png"
+
+
+class _FakeHistMsg:
+ __slots__ = ("role", "attachments")
+
+ def __init__(self, role: str, attachments: object) -> None:
+ self.role = role
+ self.attachments = attachments
+
+
+class _FakeHistStore:
+ def __init__(self, msgs: list[_FakeHistMsg]) -> None:
+ self._msgs = msgs
+
+ def get_messages(self, session_id: str, limit: int = 200) -> list[_FakeHistMsg]:
+ _ = session_id
+ return self._msgs[-limit:] if len(self._msgs) > limit else list(self._msgs)
+
+
+def test_session_fallback_prefers_latest_assistant_images() -> None:
+ """Newest assistant row with images wins over older user uploads."""
+ msgs = [
+ _FakeHistMsg(
+ "user",
+ json.dumps([{"type": "image_url", "url": "https://example.invalid/old-user.png"}]),
+ ),
+ _FakeHistMsg(
+ "assistant",
+ json.dumps([{"type": "image_url", "url": "https://example.invalid/from-assistant.png"}]),
+ ),
+ _FakeHistMsg("user", "null"),
+ ]
+ store = _FakeHistStore(msgs)
+ imgs, src = collect_legacy_lane_images_with_session_fallback(
+ store=store,
+ session_id="s1",
+ attachments=[],
+ )
+ assert src == "assistant_history"
+ assert imgs == ["https://example.invalid/from-assistant.png"]
+
+
+def test_session_fallback_user_history_when_no_assistant_images() -> None:
+ msgs = [
+ _FakeHistMsg(
+ "user",
+ json.dumps([{"type": "image_url", "url": "https://example.invalid/only-user.png"}]),
+ ),
+ _FakeHistMsg("assistant", "[]"),
+ _FakeHistMsg("user", "null"),
+ ]
+ store = _FakeHistStore(msgs)
+ imgs, src = collect_legacy_lane_images_with_session_fallback(
+ store=store,
+ session_id="s1",
+ attachments=[],
+ )
+ assert src == "user_history"
+ assert imgs == ["https://example.invalid/only-user.png"]
+
+
+def test_session_fallback_disabled_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("AIA_IMAGE_SPECIALIST_SESSION_IMAGE_FALLBACK", "0")
+ msgs = [
+ _FakeHistMsg(
+ "assistant",
+ json.dumps([{"type": "image_url", "url": "https://example.invalid/a.png"}]),
+ ),
+ _FakeHistMsg("user", "null"),
+ ]
+ store = _FakeHistStore(msgs)
+ imgs, src = collect_legacy_lane_images_with_session_fallback(
+ store=store,
+ session_id="s1",
+ attachments=[],
+ )
+ assert src == ""
+ assert imgs == []
+
+
+def test_session_fallback_current_attachments_skip_history(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("AIA_IMAGE_SPECIALIST_SESSION_IMAGE_FALLBACK", raising=False)
+ msgs = [
+ _FakeHistMsg(
+ "assistant",
+ json.dumps([{"type": "image_url", "url": "https://example.invalid/hist.png"}]),
+ ),
+ _FakeHistMsg("user", "null"),
+ ]
+ store = _FakeHistStore(msgs)
+ imgs, src = collect_legacy_lane_images_with_session_fallback(
+ store=store,
+ session_id="s1",
+ attachments=[{"type": "image_url", "url": "https://example.invalid/current.png"}],
+ )
+ assert src == "current"
+ assert imgs == ["https://example.invalid/current.png"]