mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-18 12:23:22 +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_attempt import ALL_ATTEMPT_ERROR_CODES
|
||||||
from oclaw.runtime.agent_core_run import DEFAULT_RETRYABLE_ERROR_CODES, resolve_retryable_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.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_CLEAR_KEYS = [
|
||||||
"wecom_mode",
|
"wecom_mode",
|
||||||
|
|
@ -262,6 +269,9 @@ def build_admin_router() -> APIRouter:
|
||||||
"id": str(row.get("id") or ""),
|
"id": str(row.get("id") or ""),
|
||||||
"builtin": bool(row.get("builtin")),
|
"builtin": bool(row.get("builtin")),
|
||||||
"has_required_soul": bool(row.get("has_required_soul")),
|
"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 ""),
|
"path": str(row.get("path") or ""),
|
||||||
"files": {
|
"files": {
|
||||||
"SOUL.md": str(obj.get("SOUL.md") or ""),
|
"SOUL.md": str(obj.get("SOUL.md") or ""),
|
||||||
|
|
@ -2537,10 +2547,19 @@ def build_admin_router() -> APIRouter:
|
||||||
ctx = _resolve_auth(store, authorization)
|
ctx = _resolve_auth(store, authorization)
|
||||||
_require_permission(ctx, "admin:tenant:write")
|
_require_permission(ctx, "admin:tenant:write")
|
||||||
expert_id = normalize_expert_id(payload.get("id"))
|
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 {}
|
files_raw = payload.get("files") if isinstance(payload.get("files"), dict) else {}
|
||||||
try:
|
try:
|
||||||
files = _sanitize_expert_files_payload(files_raw)
|
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:
|
except ValueError as exc:
|
||||||
return {"ok": False, "error": str(exc)}
|
return {"ok": False, "error": str(exc)}
|
||||||
store.add_admin_audit_log(
|
store.add_admin_audit_log(
|
||||||
|
|
@ -2567,9 +2586,18 @@ def build_admin_router() -> APIRouter:
|
||||||
_require_permission(ctx, "admin:tenant:write")
|
_require_permission(ctx, "admin:tenant:write")
|
||||||
files_raw = payload.get("files") if isinstance(payload.get("files"), dict) else {}
|
files_raw = payload.get("files") if isinstance(payload.get("files"), dict) else {}
|
||||||
eid = normalize_expert_id(expert_id)
|
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:
|
try:
|
||||||
files = _sanitize_expert_files_payload(files_raw)
|
files = _sanitize_expert_files_payload(files_raw)
|
||||||
update_expert_files(expert_id=eid, files=files)
|
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:
|
except ValueError as exc:
|
||||||
return {"ok": False, "error": str(exc)}
|
return {"ok": False, "error": str(exc)}
|
||||||
store.add_admin_audit_log(
|
store.add_admin_audit_log(
|
||||||
|
|
|
||||||
|
|
@ -3284,8 +3284,16 @@ async function renderModels() {
|
||||||
const expertsStatus = el("div", { class: "muted", text: "" });
|
const expertsStatus = el("div", { class: "muted", text: "" });
|
||||||
const expertsSelect = el("select", { class: "input" });
|
const expertsSelect = el("select", { class: "input" });
|
||||||
const expertNewId = el("input", { class: "input", placeholder: "new expert id, e.g. qa" });
|
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 expertNewNameEn = el("input", { class: "input", placeholder: "English name (required)" });
|
||||||
const expertRoleSystem = el("textarea", { class: "input", rows: "5", placeholder: "ROLE_SYSTEM.md (required for runtime rules)" });
|
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) {
|
async function downloadEvalExport(format) {
|
||||||
const fmt = format === "json" ? "json" : "csv";
|
const fmt = format === "json" ? "json" : "csv";
|
||||||
|
|
@ -3478,8 +3486,14 @@ async function renderModels() {
|
||||||
|
|
||||||
function fillExpertFields(item) {
|
function fillExpertFields(item) {
|
||||||
const files = (item && item.files && typeof item.files === "object") ? item.files : {};
|
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"] || "");
|
expertSoul.value = String(files["SOUL.md"] || "");
|
||||||
expertRoleSystem.value = String(files["ROLE_SYSTEM.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() {
|
function paintExperts() {
|
||||||
|
|
@ -3489,8 +3503,10 @@ async function renderModels() {
|
||||||
items.forEach((x) => {
|
items.forEach((x) => {
|
||||||
const id = String(x.id || "");
|
const id = String(x.id || "");
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const tail = x.builtin ? " (builtin)" : "";
|
const isSystem = !!(x && (x.builtin || String(x.role || "") === "system"));
|
||||||
expertsSelect.appendChild(el("option", { value: id, text: `${id}${tail}` }));
|
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;
|
if (items.some((x) => String(x.id || "") === prev)) expertsSelect.value = prev;
|
||||||
else if (items.length) expertsSelect.value = String(items[0].id || "");
|
else if (items.length) expertsSelect.value = String(items[0].id || "");
|
||||||
|
|
@ -3761,13 +3777,20 @@ async function renderModels() {
|
||||||
expertsStatus.textContent = "expert id required";
|
expertsStatus.textContent = "expert id required";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!String(expertSoul.value || "").trim()) {
|
if (!String(expertNewNameEn.value || "").trim()) {
|
||||||
expertsStatus.textContent = "SOUL.md is required";
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await apiPost("/admin/api/experts", {
|
const res = await apiPost("/admin/api/experts", {
|
||||||
id: eid,
|
id: eid,
|
||||||
|
display_name_en: String(expertNewNameEn.value || "").trim(),
|
||||||
|
display_name_zh: String(expertNewNameZh.value || "").trim(),
|
||||||
|
role: "expert",
|
||||||
files: {
|
files: {
|
||||||
"SOUL.md": expertSoul.value,
|
"SOUL.md": expertSoul.value,
|
||||||
"ROLE_SYSTEM.md": expertRoleSystem.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"));
|
if (!res || res.ok !== true) throw new Error(String((res && res.error) || "create_failed"));
|
||||||
expertNewId.value = "";
|
expertNewId.value = "";
|
||||||
|
expertNewNameEn.value = "";
|
||||||
|
expertNewNameZh.value = "";
|
||||||
expertsStatus.textContent = "expert created";
|
expertsStatus.textContent = "expert created";
|
||||||
markPrewarmReminder("expert_created");
|
markPrewarmReminder("expert_created");
|
||||||
await refresh();
|
await refresh();
|
||||||
|
|
@ -3793,13 +3818,20 @@ async function renderModels() {
|
||||||
if (!hasPermission("admin:tenant:write")) return;
|
if (!hasPermission("admin:tenant:write")) return;
|
||||||
const item = currentExpertItem();
|
const item = currentExpertItem();
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
if (!String(expertSoul.value || "").trim()) {
|
if (!String(expertNameEn.value || "").trim()) {
|
||||||
expertsStatus.textContent = "SOUL.md is required";
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const eid = String(item.id || "");
|
const eid = String(item.id || "");
|
||||||
const res = await apiRequest("PATCH", "/admin/api/experts/" + encodeURIComponent(eid), {
|
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: {
|
files: {
|
||||||
"SOUL.md": expertSoul.value,
|
"SOUL.md": expertSoul.value,
|
||||||
"ROLE_SYSTEM.md": expertRoleSystem.value,
|
"ROLE_SYSTEM.md": expertRoleSystem.value,
|
||||||
|
|
@ -3824,8 +3856,8 @@ async function renderModels() {
|
||||||
if (!hasPermission("admin:tenant:write")) return;
|
if (!hasPermission("admin:tenant:write")) return;
|
||||||
const item = currentExpertItem();
|
const item = currentExpertItem();
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
if (item.builtin) {
|
if (item.builtin || String(item.role || "") === "system") {
|
||||||
expertsStatus.textContent = "builtin expert cannot be deleted";
|
expertsStatus.textContent = "system expert cannot be deleted";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const eid = String(item.id || "");
|
const eid = String(item.id || "");
|
||||||
|
|
@ -3905,7 +3937,9 @@ async function renderModels() {
|
||||||
el("div", { class: "row" }, [el("label", { text: t("models.pickModel") }), activeSelect]),
|
el("div", { class: "row" }, [el("label", { text: t("models.pickModel") }), activeSelect]),
|
||||||
], { id: "models-overview" }),
|
], { id: "models-overview" }),
|
||||||
renderSectionCard(t("models.sectionNew"), "", [
|
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"), [
|
renderSectionCard(t("models.sectionBindings"), t("models.agentBindingHelp"), [
|
||||||
|
|
@ -3925,11 +3959,19 @@ async function renderModels() {
|
||||||
ollamaHint,
|
ollamaHint,
|
||||||
el("div", { class: "row" }, [el("label", { text: t("models.apiKey") }), keyInp]),
|
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" }, [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" }),
|
], { id: "models-api" }),
|
||||||
renderSectionCard("Experts (runtime/workspaces)", "Only SOUL.md and ROLE_SYSTEM.md are maintained in Admin. SOUL.md is required.", [
|
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, btnExpertDelete]),
|
el("div", { class: "row" }, [el("label", { text: "Existing" }), expertsSelect]),
|
||||||
el("div", { class: "row" }, [el("label", { text: "Create" }), expertNewId, btnExpertCreate]),
|
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: "SOUL.md" }), expertSoul]),
|
||||||
el("div", { class: "row" }, [el("label", { text: "ROLE_SYSTEM.md" }), expertRoleSystem]),
|
el("div", { class: "row" }, [el("label", { text: "ROLE_SYSTEM.md" }), expertRoleSystem]),
|
||||||
el("div", { class: "row" }, [btnExpertSave]),
|
el("div", { class: "row" }, [btnExpertSave]),
|
||||||
|
|
@ -5396,11 +5438,11 @@ async function renderPlugins() {
|
||||||
el("label", { text: "Tool log max chars (20000-2000000)" }),
|
el("label", { text: "Tool log max chars (20000-2000000)" }),
|
||||||
toolLogMaxCharsInput,
|
toolLogMaxCharsInput,
|
||||||
]),
|
]),
|
||||||
el("label", { class: "kv" }, [enableMcpToolsCb, document.createTextNode(" Enable MCP tools")]),
|
el("div", { class: "row" }, [el("label", { class: "kv" }, [enableMcpToolsCb, document.createTextNode(" Enable MCP tools")])]),
|
||||||
el("label", { class: "kv" }, [enablePluginToolsCb, document.createTextNode(" Enable plugin tools")]),
|
el("div", { class: "row" }, [el("label", { class: "kv" }, [enablePluginToolsCb, document.createTextNode(" Enable plugin tools")])]),
|
||||||
el("label", { class: "kv" }, [enableRunCommandCb, document.createTextNode(" Enable run_command (high-risk)")]),
|
el("div", { class: "row" }, [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("div", { class: "row" }, [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" }, [chatShowTtftDebugCb, document.createTextNode(" Show TTFT debug timings in chat status")])]),
|
||||||
el("div", { class: "row" }, [
|
el("div", { class: "row" }, [
|
||||||
el("label", { text: "Tool message max chars to LLM (0=unlimited, 4096-500000 recommended)" }),
|
el("label", { text: "Tool message max chars to LLM (0=unlimited, 4096-500000 recommended)" }),
|
||||||
toolLlmMessageMaxCharsInput,
|
toolLlmMessageMaxCharsInput,
|
||||||
|
|
@ -5418,7 +5460,7 @@ async function renderPlugins() {
|
||||||
el("label", { text: "oclaw retryable error codes (comma separated)" }),
|
el("label", { text: "oclaw retryable error codes (comma separated)" }),
|
||||||
oclawRetryableErrorCodesInput,
|
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("div", { class: "row" }, [
|
||||||
el("label", { text: "WeCom longconn workers (1-8)" }),
|
el("label", { text: "WeCom longconn workers (1-8)" }),
|
||||||
wecomLongconnWorkersInput,
|
wecomLongconnWorkersInput,
|
||||||
|
|
|
||||||
|
|
@ -1880,75 +1880,83 @@ async function renderChatUi() {
|
||||||
disabled: "disabled",
|
disabled: "disabled",
|
||||||
});
|
});
|
||||||
const composerShell = el("div", { class: "chat-composer-shell" });
|
const composerShell = el("div", { class: "chat-composer-shell" });
|
||||||
const toolToggleBtn = el("button", {
|
const toolToggleCb = el("input", { type: "checkbox", class: "switch-input" });
|
||||||
type: "button",
|
const toolToggleWrap = el("label", { class: "switch-wrap", title: t("chat.tools.visible") }, [
|
||||||
class: "btn",
|
toolToggleCb,
|
||||||
style: "padding:4px 8px;min-height:auto;font-size:12px;",
|
el("span", { class: "switch-slider" }),
|
||||||
});
|
el("span", { class: "muted", text: t("chat.tools") }),
|
||||||
const specialistSelect = el("select", {
|
]);
|
||||||
class: "input",
|
const MAIN_MODE_VALUE = "comprehensive";
|
||||||
style: "min-width:120px;max-width:160px;padding:6px 8px;",
|
const EXCLUDED_SPECIALISTS = new Set(["main", "memory", "manager_self", "pycache", "__pycache__"]);
|
||||||
});
|
let specialistCatalog = [];
|
||||||
let specialistFlags = {
|
|
||||||
generalist: true,
|
|
||||||
ops: true,
|
|
||||||
image: true,
|
|
||||||
memory: true,
|
|
||||||
};
|
|
||||||
const modeSelect = el("select", {
|
const modeSelect = el("select", {
|
||||||
class: "input",
|
class: "input",
|
||||||
style: "min-width:120px;max-width:160px;padding:6px 8px;",
|
style: "min-width:120px;max-width:160px;padding:6px 8px;",
|
||||||
});
|
});
|
||||||
modeSelect.appendChild(el("option", { value: "comprehensive", text: t("chat.modeComprehensive") }));
|
const modeOptionLabel = (v) => {
|
||||||
modeSelect.appendChild(el("option", { value: "expert", text: t("chat.modeExpert") }));
|
const key = String(v || "").trim().toLowerCase();
|
||||||
const _im = String(localStorage.getItem(CHAT_INTERACTION_MODE_KEY) || "expert").toLowerCase();
|
if (key === MAIN_MODE_VALUE) return t("chat.modeComprehensive");
|
||||||
modeSelect.value = ["comprehensive", "expert"].includes(_im) ? _im : "expert";
|
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();
|
const _mm = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase();
|
||||||
localStorage.setItem(CHAT_MEMORY_MODE_KEY, _mm === "store_only" ? "store_only" : "default");
|
localStorage.setItem(CHAT_MEMORY_MODE_KEY, _mm === "store_only" ? "store_only" : "default");
|
||||||
modeSelect.addEventListener("change", () => {
|
modeSelect.addEventListener("change", () => {
|
||||||
localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "expert"));
|
persistModeSelection();
|
||||||
saveSessionModePreference();
|
saveSessionModePreference();
|
||||||
});
|
});
|
||||||
const applySpecialistOptions = () => {
|
const loadSpecialistCatalog = async () => {
|
||||||
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 () => {
|
|
||||||
try {
|
try {
|
||||||
const r = await apiGet("/admin/api/chat/settings/specialist-flags");
|
const r = await apiGet("/admin/api/experts");
|
||||||
if (r && r.ok && r.flags && typeof r.flags === "object") {
|
const items = Array.isArray(r && r.items) ? r.items : [];
|
||||||
specialistFlags = {
|
specialistCatalog = items
|
||||||
...specialistFlags,
|
.filter((x) => {
|
||||||
...r.flags,
|
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 (_) {
|
} catch (_) {
|
||||||
// non-admin users or unavailable endpoint: keep defaults
|
specialistCatalog = [];
|
||||||
}
|
}
|
||||||
applySpecialistOptions();
|
applyModeOptions();
|
||||||
};
|
};
|
||||||
const _sp = String(localStorage.getItem(CHAT_SPECIALIST_PREF_KEY) || "generalist").toLowerCase();
|
await loadSpecialistCatalog();
|
||||||
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();
|
|
||||||
});
|
|
||||||
const activeModelText = el("span", { class: "muted", text: `${t("chat.activeModelLabel")}: -` });
|
const activeModelText = el("span", { class: "muted", text: `${t("chat.activeModelLabel")}: -` });
|
||||||
const refreshActiveModelText = async () => {
|
const refreshActiveModelText = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1969,54 +1977,33 @@ async function renderChatUi() {
|
||||||
const m = String((resp && resp.interaction_mode) || "").toLowerCase();
|
const m = String((resp && resp.interaction_mode) || "").toLowerCase();
|
||||||
const s = String((resp && resp.specialist) || "").toLowerCase();
|
const s = String((resp && resp.specialist) || "").toLowerCase();
|
||||||
const mm = String((resp && resp.memory_mode) || "").toLowerCase();
|
const mm = String((resp && resp.memory_mode) || "").toLowerCase();
|
||||||
if (["comprehensive", "expert"].includes(m)) modeSelect.value = m;
|
if (m === "comprehensive") modeSelect.value = MAIN_MODE_VALUE;
|
||||||
if (["generalist", "ops", "image", "memory"].includes(s)) specialistSelect.value = s;
|
else if (m === "expert" && isSelectableSpecialist(s)) modeSelect.value = s;
|
||||||
if (["default", "store_only"].includes(mm)) localStorage.setItem(CHAT_MEMORY_MODE_KEY, mm);
|
if (["default", "store_only"].includes(mm)) localStorage.setItem(CHAT_MEMORY_MODE_KEY, mm);
|
||||||
if (String(modeSelect.value || "").toLowerCase() !== "expert") {
|
persistModeSelection();
|
||||||
specialistSelect.value = "generalist";
|
|
||||||
}
|
|
||||||
localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "expert"));
|
|
||||||
localStorage.setItem(CHAT_SPECIALIST_PREF_KEY, String(specialistSelect.value || "generalist"));
|
|
||||||
const mml = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase();
|
const mml = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase();
|
||||||
localStorage.setItem(CHAT_MEMORY_MODE_KEY, mml === "store_only" ? "store_only" : "default");
|
localStorage.setItem(CHAT_MEMORY_MODE_KEY, mml === "store_only" ? "store_only" : "default");
|
||||||
syncModeSpecialistUi();
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
};
|
};
|
||||||
const saveSessionModePreference = async () => {
|
const saveSessionModePreference = async () => {
|
||||||
if (!activeId) return;
|
if (!activeId) return;
|
||||||
try {
|
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`, {
|
await apiPost(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/mode`, {
|
||||||
interaction_mode: String(modeSelect.value || "expert"),
|
interaction_mode: isMain ? "comprehensive" : "expert",
|
||||||
specialist:
|
specialist: isMain ? "generalist" : modeVal,
|
||||||
String(modeSelect.value || "expert").toLowerCase() === "expert"
|
|
||||||
? String(specialistSelect.value || "generalist")
|
|
||||||
: "generalist",
|
|
||||||
memory_mode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"),
|
memory_mode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"),
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
};
|
};
|
||||||
await refreshActiveModelText();
|
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;" }, [
|
const composerMetaBar = el("div", { class: "row", style: "gap:8px;padding:2px 4px 6px;align-items:center;" }, [
|
||||||
el("span", { class: "muted", text: t("chat.modeLabel") }),
|
el("span", { class: "muted", text: t("chat.modeLabel") }),
|
||||||
modeSelect,
|
modeSelect,
|
||||||
specialistLabelEl,
|
toolToggleWrap,
|
||||||
specialistSelect,
|
|
||||||
toolToggleBtn,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
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 = () => {
|
const fitComposerTextarea = () => {
|
||||||
textarea.style.height = "auto";
|
textarea.style.height = "auto";
|
||||||
const h = Math.min(Math.max(textarea.scrollHeight, 44), 200);
|
const h = Math.min(Math.max(textarea.scrollHeight, 44), 200);
|
||||||
|
|
@ -2187,11 +2174,11 @@ async function renderChatUi() {
|
||||||
|
|
||||||
attachBtn.addEventListener("click", () => fileInput.click());
|
attachBtn.addEventListener("click", () => fileInput.click());
|
||||||
const syncToolToggleBtn = () => {
|
const syncToolToggleBtn = () => {
|
||||||
toolToggleBtn.textContent = `${t("chat.tools")}: ${showToolOutput ? t("chat.tools.on") : t("chat.tools.off")}`;
|
toolToggleCb.checked = !!showToolOutput;
|
||||||
toolToggleBtn.title = showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden");
|
toolToggleWrap.title = showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden");
|
||||||
};
|
};
|
||||||
toolToggleBtn.addEventListener("click", () => {
|
toolToggleCb.addEventListener("change", () => {
|
||||||
showToolOutput = !showToolOutput;
|
showToolOutput = !!toolToggleCb.checked;
|
||||||
adminChatShowToolOutput = showToolOutput;
|
adminChatShowToolOutput = showToolOutput;
|
||||||
localStorage.setItem(CHAT_REASONING_TOGGLE_KEY, showToolOutput ? "1" : "0");
|
localStorage.setItem(CHAT_REASONING_TOGGLE_KEY, showToolOutput ? "1" : "0");
|
||||||
syncToolToggleBtn();
|
syncToolToggleBtn();
|
||||||
|
|
@ -2771,6 +2758,7 @@ async function renderChatUi() {
|
||||||
let streamStatusBase = "oclaw";
|
let streamStatusBase = "oclaw";
|
||||||
let streamDisplayTarget = "";
|
let streamDisplayTarget = "";
|
||||||
let streamDisplayShown = "";
|
let streamDisplayShown = "";
|
||||||
|
let streamTextBuffer = "";
|
||||||
let toolSeq = 0;
|
let toolSeq = 0;
|
||||||
let hasRealStreamText = false;
|
let hasRealStreamText = false;
|
||||||
let sawWsChatEvent = false;
|
let sawWsChatEvent = false;
|
||||||
|
|
@ -2929,7 +2917,7 @@ async function renderChatUi() {
|
||||||
const shown = full.slice(0, showLen);
|
const shown = full.slice(0, showLen);
|
||||||
textIdx += full.length;
|
textIdx += full.length;
|
||||||
if (!shown) continue;
|
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") {
|
} else if (seg.type === "tool") {
|
||||||
if (!showToolOutput) continue;
|
if (!showToolOutput) continue;
|
||||||
const title = String(seg.title || "tool");
|
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);
|
const currentShown = streamDisplayShown.slice(textIdx);
|
||||||
if (String(currentShown || "").trim()) {
|
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("");
|
md.innerHTML = blocks.join("");
|
||||||
}
|
}
|
||||||
|
|
@ -3009,14 +2997,13 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
||||||
}, 14);
|
}, 14);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const setStreamText = (text) => {
|
const appendStreamTextChunk = (chunk) => {
|
||||||
chatStream = String(text || "");
|
const piece = String(chunk || "");
|
||||||
// Keep current text as a dedicated trailing segment for strict in-order rendering.
|
if (!piece) return;
|
||||||
if (!chatStreamSegments.length || chatStreamSegments[chatStreamSegments.length - 1].type !== "text") {
|
streamTextBuffer = `${streamTextBuffer}${piece}`;
|
||||||
chatStreamSegments.push({ type: "text", text: chatStream });
|
chatStream = streamTextBuffer;
|
||||||
} else {
|
// Keep strict chronological order: append text deltas as independent segments.
|
||||||
chatStreamSegments[chatStreamSegments.length - 1].text = chatStream;
|
chatStreamSegments.push({ type: "text", text: piece });
|
||||||
}
|
|
||||||
streamDisplayTarget = _composeStreamPlainText();
|
streamDisplayTarget = _composeStreamPlainText();
|
||||||
_scheduleTypingTick();
|
_scheduleTypingTick();
|
||||||
};
|
};
|
||||||
|
|
@ -3081,12 +3068,13 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
||||||
sessionId: activeId,
|
sessionId: activeId,
|
||||||
text: userText,
|
text: userText,
|
||||||
attachments: attachmentPayload,
|
attachments: attachmentPayload,
|
||||||
interactionMode: String(modeSelect.value || "expert"),
|
interactionMode: String(modeSelect.value || MAIN_MODE_VALUE).toLowerCase() === MAIN_MODE_VALUE ? "comprehensive" : "expert",
|
||||||
idempotencyKey: String(turnId || ""),
|
idempotencyKey: String(turnId || ""),
|
||||||
specialist:
|
specialist: String(modeSelect.value || MAIN_MODE_VALUE).toLowerCase() === MAIN_MODE_VALUE
|
||||||
String(modeSelect.value || "expert").toLowerCase() === "expert"
|
? "generalist"
|
||||||
? String(specialistSelect.value || "generalist")
|
: (isSelectableSpecialist(String(modeSelect.value || "").toLowerCase())
|
||||||
: "generalist",
|
? String(modeSelect.value || "generalist").toLowerCase()
|
||||||
|
: "generalist"),
|
||||||
memoryMode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"),
|
memoryMode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"),
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
onEvent: async (frame) => {
|
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 next = extractWsAssistantText(payload.message);
|
||||||
const d = String(payload.delta || "");
|
const d = String(payload.delta || "");
|
||||||
if (d && !_isSilentReplyStream(d)) {
|
if (d && !_isSilentReplyStream(d)) {
|
||||||
const base = hasRealStreamText ? String(chatStream || "") : "";
|
appendStreamTextChunk(decodeEscapedNewlines(d));
|
||||||
chatStream = `${base}${decodeEscapedNewlines(d)}`;
|
|
||||||
hasRealStreamText = true;
|
hasRealStreamText = true;
|
||||||
setStreamText(chatStream);
|
|
||||||
} else if (typeof next === "string" && next && !_isSilentReplyStream(next)) {
|
} else if (typeof next === "string" && next && !_isSilentReplyStream(next)) {
|
||||||
// Fallback when gateway doesn't include delta.
|
// 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;
|
hasRealStreamText = true;
|
||||||
setStreamText(chatStream);
|
|
||||||
}
|
}
|
||||||
return;
|
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 || {}));
|
const ok = await appendFinalAssistant(payload.message, chatStream || extractWsAssistantText(payload.message || {}));
|
||||||
if (!ok) _markStreamTerminal("end", t("chat.status.end"));
|
if (!ok) _markStreamTerminal("end", t("chat.status.end"));
|
||||||
chatStream = "";
|
chatStream = "";
|
||||||
|
streamTextBuffer = "";
|
||||||
chatRunId = null;
|
chatRunId = null;
|
||||||
|
const streamedEnough = hasRealStreamText || chatStreamSegments.length > 0 || sawWsChatEvent;
|
||||||
chatStreamSegments = [];
|
chatStreamSegments = [];
|
||||||
// Final assistant body may arrive before reasoning/tool rows are fully persisted.
|
// Avoid end-of-turn flash: only reload history when stream had no usable content.
|
||||||
// Reload once shortly after final to avoid "reasoning disappears until session switch".
|
if (!streamedEnough) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
loadMessagesForActive().catch(() => {});
|
loadMessagesForActive().catch(() => {});
|
||||||
}, 350);
|
}, 350);
|
||||||
|
}
|
||||||
if (!ok) statusBar.textContent = "";
|
if (!ok) statusBar.textContent = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -3187,6 +3181,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
||||||
const ok = await appendFinalAssistant(payload.message, chatStream);
|
const ok = await appendFinalAssistant(payload.message, chatStream);
|
||||||
if (!ok) _markStreamTerminal("error", "aborted");
|
if (!ok) _markStreamTerminal("error", "aborted");
|
||||||
chatStream = "";
|
chatStream = "";
|
||||||
|
streamTextBuffer = "";
|
||||||
chatRunId = null;
|
chatRunId = null;
|
||||||
chatStreamSegments = [];
|
chatStreamSegments = [];
|
||||||
return;
|
return;
|
||||||
|
|
@ -3196,6 +3191,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
||||||
_stopDynamicStreamStatus();
|
_stopDynamicStreamStatus();
|
||||||
_markStreamTerminal("error", String(payload.errorMessage || "chat error"));
|
_markStreamTerminal("error", String(payload.errorMessage || "chat error"));
|
||||||
chatStream = "";
|
chatStream = "";
|
||||||
|
streamTextBuffer = "";
|
||||||
chatRunId = null;
|
chatRunId = null;
|
||||||
chatStreamSegments = [];
|
chatStreamSegments = [];
|
||||||
statusBar.textContent = `${t("chat.error")}: ${String(payload.errorMessage || "chat error")}`;
|
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 { 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; }
|
.card__title { font-weight: 650; margin-bottom: 10px; }
|
||||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
.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 {
|
.chat-login-fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -213,7 +228,60 @@ body {
|
||||||
flex: 0 0 auto;
|
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; }
|
.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; }
|
.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 {
|
.input--compact {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
import json
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -11,6 +12,8 @@ _REQUIRED_FILES: tuple[str, ...] = ("SOUL.md",)
|
||||||
_OPTIONAL_FILES: tuple[str, ...] = ("ROLE_SYSTEM.md",)
|
_OPTIONAL_FILES: tuple[str, ...] = ("ROLE_SYSTEM.md",)
|
||||||
_ALL_FILES: tuple[str, ...] = (*_REQUIRED_FILES, *_OPTIONAL_FILES)
|
_ALL_FILES: tuple[str, ...] = (*_REQUIRED_FILES, *_OPTIONAL_FILES)
|
||||||
_RESERVED_IDS: frozenset[str] = frozenset({"main"})
|
_RESERVED_IDS: frozenset[str] = frozenset({"main"})
|
||||||
|
_META_FILE = "META.json"
|
||||||
|
_ROLE_OPTIONS: frozenset[str] = frozenset({"system", "expert"})
|
||||||
_CACHE_LOCK = threading.Lock()
|
_CACHE_LOCK = threading.Lock()
|
||||||
_LIST_CACHE_SIGNATURE: tuple[Any, ...] | None = None
|
_LIST_CACHE_SIGNATURE: tuple[Any, ...] | None = None
|
||||||
_LIST_CACHE_ROWS: list[dict[str, Any]] = []
|
_LIST_CACHE_ROWS: list[dict[str, Any]] = []
|
||||||
|
|
@ -37,6 +40,46 @@ def is_builtin_expert(expert_id: str) -> bool:
|
||||||
return normalize_expert_id(expert_id) in _RESERVED_IDS
|
return normalize_expert_id(expert_id) in _RESERVED_IDS
|
||||||
|
|
||||||
|
|
||||||
|
def _default_role_for_expert(eid: str) -> str:
|
||||||
|
return "system" if eid in _RESERVED_IDS else "expert"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_noise_workspace_id(eid: str) -> bool:
|
||||||
|
sid = normalize_expert_id(eid)
|
||||||
|
return sid in {"pycache", "__pycache__"} or sid.endswith("pycache")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_meta(expert_dir: Path) -> dict[str, str]:
|
||||||
|
p = expert_dir / _META_FILE
|
||||||
|
if not p.exists() or not p.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
raw = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
obj = raw if isinstance(raw, dict) else {}
|
||||||
|
role = str(obj.get("role") or "").strip().lower()
|
||||||
|
if role not in _ROLE_OPTIONS:
|
||||||
|
role = ""
|
||||||
|
return {
|
||||||
|
"display_name_en": str(obj.get("display_name_en") or "").strip(),
|
||||||
|
"display_name_zh": str(obj.get("display_name_zh") or "").strip(),
|
||||||
|
"role": role,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_meta(expert_dir: Path, *, display_name_en: str | None, display_name_zh: str | None, role: str | None) -> None:
|
||||||
|
role_norm = str(role or "").strip().lower()
|
||||||
|
if role_norm and role_norm not in _ROLE_OPTIONS:
|
||||||
|
raise ValueError("invalid_role")
|
||||||
|
payload = {
|
||||||
|
"display_name_en": str(display_name_en or "").strip(),
|
||||||
|
"display_name_zh": str(display_name_zh or "").strip(),
|
||||||
|
"role": role_norm,
|
||||||
|
}
|
||||||
|
(expert_dir / _META_FILE).write_text(f"{json.dumps(payload, ensure_ascii=False, indent=2)}\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def _workspace_signature() -> tuple[Any, ...]:
|
def _workspace_signature() -> tuple[Any, ...]:
|
||||||
root = workspaces_root()
|
root = workspaces_root()
|
||||||
if not root.exists() or not root.is_dir():
|
if not root.exists() or not root.is_dir():
|
||||||
|
|
@ -50,6 +93,8 @@ def _workspace_signature() -> tuple[Any, ...]:
|
||||||
eid = normalize_expert_id(item.name)
|
eid = normalize_expert_id(item.name)
|
||||||
if not eid:
|
if not eid:
|
||||||
continue
|
continue
|
||||||
|
if _is_noise_workspace_id(eid):
|
||||||
|
continue
|
||||||
for name in _ALL_FILES:
|
for name in _ALL_FILES:
|
||||||
p = item / name
|
p = item / name
|
||||||
if not p.exists() or not p.is_file():
|
if not p.exists() or not p.is_file():
|
||||||
|
|
@ -109,6 +154,8 @@ def list_experts() -> list[dict[str, Any]]:
|
||||||
eid = normalize_expert_id(item.name)
|
eid = normalize_expert_id(item.name)
|
||||||
if not eid:
|
if not eid:
|
||||||
continue
|
continue
|
||||||
|
if _is_noise_workspace_id(eid):
|
||||||
|
continue
|
||||||
files: dict[str, str] = {}
|
files: dict[str, str] = {}
|
||||||
for name in _ALL_FILES:
|
for name in _ALL_FILES:
|
||||||
p = item / name
|
p = item / name
|
||||||
|
|
@ -119,12 +166,19 @@ def list_experts() -> list[dict[str, Any]]:
|
||||||
files[name] = p.read_text(encoding="utf-8")
|
files[name] = p.read_text(encoding="utf-8")
|
||||||
except Exception:
|
except Exception:
|
||||||
files[name] = ""
|
files[name] = ""
|
||||||
|
meta = _read_meta(item)
|
||||||
|
role = _default_role_for_expert(eid)
|
||||||
|
if str(meta.get("role") or "").strip() in _ROLE_OPTIONS:
|
||||||
|
role = str(meta.get("role") or "").strip()
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"id": eid,
|
"id": eid,
|
||||||
"path": str(item),
|
"path": str(item),
|
||||||
"builtin": is_builtin_expert(eid),
|
"builtin": is_builtin_expert(eid),
|
||||||
"has_required_soul": bool(str(files.get("SOUL.md") or "").strip()),
|
"has_required_soul": bool(str(files.get("SOUL.md") or "").strip()),
|
||||||
|
"display_name_en": str(meta.get("display_name_en") or ""),
|
||||||
|
"display_name_zh": str(meta.get("display_name_zh") or ""),
|
||||||
|
"role": role,
|
||||||
"files": files,
|
"files": files,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -227,7 +281,14 @@ def _workspace_dir(expert_id: str) -> Path:
|
||||||
return (workspaces_root() / eid).resolve()
|
return (workspaces_root() / eid).resolve()
|
||||||
|
|
||||||
|
|
||||||
def create_expert(*, expert_id: str, files: dict[str, Any]) -> dict[str, Any]:
|
def create_expert(
|
||||||
|
*,
|
||||||
|
expert_id: str,
|
||||||
|
files: dict[str, Any],
|
||||||
|
display_name_en: str | None = None,
|
||||||
|
display_name_zh: str | None = None,
|
||||||
|
role: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
eid = normalize_expert_id(expert_id)
|
eid = normalize_expert_id(expert_id)
|
||||||
if not eid or eid in _RESERVED_IDS:
|
if not eid or eid in _RESERVED_IDS:
|
||||||
raise ValueError("invalid_expert_id")
|
raise ValueError("invalid_expert_id")
|
||||||
|
|
@ -238,8 +299,9 @@ def create_expert(*, expert_id: str, files: dict[str, Any]) -> dict[str, Any]:
|
||||||
raise ValueError("expert_exists")
|
raise ValueError("expert_exists")
|
||||||
clean_files = _normalize_supported_files(files)
|
clean_files = _normalize_supported_files(files)
|
||||||
soul = str(clean_files.get("SOUL.md") or "").strip()
|
soul = str(clean_files.get("SOUL.md") or "").strip()
|
||||||
if not soul:
|
role_system = str(clean_files.get("ROLE_SYSTEM.md") or "").strip()
|
||||||
raise ValueError("soul_required")
|
if not soul and not role_system:
|
||||||
|
raise ValueError("soul_or_role_system_required")
|
||||||
target.mkdir(parents=True, exist_ok=False)
|
target.mkdir(parents=True, exist_ok=False)
|
||||||
for name in _ALL_FILES:
|
for name in _ALL_FILES:
|
||||||
body = str(clean_files.get(name) or "")
|
body = str(clean_files.get(name) or "")
|
||||||
|
|
@ -248,7 +310,15 @@ def create_expert(*, expert_id: str, files: dict[str, Any]) -> dict[str, Any]:
|
||||||
if body:
|
if body:
|
||||||
(target / name).write_text(body.strip() + "\n", encoding="utf-8")
|
(target / name).write_text(body.strip() + "\n", encoding="utf-8")
|
||||||
if not (target / "SOUL.md").exists():
|
if not (target / "SOUL.md").exists():
|
||||||
|
if soul:
|
||||||
(target / "SOUL.md").write_text(soul + "\n", encoding="utf-8")
|
(target / "SOUL.md").write_text(soul + "\n", encoding="utf-8")
|
||||||
|
role_save = str(role or "").strip().lower() or _default_role_for_expert(eid)
|
||||||
|
_write_meta(
|
||||||
|
target,
|
||||||
|
display_name_en=display_name_en,
|
||||||
|
display_name_zh=display_name_zh,
|
||||||
|
role=role_save,
|
||||||
|
)
|
||||||
_clear_experts_cache()
|
_clear_experts_cache()
|
||||||
return {"id": eid, "path": str(target)}
|
return {"id": eid, "path": str(target)}
|
||||||
|
|
||||||
|
|
@ -268,8 +338,8 @@ def update_expert_files(*, expert_id: str, files: dict[str, Any]) -> dict[str, A
|
||||||
else:
|
else:
|
||||||
p = target / name
|
p = target / name
|
||||||
next_files[name] = p.read_text(encoding="utf-8") if p.exists() and p.is_file() else ""
|
next_files[name] = p.read_text(encoding="utf-8") if p.exists() and p.is_file() else ""
|
||||||
if not str(next_files.get("SOUL.md") or "").strip():
|
if not str(next_files.get("SOUL.md") or "").strip() and not str(next_files.get("ROLE_SYSTEM.md") or "").strip():
|
||||||
raise ValueError("soul_required")
|
raise ValueError("soul_or_role_system_required")
|
||||||
for name in _ALL_FILES:
|
for name in _ALL_FILES:
|
||||||
p = target / name
|
p = target / name
|
||||||
body = str(next_files.get(name) or "")
|
body = str(next_files.get(name) or "")
|
||||||
|
|
@ -281,6 +351,30 @@ def update_expert_files(*, expert_id: str, files: dict[str, Any]) -> dict[str, A
|
||||||
return {"id": eid, "path": str(target)}
|
return {"id": eid, "path": str(target)}
|
||||||
|
|
||||||
|
|
||||||
|
def update_expert_meta(
|
||||||
|
*,
|
||||||
|
expert_id: str,
|
||||||
|
display_name_en: str | None = None,
|
||||||
|
display_name_zh: str | None = None,
|
||||||
|
role: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
eid = normalize_expert_id(expert_id)
|
||||||
|
if not eid:
|
||||||
|
raise ValueError("invalid_expert_id")
|
||||||
|
target = _workspace_dir(eid)
|
||||||
|
if not target.exists() or not target.is_dir():
|
||||||
|
raise ValueError("expert_not_found")
|
||||||
|
role_save = str(role or "").strip().lower() or _default_role_for_expert(eid)
|
||||||
|
_write_meta(
|
||||||
|
target,
|
||||||
|
display_name_en=display_name_en,
|
||||||
|
display_name_zh=display_name_zh,
|
||||||
|
role=role_save,
|
||||||
|
)
|
||||||
|
_clear_experts_cache()
|
||||||
|
return {"id": eid, "path": str(target)}
|
||||||
|
|
||||||
|
|
||||||
def delete_expert(expert_id: str) -> None:
|
def delete_expert(expert_id: str) -> None:
|
||||||
eid = normalize_expert_id(expert_id)
|
eid = normalize_expert_id(expert_id)
|
||||||
if not eid:
|
if not eid:
|
||||||
|
|
@ -308,6 +402,7 @@ __all__ = [
|
||||||
"is_builtin_expert",
|
"is_builtin_expert",
|
||||||
"list_experts",
|
"list_experts",
|
||||||
"normalize_expert_id",
|
"normalize_expert_id",
|
||||||
|
"update_expert_meta",
|
||||||
"update_expert_files",
|
"update_expert_files",
|
||||||
"warm_expert_workspace_cache",
|
"warm_expert_workspace_cache",
|
||||||
"workspaces_root",
|
"workspaces_root",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue