feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1
@@ -151,3 +151,33 @@ export async function gatewayOnline(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// —— 服务状态:基建 / 应用服务探活 + MCP 工具注册 ——
|
||||
export interface StatusItem {
|
||||
name: string;
|
||||
up: boolean;
|
||||
detail?: string;
|
||||
latency_ms?: number;
|
||||
}
|
||||
export interface ToolInfo {
|
||||
name: string;
|
||||
cn: string;
|
||||
desc: string;
|
||||
}
|
||||
export interface ToolGroup {
|
||||
server: string;
|
||||
up: boolean;
|
||||
tools: ToolInfo[] | null;
|
||||
}
|
||||
export interface SystemStatus {
|
||||
checked_at: string;
|
||||
infra: StatusItem[];
|
||||
services: StatusItem[];
|
||||
tools: ToolGroup[];
|
||||
}
|
||||
|
||||
export async function getStatus(): Promise<SystemStatus> {
|
||||
const res = guard(await fetch(`${ADMIN}/status`, { headers: authHeaders() }));
|
||||
if (!res.ok) throw new Error(`status failed: ${res.status}`);
|
||||
return (await res.json()) as SystemStatus;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getStatus, type StatusItem, type SystemStatus, type ToolInfo } from "../api";
|
||||
|
||||
const REFRESH_SEC = 5;
|
||||
|
||||
// 服务/基建的展示元数据(角色说明 + 图标)。
|
||||
const SERVICE_META: Record<string, { role: string; icon: IconName }> = {
|
||||
gateway: { role: "HTTP 接入层 · 鉴权 / 限流 / SSE", icon: "gateway" },
|
||||
dispatcher: { role: "编排执行 · Eino 图引擎", icon: "cpu" },
|
||||
"mcp-go": { role: "Go I/O 工具 · RAG / 记忆 / 报告", icon: "tool" },
|
||||
"mcp-py": { role: "Python 算法工具 · 沙箱 / 解析", icon: "box" },
|
||||
};
|
||||
const INFRA_META: Record<string, { role: string; icon: IconName }> = {
|
||||
postgres: { role: "关系库 · 5432", icon: "db" },
|
||||
redis: { role: "缓存 / 限流 · 6379", icon: "db" },
|
||||
nats: { role: "消息总线 · 4222", icon: "bus" },
|
||||
milvus: { role: "向量库 · 19530", icon: "db" },
|
||||
neo4j: { role: "图数据库 · 7687", icon: "bus" },
|
||||
};
|
||||
|
||||
// 工具按名称前缀归类,便于一眼看清能力域。
|
||||
function toolCategory(t: string): string {
|
||||
if (t.startsWith("memory_")) return "记忆";
|
||||
if (t.startsWith("kb_") || t.startsWith("wiki_")) return "知识库 / 检索";
|
||||
if (t.startsWith("report_")) return "报告";
|
||||
if (t.startsWith("history_")) return "会话历史";
|
||||
if (t.startsWith("external_")) return "外部接入";
|
||||
if (["run_code", "secure_sandbox", "parse_document"].includes(t)) return "算法 / 沙箱";
|
||||
return "系统";
|
||||
}
|
||||
|
||||
export function StatusPage() {
|
||||
const [data, setData] = useState<SystemStatus | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [auto, setAuto] = useState(true);
|
||||
const [countdown, setCountdown] = useState(REFRESH_SEC);
|
||||
const autoRef = useRef(auto);
|
||||
autoRef.current = auto;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setData(await getStatus());
|
||||
setErr("");
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setBusy(false);
|
||||
setCountdown(REFRESH_SEC);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// 1s 心跳:倒计时显示 + 到点自动刷新(可暂停)。
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
if (!autoRef.current) return;
|
||||
setCountdown((c) => {
|
||||
if (c <= 1) {
|
||||
void load();
|
||||
return REFRESH_SEC;
|
||||
}
|
||||
return c - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const svc = (n: string) => data?.services.find((s) => s.name === n);
|
||||
const infra = (n: string) => data?.infra.find((s) => s.name === n);
|
||||
|
||||
const servicesUp = data?.services.filter((s) => s.up).length ?? 0;
|
||||
const infraUp = data?.infra.filter((s) => s.up).length ?? 0;
|
||||
const toolCount = data?.tools.reduce((n, g) => n + (g.up ? g.tools?.length ?? 0 : 0), 0) ?? 0;
|
||||
const downCount =
|
||||
(data?.services.filter((s) => !s.up).length ?? 0) + (data?.infra.filter((s) => !s.up).length ?? 0);
|
||||
const allUp = data != null && downCount === 0;
|
||||
|
||||
if (loading && !data) return <div className="text-sm text-gray-400">加载中…</div>;
|
||||
if (!data) return <div className="text-sm text-rose-600">拉取失败:{err}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 总览横幅 */}
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-4 rounded-xl border p-4 ${
|
||||
allUp ? "border-emerald-200 bg-emerald-50/60" : "border-amber-200 bg-amber-50/60"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-11 w-11 items-center justify-center rounded-full ${
|
||||
allUp ? "bg-emerald-500" : "bg-amber-500"
|
||||
} text-white`}
|
||||
>
|
||||
<Icon name={allUp ? "check" : "alert"} className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-800">
|
||||
{allUp ? "系统运行正常" : `${downCount} 项异常`}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{allUp ? "所有服务与基建均已就绪" : "部分服务或基建未就绪,请检查下方明细"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-gray-500">
|
||||
<button
|
||||
onClick={() => setAuto((a) => !a)}
|
||||
className={`rounded-full px-2.5 py-1 ${
|
||||
auto ? "bg-emerald-100 text-emerald-700" : "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
title="自动刷新"
|
||||
>
|
||||
{auto ? `自动刷新 · ${countdown}s` : "已暂停"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
className="flex items-center gap-1.5 rounded-full border bg-white px-3 py-1 text-violet-600 hover:bg-violet-50"
|
||||
>
|
||||
<Icon name="refresh" className={`h-3.5 w-3.5 ${busy ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 摘要数字 */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label="应用服务" value={`${servicesUp}/${data.services.length}`} ok={servicesUp === data.services.length} />
|
||||
<Stat label="基建环境" value={`${infraUp}/${data.infra.length}`} ok={infraUp === data.infra.length} />
|
||||
<Stat label="已注册工具" value={toolCount} ok />
|
||||
<Stat label="最后检查" value={new Date(data.checked_at).toLocaleTimeString()} ok muted />
|
||||
</div>
|
||||
|
||||
{/* 请求链路拓扑 */}
|
||||
<Panel title="请求链路" hint="桌面端 / 管理端 → 网关 → 总线 → 调度 → 工具层">
|
||||
<div className="flex flex-wrap items-stretch gap-1 overflow-x-auto py-1">
|
||||
<FlowNode icon="monitor" label="客户端" sub="桌面端 / 管理端" state="up" />
|
||||
<Arrow ok={svc("gateway")?.up} />
|
||||
<FlowNode icon="gateway" label="网关" sub="Gateway :8080" state={state(svc("gateway")?.up)} />
|
||||
<Arrow ok={infra("nats")?.up} />
|
||||
<FlowNode icon="bus" label="NATS 总线" sub="JetStream" state={state(infra("nats")?.up)} />
|
||||
<Arrow ok={svc("dispatcher")?.up} />
|
||||
<FlowNode icon="cpu" label="调度中心" sub="Dispatcher" state={state(svc("dispatcher")?.up)} />
|
||||
<Arrow ok={svc("mcp-go")?.up || svc("mcp-py")?.up} />
|
||||
<FlowNode
|
||||
icon="tool"
|
||||
label="MCP 工具层"
|
||||
sub="mcp-go · mcp-py"
|
||||
state={mcpState(svc("mcp-go")?.up, svc("mcp-py")?.up)}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* 应用服务 */}
|
||||
<Panel title="应用服务" hint="四个进程的存活与探针耗时">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{data.services.map((s) => (
|
||||
<ServiceCard key={s.name} item={s} />
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* 基建环境 */}
|
||||
<Panel title="基建环境" hint="Postgres / Redis / NATS / Milvus / Neo4j">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
{data.infra.map((s) => (
|
||||
<InfraTile key={s.name} item={s} />
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* MCP 工具注册 */}
|
||||
<Panel title="MCP 工具注册" hint="各 MCP 服务在线时上报、按能力域分组">
|
||||
<div className="space-y-3">
|
||||
{data.tools.map((g) => (
|
||||
<ToolServer key={g.server} server={g.server} up={g.up} tools={g.tools ?? []} />
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 子组件 ----
|
||||
|
||||
type FlowState = "up" | "down" | "partial";
|
||||
const state = (up?: boolean): FlowState => (up ? "up" : "down");
|
||||
const mcpState = (a?: boolean, b?: boolean): FlowState => (a && b ? "up" : a || b ? "partial" : "down");
|
||||
|
||||
function Stat({ label, value, ok, muted }: { label: string; value: string | number; ok: boolean; muted?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-white p-3">
|
||||
<div className="text-[11px] text-gray-400">{label}</div>
|
||||
<div className={`mt-1 text-xl font-semibold ${muted ? "text-gray-700" : ok ? "text-gray-800" : "text-rose-600"}`}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, hint, children }: { title: string; hint: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<div className="mb-3 flex items-baseline gap-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700">{title}</h3>
|
||||
<span className="text-[11px] text-gray-400">{hint}</span>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const FLOW_TONE: Record<FlowState, string> = {
|
||||
up: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
partial: "border-amber-200 bg-amber-50 text-amber-700",
|
||||
down: "border-rose-200 bg-rose-50 text-rose-700",
|
||||
};
|
||||
|
||||
function FlowNode({ icon, label, sub, state }: { icon: IconName; label: string; sub: string; state: FlowState }) {
|
||||
return (
|
||||
<div className={`flex min-w-[120px] flex-1 flex-col items-center rounded-lg border px-3 py-2.5 text-center ${FLOW_TONE[state]}`}>
|
||||
<Icon name={icon} className="mb-1 h-5 w-5" />
|
||||
<div className="text-xs font-semibold">{label}</div>
|
||||
<div className="text-[10px] opacity-70">{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Arrow({ ok }: { ok?: boolean }) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center px-0.5">
|
||||
<svg viewBox="0 0 24 24" className={`h-4 w-8 ${ok ? "text-emerald-400" : "text-rose-300"}`}>
|
||||
<path d="M2 12 h17 M15 7 l5 5 -5 5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceCard({ item }: { item: StatusItem }) {
|
||||
const meta = SERVICE_META[item.name];
|
||||
return (
|
||||
<div className={`rounded-lg border p-3.5 ${item.up ? "bg-white" : "border-rose-200 bg-rose-50/50"}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
|
||||
item.up ? "bg-violet-50 text-violet-600" : "bg-rose-100 text-rose-500"
|
||||
}`}
|
||||
>
|
||||
<Icon name={meta?.icon ?? "tool"} className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800">{item.name}</span>
|
||||
<StatusPill up={item.up} />
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-gray-400">{meta?.role}</div>
|
||||
</div>
|
||||
{item.up && item.latency_ms != null && (
|
||||
<span className="ml-auto flex items-center gap-1 text-[11px] text-gray-400" title="探针往返耗时">
|
||||
<Icon name="bolt" className="h-3 w-3" />
|
||||
{item.latency_ms}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`mt-2 border-t pt-2 text-xs ${item.up ? "text-gray-500" : "text-rose-600"}`}>{item.detail}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfraTile({ item }: { item: StatusItem }) {
|
||||
const meta = INFRA_META[item.name];
|
||||
return (
|
||||
<div className={`rounded-lg border p-3 ${item.up ? "bg-white" : "border-rose-200 bg-rose-50/50"}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name={meta?.icon ?? "db"} className={`h-4 w-4 ${item.up ? "text-gray-400" : "text-rose-400"}`} />
|
||||
<span className="text-sm font-medium text-gray-800">{item.name}</span>
|
||||
<span className={`ml-auto h-2 w-2 rounded-full ${item.up ? "bg-emerald-500" : "bg-rose-500"}`} />
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-gray-400">{meta?.role}</div>
|
||||
<div className={`mt-0.5 text-[11px] font-medium ${item.up ? "text-emerald-600" : "text-rose-600"}`}>
|
||||
{item.up ? "就绪" : "离线"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolServer({ server, up, tools }: { server: string; up: boolean; tools: ToolInfo[] }) {
|
||||
// 按能力域分组。
|
||||
const groups: Record<string, ToolInfo[]> = {};
|
||||
for (const t of tools) (groups[toolCategory(t.name)] ??= []).push(t);
|
||||
const order = ["知识库 / 检索", "记忆", "报告", "会话历史", "外部接入", "算法 / 沙箱", "系统"];
|
||||
const cats = Object.keys(groups).sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border p-3 ${up ? "" : "border-rose-200 bg-rose-50/40"}`}>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${up ? "bg-emerald-500" : "bg-rose-500"}`} />
|
||||
<span className="text-sm font-medium text-gray-800">{server}</span>
|
||||
<span className="text-xs text-gray-400">{up ? `${tools.length} 个工具` : "无响应(未启动?)"}</span>
|
||||
</div>
|
||||
{up && cats.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{cats.map((c) => (
|
||||
<div key={c}>
|
||||
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-gray-400">{c}</div>
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{groups[c].map((t) => (
|
||||
<div key={t.name} className="rounded-lg border bg-gray-50/60 px-2.5 py-1.5">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-xs font-medium text-gray-700">{t.cn}</span>
|
||||
<span className="font-mono text-[10px] text-gray-400">{t.name}</span>
|
||||
</div>
|
||||
<div className="text-[10px] leading-snug text-gray-500">{t.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ up }: { up: boolean }) {
|
||||
return up ? (
|
||||
<span className="flex items-center gap-1 rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] text-emerald-700">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
运行中
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-rose-100 px-1.5 py-0.5 text-[10px] text-rose-700">离线</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 内联图标(无依赖;lucide 风格 stroke 路径)----
|
||||
type IconName =
|
||||
| "check" | "alert" | "refresh" | "monitor" | "gateway" | "bus" | "cpu" | "tool" | "box" | "db" | "bolt";
|
||||
|
||||
const PATHS: Record<IconName, string> = {
|
||||
check: "M20 6 9 17l-5-5",
|
||||
alert: "M12 9v4 M12 17h.01 M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z",
|
||||
refresh: "M21 12a9 9 0 1 1-3-6.7L21 8 M21 3v5h-5",
|
||||
monitor: "M3 4h18v12H3z M8 20h8 M12 16v4",
|
||||
gateway: "M4 4h16v6H4z M4 14h16v6H4z M8 7h.01 M8 17h.01",
|
||||
bus: "M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M6 15a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M18 16a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M8.6 13.5l6.8 4 M15.4 6.5l-6.8 4",
|
||||
cpu: "M6 6h12v12H6z M9 9h6v6H9z M9 1v3 M15 1v3 M9 20v3 M15 20v3 M1 9h3 M1 15h3 M20 9h3 M20 15h3",
|
||||
tool: "M14.7 6.3a4 4 0 0 1-5.4 5.4L4 17v3h3l5.3-5.3a4 4 0 0 0 5.4-5.4l-2.7 2.7-2-2 2.7-2.7z",
|
||||
box: "M21 8 12 3 3 8v8l9 5 9-5z M3 8l9 5 9-5 M12 13v8",
|
||||
db: "M12 3c4.4 0 8 1.3 8 3s-3.6 3-8 3-8-1.3-8-3 3.6-3 8-3z M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6 M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6",
|
||||
bolt: "M13 2 3 14h7l-1 8 10-12h-7l1-8z",
|
||||
};
|
||||
|
||||
function Icon({ name, className }: { name: IconName; className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={PATHS[name]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Soon } from "./components/Soon";
|
||||
const ModelsPage = lazy(() => import("./pages/ModelsPage").then((m) => ({ default: m.ModelsPage })));
|
||||
const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage })));
|
||||
const PricingPage = lazy(() => import("./pages/PricingPage").then((m) => ({ default: m.PricingPage })));
|
||||
const StatusPage = lazy(() => import("./pages/StatusPage").then((m) => ({ default: m.StatusPage })));
|
||||
|
||||
export interface RouteDef {
|
||||
path: string;
|
||||
@@ -37,6 +38,13 @@ export const routes: RouteDef[] = [
|
||||
ready: true,
|
||||
element: <PricingPage />,
|
||||
},
|
||||
{
|
||||
path: "/status",
|
||||
label: "服务状态",
|
||||
group: "运维",
|
||||
ready: true,
|
||||
element: <StatusPage />,
|
||||
},
|
||||
{
|
||||
path: "/tenants",
|
||||
label: "租户",
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -46,6 +47,23 @@ func main() {
|
||||
log.Fatalf("[dispatcher] build eino graph: %v", err)
|
||||
}
|
||||
|
||||
// 健康心跳:dispatcher 无 HTTP/工具端点,挂一个 NATS 应答让管理端「服务状态」探到它在线。
|
||||
startedAt := time.Now()
|
||||
if unsub, herr := sub.ServeHealth(func() []byte {
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
"ok": true,
|
||||
"service": "dispatcher",
|
||||
"model": pool.ModelName(),
|
||||
"ready": pool.Ready(),
|
||||
"uptime_s": int(time.Since(startedAt).Seconds()),
|
||||
})
|
||||
return data
|
||||
}); herr != nil {
|
||||
log.Printf("[dispatcher] serve health: %v", herr)
|
||||
} else {
|
||||
defer func() { _ = unsub() }()
|
||||
}
|
||||
|
||||
// 监听退出信号,优雅停止消费。
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
@@ -52,6 +52,14 @@ func (p *Pool) config() *contract.ModelConfig {
|
||||
// Ready 报告是否已配置可用后端。
|
||||
func (p *Pool) Ready() bool { return p.config().Ready() }
|
||||
|
||||
// ModelName 返回当前激活的对话模型名(未配置则空)—— 供服务状态面板展示。
|
||||
func (p *Pool) ModelName() string {
|
||||
if cfg := p.config(); cfg != nil {
|
||||
return cfg.Model
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ChatStream 以 OpenAI 兼容协议流式推理,逐 token 回调 onToken。
|
||||
// 仅在 Ready() 时可用(调用方据此决定真实推理或降级桩)。
|
||||
func (p *Pool) ChatStream(ctx context.Context, msgs []ChatMessage, onToken func(string)) error {
|
||||
|
||||
@@ -68,6 +68,11 @@ func (s *Subscriber) CallTool(ctx context.Context, subject string, call *contrac
|
||||
return s.inner.CallTool(ctx, subject, call)
|
||||
}
|
||||
|
||||
// ServeHealth 在 dispatcher 心跳主题上应答探活,让管理端「服务状态」判定其在线。
|
||||
func (s *Subscriber) ServeHealth(provide func() []byte) (func() error, error) {
|
||||
return s.inner.ServeHealth(contract.SubjectHealthDispatcher, provide)
|
||||
}
|
||||
|
||||
// RequestModelConfig 向控制面(Gateway)取当前激活的对话模型配置。
|
||||
func (s *Subscriber) RequestModelConfig(ctx context.Context) (*contract.ModelConfig, error) {
|
||||
return s.inner.RequestConfig(ctx, contract.ConfigKindChat)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
)
|
||||
|
||||
// statusItem 是一项依赖/服务的存活状态(基建灯、服务灯共用)。
|
||||
type statusItem struct {
|
||||
Name string `json:"name"`
|
||||
Up bool `json:"up"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Latency int `json:"latency_ms,omitempty"` // NATS 探针往返耗时(毫秒),本地检查项为 0
|
||||
}
|
||||
|
||||
// toolInfo 是一个注册工具的元信息(透传各 MCP 服务 list_tools 的上报)。
|
||||
type toolInfo struct {
|
||||
Name string `json:"name"`
|
||||
CN string `json:"cn"`
|
||||
Desc string `json:"desc"`
|
||||
}
|
||||
|
||||
// toolGroup 是一台 MCP 服务的工具注册情况。
|
||||
type toolGroup struct {
|
||||
Server string `json:"server"`
|
||||
Up bool `json:"up"`
|
||||
Tools []toolInfo `json:"tools"`
|
||||
}
|
||||
|
||||
// systemStatus 是「服务状态」面板的聚合视图:基建 / 应用服务 / MCP 工具注册。
|
||||
type systemStatus struct {
|
||||
CheckedAt string `json:"checked_at"`
|
||||
Infra []statusItem `json:"infra"`
|
||||
Services []statusItem `json:"services"`
|
||||
Tools []toolGroup `json:"tools"`
|
||||
}
|
||||
|
||||
// probeTimeout 是各探针的单次超时(无响应即判为下线)。
|
||||
const probeTimeout = 2 * time.Second
|
||||
|
||||
// AdminStatus: GET /api/v1/admin/status —— 聚合基建、应用服务与 MCP 工具注册的实时状态,
|
||||
// 供管理端「服务状态」一眼看出哪个服务没起、基建是否就绪、工具是否注册。
|
||||
func (h *Handler) AdminStatus(c *gin.Context) {
|
||||
parent := c.Request.Context()
|
||||
|
||||
// 各探针互不依赖,并发执行,整体只等最慢的一个。
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
|
||||
milvus, neo4j bool // mcp-go health
|
||||
goUp bool // mcp-go 在线
|
||||
goTools []toolInfo // mcp-go 注册工具
|
||||
goLatency int // mcp-go 探针耗时
|
||||
pyUp bool // mcp-py 在线
|
||||
pyTools []toolInfo // mcp-py 注册工具
|
||||
pyLatency int // mcp-py 探针耗时
|
||||
dispUp bool // dispatcher 在线
|
||||
dispDetail string // dispatcher 详情(模型/运行时长)
|
||||
dispLatency int // dispatcher 探针耗时
|
||||
)
|
||||
|
||||
wg.Add(4)
|
||||
|
||||
// 1) mcp-go health → milvus / neo4j 基建灯
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||
defer cancel()
|
||||
if res, err := h.bus.CallTool(ctx, contract.ToolSubjectGo("health"),
|
||||
&contract.ToolCall{Tool: "health"}); err == nil && res != nil && res.OK {
|
||||
var sub map[string]bool
|
||||
if json.Unmarshal([]byte(res.Content), &sub) == nil {
|
||||
milvus, neo4j = sub["milvus"], sub["neo4j"]
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 2) mcp-go list_tools → 在线判定 + 工具清单
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
goUp, goTools, goLatency = h.probeTools(parent, contract.ToolSubjectGo("list_tools"))
|
||||
}()
|
||||
|
||||
// 3) mcp-py list_tools → 在线判定 + 工具清单
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
pyUp, pyTools, pyLatency = h.probeTools(parent, contract.ToolSubjectPy("list_tools"))
|
||||
}()
|
||||
|
||||
// 4) dispatcher 心跳 → 在线判定 + 模型/运行时长
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
if data, err := h.bus.Ping(ctx, contract.SubjectHealthDispatcher); err == nil {
|
||||
dispUp = true
|
||||
dispLatency = int(time.Since(start).Milliseconds())
|
||||
var st struct {
|
||||
Model string `json:"model"`
|
||||
Ready bool `json:"ready"`
|
||||
UptimeS int `json:"uptime_s"`
|
||||
}
|
||||
if json.Unmarshal(data, &st) == nil {
|
||||
dispDetail = dispatcherDetail(st.Model, st.Ready, st.UptimeS)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
c.JSON(http.StatusOK, systemStatus{
|
||||
CheckedAt: time.Now().Format(time.RFC3339),
|
||||
Infra: []statusItem{
|
||||
{Name: "postgres", Up: h.db.Enabled()},
|
||||
{Name: "redis", Up: h.cache.Enabled()},
|
||||
{Name: "nats", Up: true}, // 网关连不上 NATS 即 fatal,能应答即在线
|
||||
{Name: "milvus", Up: milvus},
|
||||
{Name: "neo4j", Up: neo4j},
|
||||
},
|
||||
Services: []statusItem{
|
||||
{Name: "gateway", Up: true, Detail: "在线"},
|
||||
{Name: "dispatcher", Up: dispUp, Detail: serviceDetail(dispUp, dispDetail), Latency: dispLatency},
|
||||
{Name: "mcp-go", Up: goUp, Detail: toolsDetail(goUp, len(goTools)), Latency: goLatency},
|
||||
{Name: "mcp-py", Up: pyUp, Detail: toolsDetail(pyUp, len(pyTools)), Latency: pyLatency},
|
||||
},
|
||||
Tools: []toolGroup{
|
||||
{Server: "mcp-go", Up: goUp, Tools: goTools},
|
||||
{Server: "mcp-py", Up: pyUp, Tools: pyTools},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// probeTools 调一台 MCP 服务的 list_tools:能应答即在线,并解析其工具清单(含中文名/作用)+ 往返耗时。
|
||||
func (h *Handler) probeTools(parent context.Context, subject string) (up bool, tools []toolInfo, latency int) {
|
||||
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
res, err := h.bus.CallTool(ctx, subject, &contract.ToolCall{Tool: "list_tools"})
|
||||
if err != nil || res == nil || !res.OK {
|
||||
return false, nil, 0
|
||||
}
|
||||
var payload struct {
|
||||
Tools []toolInfo `json:"tools"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(res.Content), &payload)
|
||||
return true, payload.Tools, int(time.Since(start).Milliseconds())
|
||||
}
|
||||
|
||||
func dispatcherDetail(model string, ready bool, uptimeS int) string {
|
||||
d := "运行 " + humanDuration(uptimeS)
|
||||
if model != "" {
|
||||
d = "模型 " + model + " · " + d
|
||||
}
|
||||
if !ready {
|
||||
d += "(模型未配置,降级桩)"
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func serviceDetail(up bool, detail string) string {
|
||||
if !up {
|
||||
return "无响应(未启动?)"
|
||||
}
|
||||
if detail == "" {
|
||||
return "在线"
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func toolsDetail(up bool, n int) string {
|
||||
if !up {
|
||||
return "无响应(未启动?)"
|
||||
}
|
||||
return fmt.Sprintf("%d 个工具", n)
|
||||
}
|
||||
|
||||
// humanDuration 把秒数转人读(< 1h 显示分钟,否则小时+分钟)。
|
||||
func humanDuration(s int) string {
|
||||
if s < 60 {
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
m := s / 60
|
||||
if m < 60 {
|
||||
return fmt.Sprintf("%dm", m)
|
||||
}
|
||||
return fmt.Sprintf("%dh%dm", m/60, m%60)
|
||||
}
|
||||
@@ -54,6 +54,11 @@ func (b *Bus) CallTool(ctx context.Context, subject string, call *contract.ToolC
|
||||
return b.inner.CallTool(ctx, subject, call)
|
||||
}
|
||||
|
||||
// Ping 同步探测某节点健康(如 dispatcher 心跳主题)。无人应答 / 超时即返回错误(视为下线)。
|
||||
func (b *Bus) Ping(ctx context.Context, subject string) ([]byte, error) {
|
||||
return b.inner.Ping(ctx, subject)
|
||||
}
|
||||
|
||||
// ServeConfig 让网关作为配置控制面,响应某 kind 的配置请求。
|
||||
func (b *Bus) ServeConfig(kind string, provide func() *contract.ModelConfig) (func() error, error) {
|
||||
return b.inner.ServeConfig(kind, provide)
|
||||
|
||||
@@ -79,6 +79,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
||||
admin.POST("/models/test", h.TestModel)
|
||||
admin.GET("/pricing", h.ListPricing) // 各模型计价(token↔真钱)
|
||||
admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价
|
||||
admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
|
||||
}
|
||||
}
|
||||
return r
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,10 +29,20 @@ type Gateway struct {
|
||||
memory *memory.Store
|
||||
history *history.Store
|
||||
rag *rag.Engine
|
||||
tools map[string]toolDef // 工具注册表:唯一事实源,dispatch 与 list_tools 共用,杜绝漂移
|
||||
}
|
||||
|
||||
// toolDef 是一个注册工具的元信息(中文名 / 作用)+ 处理函数。
|
||||
type toolDef struct {
|
||||
cn string // 中文名
|
||||
desc string // 作用简述
|
||||
handler func(context.Context, *contract.ToolCall) *contract.ToolResult
|
||||
}
|
||||
|
||||
func NewGateway(b *sharedbus.Bus, s *search.Hybrid, m *memory.Store, h *history.Store, r *rag.Engine) *Gateway {
|
||||
return &Gateway{bus: b, search: s, memory: m, history: h, rag: r}
|
||||
g := &Gateway{bus: b, search: s, memory: m, history: h, rag: r}
|
||||
g.tools = g.buildRegistry()
|
||||
return g
|
||||
}
|
||||
|
||||
// Serve 以队列组通配订阅 sundynix.tools.go.>,按工具名分发并阻塞。
|
||||
@@ -47,46 +58,64 @@ func (g *Gateway) Serve(ctx context.Context) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// dispatch 按 ToolCall.Tool 路由到具体工具实现。
|
||||
func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
|
||||
log.Printf("[mcp_go] tool=%s task=%s args=%v", call.Tool, call.TaskID, call.Args)
|
||||
switch call.Tool {
|
||||
case "wiki_search":
|
||||
return g.wikiSearch(ctx, call)
|
||||
case "kb_ingest":
|
||||
return g.kbIngest(ctx, call)
|
||||
case "kb_search":
|
||||
return g.kbSearch(ctx, call)
|
||||
case "kb_graph":
|
||||
return g.kbGraph(ctx, call)
|
||||
case "report_render":
|
||||
return g.reportRender(ctx, call)
|
||||
case "report_store":
|
||||
return g.reportStore(ctx, call)
|
||||
case "report_export":
|
||||
return g.reportExport(ctx, call)
|
||||
case "external_api":
|
||||
return g.externalAPI(ctx, call)
|
||||
case "health":
|
||||
// buildRegistry 注册 mcp-go 全部工具:名称 → (中文名, 作用, 处理函数)。
|
||||
// 这是工具的唯一事实源——dispatch 据此路由、list_tools 据此上报,二者永不漂移。
|
||||
func (g *Gateway) buildRegistry() map[string]toolDef {
|
||||
return map[string]toolDef{
|
||||
"wiki_search": {"知识检索", "向量检索知识库(Milvus),返回最相关片段", g.wikiSearch},
|
||||
"kb_ingest": {"知识入库", "文本切块 → 向量化 → 写入 Milvus / Bleve", g.kbIngest},
|
||||
"kb_search": {"检索台查询", "结构化返回命中内容与相似度分数", g.kbSearch},
|
||||
"kb_graph": {"知识图谱", "取某库的实体关系三元组(Neo4j)", g.kbGraph},
|
||||
"report_render": {"报告渲染", "把结构化报告渲染为 Word(.docx)", g.reportRender},
|
||||
"report_store": {"报告存源", "暂存报告源数据,供导出时按需渲染", g.reportStore},
|
||||
"report_export": {"报告导出", "按需把已存报告导出为 Word / Markdown", g.reportExport},
|
||||
"external_api": {"外部接口", "受控调用第三方 HTTP API(带 SSRF 校验)", g.externalAPI},
|
||||
"memory_get": {"记忆召回", "取用户长期画像(已按打分排序)", g.memoryGet},
|
||||
"memory_upsert": {"记忆写入", "新增 / 更新一条用户偏好(带重要度)", g.memoryUpsert},
|
||||
"memory_delete": {"记忆删除", "软删一条偏好(对账判定过时 / 矛盾时)", g.memoryDelete},
|
||||
"memory_list": {"记忆列表", "列出用户全部偏好(供管理面板查看)", g.memoryList},
|
||||
"history_get": {"历史召回", "取会话最近多轮对话", g.historyGet},
|
||||
"history_append": {"历史追加", "往会话写入一条消息", g.historyAppend},
|
||||
"health": {"健康检查", "上报 Milvus / Neo4j / embedding 就绪情况",
|
||||
func(_ context.Context, _ *contract.ToolCall) *contract.ToolResult {
|
||||
data, _ := json.Marshal(g.rag.Status())
|
||||
return &contract.ToolResult{OK: true, Content: string(data)}
|
||||
case "memory_get":
|
||||
return g.memoryGet(ctx, call)
|
||||
case "memory_upsert":
|
||||
return g.memoryUpsert(ctx, call)
|
||||
case "memory_delete":
|
||||
return g.memoryDelete(ctx, call)
|
||||
case "memory_list":
|
||||
return g.memoryList(ctx, call)
|
||||
case "history_get":
|
||||
return g.historyGet(ctx, call)
|
||||
case "history_append":
|
||||
return g.historyAppend(ctx, call)
|
||||
case "echo":
|
||||
}},
|
||||
"echo": {"回显", "原样返回入参(调试用)",
|
||||
func(_ context.Context, call *contract.ToolCall) *contract.ToolResult {
|
||||
return &contract.ToolResult{OK: true, Content: fmt.Sprint(call.Args["text"])}
|
||||
default:
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch 按 ToolCall.Tool 从注册表路由到具体工具实现。
|
||||
// list_tools 是元工具(自省),不在业务注册表内,单独处理。
|
||||
func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
|
||||
log.Printf("[mcp_go] tool=%s task=%s args=%v", call.Tool, call.TaskID, call.Args)
|
||||
if call.Tool == "list_tools" {
|
||||
return g.listTools()
|
||||
}
|
||||
td, ok := g.tools[call.Tool]
|
||||
if !ok {
|
||||
return &contract.ToolResult{OK: false, Error: "unknown tool: " + call.Tool}
|
||||
}
|
||||
return td.handler(ctx, call)
|
||||
}
|
||||
|
||||
// listTools 自省:上报本服务注册的工具清单(名称 + 中文名 + 作用),供管理端展示。
|
||||
func (g *Gateway) listTools() *contract.ToolResult {
|
||||
type info struct {
|
||||
Name string `json:"name"`
|
||||
CN string `json:"cn"`
|
||||
Desc string `json:"desc"`
|
||||
}
|
||||
out := make([]info, 0, len(g.tools))
|
||||
for name, td := range g.tools {
|
||||
out = append(out, info{Name: name, CN: td.cn, Desc: td.desc})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) // map 无序 → 稳定输出
|
||||
data, _ := json.Marshal(map[string]any{"service": "mcp-go", "tools": out})
|
||||
return &contract.ToolResult{OK: true, Content: string(data)}
|
||||
}
|
||||
|
||||
// memoryGet 召回某用户的常驻画像(已渲染为可注入 prompt 的多行文本)。
|
||||
|
||||
@@ -25,6 +25,14 @@ log = logging.getLogger("mcp_py")
|
||||
SUBJECT_PY_ALL = "sundynix.tools.py.>"
|
||||
QUEUE_PY = "mcp-py-workers"
|
||||
|
||||
# 工具元信息:名称 → (中文名, 作用简述)。list_tools 据此上报给管理端展示。
|
||||
TOOL_META = {
|
||||
"echo": ("回显", "原样返回入参(调试用)"),
|
||||
"run_code": ("代码执行", "静态守卫 + Docker 隔离沙箱运行代码(标准档 256m/10s)"),
|
||||
"parse_document": ("文档解析", "文件 → 纯文本(MinerU / PaddleOCR)"),
|
||||
"secure_sandbox": ("安全沙箱", "更严资源档(128m/5s)的隔离执行,用于高风险代码"),
|
||||
}
|
||||
|
||||
|
||||
class McpGateway:
|
||||
def __init__(self) -> None:
|
||||
@@ -41,6 +49,7 @@ class McpGateway:
|
||||
"run_code": self._run_code,
|
||||
"parse_document": self._parse_document,
|
||||
"secure_sandbox": self._secure_sandbox,
|
||||
"list_tools": self._list_tools,
|
||||
}
|
||||
|
||||
async def serve(self, url: str | None = None) -> None:
|
||||
@@ -92,6 +101,15 @@ class McpGateway:
|
||||
async def _echo(self, args: dict) -> str:
|
||||
return str(args.get("text", ""))
|
||||
|
||||
async def _list_tools(self, args: dict) -> str:
|
||||
"""自省:上报业务工具清单(名称 + 中文名 + 作用),供管理端探活 + 展示。"""
|
||||
tools = [
|
||||
{"name": n, "cn": cn, "desc": d}
|
||||
for n, (cn, d) in TOOL_META.items()
|
||||
if n in self._tools # 仅上报真正注册的业务工具(list_tools 自身不计入)
|
||||
]
|
||||
return json.dumps({"service": "mcp-py", "tools": tools})
|
||||
|
||||
async def _run_code(self, args: dict) -> str:
|
||||
"""静态守卫 → Docker 隔离执行(标准档 256m/0.5cpu/10s)。"""
|
||||
code = str(args.get("code", ""))
|
||||
|
||||
@@ -214,6 +214,29 @@ func respond(m *nats.Msg, res *contract.ToolResult) {
|
||||
_ = m.Respond(data)
|
||||
}
|
||||
|
||||
// ---- 服务探活(core NATS request-reply 心跳)----
|
||||
|
||||
// ServeHealth 在 subject 上应答健康探测,provide 返回本节点状态 JSON(可为空)。
|
||||
// 用于无 HTTP/工具端点的节点(如 dispatcher)向控制面暴露存活。
|
||||
func (b *Bus) ServeHealth(subject string, provide func() []byte) (unsub func() error, err error) {
|
||||
sub, err := b.nc.Subscribe(subject, func(m *nats.Msg) {
|
||||
_ = m.Respond(provide())
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("serve health %s: %w", subject, err)
|
||||
}
|
||||
return sub.Unsubscribe, nil
|
||||
}
|
||||
|
||||
// Ping 同步探测某节点健康:发到 subject 等应答。无人应答 / 超时即返回错误(视为下线)。
|
||||
func (b *Bus) Ping(ctx context.Context, subject string) ([]byte, error) {
|
||||
msg, err := b.nc.RequestWithContext(ctx, subject, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg.Data, nil
|
||||
}
|
||||
|
||||
// ---- 配置控制面(core NATS request-reply + broadcast)----
|
||||
|
||||
// RequestConfig 向控制面(Gateway)请求某 kind 当前激活配置(chat/embedding)。
|
||||
|
||||
@@ -25,6 +25,10 @@ const (
|
||||
QueueToolsGo = "mcp-go-workers" // mcp-go 队列组(多副本负载均衡)
|
||||
QueueToolsPy = "mcp-py-workers" // mcp-py 队列组
|
||||
|
||||
// 服务探活:dispatcher 既无 HTTP 端点也不挂工具,单独用一个 core NATS
|
||||
// request-reply 心跳主题让控制面(管理端「服务状态」)能判定它在不在线。
|
||||
SubjectHealthDispatcher = "sundynix.health.dispatcher"
|
||||
|
||||
// MetaUserID 是 Task.Meta 中承载已登录用户标识的键(用于偏好记忆召回)。
|
||||
MetaUserID = "user_id"
|
||||
// MetaSessionID 是 Task.Meta 中承载会话标识的键(用于短期多轮历史)。
|
||||
|
||||
Reference in New Issue
Block a user