mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 04:40:45 +08:00
恢复并增强 Admin/Chat 专家配置与流式渲染体验,统一开关与布局规范。
将专家元数据接入后端和管理页面,并恢复合并模式选择与稳定流式增量显示,减少末尾闪烁与配置混乱。 Made-with: Cursor
This commit is contained in:
parent
8989dfea2f
commit
ca354c0a71
5 changed files with 372 additions and 143 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")}`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue