From 3a69aa03af8722dfbfd0cb9b0f600a99d16a428f Mon Sep 17 00:00:00 2001 From: jiang Date: Tue, 28 Apr 2026 15:18:18 +0800 Subject: [PATCH 1/3] feat(viewer): UI improvements, i18n fixes, global search, markdown chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Global search: real-time categorized dropdown (memories, tasks, skills, experiences, env knowledge) with top 3 results per category - Added backend `q` parameter support for skills and episodes APIs - Help page: full bilingual (en/zh) support for all sections - Settings: translated team sharing subtitle via i18n - Tasks page: skill pipeline reasons now localized via reasonKey/reasonParams - Chat bubbles: render user/assistant/thinking as Markdown (new component) - Header brand: simplified to "MemOS / 记忆面板" - Search bar: expanded to fill full topbar width - memory_add logs: fix empty content for tool sub-steps, fix role inference - Version: bridge.cts reads from package.json (no more alpha/beta mismatch) - Health endpoint: read model names from disk config (reflects unsaved changes) - Admin restart: Hermes bridge now exits on restart (like OpenClaw) --- apps/memos-local-plugin/bridge.cts | 2 +- .../core/pipeline/memory-core.ts | 123 +++++-- .../memos-local-plugin/server/routes/admin.ts | 6 +- .../server/routes/session.ts | 20 +- .../memos-local-plugin/server/routes/skill.ts | 10 +- .../web/src/components/Header.tsx | 221 +++++++++++-- .../web/src/components/Markdown.tsx | 127 ++++++++ .../memos-local-plugin/web/src/stores/i18n.ts | 54 +++- .../web/src/stores/restart.ts | 21 +- .../web/src/styles/components.css | 65 +++- .../web/src/styles/layout.css | 96 +++++- .../web/src/views/HelpView.tsx | 303 ++++++++++++------ .../web/src/views/SettingsView.tsx | 2 +- .../web/src/views/TasksView.tsx | 15 +- .../web/src/views/tasks-chat.tsx | 7 +- 15 files changed, 903 insertions(+), 169 deletions(-) create mode 100644 apps/memos-local-plugin/web/src/components/Markdown.tsx diff --git a/apps/memos-local-plugin/bridge.cts b/apps/memos-local-plugin/bridge.cts index af796387c..46a911c6e 100644 --- a/apps/memos-local-plugin/bridge.cts +++ b/apps/memos-local-plugin/bridge.cts @@ -62,7 +62,7 @@ async function main(): Promise { pathToEsmUrl(path.resolve(__dirname, "server/http.ts")) )) as typeof import("./server/http.js"); - const pkgVersion = "2.0.0-alpha.1"; + const pkgVersion = require("./package.json").version; const { core, config, home } = await bootstrapMemoryCoreFull({ agent: args.agent, pkgVersion, diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index d8a41dfa2..6a2c1338e 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -417,7 +417,12 @@ export function createMemoryCore( role: inferTurnRole(tc), action: phase === "lite" ? ("stored" as const) : ("reflected" as const), summary: tc.reflection?.text ?? null, - content: (tc.userText || tc.agentText || "").slice(0, 400), + content: ( + tc.userText || + tc.agentText || + summarizeToolCalls(tc.toolCalls) || + "" + ).slice(0, 400), traceId: tc.traceId, })); handle.repos.apiLogs.insert({ @@ -555,6 +560,40 @@ export function createMemoryCore( } async function health(): Promise { + // Read the latest on-disk config so that model names reflect what + // the user last saved, even before a restart applies the change. + let diskConfig: Record | null = null; + try { + const { loadConfig } = await import("../config/index.js"); + const { config } = await loadConfig(handle.home); + diskConfig = config as unknown as Record; + } catch { + /* fall through to in-memory */ + } + + const llmInfo = llmHealth(handle.llm, latestTraceTs()); + const embedderInfo = embedderHealth(handle.embedder, latestTraceTs()); + const skillEvolverInfo = resolveSkillEvolver( + diskConfig ?? handle.config, + handle.llm, + latestTraceTs(), + ); + + // Override model names from disk config if they differ from the + // in-memory client (user saved new settings but hasn't restarted). + if (diskConfig) { + const diskLlm = diskConfig.llm as { model?: string; provider?: string } | undefined; + if (diskLlm?.model && diskLlm.model !== llmInfo.model) { + llmInfo.model = diskLlm.model; + if (diskLlm.provider) llmInfo.provider = diskLlm.provider; + } + const diskEmb = diskConfig.embedding as { model?: string; provider?: string } | undefined; + if (diskEmb?.model && diskEmb.model !== embedderInfo.model) { + embedderInfo.model = diskEmb.model; + if (diskEmb.provider) embedderInfo.provider = diskEmb.provider; + } + } + return { ok: initialized && !shutDown, version: pkgVersion, @@ -567,17 +606,9 @@ export function createMemoryCore( skills: home.skillsDir, logs: home.logsDir, }, - // V7 overview card: fall back to the newest captured trace as - // a proxy for "LLM + embedder were OK recently" when the live - // `stats().lastOkAt` counter hasn't yet been populated in this - // process. Every captured trace is proof that reflection / α - // scoring (LLM) and summary embedding (embedder) both - // succeeded at that moment — so reading the DB max ts gives a - // correct, non-fabricated lower bound that survives plugin - // restarts without misleading the user. - llm: llmHealth(handle.llm, latestTraceTs()), - embedder: embedderHealth(handle.embedder, latestTraceTs()), - skillEvolver: resolveSkillEvolver(handle.config, handle.llm, latestTraceTs()), + llm: llmInfo, + embedder: embedderInfo, + skillEvolver: skillEvolverInfo, }; } @@ -1383,6 +1414,8 @@ export function createMemoryCore( tags: tagSet.size > 0 ? Array.from(tagSet).sort() : undefined, skillStatus: derivation.status, skillReason: derivation.reason, + skillReasonKey: derivation.reasonKey, + skillReasonParams: derivation.reasonParams, linkedSkillId: derivation.linkedSkillId, closeReason, abandonReason, @@ -2490,37 +2523,52 @@ export function deriveSkillStatus( ): { status: EpisodeListItemDTO["skillStatus"]; reason: string | null; + reasonKey: string | null; + reasonParams: Record | null; linkedSkillId: SkillId | null; } { if (ep.status === "open") { - return { status: "queued", reason: "任务仍在进行中,技能流水线尚未启动", linkedSkillId: null }; + return { + status: "queued", + reason: "任务仍在进行中,技能流水线尚未启动", + reasonKey: "tasks.skillReason.queued.inProgress", + reasonParams: null, + linkedSkillId: null, + }; } if (ep.rTask == null) { return { status: "queued", reason: "Reward 评分尚未完成,技能流水线将在评分后启动", + reasonKey: "tasks.skillReason.queued.rewardPending", + reasonParams: null, linkedSkillId: null, }; } if (ep.rTask <= R_NEGATIVE_FLOOR) { return { status: "skipped", - reason: `任务评分为明显负分 (R=${ep.rTask.toFixed(2)}),视为反例;不会沉淀出新的 L2 经验或技能,但原始 L1 轨迹会作为反面教材保留,在后续 Decision Repair 中生成 anti-pattern 规避下次同类错误`, + reason: `任务评分为明显负分 (R=${ep.rTask.toFixed(2)}),视为反例`, + reasonKey: "tasks.skillReason.skipped", + reasonParams: { rTask: ep.rTask.toFixed(2) }, linkedSkillId: null, }; } if (ep.rTask < R_BELOW_THRESHOLD) { return { status: "not_generated", - reason: `任务评分 R=${ep.rTask.toFixed(2)} 未达到沉淀阈值 (≥ ${R_BELOW_THRESHOLD.toFixed(2)})——对话本身正常,只是还不够强到能泛化成 L2 经验;多做几个相似任务后会自动积累`, + reason: `任务评分 R=${ep.rTask.toFixed(2)} 未达到沉淀阈值`, + reasonKey: "tasks.skillReason.not_generated.belowThreshold", + reasonParams: { rTask: ep.rTask.toFixed(2), threshold: R_BELOW_THRESHOLD.toFixed(2) }, linkedSkillId: null, }; } if (relatedPolicies.length === 0) { return { status: "not_generated", - reason: - "暂未归纳出 L2 经验——单个任务无法跨任务泛化;需要至少 2 个相似任务(minEpisodesForInduction),且 V 值 ≥ 0.1 才能触发 L2 诱导,之后支撑 ≥ 3 个相似任务才会结晶为技能", + reason: "暂未归纳出 L2 经验", + reasonKey: "tasks.skillReason.not_generated.noPolicy", + reasonParams: null, linkedSkillId: null, }; } @@ -2528,39 +2576,72 @@ export function deriveSkillStatus( const policyBucket = skillsByPolicy.get(best.id) ?? []; if (policyBucket.length > 0) { const active = policyBucket.find((s) => s.status !== "archived") ?? policyBucket[0]!; + const isUpgraded = best.updatedAt > active.updatedAt; return { - status: best.updatedAt > active.updatedAt ? "upgraded" : "generated", + status: isUpgraded ? "upgraded" : "generated", reason: `技能「${active.name ?? active.id}」已从经验 ${best.id.slice(0, 8)} 结晶`, + reasonKey: isUpgraded ? "tasks.skillReason.upgraded" : "tasks.skillReason.generated", + reasonParams: { skillName: active.name ?? active.id, policyId: best.id.slice(0, 8) }, linkedSkillId: active.id as SkillId, }; } if (best.status !== "active") { return { status: "queued", - reason: `经验 ${best.id.slice(0, 8)} 状态为 ${best.status}——需要更多支撑任务才能结晶为技能(当前 support=${best.support ?? 0},需 ≥3)`, + reason: `经验 ${best.id.slice(0, 8)} 需要更多支撑任务`, + reasonKey: "tasks.skillReason.queued.policyPending", + reasonParams: { support: String(best.support ?? 0) }, linkedSkillId: null, }; } return { status: "queued", - reason: `经验 ${best.id.slice(0, 8)} 已就绪(gain=${best.gain.toFixed(2)},support=${best.support ?? 0}),技能结晶将在下次 reward 评分后自动触发`, + reason: `经验 ${best.id.slice(0, 8)} 已就绪`, + reasonKey: "tasks.skillReason.queued.ready", + reasonParams: { gain: best.gain.toFixed(2), support: String(best.support ?? 0) }, linkedSkillId: null, }; } +/** + * Produce a short content string from toolCalls when userText/agentText + * are both empty (sub-steps after the first in a multi-tool turn). + */ +function summarizeToolCalls( + toolCalls?: readonly { name?: string; output?: unknown }[] | null, +): string { + if (!toolCalls || toolCalls.length === 0) return ""; + return toolCalls + .map((tc) => { + const name = tc.name ?? "tool"; + const out = typeof tc.output === "string" + ? tc.output.slice(0, 200) + : tc.output != null + ? JSON.stringify(tc.output).slice(0, 200) + : ""; + return out ? `[${name}] ${out}` : `[${name}]`; + }) + .join("\n"); +} + /** * Heuristic role inference for api_logs "memory_add" rows — mirrors * the legacy plugin's behaviour where each captured turn showed up * labelled `user` / `assistant` / `tool` on the Logs page. + * + * Priority: if the step carries userText (the user's query), label it + * "user" even when toolCalls are present — this is the first sub-step + * of a multi-tool turn and semantically represents the user request. */ function inferTurnRole(step: { userText?: string; agentText?: string; toolCalls?: readonly unknown[]; }): "user" | "assistant" | "tool" | "other" { - if ((step.toolCalls?.length ?? 0) > 0) return "tool"; const u = (step.userText ?? "").length; const a = (step.agentText ?? "").length; + if (u > 0 && (step.toolCalls?.length ?? 0) > 0) return "user"; + if ((step.toolCalls?.length ?? 0) > 0) return "tool"; if (u >= a && u > 0) return "user"; if (a > 0) return "assistant"; return "other"; diff --git a/apps/memos-local-plugin/server/routes/admin.ts b/apps/memos-local-plugin/server/routes/admin.ts index f070ab9fe..dd768617c 100644 --- a/apps/memos-local-plugin/server/routes/admin.ts +++ b/apps/memos-local-plugin/server/routes/admin.ts @@ -42,7 +42,9 @@ export function registerAdminRoutes(routes: Routes, deps: ServerDeps, options: S setTimeout(() => process.exit(0), 300); return { ok: true, restarting: true }; } - // Hermes / other hosts: no-op — the viewer shows a manual-restart toast. - return { ok: true, restarting: false, note: "config persisted; restart the agent process to apply" }; + // Hermes: the bridge IS the current process. Exit so that the next + // `hermes chat` invocation spawns a fresh bridge with updated config. + setTimeout(() => process.exit(0), 300); + return { ok: true, restarting: true }; }); } diff --git a/apps/memos-local-plugin/server/routes/session.ts b/apps/memos-local-plugin/server/routes/session.ts index 1bc7f9fa5..68545d669 100644 --- a/apps/memos-local-plugin/server/routes/session.ts +++ b/apps/memos-local-plugin/server/routes/session.ts @@ -57,6 +57,7 @@ export function registerSessionRoutes(routes: Routes, deps: ServerDeps): void { routes.set("GET /api/v1/episodes", async (ctx) => { const sessionId = (ctx.url.searchParams.get("sessionId") as SessionId | null) ?? undefined; + const q = (ctx.url.searchParams.get("q") || "").trim().toLowerCase(); const rawLimit = numberOrUndefined(ctx.url.searchParams.get("limit")); const rawOffset = numberOrUndefined(ctx.url.searchParams.get("offset")); const limit = rawLimit && rawLimit > 0 ? rawLimit : 50; @@ -76,7 +77,24 @@ export function registerSessionRoutes(routes: Routes, deps: ServerDeps): void { nextOffset: episodeIds.length === limit ? offset + limit : undefined, }; } - const episodes = await deps.core.listEpisodeRows({ sessionId, limit, offset }); + let episodes = await deps.core.listEpisodeRows({ + sessionId, + limit: q ? 200 : limit, + offset: q ? 0 : offset, + }); + if (q) { + episodes = episodes.filter( + (ep: { preview?: string }) => ep.preview && ep.preview.toLowerCase().includes(q), + ); + const paged = episodes.slice(offset, offset + limit); + return { + episodes: paged, + limit, + offset, + total: episodes.length, + nextOffset: episodes.length > offset + limit ? offset + limit : undefined, + }; + } return { episodes, limit, diff --git a/apps/memos-local-plugin/server/routes/skill.ts b/apps/memos-local-plugin/server/routes/skill.ts index f6f14ffbd..44425cac4 100644 --- a/apps/memos-local-plugin/server/routes/skill.ts +++ b/apps/memos-local-plugin/server/routes/skill.ts @@ -14,14 +14,20 @@ import { parseJson, writeError, type Routes } from "./registry.js"; export function registerSkillRoutes(routes: Routes, deps: ServerDeps): void { routes.set("GET /api/v1/skills", async (ctx) => { const status = (ctx.url.searchParams.get("status") as SkillDTO["status"] | null) ?? undefined; + const q = (ctx.url.searchParams.get("q") || "").trim().toLowerCase(); // Viewer needs prev/next pagination — ask for one extra page so we // can tell the client whether there's more without a count query. const pageSize = limitOrUndefined(ctx.url.searchParams.get("limit")) ?? 50; const offset = Math.max(0, Number(ctx.url.searchParams.get("offset") ?? 0) || 0); - const all = await deps.core.listSkills({ status, limit: pageSize + offset + 1 }); + let all = await deps.core.listSkills({ status, limit: q ? 5000 : pageSize + offset + 1 }); + if (q) { + all = all.filter( + (s) => s.name.toLowerCase().includes(q) || s.invocationGuide.toLowerCase().includes(q), + ); + } const page = all.slice(offset, offset + pageSize); const hasMore = all.length > offset + pageSize; - const total = await deps.core.countSkills({ status }); + const total = q ? all.length : await deps.core.countSkills({ status }); return { skills: page, limit: pageSize, diff --git a/apps/memos-local-plugin/web/src/components/Header.tsx b/apps/memos-local-plugin/web/src/components/Header.tsx index cd12e34d4..5cce6e669 100644 --- a/apps/memos-local-plugin/web/src/components/Header.tsx +++ b/apps/memos-local-plugin/web/src/components/Header.tsx @@ -1,41 +1,183 @@ /** - * Top bar — brand (logo + version pill), global search, peer agents, - * theme + language switchers. The notification bell was removed in - * favour of inline toasts; per-event status now surfaces in Logs/Live. + * Top bar — brand (logo + version pill), global search with categorized + * dropdown, peer agents, theme + language switchers. */ -import { useState, useEffect } from "preact/hooks"; +import { useState, useEffect, useRef, useCallback } from "preact/hooks"; import { t } from "../stores/i18n"; import { health } from "../stores/health"; import { peers, discoverPeers } from "../stores/peers"; -import { Icon } from "./Icon"; +import { Icon, type IconName } from "./Icon"; import { navigate } from "../stores/router"; import { ThemeLangFooter } from "./ThemeLangFooter"; +import { api } from "../api/client"; + +interface SearchCategory { + key: string; + icon: IconName; + labelKey: string; + route: string; + items: { id: string; text: string }[]; + loading: boolean; +} export function Header() { const h = health.value; const [searchQ, setSearchQ] = useState(""); + const [showDropdown, setShowDropdown] = useState(false); + const [categories, setCategories] = useState([]); + const containerRef = useRef(null); + const abortRef = useRef(null); const runSearch = (e: Event) => { e.preventDefault(); const q = searchQ.trim(); if (!q) return; + setShowDropdown(false); navigate("/memories", { q }); }; - // Discover other agent viewers running on nearby ports once after - // health is known. Updates `peers` for the agent switcher. + const fetchResults = useCallback(async (q: string) => { + if (abortRef.current) abortRef.current.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + + const empty: SearchCategory[] = [ + { key: "memories", icon: "brain-circuit", labelKey: "nav.memories", route: "/memories", items: [], loading: true }, + { key: "tasks", icon: "list-checks", labelKey: "nav.tasks", route: "/tasks", items: [], loading: true }, + { key: "skills", icon: "wand-sparkles", labelKey: "nav.skills", route: "/skills", items: [], loading: true }, + { key: "policies", icon: "sparkles", labelKey: "nav.policies", route: "/policies", items: [], loading: true }, + { key: "world-models", icon: "globe", labelKey: "nav.worldModels", route: "/world-models", items: [], loading: true }, + ]; + setCategories(empty); + setShowDropdown(true); + + const signal = ctrl.signal; + const limit = 3; + + const fetchers = [ + api + .get<{ traces: { id: string; summary?: string; userText?: string }[] }>( + `/api/v1/traces?q=${encodeURIComponent(q)}&limit=${limit}`, + { signal }, + ) + .then((r) => + (r.traces ?? []).map((t) => ({ + id: t.id, + text: (t.summary || t.userText || "").slice(0, 80), + })), + ) + .catch(() => [] as { id: string; text: string }[]), + + api + .get<{ episodes: { id: string; preview?: string }[] }>( + `/api/v1/episodes?q=${encodeURIComponent(q)}&limit=${limit}`, + { signal }, + ) + .then((r) => + (r.episodes ?? []).map((ep) => ({ + id: ep.id, + text: (ep.preview || "").slice(0, 80), + })), + ) + .catch(() => [] as { id: string; text: string }[]), + + api + .get<{ skills: { id: string; name: string }[] }>( + `/api/v1/skills?q=${encodeURIComponent(q)}&limit=${limit}`, + { signal }, + ) + .then((r) => + (r.skills ?? []).map((s) => ({ + id: s.id, + text: s.name, + })), + ) + .catch(() => [] as { id: string; text: string }[]), + + api + .get<{ policies: { id: string; title?: string; trigger?: string }[] }>( + `/api/v1/policies?q=${encodeURIComponent(q)}&limit=${limit}`, + { signal }, + ) + .then((r) => + (r.policies ?? []).map((p) => ({ + id: p.id, + text: (p.title || p.trigger || "").slice(0, 80), + })), + ) + .catch(() => [] as { id: string; text: string }[]), + + api + .get<{ worldModels: { id: string; title?: string }[] }>( + `/api/v1/world-models?q=${encodeURIComponent(q)}&limit=${limit}`, + { signal }, + ) + .then((r) => + (r.worldModels ?? []).map((w) => ({ + id: w.id, + text: (w.title || "").slice(0, 80), + })), + ) + .catch(() => [] as { id: string; text: string }[]), + ]; + + const results = await Promise.allSettled(fetchers); + if (signal.aborted) return; + + setCategories((prev) => + prev.map((cat, i) => ({ + ...cat, + items: results[i].status === "fulfilled" ? results[i].value : [], + loading: false, + })), + ); + }, []); + + useEffect(() => { + const q = searchQ.trim(); + if (!q) { + setShowDropdown(false); + setCategories([]); + return; + } + const timer = setTimeout(() => void fetchResults(q), 250); + return () => clearTimeout(timer); + }, [searchQ, fetchResults]); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setShowDropdown(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const handleItemClick = (cat: SearchCategory, itemId: string) => { + setShowDropdown(false); + setSearchQ(""); + navigate(cat.route, { q: searchQ.trim() }); + }; + + const handleCategoryMore = (cat: SearchCategory) => { + setShowDropdown(false); + setSearchQ(""); + navigate(cat.route, { q: searchQ.trim() }); + }; + const peerList = peers.value; useEffect(() => { if (!h?.agent) return; void discoverPeers(); }, [h?.agent]); + const totalResults = categories.reduce((sum, c) => sum + c.items.length, 0); + const anyLoading = categories.some((c) => c.loading); + return (
- {/* - * Brand: local MemOS logo + a small OpenClaw/Hermes agent icon. - */}