diff --git a/interfaces/admin/routes.py b/interfaces/admin/routes.py index b918a532..2cc073c8 100644 --- a/interfaces/admin/routes.py +++ b/interfaces/admin/routes.py @@ -53,7 +53,14 @@ from oclaw.platform.llm.chat_models import LLMToolCall from oclaw.runtime.agent_core_attempt import ALL_ATTEMPT_ERROR_CODES from oclaw.runtime.agent_core_run import DEFAULT_RETRYABLE_ERROR_CODES, resolve_retryable_error_codes from oclaw.runtime.prompt_prebuild import run_runtime_prewarm, runtime_prewarm_prompts_snapshot, runtime_prewarm_status -from oclaw.runtime.workspaces.experts import create_expert, delete_expert, list_experts, normalize_expert_id, update_expert_files +from oclaw.runtime.workspaces.experts import ( + create_expert, + delete_expert, + list_experts, + normalize_expert_id, + update_expert_files, + update_expert_meta, +) _WECOM_CLEAR_KEYS = [ "wecom_mode", @@ -262,6 +269,9 @@ def build_admin_router() -> APIRouter: "id": str(row.get("id") or ""), "builtin": bool(row.get("builtin")), "has_required_soul": bool(row.get("has_required_soul")), + "display_name_en": str(row.get("display_name_en") or ""), + "display_name_zh": str(row.get("display_name_zh") or ""), + "role": str(row.get("role") or "expert"), "path": str(row.get("path") or ""), "files": { "SOUL.md": str(obj.get("SOUL.md") or ""), @@ -2537,10 +2547,19 @@ def build_admin_router() -> APIRouter: ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") expert_id = normalize_expert_id(payload.get("id")) + display_name_en = str(payload.get("display_name_en") or "").strip() + display_name_zh = str(payload.get("display_name_zh") or "").strip() + role = str(payload.get("role") or "").strip().lower() files_raw = payload.get("files") if isinstance(payload.get("files"), dict) else {} try: files = _sanitize_expert_files_payload(files_raw) - created = create_expert(expert_id=expert_id, files=files) + created = create_expert( + expert_id=expert_id, + files=files, + display_name_en=display_name_en, + display_name_zh=display_name_zh, + role=role or None, + ) except ValueError as exc: return {"ok": False, "error": str(exc)} store.add_admin_audit_log( @@ -2567,9 +2586,18 @@ def build_admin_router() -> APIRouter: _require_permission(ctx, "admin:tenant:write") files_raw = payload.get("files") if isinstance(payload.get("files"), dict) else {} eid = normalize_expert_id(expert_id) + display_name_en = str(payload.get("display_name_en") or "").strip() + display_name_zh = str(payload.get("display_name_zh") or "").strip() + role = str(payload.get("role") or "").strip().lower() try: files = _sanitize_expert_files_payload(files_raw) update_expert_files(expert_id=eid, files=files) + update_expert_meta( + expert_id=eid, + display_name_en=display_name_en, + display_name_zh=display_name_zh, + role=role or None, + ) except ValueError as exc: return {"ok": False, "error": str(exc)} store.add_admin_audit_log( diff --git a/interfaces/admin/static/app.js b/interfaces/admin/static/app.js index de8e07f1..2e6be147 100644 --- a/interfaces/admin/static/app.js +++ b/interfaces/admin/static/app.js @@ -3284,8 +3284,16 @@ async function renderModels() { const expertsStatus = el("div", { class: "muted", text: "" }); const expertsSelect = el("select", { class: "input" }); const expertNewId = el("input", { class: "input", placeholder: "new expert id, e.g. qa" }); - const expertSoul = el("textarea", { class: "input", rows: "5", placeholder: "SOUL.md (required)" }); - const expertRoleSystem = el("textarea", { class: "input", rows: "5", placeholder: "ROLE_SYSTEM.md (required for runtime rules)" }); + const expertNewNameEn = el("input", { class: "input", placeholder: "English name (required)" }); + const expertNewNameZh = el("input", { class: "input", placeholder: "中文名(可选)" }); + const expertRoleSel = el("select", { class: "input" }, [ + el("option", { value: "expert", text: "expert" }), + el("option", { value: "system", text: "system" }), + ]); + const expertNameEn = el("input", { class: "input", placeholder: "English name (required)" }); + const expertNameZh = el("input", { class: "input", placeholder: "中文名(可选)" }); + const expertSoul = el("textarea", { class: "input", rows: "5", placeholder: "SOUL.md (optional)" }); + const expertRoleSystem = el("textarea", { class: "input", rows: "5", placeholder: "ROLE_SYSTEM.md (optional)" }); async function downloadEvalExport(format) { const fmt = format === "json" ? "json" : "csv"; @@ -3478,8 +3486,14 @@ async function renderModels() { function fillExpertFields(item) { const files = (item && item.files && typeof item.files === "object") ? item.files : {}; + expertNameEn.value = String((item && item.display_name_en) || ""); + expertNameZh.value = String((item && item.display_name_zh) || ""); + expertRoleSel.value = String((item && item.role) || "expert"); expertSoul.value = String(files["SOUL.md"] || ""); expertRoleSystem.value = String(files["ROLE_SYSTEM.md"] || ""); + const isSystem = !!(item && (item.builtin || String(item.role || "") === "system")); + expertRoleSel.disabled = isSystem; + btnExpertDelete.disabled = isSystem; } function paintExperts() { @@ -3489,8 +3503,10 @@ async function renderModels() { items.forEach((x) => { const id = String(x.id || ""); if (!id) return; - const tail = x.builtin ? " (builtin)" : ""; - expertsSelect.appendChild(el("option", { value: id, text: `${id}${tail}` })); + const isSystem = !!(x && (x.builtin || String(x.role || "") === "system")); + const tail = isSystem ? " (system)" : ""; + const name = String(x.display_name_en || "").trim() || id; + expertsSelect.appendChild(el("option", { value: id, text: `${name} [${id}]${tail}` })); }); if (items.some((x) => String(x.id || "") === prev)) expertsSelect.value = prev; else if (items.length) expertsSelect.value = String(items[0].id || ""); @@ -3761,13 +3777,20 @@ async function renderModels() { expertsStatus.textContent = "expert id required"; return; } - if (!String(expertSoul.value || "").trim()) { - expertsStatus.textContent = "SOUL.md is required"; + if (!String(expertNewNameEn.value || "").trim()) { + expertsStatus.textContent = "English name is required"; + return; + } + if (!String(expertSoul.value || "").trim() && !String(expertRoleSystem.value || "").trim()) { + expertsStatus.textContent = "SOUL.md or ROLE_SYSTEM.md is required"; return; } try { const res = await apiPost("/admin/api/experts", { id: eid, + display_name_en: String(expertNewNameEn.value || "").trim(), + display_name_zh: String(expertNewNameZh.value || "").trim(), + role: "expert", files: { "SOUL.md": expertSoul.value, "ROLE_SYSTEM.md": expertRoleSystem.value, @@ -3775,6 +3798,8 @@ async function renderModels() { }); if (!res || res.ok !== true) throw new Error(String((res && res.error) || "create_failed")); expertNewId.value = ""; + expertNewNameEn.value = ""; + expertNewNameZh.value = ""; expertsStatus.textContent = "expert created"; markPrewarmReminder("expert_created"); await refresh(); @@ -3793,13 +3818,20 @@ async function renderModels() { if (!hasPermission("admin:tenant:write")) return; const item = currentExpertItem(); if (!item) return; - if (!String(expertSoul.value || "").trim()) { - expertsStatus.textContent = "SOUL.md is required"; + if (!String(expertNameEn.value || "").trim()) { + expertsStatus.textContent = "English name is required"; + return; + } + if (!String(expertSoul.value || "").trim() && !String(expertRoleSystem.value || "").trim()) { + expertsStatus.textContent = "SOUL.md or ROLE_SYSTEM.md is required"; return; } try { const eid = String(item.id || ""); const res = await apiRequest("PATCH", "/admin/api/experts/" + encodeURIComponent(eid), { + display_name_en: String(expertNameEn.value || "").trim(), + display_name_zh: String(expertNameZh.value || "").trim(), + role: String(expertRoleSel.value || "expert"), files: { "SOUL.md": expertSoul.value, "ROLE_SYSTEM.md": expertRoleSystem.value, @@ -3824,8 +3856,8 @@ async function renderModels() { if (!hasPermission("admin:tenant:write")) return; const item = currentExpertItem(); if (!item) return; - if (item.builtin) { - expertsStatus.textContent = "builtin expert cannot be deleted"; + if (item.builtin || String(item.role || "") === "system") { + expertsStatus.textContent = "system expert cannot be deleted"; return; } const eid = String(item.id || ""); @@ -3905,7 +3937,9 @@ async function renderModels() { el("div", { class: "row" }, [el("label", { text: t("models.pickModel") }), activeSelect]), ], { id: "models-overview" }), renderSectionCard(t("models.sectionNew"), "", [ - el("div", { class: "row" }, [newName, newMode, btnCreate]), + el("div", { class: "row" }, [el("label", { text: t("models.profileName") }), newName]), + el("div", { class: "row" }, [el("label", { text: t("models.mode") }), newMode]), + el("div", { class: "row" }, [btnCreate]), ]), ]), renderSectionCard(t("models.sectionBindings"), t("models.agentBindingHelp"), [ @@ -3925,11 +3959,19 @@ async function renderModels() { ollamaHint, el("div", { class: "row" }, [el("label", { text: t("models.apiKey") }), keyInp]), el("div", { class: "row" }, [el("label", { text: t("models.rememberKey") }), rememberCb]), - el("div", { class: "row" }, [btnSave, btnDelete]), + el("div", { class: "row" }, [btnSave]), + el("div", { class: "row" }, [btnDelete]), ], { id: "models-api" }), - renderSectionCard("Experts (runtime/workspaces)", "Only SOUL.md and ROLE_SYSTEM.md are maintained in Admin. SOUL.md is required.", [ - el("div", { class: "row" }, [el("label", { text: "Existing" }), expertsSelect, btnExpertDelete]), - el("div", { class: "row" }, [el("label", { text: "Create" }), expertNewId, btnExpertCreate]), + renderSectionCard("Experts (runtime/workspaces)", "English name required; Chinese optional. SOUL.md or ROLE_SYSTEM.md is required.", [ + el("div", { class: "row" }, [el("label", { text: "Existing" }), expertsSelect]), + el("div", { class: "row" }, [btnExpertDelete]), + el("div", { class: "row" }, [el("label", { text: "Create ID" }), expertNewId]), + el("div", { class: "row" }, [el("label", { text: "Create Name(en)" }), expertNewNameEn]), + el("div", { class: "row" }, [el("label", { text: "Create Name(zh)" }), expertNewNameZh]), + el("div", { class: "row" }, [btnExpertCreate]), + el("div", { class: "row" }, [el("label", { text: "Name(en)" }), expertNameEn]), + el("div", { class: "row" }, [el("label", { text: "Name(zh)" }), expertNameZh]), + el("div", { class: "row" }, [el("label", { text: "Role" }), expertRoleSel]), el("div", { class: "row" }, [el("label", { text: "SOUL.md" }), expertSoul]), el("div", { class: "row" }, [el("label", { text: "ROLE_SYSTEM.md" }), expertRoleSystem]), el("div", { class: "row" }, [btnExpertSave]), @@ -5396,11 +5438,11 @@ async function renderPlugins() { el("label", { text: "Tool log max chars (20000-2000000)" }), toolLogMaxCharsInput, ]), - el("label", { class: "kv" }, [enableMcpToolsCb, document.createTextNode(" Enable MCP tools")]), - el("label", { class: "kv" }, [enablePluginToolsCb, document.createTextNode(" Enable plugin tools")]), - el("label", { class: "kv" }, [enableRunCommandCb, document.createTextNode(" Enable run_command (high-risk)")]), - el("label", { class: "kv" }, [toolContextTruncateCb, document.createTextNode(" Compress tool result in agent context (50 chars + hint)")]), - el("label", { class: "kv" }, [chatShowTtftDebugCb, document.createTextNode(" Show TTFT debug timings in chat status")]), + el("div", { class: "row" }, [el("label", { class: "kv" }, [enableMcpToolsCb, document.createTextNode(" Enable MCP tools")])]), + el("div", { class: "row" }, [el("label", { class: "kv" }, [enablePluginToolsCb, document.createTextNode(" Enable plugin tools")])]), + el("div", { class: "row" }, [el("label", { class: "kv" }, [enableRunCommandCb, document.createTextNode(" Enable run_command (high-risk)")])]), + el("div", { class: "row" }, [el("label", { class: "kv" }, [toolContextTruncateCb, document.createTextNode(" Compress tool result in agent context (50 chars + hint)")])]), + el("div", { class: "row" }, [el("label", { class: "kv" }, [chatShowTtftDebugCb, document.createTextNode(" Show TTFT debug timings in chat status")])]), el("div", { class: "row" }, [ el("label", { text: "Tool message max chars to LLM (0=unlimited, 4096-500000 recommended)" }), toolLlmMessageMaxCharsInput, @@ -5418,7 +5460,7 @@ async function renderPlugins() { el("label", { text: "oclaw retryable error codes (comma separated)" }), oclawRetryableErrorCodesInput, ]), - el("label", { class: "kv" }, [oclawRetryCodesStrictModeCb, document.createTextNode(" Strict mode: reject unknown retry codes")]), + el("div", { class: "row" }, [el("label", { class: "kv" }, [oclawRetryCodesStrictModeCb, document.createTextNode(" Strict mode: reject unknown retry codes")])]), el("div", { class: "row" }, [ el("label", { text: "WeCom longconn workers (1-8)" }), wecomLongconnWorkersInput, diff --git a/interfaces/admin/static/chat.js b/interfaces/admin/static/chat.js index ab3360bb..d49c6528 100644 --- a/interfaces/admin/static/chat.js +++ b/interfaces/admin/static/chat.js @@ -1880,75 +1880,83 @@ async function renderChatUi() { disabled: "disabled", }); const composerShell = el("div", { class: "chat-composer-shell" }); - const toolToggleBtn = el("button", { - type: "button", - class: "btn", - style: "padding:4px 8px;min-height:auto;font-size:12px;", - }); - const specialistSelect = el("select", { - class: "input", - style: "min-width:120px;max-width:160px;padding:6px 8px;", - }); - let specialistFlags = { - generalist: true, - ops: true, - image: true, - memory: true, - }; + const toolToggleCb = el("input", { type: "checkbox", class: "switch-input" }); + const toolToggleWrap = el("label", { class: "switch-wrap", title: t("chat.tools.visible") }, [ + toolToggleCb, + el("span", { class: "switch-slider" }), + el("span", { class: "muted", text: t("chat.tools") }), + ]); + const MAIN_MODE_VALUE = "comprehensive"; + const EXCLUDED_SPECIALISTS = new Set(["main", "memory", "manager_self", "pycache", "__pycache__"]); + let specialistCatalog = []; const modeSelect = el("select", { class: "input", style: "min-width:120px;max-width:160px;padding:6px 8px;", }); - modeSelect.appendChild(el("option", { value: "comprehensive", text: t("chat.modeComprehensive") })); - modeSelect.appendChild(el("option", { value: "expert", text: t("chat.modeExpert") })); - const _im = String(localStorage.getItem(CHAT_INTERACTION_MODE_KEY) || "expert").toLowerCase(); - modeSelect.value = ["comprehensive", "expert"].includes(_im) ? _im : "expert"; + const modeOptionLabel = (v) => { + const key = String(v || "").trim().toLowerCase(); + if (key === MAIN_MODE_VALUE) return t("chat.modeComprehensive"); + if (key === "generalist") return t("chat.specialistGeneralist"); + const row = specialistCatalog.find((x) => String(x.id || "").toLowerCase() === key); + if (!row) return key || t("chat.modeComprehensive"); + const zh = String(row.display_name_zh || "").trim(); + const en = String(row.display_name_en || "").trim(); + return currentLang === "zh" ? (zh || en || key) : (en || zh || key); + }; + const isSelectableSpecialist = (v) => { + const key = String(v || "").trim().toLowerCase(); + if (!key || key === MAIN_MODE_VALUE) return false; + if (key === "generalist") return true; + return specialistCatalog.some((x) => String(x.id || "").toLowerCase() === key); + }; + const persistModeSelection = () => { + const v = String(modeSelect.value || MAIN_MODE_VALUE).toLowerCase(); + localStorage.setItem(CHAT_INTERACTION_MODE_KEY, v); + if (v !== MAIN_MODE_VALUE) localStorage.setItem(CHAT_SPECIALIST_PREF_KEY, v); + }; + const applyModeOptions = () => { + const prev = String(modeSelect.value || localStorage.getItem(CHAT_INTERACTION_MODE_KEY) || MAIN_MODE_VALUE).toLowerCase(); + modeSelect.innerHTML = ""; + modeSelect.appendChild(el("option", { value: MAIN_MODE_VALUE, text: modeOptionLabel(MAIN_MODE_VALUE) })); + modeSelect.appendChild(el("option", { value: "generalist", text: modeOptionLabel("generalist") })); + specialistCatalog.forEach((x) => { + const sid = String(x.id || "").toLowerCase(); + if (!sid || sid === "generalist") return; + modeSelect.appendChild(el("option", { value: sid, text: modeOptionLabel(sid) })); + }); + if (Array.from(modeSelect.options).some((o) => String(o.value || "") === prev)) modeSelect.value = prev; + else modeSelect.value = MAIN_MODE_VALUE; + }; + const _im = String(localStorage.getItem(CHAT_INTERACTION_MODE_KEY) || MAIN_MODE_VALUE).toLowerCase(); + modeSelect.value = _im; const _mm = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase(); localStorage.setItem(CHAT_MEMORY_MODE_KEY, _mm === "store_only" ? "store_only" : "default"); modeSelect.addEventListener("change", () => { - localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "expert")); + persistModeSelection(); saveSessionModePreference(); }); - const applySpecialistOptions = () => { - specialistSelect.innerHTML = ""; - const opts = [ - { value: "generalist", text: t("chat.specialistGeneralist"), enabled: true }, - { value: "ops", text: t("chat.specialistOps"), enabled: !!specialistFlags.ops }, - { value: "image", text: t("chat.specialistImage"), enabled: !!specialistFlags.image }, - { value: "memory", text: t("chat.specialistMemory"), enabled: !!specialistFlags.memory }, - ]; - for (const op of opts) { - if (!op.enabled) continue; - specialistSelect.appendChild(el("option", { value: op.value, text: op.text })); - } - if (!specialistSelect.value) { - specialistSelect.value = "generalist"; - } - if (!Array.from(specialistSelect.options).some((o) => String(o.value || "") === String(specialistSelect.value || ""))) { - specialistSelect.value = "generalist"; - } - }; - const loadSpecialistFlags = async () => { + const loadSpecialistCatalog = async () => { try { - const r = await apiGet("/admin/api/chat/settings/specialist-flags"); - if (r && r.ok && r.flags && typeof r.flags === "object") { - specialistFlags = { - ...specialistFlags, - ...r.flags, - }; - } + const r = await apiGet("/admin/api/experts"); + const items = Array.isArray(r && r.items) ? r.items : []; + specialistCatalog = items + .filter((x) => { + const id = String((x && x.id) || "").trim().toLowerCase(); + if (!id || EXCLUDED_SPECIALISTS.has(id)) return false; + const role = String((x && x.role) || "").trim().toLowerCase(); + return role === "expert" || id === "generalist"; + }) + .map((x) => ({ + id: String(x.id || "").trim().toLowerCase(), + display_name_en: String(x.display_name_en || "").trim(), + display_name_zh: String(x.display_name_zh || "").trim(), + })); } catch (_) { - // non-admin users or unavailable endpoint: keep defaults + specialistCatalog = []; } - applySpecialistOptions(); + applyModeOptions(); }; - const _sp = String(localStorage.getItem(CHAT_SPECIALIST_PREF_KEY) || "generalist").toLowerCase(); - specialistSelect.value = ["generalist", "ops", "image", "memory"].includes(_sp) ? _sp : "generalist"; - await loadSpecialistFlags(); - specialistSelect.addEventListener("change", () => { - localStorage.setItem(CHAT_SPECIALIST_PREF_KEY, String(specialistSelect.value || "generalist")); - saveSessionModePreference(); - }); + await loadSpecialistCatalog(); const activeModelText = el("span", { class: "muted", text: `${t("chat.activeModelLabel")}: -` }); const refreshActiveModelText = async () => { try { @@ -1969,54 +1977,33 @@ async function renderChatUi() { const m = String((resp && resp.interaction_mode) || "").toLowerCase(); const s = String((resp && resp.specialist) || "").toLowerCase(); const mm = String((resp && resp.memory_mode) || "").toLowerCase(); - if (["comprehensive", "expert"].includes(m)) modeSelect.value = m; - if (["generalist", "ops", "image", "memory"].includes(s)) specialistSelect.value = s; + if (m === "comprehensive") modeSelect.value = MAIN_MODE_VALUE; + else if (m === "expert" && isSelectableSpecialist(s)) modeSelect.value = s; if (["default", "store_only"].includes(mm)) localStorage.setItem(CHAT_MEMORY_MODE_KEY, mm); - if (String(modeSelect.value || "").toLowerCase() !== "expert") { - specialistSelect.value = "generalist"; - } - localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "expert")); - localStorage.setItem(CHAT_SPECIALIST_PREF_KEY, String(specialistSelect.value || "generalist")); + persistModeSelection(); const mml = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase(); localStorage.setItem(CHAT_MEMORY_MODE_KEY, mml === "store_only" ? "store_only" : "default"); - syncModeSpecialistUi(); } catch (_) {} }; const saveSessionModePreference = async () => { if (!activeId) return; try { + const modeVal = String(modeSelect.value || MAIN_MODE_VALUE).toLowerCase(); + const isMain = modeVal === MAIN_MODE_VALUE; await apiPost(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/mode`, { - interaction_mode: String(modeSelect.value || "expert"), - specialist: - String(modeSelect.value || "expert").toLowerCase() === "expert" - ? String(specialistSelect.value || "generalist") - : "generalist", + interaction_mode: isMain ? "comprehensive" : "expert", + specialist: isMain ? "generalist" : modeVal, memory_mode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"), }); } catch (_) {} }; await refreshActiveModelText(); - const specialistLabelEl = el("span", { class: "muted", text: t("chat.specialistLabel") }); const composerMetaBar = el("div", { class: "row", style: "gap:8px;padding:2px 4px 6px;align-items:center;" }, [ el("span", { class: "muted", text: t("chat.modeLabel") }), modeSelect, - specialistLabelEl, - specialistSelect, - toolToggleBtn, + toolToggleWrap, ]); - const syncModeSpecialistUi = () => { - const m = String(modeSelect.value || "expert").toLowerCase(); - const showSpecialist = m === "expert"; - specialistLabelEl.style.display = showSpecialist ? "" : "none"; - specialistSelect.style.display = showSpecialist ? "" : "none"; - specialistSelect.disabled = !showSpecialist; - }; - modeSelect.addEventListener("change", () => { - syncModeSpecialistUi(); - }); - syncModeSpecialistUi(); - const fitComposerTextarea = () => { textarea.style.height = "auto"; const h = Math.min(Math.max(textarea.scrollHeight, 44), 200); @@ -2187,11 +2174,11 @@ async function renderChatUi() { attachBtn.addEventListener("click", () => fileInput.click()); const syncToolToggleBtn = () => { - toolToggleBtn.textContent = `${t("chat.tools")}: ${showToolOutput ? t("chat.tools.on") : t("chat.tools.off")}`; - toolToggleBtn.title = showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden"); + toolToggleCb.checked = !!showToolOutput; + toolToggleWrap.title = showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden"); }; - toolToggleBtn.addEventListener("click", () => { - showToolOutput = !showToolOutput; + toolToggleCb.addEventListener("change", () => { + showToolOutput = !!toolToggleCb.checked; adminChatShowToolOutput = showToolOutput; localStorage.setItem(CHAT_REASONING_TOGGLE_KEY, showToolOutput ? "1" : "0"); syncToolToggleBtn(); @@ -2771,6 +2758,7 @@ async function renderChatUi() { let streamStatusBase = "oclaw"; let streamDisplayTarget = ""; let streamDisplayShown = ""; + let streamTextBuffer = ""; let toolSeq = 0; let hasRealStreamText = false; let sawWsChatEvent = false; @@ -2929,7 +2917,7 @@ async function renderChatUi() { const shown = full.slice(0, showLen); textIdx += full.length; if (!shown) continue; - blocks.push(`