修复 MCP 图片在流式与历史会话中的显示一致性。

将 tool_result 图片落库为 attachments,并在聊天渲染中保留单气泡与时序位置,避免流式结束后图片消失或刷新后错位。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-28 03:35:51 +08:00
parent 7253f6795d
commit d57969d66d
5 changed files with 636 additions and 38 deletions

View file

@ -111,6 +111,30 @@ Linux/macOS:
- `http://127.0.0.1:8787/admin`
- `http://127.0.0.1:8787/chat`
---
## 7. Admin Chat:WS 请求超时(`ws_send_timeout:*`)
现象:
- 管理台聊天页流式请求中途提示:`请求失败: Error: ws_send_timeout:180000`(或其他毫秒值)
说明:
- 这是**前端**在 `interfaces/admin/static/chat.js` 中实现的 WS “无活动超时”看门狗(不是后端限流/超时)。
- 触发条件通常为:在指定毫秒窗口内(默认曾为 180000ms)没有收到任何 WS 活动(delta / event / ack 等)。
- 某些模型/网关可能会出现“长时间无增量、最终一次性返回”的情况,此时该看门狗会误判并提前报错。
当前默认行为:
- 默认已禁用(`WS_CHAT_SEND_TIMEOUT_MS = 0`)。
如何调整:
- 修改 `interfaces/admin/static/chat.js` 中常量 `WS_CHAT_SEND_TIMEOUT_MS`:
- `0`:禁用
- `> 0`:启用并作为无活动超时毫秒数
### 6.1 初始化管理员(幂等)
```bash
@ -211,6 +235,55 @@ Linux/macOS:
详细开发与接入参考:`docs/MCP_LOCAL_SERVER.md`
### 8.1 `images-mcp`(Windows)安装与 Health 通过指引
现象:
- `Install` 显示成功,但 `Health` 报错:`mcp_runtime_empty_response`
- 手动执行 `npx -y images-mcp` 只输出 CLI 帮助并退出
根因:
- `images-mcp` 的 npm bin 入口默认是 CLI(`cli.ts`),不是 MCP stdio 服务入口。
- 健康检查要求进程保持 MCP JSON-RPC(stdio)模式;CLI 进程会立即退出,导致空响应。
前置检查(PowerShell):
```powershell
where bun
where bunx
bun --version
bunx --version
```
若 `where bun` 无结果,先安装/修复 Bun,再重启终端与网关进程。
推荐安装方式(本地 runtime 目录):
```powershell
mkdir D:\tools\images-mcp-runtime -Force
cd D:\tools\images-mcp-runtime
npm init -y
npm install images-mcp
```
将 MCP Server 启动配置改为(管理台或数据库):
- `entry_command`: `powershell`
- `entry_args`:
`["-NoProfile","-Command","Set-Location D:\\tools\\images-mcp-runtime; bun run node_modules/images-mcp/mcp.ts"]`
预期结果:
- 手动验证启动命令时,会看到 `Images MCP server running on stdio`
- 管理台 `Health` 返回 `ok`
- `Sync Tools` 后应看到 2 个工具(OpenAI / Gemini)
排障补充:
- 若仍失败,优先检查 `entry_args` 是否仍是 `npx -y images-mcp`(该配置会回到 CLI 模式,Health 继续失败)。
- 若提示找不到 `bun`,确认网关进程启动时的 PATH 已包含 `C:\Users\<用户名>\.bun\bin`。
---
## 9. 向量记忆配置(可选)

View file

@ -341,6 +341,13 @@ const I18N = {
"models.baseUrl": "Base URL",
"models.baseUrlPlaceholder": "可选,留空用环境默认",
"models.apiKey": "API Key",
"models.thinkMode": "Think 模式(仅当前 API)",
"models.thinkModeHint": "开启后仅对此配置回放完整 reasoning_content(默认关闭)",
"models.reasoningEffort": "Reasoning 强度",
"models.reasoningEffortDefault": "默认",
"models.reasoningEffortLow": "低",
"models.reasoningEffortMedium": "中",
"models.reasoningEffortHigh": "高",
"models.rememberKey": "记住密钥(存库加密)",
"models.save": "保存",
"models.delete": "删除此配置",
@ -760,6 +767,13 @@ const I18N = {
"models.baseUrl": "Base URL",
"models.baseUrlPlaceholder": "Optional",
"models.apiKey": "API key",
"models.thinkMode": "Think mode (this API profile only)",
"models.thinkModeHint": "When on, replay full assistant reasoning_content for this profile only (default off).",
"models.reasoningEffort": "Reasoning effort",
"models.reasoningEffortDefault": "Default",
"models.reasoningEffortLow": "Low",
"models.reasoningEffortMedium": "Medium",
"models.reasoningEffortHigh": "High",
"models.rememberKey": "Remember key (encrypted in DB)",
"models.save": "Save",
"models.delete": "Delete profile",
@ -3269,6 +3283,13 @@ async function renderModels() {
const modeSel = el("select", { class: "input" });
const modelInp = el("input", { class: "input" });
const baseInp = el("input", { class: "input", placeholder: t("models.baseUrlPlaceholder") });
const thinkingModeCb = el("input", { type: "checkbox" });
const reasoningEffortSel = el("select", { class: "input", style: "max-width:180px" }, [
el("option", { value: "", text: t("models.reasoningEffortDefault") }),
el("option", { value: "low", text: t("models.reasoningEffortLow") }),
el("option", { value: "medium", text: t("models.reasoningEffortMedium") }),
el("option", { value: "high", text: t("models.reasoningEffortHigh") }),
]);
const keyInp = el("input", { class: "input", type: "password", autocomplete: "off" });
const rememberCb = el("input", { type: "checkbox" });
const readonlyProfileHint = el("div", { class: "muted", text: "" });
@ -3661,12 +3682,16 @@ async function renderModels() {
}
modelInp.value = String(selProf.model || "");
baseInp.value = String(selProf.base_url || "");
thinkingModeCb.checked = !!selProf.thinking_mode_enabled;
reasoningEffortSel.value = String(selProf.reasoning_effort || "");
keyInp.value = String(state.profile_secret || "");
rememberCb.checked = !!selProf.has_key;
profName.disabled = !canEditFields;
modelInp.disabled = !canEditFields;
baseInp.disabled = !canEditFields;
thinkingModeCb.disabled = !canEditFields;
reasoningEffortSel.disabled = !canEditFields;
keyInp.disabled = !canEditFields;
rememberCb.disabled = !canEditFields;
@ -3739,6 +3764,8 @@ async function renderModels() {
mode: modeSave,
model: modelInp.value.trim(),
base_url: baseInp.value.trim(),
thinking_mode_enabled: !!thinkingModeCb.checked,
reasoning_effort: String(reasoningEffortSel.value || ""),
});
await apiRequest("POST", "/admin/api/models/profiles/" + encodeURIComponent(pid) + "/secret", {
remember: rememberCb.checked,
@ -3954,6 +3981,9 @@ async function renderModels() {
el("div", { class: "row" }, [el("label", { text: t("models.mode") }), modeSel]),
el("div", { class: "row" }, [el("label", { text: t("models.model") }), modelInp]),
el("div", { class: "row" }, [el("label", { text: t("models.baseUrl") }), baseInp]),
el("div", { class: "row" }, [el("label", { text: t("models.thinkMode") }), thinkingModeCb]),
el("div", { class: "muted", text: t("models.thinkModeHint") }),
el("div", { class: "row" }, [el("label", { text: t("models.reasoningEffort") }), reasoningEffortSel]),
warnKey,
openaiHint,
ollamaHint,
@ -4530,6 +4560,96 @@ async function renderPlugins() {
"Paste install JSON: one object, array of objects, or { servers: [...] } / { payload: {...} } (seed file shape)",
rows: "6",
});
const cliInstallInput = el("input", {
class: "input",
placeholder: "CLI one-liner, e.g. npx -y mcp-fetch-server | pip install mcp-server-time | pip install git+https://... && python -m <module>",
});
const _parseCliInstall = (raw) => {
const s = String(raw || "").trim();
if (!s) return { ok: false, error: "empty_command" };
// Support "A && B" one-liners (we only parse safe shapes; never shell-eval).
const segs = s.split(/\s*&&\s*/).map((x) => String(x || "").trim()).filter(Boolean);
const s0 = String(segs[0] || "").trim();
const s1 = String(segs[1] || "").trim();
// normalize spaces for simple token parse (no shell eval).
const parts = s0.split(/\s+/).filter(Boolean);
const low0 = String(parts[0] || "").toLowerCase();
const low1 = String(parts[1] || "").toLowerCase();
const idxPkg = (arr) => arr.findIndex((x) => !String(x || "").startsWith("-"));
if (low0 === "npx" || (low0 === "npm" && low1 === "exec")) {
// npx -y <pkg>
const rest = low0 === "npx" ? parts.slice(1) : parts.slice(2);
const i = idxPkg(rest);
const pkg = i >= 0 ? String(rest[i] || "").trim() : "";
if (!pkg) return { ok: false, error: "npx_package_missing" };
const tail = i >= 0 ? rest.slice(i + 1).map((x) => String(x || "").trim()).filter(Boolean) : [];
return {
ok: true,
payload: {
source_type: "npm",
source_ref: pkg,
server_id: pkg,
entry_command: "npx",
// Keep package tail args (e.g. "run <token>") for MCP CLIs that require subcommands/auth.
entry_args: ["-y", pkg, ...tail],
},
};
}
if (low0 === "npm" && low1 === "install") {
// npm install [-g] <pkg>
const rest = parts.slice(2);
const i = idxPkg(rest);
const pkg = i >= 0 ? String(rest[i] || "").trim() : "";
if (!pkg) return { ok: false, error: "npm_package_missing" };
return {
ok: true,
payload: {
source_type: "npm",
source_ref: pkg,
server_id: pkg,
entry_command: "npx",
entry_args: ["-y", pkg],
},
};
}
if ((low0 === "pip" && low1 === "install") || (low0 === "python" && low1 === "-m" && String(parts[2] || "").toLowerCase() === "pip")) {
const rest = low0 === "pip" ? parts.slice(2) : parts.slice(4);
const i = idxPkg(rest);
const pkg = i >= 0 ? String(rest[i] || "").trim() : "";
if (!pkg) return { ok: false, error: "pypi_package_missing" };
// If pip install uses git+... URL, require explicit entry module via "&& python -m <module>".
if (pkg.toLowerCase().startsWith("git+")) {
const p1 = s1.split(/\s+/).filter(Boolean);
const p10 = String(p1[0] || "").toLowerCase();
const p11 = String(p1[1] || "").toLowerCase();
const mod = (p10 === "python" && p11 === "-m") ? String(p1[2] || "").trim() : "";
if (!mod) return { ok: false, error: "git_pip_requires_python_m" };
const repoName = pkg.replace(/^git\+/i, "").split(/[?#]/)[0].split("/").filter(Boolean).pop() || "mcp-server";
const sid = String(repoName).replace(/\.git$/i, "");
return {
ok: true,
payload: {
source_type: "pypi",
source_ref: pkg,
server_id: sid,
entry_command: "python",
entry_args: ["-m", mod],
},
};
}
return {
ok: true,
payload: {
source_type: "pypi",
source_ref: pkg,
server_id: pkg,
entry_command: "python",
entry_args: ["-m", pkg.replace(/-/g, "_")],
},
};
}
return { ok: false, error: "unsupported_cli_command" };
};
const installBtn = el("button", {
class: "btn btn--primary",
text: "Install MCP",
@ -4659,6 +4779,72 @@ async function renderPlugins() {
router();
},
});
const cliInstallModal = el("div", { class: "session-monitor-modal", style: "display:none;" });
const cliInstallModalTitle = el("div", { class: "card__title", text: "MCP CLI install" });
const cliInstallModalBody = el("pre", { class: "pre", text: "" });
const closeCliInstallModal = () => {
cliInstallModal.style.display = "none";
};
const openCliInstallModal = (title, text) => {
cliInstallModalTitle.textContent = String(title || "MCP CLI install");
cliInstallModalBody.textContent = String(text || "");
cliInstallModal.style.display = "flex";
};
const setCliInstallModal = (title, text) => {
if (title) cliInstallModalTitle.textContent = String(title);
if (text != null) cliInstallModalBody.textContent = String(text);
};
cliInstallModal.addEventListener("click", (e) => {
if (e.target === cliInstallModal) closeCliInstallModal();
});
cliInstallModal.appendChild(
el("div", { class: "card session-monitor-modal__card", style: "width:min(720px,96vw);" }, [
cliInstallModalTitle,
cliInstallModalBody,
el("div", { class: "row", style: "justify-content:flex-end;margin-top:10px;" }, [
el("button", { class: "btn", text: "Close", onclick: closeCliInstallModal }),
]),
]),
);
const cliInstallBtn = el("button", {
class: "btn",
text: "Install from CLI",
onclick: async () => {
preflightFixWrap.innerHTML = "";
openCliInstallModal("MCP CLI install", "Parsing command...");
const parsed = _parseCliInstall(cliInstallInput.value);
if (!parsed.ok) {
installStatus.textContent = `[cli] ${String(parsed.error || "parse_failed")}`;
setCliInstallModal("MCP CLI install failed", installStatus.textContent);
return;
}
const payload = {
source_type: String(parsed.payload.source_type || "").trim(),
source_ref: String(parsed.payload.source_ref || "").trim(),
server_id: String(parsed.payload.server_id || "").trim(),
entry_command: String(parsed.payload.entry_command || "").trim(),
entry_args: Array.isArray(parsed.payload.entry_args) ? parsed.payload.entry_args.map((x) => String(x)) : [],
version: "",
};
setCliInstallModal("MCP CLI install", "Running preflight...");
const pre = await apiPost("/admin/api/mcp/preflight", payload);
if (!pre.ok) {
installStatus.textContent = `[cli/preflight] ${String(pre.error_code || "")} ${String(pre.error || "")}`.trim();
setCliInstallModal("MCP CLI preflight failed", installStatus.textContent);
return;
}
setCliInstallModal("MCP CLI install", "Installing...");
const res = await apiPost("/admin/api/mcp/install", payload);
installStatus.textContent = `[cli] ` + JSON.stringify(res);
if (res && res.ok) {
setCliInstallModal("MCP CLI install success", installStatus.textContent);
markPrewarmReminder("mcp_installed");
router();
} else {
setCliInstallModal("MCP CLI install failed", installStatus.textContent);
}
},
});
const mcpServerList = Array.isArray(mcp.servers) ? mcp.servers : [];
const expertBindingDashTbody = el("tbody");
repaintExpertBindingDashboard = () => {
@ -5492,6 +5678,29 @@ async function renderPlugins() {
const foldMcpInstall = pluginsFold("【3】MCP 安装(表单 / JSON / 运维)", [
el("div", { class: "row" }, [sourceType, sourceRef, version]),
el("div", { class: "row" }, [entryCmd, entryArgs, installBtn]),
el("div", { class: "muted", text: "CLI direct install (paste one command)" }),
el("div", { class: "row" }, [cliInstallInput, cliInstallBtn]),
el("div", { class: "muted", text: "常用命令行安装示例(可先本机验证,再填上方表单)" }),
el("pre", {
class: "pre",
text:
`# npm 包(本地安装)
npm install mcp-fetch-server
# 直接运行(推荐)
npx -y mcp-fetch-server
# 全局安装后运行
npm install -g mcp-fetch-server
mcp-fetch-server
# Python 包示例
pip install mcp-server-time
python -m mcp_server_time
# Python(Git URL / VCS)示例:必须显式指定 entry module
pip install git+https://github.com/philschmid/code-sandbox-mcp.git && python -m code_sandbox_mcp`,
}),
el("div", { class: "muted", text: "JSON install (single object or array)" }),
jsonInstallInput,
el("div", { class: "row" }, [jsonInstallBtn]),
@ -5739,6 +5948,7 @@ async function renderPlugins() {
foldExpertBindingDash,
el("div", { id: "plugins-binding" }, [foldMcpBinding]),
]),
cliInstallModal,
]);
}

