恢复并增强 Admin/Chat 专家配置与流式渲染体验,统一开关与布局规范。

将专家元数据接入后端和管理页面,并恢复合并模式选择与稳定流式增量显示,减少末尾闪烁与配置混乱。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 21:16:14 +08:00
parent 8989dfea2f
commit ca354c0a71
5 changed files with 372 additions and 143 deletions

View file

@ -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(

View file

@ -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,

View file

@ -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(`<div class="chat-msg__plain">${escapeHtml(decodeEscapedNewlines(shown))}</div>`);
blocks.push(`<div class="chat-msg__md">${renderMarkdownHtml(decodeEscapedNewlines(shown))}</div>`);
} else if (seg.type === "tool") {
if (!showToolOutput) continue;
const title = String(seg.title || "tool");
@ -2965,7 +2953,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
}
const currentShown = streamDisplayShown.slice(textIdx);
if (String(currentShown || "").trim()) {
blocks.push(`<div class="chat-msg__plain">${escapeHtml(decodeEscapedNewlines(currentShown))}</div>`);
blocks.push(`<div class="chat-msg__md">${renderMarkdownHtml(decodeEscapedNewlines(currentShown))}</div>`);
}
md.innerHTML = blocks.join("");
}
@ -3009,14 +2997,13 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
}, 14);
});
};
const setStreamText = (text) => {
chatStream = String(text || "");
// Keep current text as a dedicated trailing segment for strict in-order rendering.
if (!chatStreamSegments.length || chatStreamSegments[chatStreamSegments.length - 1].type !== "text") {
chatStreamSegments.push({ type: "text", text: chatStream });
} else {
chatStreamSegments[chatStreamSegments.length - 1].text = chatStream;
}
const appendStreamTextChunk = (chunk) => {
const piece = String(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();
_scheduleTypingTick();
};
@ -3081,12 +3068,13 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
sessionId: activeId,
text: userText,
attachments: attachmentPayload,
interactionMode: String(modeSelect.value || "expert"),
interactionMode: String(modeSelect.value || MAIN_MODE_VALUE).toLowerCase() === MAIN_MODE_VALUE ? "comprehensive" : "expert",
idempotencyKey: String(turnId || ""),
specialist:
String(modeSelect.value || "expert").toLowerCase() === "expert"
? String(specialistSelect.value || "generalist")
: "generalist",
specialist: String(modeSelect.value || MAIN_MODE_VALUE).toLowerCase() === MAIN_MODE_VALUE
? "generalist"
: (isSelectableSpecialist(String(modeSelect.value || "").toLowerCase())
? String(modeSelect.value || "generalist").toLowerCase()
: "generalist"),
memoryMode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"),
signal: abortController.signal,
onEvent: async (frame) => {
@ -3151,15 +3139,18 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
const next = extractWsAssistantText(payload.message);
const d = String(payload.delta || "");
if (d && !_isSilentReplyStream(d)) {
const base = hasRealStreamText ? String(chatStream || "") : "";
chatStream = `${base}${decodeEscapedNewlines(d)}`;
appendStreamTextChunk(decodeEscapedNewlines(d));
hasRealStreamText = true;
setStreamText(chatStream);
} else if (typeof next === "string" && next && !_isSilentReplyStream(next)) {
// Fallback when gateway doesn't include delta.
chatStream = next;
// Prefer monotonic append from snapshot deltas.
const snapshot = decodeEscapedNewlines(next);
if (snapshot.startsWith(streamTextBuffer)) {
appendStreamTextChunk(snapshot.slice(streamTextBuffer.length));
} else if (!streamTextBuffer.startsWith(snapshot)) {
appendStreamTextChunk(snapshot);
}
hasRealStreamText = true;
setStreamText(chatStream);
}
return;
}
@ -3170,13 +3161,16 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
const ok = await appendFinalAssistant(payload.message, chatStream || extractWsAssistantText(payload.message || {}));
if (!ok) _markStreamTerminal("end", t("chat.status.end"));
chatStream = "";
streamTextBuffer = "";
chatRunId = null;
const streamedEnough = hasRealStreamText || chatStreamSegments.length > 0 || sawWsChatEvent;
chatStreamSegments = [];
// Final assistant body may arrive before reasoning/tool rows are fully persisted.
// Reload once shortly after final to avoid "reasoning disappears until session switch".
setTimeout(() => {
loadMessagesForActive().catch(() => {});
}, 350);
// Avoid end-of-turn flash: only reload history when stream had no usable content.
if (!streamedEnough) {
setTimeout(() => {
loadMessagesForActive().catch(() => {});
}, 350);
}
if (!ok) statusBar.textContent = "";
return;
}
@ -3187,6 +3181,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
const ok = await appendFinalAssistant(payload.message, chatStream);
if (!ok) _markStreamTerminal("error", "aborted");
chatStream = "";
streamTextBuffer = "";
chatRunId = null;
chatStreamSegments = [];
return;
@ -3196,6 +3191,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
_stopDynamicStreamStatus();
_markStreamTerminal("error", String(payload.errorMessage || "chat error"));
chatStream = "";
streamTextBuffer = "";
chatRunId = null;
chatStreamSegments = [];
statusBar.textContent = `${t("chat.error")}: ${String(payload.errorMessage || "chat error")}`;

View file

@ -174,6 +174,21 @@ body {
.card { background: rgba(15,23,42,0.9); border: 1px solid rgba(148,163,184,0.18); border-radius: 14px; padding: 14px; margin-bottom: 12px; }
.card__title { font-weight: 650; margin-bottom: 10px; }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.section-card .row {
width: 100%;
flex-wrap: nowrap;
align-items: center;
}
.section-card .row > label:first-child {
min-width: 170px;
}
.section-card .row > .input,
.section-card .row > textarea.input,
.section-card .row > select.input {
width: 100%;
max-width: 100%;
min-width: 0;
}
.chat-login-fields {
display: flex;
flex-direction: column;
@ -213,7 +228,60 @@ body {
flex: 0 0 auto;
}
.kv { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; background: rgba(148,163,184,0.08); border: 1px solid rgba(148,163,184,0.14); padding: 6px 8px; border-radius: 10px; }
.section-card .kv {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
box-sizing: border-box;
}
.input { background: rgba(2,6,23,0.65); border: 1px solid rgba(148,163,184,0.25); color: #e2e8f0; border-radius: 10px; padding: 8px 10px; min-width: 240px; }
/* Unified switch style for admin/chat checkboxes */
input[type="checkbox"],
.switch-input {
appearance: none;
-webkit-appearance: none;
width: 40px;
height: 22px;
border-radius: 999px;
border: 1px solid rgba(148,163,184,0.35);
background: rgba(15,23,42,0.75);
position: relative;
cursor: pointer;
transition: background .18s ease, border-color .18s ease;
}
input[type="checkbox"]::before,
.switch-input::before {
content: "";
position: absolute;
width: 16px;
height: 16px;
left: 2px;
top: 2px;
border-radius: 50%;
background: #cbd5e1;
transition: transform .18s ease;
}
input[type="checkbox"]:checked,
.switch-input:checked {
background: rgba(34,197,94,0.45);
border-color: rgba(34,197,94,0.85);
}
input[type="checkbox"]:checked::before,
.switch-input:checked::before {
transform: translateX(18px);
background: #ffffff;
}
.switch-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.switch-slider {
display: none;
}
.input--compact {
min-width: 0;
padding: 6px 8px;