feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1

Merged
Blizzard merged 181 commits from feat/wails3 into main 2026-07-17 01:12:32 +00:00
13 changed files with 750 additions and 37 deletions
Showing only changes of commit 8bcb90cdb2 - Show all commits
+30
View File
@@ -151,3 +151,33 @@ export async function gatewayOnline(): Promise<boolean> {
return false; 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;
}
+368
View File
@@ -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>
);
}
+8
View File
@@ -6,6 +6,7 @@ import { Soon } from "./components/Soon";
const ModelsPage = lazy(() => import("./pages/ModelsPage").then((m) => ({ default: m.ModelsPage }))); const ModelsPage = lazy(() => import("./pages/ModelsPage").then((m) => ({ default: m.ModelsPage })));
const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage }))); const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage })));
const PricingPage = lazy(() => import("./pages/PricingPage").then((m) => ({ default: m.PricingPage }))); 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 { export interface RouteDef {
path: string; path: string;
@@ -37,6 +38,13 @@ export const routes: RouteDef[] = [
ready: true, ready: true,
element: <PricingPage />, element: <PricingPage />,
}, },
{
path: "/status",
label: "服务状态",
group: "运维",
ready: true,
element: <StatusPage />,
},
{ {
path: "/tenants", path: "/tenants",
label: "租户", label: "租户",
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"encoding/json"
"log" "log"
"os" "os"
"os/signal" "os/signal"
@@ -46,6 +47,23 @@ func main() {
log.Fatalf("[dispatcher] build eino graph: %v", err) 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) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
+8
View File
@@ -52,6 +52,14 @@ func (p *Pool) config() *contract.ModelConfig {
// Ready 报告是否已配置可用后端。 // Ready 报告是否已配置可用后端。
func (p *Pool) Ready() bool { return p.config().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。 // ChatStream 以 OpenAI 兼容协议流式推理,逐 token 回调 onToken。
// 仅在 Ready() 时可用(调用方据此决定真实推理或降级桩)。 // 仅在 Ready() 时可用(调用方据此决定真实推理或降级桩)。
func (p *Pool) ChatStream(ctx context.Context, msgs []ChatMessage, onToken func(string)) error { 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) 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)取当前激活的对话模型配置。 // RequestModelConfig 向控制面(Gateway)取当前激活的对话模型配置。
func (s *Subscriber) RequestModelConfig(ctx context.Context) (*contract.ModelConfig, error) { func (s *Subscriber) RequestModelConfig(ctx context.Context) (*contract.ModelConfig, error) {
return s.inner.RequestConfig(ctx, contract.ConfigKindChat) 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) 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 的配置请求。 // ServeConfig 让网关作为配置控制面,响应某 kind 的配置请求。
func (b *Bus) ServeConfig(kind string, provide func() *contract.ModelConfig) (func() error, error) { func (b *Bus) ServeConfig(kind string, provide func() *contract.ModelConfig) (func() error, error) {
return b.inner.ServeConfig(kind, provide) 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.POST("/models/test", h.TestModel)
admin.GET("/pricing", h.ListPricing) // 各模型计价(token↔真钱) admin.GET("/pricing", h.ListPricing) // 各模型计价(token↔真钱)
admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价 admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价
admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
} }
} }
return r return r
+65 -36
View File
@@ -8,6 +8,7 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time" "time"
@@ -28,10 +29,20 @@ type Gateway struct {
memory *memory.Store memory *memory.Store
history *history.Store history *history.Store
rag *rag.Engine 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 { 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.>,按工具名分发并阻塞。 // Serve 以队列组通配订阅 sundynix.tools.go.>,按工具名分发并阻塞。
@@ -47,46 +58,64 @@ func (g *Gateway) Serve(ctx context.Context) error {
return ctx.Err() return ctx.Err()
} }
// dispatch 按 ToolCall.Tool 路由到具体工具实现 // buildRegistry 注册 mcp-go 全部工具:名称 → (中文名, 作用, 处理函数)
func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contract.ToolResult { // 这是工具的唯一事实源——dispatch 据此路由、list_tools 据此上报,二者永不漂移。
log.Printf("[mcp_go] tool=%s task=%s args=%v", call.Tool, call.TaskID, call.Args) func (g *Gateway) buildRegistry() map[string]toolDef {
switch call.Tool { return map[string]toolDef{
case "wiki_search": "wiki_search": {"知识检索", "向量检索知识库(Milvus),返回最相关片段", g.wikiSearch},
return g.wikiSearch(ctx, call) "kb_ingest": {"知识入库", "文本切块 → 向量化 → 写入 Milvus / Bleve", g.kbIngest},
case "kb_ingest": "kb_search": {"检索台查询", "结构化返回命中内容与相似度分数", g.kbSearch},
return g.kbIngest(ctx, call) "kb_graph": {"知识图谱", "取某库的实体关系三元组(Neo4j)", g.kbGraph},
case "kb_search": "report_render": {"报告渲染", "把结构化报告渲染为 Word.docx", g.reportRender},
return g.kbSearch(ctx, call) "report_store": {"报告存源", "暂存报告源数据,供导出时按需渲染", g.reportStore},
case "kb_graph": "report_export": {"报告导出", "按需把已存报告导出为 Word / Markdown", g.reportExport},
return g.kbGraph(ctx, call) "external_api": {"外部接口", "受控调用第三方 HTTP API(带 SSRF 校验)", g.externalAPI},
case "report_render": "memory_get": {"记忆召回", "取用户长期画像(已按打分排序)", g.memoryGet},
return g.reportRender(ctx, call) "memory_upsert": {"记忆写入", "新增 / 更新一条用户偏好(带重要度)", g.memoryUpsert},
case "report_store": "memory_delete": {"记忆删除", "软删一条偏好(对账判定过时 / 矛盾时)", g.memoryDelete},
return g.reportStore(ctx, call) "memory_list": {"记忆列表", "列出用户全部偏好(供管理面板查看)", g.memoryList},
case "report_export": "history_get": {"历史召回", "取会话最近多轮对话", g.historyGet},
return g.reportExport(ctx, call) "history_append": {"历史追加", "往会话写入一条消息", g.historyAppend},
case "external_api": "health": {"健康检查", "上报 Milvus / Neo4j / embedding 就绪情况",
return g.externalAPI(ctx, call) func(_ context.Context, _ *contract.ToolCall) *contract.ToolResult {
case "health":
data, _ := json.Marshal(g.rag.Status()) data, _ := json.Marshal(g.rag.Status())
return &contract.ToolResult{OK: true, Content: string(data)} return &contract.ToolResult{OK: true, Content: string(data)}
case "memory_get": }},
return g.memoryGet(ctx, call) "echo": {"回显", "原样返回入参(调试用)",
case "memory_upsert": func(_ context.Context, call *contract.ToolCall) *contract.ToolResult {
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":
return &contract.ToolResult{OK: true, Content: fmt.Sprint(call.Args["text"])} 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 &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 的多行文本)。 // memoryGet 召回某用户的常驻画像(已渲染为可注入 prompt 的多行文本)。
@@ -25,6 +25,14 @@ log = logging.getLogger("mcp_py")
SUBJECT_PY_ALL = "sundynix.tools.py.>" SUBJECT_PY_ALL = "sundynix.tools.py.>"
QUEUE_PY = "mcp-py-workers" 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: class McpGateway:
def __init__(self) -> None: def __init__(self) -> None:
@@ -41,6 +49,7 @@ class McpGateway:
"run_code": self._run_code, "run_code": self._run_code,
"parse_document": self._parse_document, "parse_document": self._parse_document,
"secure_sandbox": self._secure_sandbox, "secure_sandbox": self._secure_sandbox,
"list_tools": self._list_tools,
} }
async def serve(self, url: str | None = None) -> None: async def serve(self, url: str | None = None) -> None:
@@ -92,6 +101,15 @@ class McpGateway:
async def _echo(self, args: dict) -> str: async def _echo(self, args: dict) -> str:
return str(args.get("text", "")) 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: async def _run_code(self, args: dict) -> str:
"""静态守卫 → Docker 隔离执行(标准档 256m/0.5cpu/10s)。""" """静态守卫 → Docker 隔离执行(标准档 256m/0.5cpu/10s)。"""
code = str(args.get("code", "")) code = str(args.get("code", ""))
+23
View File
@@ -214,6 +214,29 @@ func respond(m *nats.Msg, res *contract.ToolResult) {
_ = m.Respond(data) _ = 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---- // ---- 配置控制面(core NATS request-reply + broadcast----
// RequestConfig 向控制面(Gateway)请求某 kind 当前激活配置(chat/embedding)。 // RequestConfig 向控制面(Gateway)请求某 kind 当前激活配置(chat/embedding)。
+4
View File
@@ -25,6 +25,10 @@ const (
QueueToolsGo = "mcp-go-workers" // mcp-go 队列组(多副本负载均衡) QueueToolsGo = "mcp-go-workers" // mcp-go 队列组(多副本负载均衡)
QueueToolsPy = "mcp-py-workers" // mcp-py 队列组 QueueToolsPy = "mcp-py-workers" // mcp-py 队列组
// 服务探活:dispatcher 既无 HTTP 端点也不挂工具,单独用一个 core NATS
// request-reply 心跳主题让控制面(管理端「服务状态」)能判定它在不在线。
SubjectHealthDispatcher = "sundynix.health.dispatcher"
// MetaUserID 是 Task.Meta 中承载已登录用户标识的键(用于偏好记忆召回)。 // MetaUserID 是 Task.Meta 中承载已登录用户标识的键(用于偏好记忆召回)。
MetaUserID = "user_id" MetaUserID = "user_id"
// MetaSessionID 是 Task.Meta 中承载会话标识的键(用于短期多轮历史)。 // MetaSessionID 是 Task.Meta 中承载会话标识的键(用于短期多轮历史)。