View file

@ -389,10 +389,32 @@ function _appendAssistantTextSegments(inner, rawText, collapsedItems) {
function _buildAggregatedAssistantBubble(tsIso, items) {
const inner = el("div", { class: "chat-msg chat-msg--assistant chat-msg--rich" });
const collapsedItems = [];
const _inlineImageHtmlFromAttachments = (raw) => {
const list = parseAttachments(raw);
if (!Array.isArray(list) || !list.length) return "";
const blocks = [];
for (const att of list) {
if (!att || typeof att !== "object") continue;
const typ = String(att.type || "").toLowerCase();
if (typ === "image" || typ === "input_image") {
const b64 = String(att.image_base64 || att.data || "").trim();
if (!b64) continue;
const mime = String(att.mime || att.mime_type || "image/png").trim() || "image/png";
const src = `data:${mime};base64,${b64.replace(/\s/g, "")}`;
blocks.push(`<div class="chat-att-wrap"><img class="chat-att-img" src="${escapeHtml(src)}" alt="tool image" /></div>`);
} else if (typ === "image_url") {
const src = String(att.url || att.image_url || "").trim();
if (!src) continue;
blocks.push(`<div class="chat-att-wrap"><img class="chat-att-img" src="${escapeHtml(src)}" alt="tool image" /></div>`);
}
}
return blocks.join("");
};
for (const it of items || []) {
const kind = String((it && it.kind) || "");
const text = String((it && it.text) || "");
if (!text.trim()) continue;
const hasInlineAtt = !!(it && it.attachments);
if (!text.trim() && !(kind === "tool_result" && hasInlineAtt)) continue;
if (kind === "assistant_text") {
_appendAssistantTextSegments(inner, text, collapsedItems);
} else if (kind === "reasoning") {
@ -401,6 +423,8 @@ function _buildAggregatedAssistantBubble(tsIso, items) {
if (adminChatShowToolOutput) collapsedItems.push({ title: _toolSummaryTitle("tool_call"), text });
} else if (kind === "tool_result") {
if (adminChatShowToolOutput) collapsedItems.push({ title: _toolSummaryTitle("tool"), text });
const inlineImages = _inlineImageHtmlFromAttachments(it && it.attachments);
if (inlineImages) inner.appendChild(el("div", { class: "chat-msg__md", html: inlineImages }));
} else {
inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(text) }));
}
@ -488,7 +512,8 @@ function _buildRenderRows(msgs) {
}
if (m.attachments) agg.attachments = m.attachments;
} else if (String(content || "").trim()) {
agg._items.push({ kind: "tool_result", text: content });
agg._items.push({ kind: "tool_result", text: content, attachments: m.attachments || null });
if (m.attachments) agg.attachments = m.attachments;
}
continue;
}
@ -1304,6 +1329,71 @@ function extractSqlAuditPayload(payload) {
};
}
function extractToolImageItems(payload) {
const _toObj = (v) => {
if (v && typeof v === "object") return v;
if (typeof v !== "string") return null;
const s = String(v || "").trim();
if (!s) return null;
try {
const j = JSON.parse(s);
return j && typeof j === "object" ? j : null;
} catch (_) {
return null;
}
};
const p = _toObj(payload) || {};
const cands = [p, _toObj(p.payload), _toObj(p.result), _toObj(p.payload && p.payload.result), _toObj(p.result && p.result.result)].filter(Boolean);
const out = [];
for (const cand of cands) {
const content = Array.isArray(cand.content) ? cand.content : [];
for (const item of content) {
if (!item || typeof item !== "object") continue;
const typ = String(item.type || "").trim().toLowerCase();
if (typ === "image" || typ === "input_image") {
const srcObj = item.source && typeof item.source === "object" ? item.source : {};
const b64 = String(item.image_base64 || item.data || srcObj.data || "").trim();
if (!b64) continue;
const mime = String(item.mime_type || item.mime || srcObj.media_type || "image/png").trim() || "image/png";
out.push({ type: "image", src: `data:${mime};base64,${b64.replace(/\s/g, "")}` });
continue;
}
if (typ === "image_url") {
const urlObj = item.image_url && typeof item.image_url === "object" ? item.image_url : {};
const url = String(item.url || item.image_url || urlObj.url || "").trim();
if (!url) continue;
out.push({ type: "image_url", src: url });
}
}
}
// Fallback: direct attachment-like payload shape.
const direct = _toObj(p.attachments);
const atts = Array.isArray(direct) ? direct : [];
for (const a of atts) {
if (!a || typeof a !== "object") continue;
const t = String(a.type || "").toLowerCase();
if (t === "image" || t === "input_image") {
const b64 = String(a.image_base64 || a.data || "").trim();
if (!b64) continue;
const mime = String(a.mime || a.mime_type || "image/png").trim() || "image/png";
out.push({ type: "image", src: `data:${mime};base64,${b64.replace(/\s/g, "")}` });
} else if (t === "image_url") {
const src = String(a.url || a.image_url || "").trim();
if (src) out.push({ type: "image_url", src });
}
}
// De-dup by src.
const uniq = [];
const seen = new Set();
for (const it of out) {
const k = String((it && it.src) || "");
if (!k || seen.has(k)) continue;
seen.add(k);
uniq.push(it);
}
return uniq;
}
function _sqlLimitSuffix(inputSql, executedSql) {
const a = String(inputSql || "").trim();
const b = String(executedSql || "").trim();
@ -1616,7 +1706,9 @@ async function appendMessageRow(messagesEl, m) {
const bubble = Array.isArray(m._items)
? _buildAggregatedAssistantBubble(ts, m._items)
: await buildMessageBubble(role, content, ts);
const att = await renderAttachmentsEl(m.attachments);
// For aggregated assistant bubbles, attachments should be rendered inline
// at tool_result positions, not appended at bubble tail.
const att = Array.isArray(m._items) ? null : await renderAttachmentsEl(m.attachments);
if (att) {
const innerBubble = bubble.querySelector(".chat-msg-col .chat-msg");
if (innerBubble) innerBubble.appendChild(att);
@ -2784,7 +2876,9 @@ async function renderChatUi() {
let currentStreamAbortController = null;
let currentWsTransport = null;
let currentAbortMeta = { sessionId: "", runId: "" };
const WS_CHAT_SEND_TIMEOUT_MS = 180000;
// Disable WS send inactivity timeout by default (0 = disabled).
// Some gateways can legitimately stream slower than 3 minutes.
const WS_CHAT_SEND_TIMEOUT_MS = 0;
const isAbortError = (err) => {
const name = String(err && err.name ? err.name : "");
const msg = String(err && err.message ? err.message : err || "");
@ -2822,6 +2916,9 @@ async function renderChatUi() {
let streamDisplayTarget = "";
let streamDisplayShown = "";
let streamTextBuffer = "";
const streamStitcher = createStreamStitcher();
let perCharNewlineMode = false;
let perCharNewlineScore = 0;
let toolSeq = 0;
let hasRealStreamText = false;
let sawWsChatEvent = false;
@ -2944,6 +3041,38 @@ async function renderChatUi() {
// Never inject separators here; otherwise typewriter offsets drift.
return decodeEscapedNewlines(chunks.join(""));
};
const _normalizeStreamTargetForRender = (raw) => {
let s = String(raw || "").replace(/\r/g, "");
if (!s) return "";
// Streaming-only guard: some gateways emit per-char newlines in delta chunks,
// producing vertical "one character per line" layout.
const lines = s.split("\n");
if (lines.length >= 4) {
let short = 0;
let nonEmpty = 0;
let totalLen = 0;
for (const ln of lines) {
const t = String(ln || "");
if (!t) continue;
nonEmpty += 1;
totalLen += t.length;
if (t.length <= 2) short += 1;
}
const avgLen = nonEmpty ? totalLen / nonEmpty : 0;
const shortRatio = nonEmpty ? short / nonEmpty : 0;
if (shortRatio >= 0.75 && avgLen <= 2.0) {
// Join characters back for live display.
s = lines.join("");
} else {
// Fallback: if newline density is abnormally high, collapse line breaks for live view.
const nl = lines.length - 1;
if (nl >= 8 && nl >= Math.floor(s.length * 0.2)) {
s = s.replace(/\n+/g, "");
}
}
}
return s;
};
const _renderStreamCompositeNow = () => {
const bubble = ensureStreamBubble();
const md = bubble.querySelector(".chat-msg__md");
@ -2977,6 +3106,17 @@ async function renderChatUi() {
}
}
let textIdx = 0;
let textBuf = "";
const flushTextBuf = () => {
if (!String(textBuf || "").trim()) {
textBuf = "";
return;
}
blocks.push(
`<div class="chat-msg__plain">${escapeHtml(decodeEscapedNewlines(textBuf)).replace(/\\n/g, "<br/>")}</div>`,
);
textBuf = "";
};
for (const seg of chatStreamSegments) {
if (!seg) continue;
if (seg.type === "text") {
@ -2986,18 +3126,23 @@ async function renderChatUi() {
const shown = full.slice(0, showLen);
textIdx += full.length;
if (!shown) continue;
blocks.push(`<div class="chat-msg__md">${renderMarkdownHtml(decodeEscapedNewlines(shown))}</div>`);
} else if (seg.type === "tool") {
if (!showToolOutput) continue;
textBuf += shown;
continue;
}
if (seg.type === "tool") {
const title = String(seg.title || "tool");
const body = String(seg.body || "");
const images = Array.isArray(seg.images) ? seg.images : [];
if (!showToolOutput && !images.length) continue;
flushTextBuf();
const audit = seg.sqlAudit && typeof seg.sqlAudit === "object" ? seg.sqlAudit : null;
if (audit) {
const guard = audit.guard && typeof audit.guard === "object" ? audit.guard : {};
const autoLimit = _sqlLimitSuffix(audit.inputSql, audit.executedSql);
const executedDiffHtml = _renderExecutedSqlWithAddedHighlight(audit.inputSql, audit.executedSql);
blocks.push(
`<details class="chat-msg__reasoning"><summary>${escapeHtml(title)} · SQL audit</summary>
if (showToolOutput) {
if (audit) {
const guard = audit.guard && typeof audit.guard === "object" ? audit.guard : {};
const autoLimit = _sqlLimitSuffix(audit.inputSql, audit.executedSql);
const executedDiffHtml = _renderExecutedSqlWithAddedHighlight(audit.inputSql, audit.executedSql);
blocks.push(
`<details class="chat-msg__reasoning"><summary>${escapeHtml(title)} · SQL audit</summary>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px;">
<div><div class="muted" style="margin-bottom:4px;">input SQL</div><pre class="chat-msg__reasoning-pre">${escapeHtml(String(audit.inputSql || ""))}</pre></div>
<div><div class="muted" style="margin-bottom:4px;">executed SQL (added highlighted)</div><pre class="chat-msg__reasoning-pre">${executedDiffHtml}</pre></div>
@ -3012,18 +3157,23 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
rows_returned=${escapeHtml(String(audit.rowsReturned != null ? audit.rowsReturned : ""))}
</div>
</details>`,
);
} else {
blocks.push(
`<details class="chat-msg__reasoning"><summary>${escapeHtml(title)}</summary><pre class="chat-msg__reasoning-pre">${escapeHtml(body)}</pre></details>`,
);
);
} else {
blocks.push(
`<details class="chat-msg__reasoning"><summary>${escapeHtml(title)}</summary><pre class="chat-msg__reasoning-pre">${escapeHtml(body)}</pre></details>`,
);
}
}
for (const im of images) {
const src = String((im && im.src) || "").trim();
if (!src) continue;
blocks.push(`<div class="chat-att-wrap"><img class="chat-att-img" src="${escapeHtml(src)}" alt="tool image" /></div>`);
}
}
}
const currentShown = streamDisplayShown.slice(textIdx);
if (String(currentShown || "").trim()) {
blocks.push(`<div class="chat-msg__md">${renderMarkdownHtml(decodeEscapedNewlines(currentShown))}</div>`);
}
if (String(currentShown || "").trim()) textBuf += currentShown;
flushTextBuf();
md.innerHTML = blocks.join("");
}
scrollMessagesToBottom();
@ -3066,14 +3216,33 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
}, 14);
});
};
const _sanitizeStreamDelta = (delta) => {
let s = streamStitcher.push(delta);
if (!s) return "";
// Detect and suppress "one char per line" noise early in the stream.
// Typical pattern: "好\n问\n题\n" or chunks like "好\n".
const nl = (s.match(/\n/g) || []).length;
const nonNlLen = s.replace(/\n/g, "").length;
if (nl > 0 && nonNlLen > 0 && nonNlLen <= 2 && nl >= nonNlLen) {
perCharNewlineScore += 1;
} else if (nonNlLen >= 4 && nl === 0) {
perCharNewlineScore = Math.max(0, perCharNewlineScore - 1);
}
if (!perCharNewlineMode && perCharNewlineScore >= 3) perCharNewlineMode = true;
if (perCharNewlineMode) {
// Drop all newlines in this mode; rely on natural wrapping.
s = s.replace(/\n+/g, "");
}
return s;
};
const appendStreamTextChunk = (chunk) => {
const piece = String(chunk || "");
const piece = _sanitizeStreamDelta(chunk);
if (!piece) return;
streamTextBuffer = `${streamTextBuffer}${piece}`;
chatStream = streamTextBuffer;
// Keep strict chronological order: append text deltas as independent segments.
chatStreamSegments.push({ type: "text", text: piece });
streamDisplayTarget = _composeStreamPlainText();
streamDisplayTarget = _normalizeStreamTargetForRender(_composeStreamPlainText());
_scheduleTypingTick();
};
const appendToolSegment = (payload) => {
@ -3084,7 +3253,8 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
const rawPayload = p.payload != null ? p.payload : p;
const body = formatToolPanelText(name, rawPayload, { streamMode: true });
const sqlAudit = extractSqlAuditPayload(rawPayload);
chatStreamSegments.push({ type: "tool", key, title: `${name} ${liveTag}`, body, sqlAudit });
const images = extractToolImageItems(rawPayload);
chatStreamSegments.push({ type: "tool", key, title: `${name} ${liveTag}`, body, sqlAudit, images });
};
const appendFinalAssistant = async (message, fallbackText) => {
const normalized = _normalizeAssistantMessage(message, { requireRole: false, requireContentArray: false });
@ -3198,7 +3368,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
_setStreamStatusBase("tool");
// Keep strict order: tool card appears exactly where tool event arrives.
appendToolSegment(payload);
streamDisplayTarget = _composeStreamPlainText();
streamDisplayTarget = _normalizeStreamTargetForRender(_composeStreamPlainText());
_scheduleTypingTick();
return;
}
@ -3233,7 +3403,18 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
sawWsTerminalEvent = true;
_stopDynamicStreamStatus();
streamDisplayShown = streamDisplayTarget;
const ok = await appendFinalAssistant(payload.message, chatStream || extractWsAssistantText(payload.message || {}));
const hasStreamToolImages = chatStreamSegments.some(
(seg) => seg && seg.type === "tool" && Array.isArray(seg.images) && seg.images.length > 0,
);
let ok = false;
if (hasStreamToolImages) {
// Keep stream bubble as final UI when it already contains image blocks.
renderStreamComposite();
_markStreamTerminal("end", t("chat.status.end"));
ok = true;
} else {
ok = await appendFinalAssistant(payload.message, chatStream || extractWsAssistantText(payload.message || {}));
}
if (!ok) _markStreamTerminal("end", t("chat.status.end"));
chatStream = "";
streamTextBuffer = "";
@ -3280,24 +3461,28 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
},
});
let wsWatchdog = 0;
const wsInactivityTimeoutPromise = new Promise((_, reject) => {
wsWatchdog = setInterval(() => {
if (Date.now() - wsLastActivityAt > WS_CHAT_SEND_TIMEOUT_MS) {
if (wsWatchdog) {
clearInterval(wsWatchdog);
wsWatchdog = 0;
}
reject(new Error(`ws_send_timeout:${WS_CHAT_SEND_TIMEOUT_MS}`));
}
}, 1000);
});
const wsSendObserved = wsSendPromise.finally(() => {
if (wsWatchdog) {
clearInterval(wsWatchdog);
wsWatchdog = 0;
}
});
doneMeta = await Promise.race([wsSendObserved, wsInactivityTimeoutPromise]);
if (Number(WS_CHAT_SEND_TIMEOUT_MS || 0) > 0) {
const wsInactivityTimeoutPromise = new Promise((_, reject) => {
wsWatchdog = setInterval(() => {
if (Date.now() - wsLastActivityAt > WS_CHAT_SEND_TIMEOUT_MS) {
if (wsWatchdog) {
clearInterval(wsWatchdog);
wsWatchdog = 0;
}
reject(new Error(`ws_send_timeout:${WS_CHAT_SEND_TIMEOUT_MS}`));
}
}, 1000);
});
doneMeta = await Promise.race([wsSendObserved, wsInactivityTimeoutPromise]);
} else {
doneMeta = await wsSendObserved;
}
if (doneMeta && typeof doneMeta === "object") doneMeta.__transport = "ws";
if (doneMeta && typeof doneMeta === "object") {
const startToRunning =

View file

@ -52,6 +52,95 @@ _VIDEO_QUERY_TOOL_NAMES = {
}
def _attachments_from_tool_result(result: Any) -> list[dict[str, Any]]:
"""Extract renderable attachments from tool results for durable chat history."""
if not isinstance(result, dict):
return []
out: list[dict[str, Any]] = []
aid = str(result.get("attachment_id") or "").strip()
if aid:
out.append(
{
"type": "image_ref",
"attachment_id": aid,
"name": str(result.get("name") or "generated-image"),
"mime": str(result.get("mime") or "image/png"),
"bytes": result.get("bytes"),
"width": result.get("width"),
"height": result.get("height"),
}
)
refs = result.get("attachments")
if isinstance(refs, list):
for r in refs:
if not isinstance(r, dict):
continue
p_uri = str(r.get("pointer_uri") or "").strip()
if p_uri:
out.append(
{
"type": "relay_pointer",
"pointer_uri": p_uri,
"rel_path": str(r.get("rel_path") or ""),
"mime": str(r.get("mime_type") or r.get("mime") or ""),
"bytes": r.get("bytes"),
"sha256": str(r.get("sha256") or ""),
"name": str(r.get("name") or ""),
}
)
continue
r_aid = str(r.get("attachment_id") or "").strip()
if r_aid:
out.append(
{
"type": "image_ref",
"attachment_id": r_aid,
"name": str(r.get("name") or "generated-image"),
"mime": str(r.get("mime") or "image/png"),
"bytes": r.get("bytes"),
"width": r.get("width"),
"height": r.get("height"),
}
)
inner = result.get("result")
if isinstance(inner, dict):
content = inner.get("content")
if isinstance(content, list):
for item in content:
if not isinstance(item, dict):
continue
typ = str(item.get("type") or "").strip().lower()
if typ in {"image", "input_image"}:
b64 = item.get("image_base64") or item.get("data")
if isinstance(b64, str) and b64.strip():
out.append(
{
"type": "image",
"data": b64.strip(),
"mime": str(item.get("mime_type") or item.get("mime") or "image/png"),
"name": str(item.get("name") or "tool-image"),
}
)
elif typ == "image_url":
src = str(item.get("url") or item.get("image_url") or "").strip()
if src:
out.append({"type": "image_url", "url": src, "name": str(item.get("name") or "tool-image")})
uniq: list[dict[str, Any]] = []
seen: set[str] = set()
for a in out:
k = str(
a.get("attachment_id")
or a.get("pointer_uri")
or a.get("url")
or (f"b64:{a.get('mime')}:{len(str(a.get('data') or ''))}" if a.get("data") else "")
).strip()
if not k or k in seen:
continue
seen.add(k)
uniq.append(a)
return uniq
def _message_has_tabular_ref(raw_attachments: Any) -> bool:
if raw_attachments is None:
return False
@ -781,6 +870,7 @@ class ToolExecutor:
role="tool",
content=tool_content,
tool_calls={"tool_call_id": tc.id, "name": tc.name, "assistant_message_id": assistant_msg_id},
attachments=_attachments_from_tool_result(result) or None,
turn_uuid=ctx.turn_uuid,
event_type="tool_result",
event_payload={"tool_name": tc.name, "observed_rows": int(observed_rows_this_call)},

View file

@ -392,3 +392,43 @@ def test_video_tools_allowed_with_video_ref(tmp_path: Path) -> None:
assert bool(ok_res.get("ok"))
assert calls["n"] == 1
def test_tool_result_image_payload_persisted_as_attachments(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "g12.sqlite"))
sess = store.create_session("t")
def _handler(_args):
return {
"ok": True,
"result": {
"content": [
{"type": "image", "data": "YWJj", "mime_type": "image/png"},
{"type": "image_url", "url": "https://example.com/a.png"},
]
},
}
reg = ToolRegistry(
[
ToolSpec(
name="make_image",
description="make image",
parameters={"type": "object", "properties": {}},
handler=_handler,
read_only=True,
)
]
)
tool_uses = [LLMToolCall(id="c1", name="make_image", arguments={})]
ToolExecutor().execute_tool_uses(
ctx=ToolExecutionContext(store=store, tools=reg, session_id=sess.id),
assistant_msg_id=1,
tool_uses=tool_uses,
)
rows = store.get_messages(session_id=sess.id, limit=10)
tool_rows = [m for m in rows if str(getattr(m, "role", "") or "") == "tool"]
assert len(tool_rows) == 1
attachments = json.loads(str(getattr(tool_rows[0], "attachments", "") or "[]"))
assert any(str(a.get("type") or "") == "image" and str(a.get("data") or "") == "YWJj" for a in attachments)
assert any(str(a.get("type") or "") == "image_url" and str(a.get("url") or "").endswith("/a.png") for a in attachments)