feat(chat): image specialist session image fallback and WS UX

- Resolve legacy multimodal inputs from session history when the user sends
  text-only (assistant images first, then user uploads); optional env toggles.
- Support relay_pointer image resolution in legacy lane collector.
- Align uvicorn WebSocket frame limit with MAX_PAYLOAD_BYTES; document
  OCLAW_UVICORN_WS_MAX_SIZE and compatible-mode env knobs.
- Admin chat: encode attachments after bubble preview; FileReader-based
  base64 for large files.
- Tests and docs for new behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-10 11:25:21 +08:00
parent faeb067856
commit 396dba76ea
8 changed files with 368 additions and 28 deletions

View file

@ -17,6 +17,7 @@ const I18N = {
"chat.noSessions": "暂无会话,点击新建开始。",
"chat.loading": "加载中…",
"chat.sending": "发送中…",
"chat.encodingAttachments": "编码附件中…",
"chat.error": "请求失败",
"chat.loadMore": "加载更多",
"chat.sessionMenu": "会话操作",
@ -193,6 +194,7 @@ const I18N = {
"chat.noSessions": "No sessions yet. Create one to start.",
"chat.loading": "Loading…",
"chat.sending": "Sending…",
"chat.encodingAttachments": "Encoding attachments…",
"chat.error": "Request failed",
"chat.loadMore": "Load more",
"chat.sessionMenu": "Session actions",
@ -2588,12 +2590,17 @@ function syncAuthUserLabel() {
}
async function fileToPayloadEntry(file) {
const buf = await file.arrayBuffer();
const bytes = new Uint8Array(buf);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
const data_base64 = btoa(binary);
return { name: file.name || "file", data_base64 };
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => {
const dataUrl = String(r.result || "");
const idx = dataUrl.indexOf(",");
const data_base64 = idx >= 0 ? dataUrl.slice(idx + 1) : dataUrl;
resolve({ name: file.name || "file", data_base64 });
};
r.onerror = () => reject(r.error || new Error("file_read_failed"));
r.readAsDataURL(file);
});
}
async function renderLogin() {
@ -4939,16 +4946,10 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">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 ? `<div style="margin-top:8px;"><span class="muted">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);