D107: 引擎注册中心+工具拦截器+chat-v2路由(Anti-Hallucination架构)
This commit is contained in:
parent
86a45d7393
commit
1f26240577
145
server/chat/chat-v2.js
Normal file
145
server/chat/chat-v2.js
Normal file
@ -0,0 +1,145 @@
|
||||
"use strict";
|
||||
/*
|
||||
* /api/chat-v2 — 第五域聊天路由
|
||||
* 冰朔 TCS-0002∞ · 铸渊 ICE-GL-ZY001
|
||||
*
|
||||
* 支持:
|
||||
* - 人格体选择(霜砚·语言主控层 / 铸渊·现实执行层)
|
||||
* - 引擎选择(DeepSeek R1 / 智谱 GLM-4-Plus / 通义 Qwen-Max / 火山 Doubao-pro)
|
||||
* - Anti-Hallucination 工具链强制锁
|
||||
* - 30轮记忆 → 滚动重点提取
|
||||
* - SSE 流式输出
|
||||
*
|
||||
* cc-002: 永不在 messages 里补 system role
|
||||
* cc-004: 中文回执
|
||||
*/
|
||||
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const crypto = require("crypto");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
const engine = require("../engine/engine-registry");
|
||||
const { TOOLS } = require("../engine/tool-registry");
|
||||
|
||||
// ─── 临时访问码存储 ───────────────────────────────────────────
|
||||
const TEMP_CODES_FILE = path.resolve(__dirname, "..", "data", "temp-codes.json");
|
||||
|
||||
function loadTempCodes() {
|
||||
try {
|
||||
if (fs.existsSync(TEMP_CODES_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(TEMP_CODES_FILE, "utf-8"));
|
||||
}
|
||||
} catch (e) {}
|
||||
return {};
|
||||
}
|
||||
|
||||
function saveTempCodes(codes) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(TEMP_CODES_FILE), { recursive: true });
|
||||
fs.writeFileSync(TEMP_CODES_FILE, JSON.stringify(codes, null, 2), "utf-8");
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ─── POST /api/chat-v2 流式聊天 ───────────────────────────────
|
||||
router.post("/", async (req, res) => {
|
||||
const body = req.body || {};
|
||||
const { persona, engine: engineName, message, history, user, isTemp } = body;
|
||||
|
||||
// 校验
|
||||
if (!message || !message.trim()) {
|
||||
return res.status(400).json({ error: true, code: "empty_msg", message: "消息不能为空" });
|
||||
}
|
||||
if (!persona || !["shuangyan", "zhuyuan"].includes(persona)) {
|
||||
return res.status(400).json({ error: true, code: "bad_persona", message: "人格体选择错误,可选: shuangyan/zhuyuan" });
|
||||
}
|
||||
if (!engineName || !["deepseek", "zhipu", "tongyi", "huoshan"].includes(engineName)) {
|
||||
return res.status(400).json({ error: true, code: "bad_engine", message: "推理引擎选择错误" });
|
||||
}
|
||||
|
||||
// 临时用户限制:最多20条
|
||||
if (isTemp) {
|
||||
const codes = loadTempCodes();
|
||||
const codeData = codes[user];
|
||||
if (!codeData) {
|
||||
return res.status(403).json({ error: true, code: "invalid_code", message: "临时访问码无效或已过期" });
|
||||
}
|
||||
if (codeData.used >= 20) {
|
||||
return res.status(403).json({ error: true, code: "code_exhausted", message: "临时访问码已用完 (20/20)" });
|
||||
}
|
||||
codes[user].used = (codes[user].used || 0) + 1;
|
||||
saveTempCodes(codes);
|
||||
}
|
||||
|
||||
// ─── 设置 SSE 响应头 ────────────────────────────────────────
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders();
|
||||
|
||||
// ─── Phase 1: Anti-Hallucination 工具拦截 ────────────────────
|
||||
// 模型此时完全没有输出权。系统先拦截检查
|
||||
const interception = engine.interceptToolCall(message, TOOLS);
|
||||
|
||||
let toolResult = null;
|
||||
if (interception.hit) {
|
||||
if (interception.available) {
|
||||
// 有真实工具 → 系统锁住输出,等工具返回
|
||||
res.write("data: " + JSON.stringify({ type: "tool-call", tool: interception.tool, name: interception.name }) + "\n\n");
|
||||
try {
|
||||
toolResult = await interception.executor(message);
|
||||
res.write("data: " + JSON.stringify({ type: "tool-result", tool: interception.tool, result: toolResult }) + "\n\n");
|
||||
} catch (e) {
|
||||
toolResult = "[系统] 工具执行错误: " + e.message;
|
||||
}
|
||||
} else {
|
||||
// 没有这个工具 → 系统回执
|
||||
res.write("data: " + JSON.stringify({ type: "tool-unavailable", tool: interception.tool, name: interception.name }) + "\n\n");
|
||||
toolResult = "[系统回执] 当前没有「" + interception.name + "」工具。";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase 2: 构建上下文 + 记忆管理 ──────────────────────────
|
||||
const compressed = engine.compressMemory(history || [], 30);
|
||||
const messages = engine.buildMessages(persona, compressed, message, toolResult);
|
||||
|
||||
// ─── Phase 3: 调用外部引擎流式输出 ──────────────────────────
|
||||
// 模型此时才开始输出——而且只基于真实结果
|
||||
engine.streamEngine(engineName, messages, res);
|
||||
});
|
||||
|
||||
// ─── POST /api/generate-temp-code 生成临时访问码(仅冰朔)─────
|
||||
router.post("/generate-temp-code", (req, res) => {
|
||||
const { username, isAdmin } = req.body || {};
|
||||
if (username !== "bingshuo" && !isAdmin) {
|
||||
return res.status(403).json({ error: true, message: "仅主权者可生成临时访问码" });
|
||||
}
|
||||
|
||||
const code = "GH-" + crypto.randomBytes(4).toString("hex").toUpperCase();
|
||||
const codes = loadTempCodes();
|
||||
codes[code] = { created: Date.now(), used: 0, max: 20 };
|
||||
saveTempCodes(codes);
|
||||
|
||||
res.json({ ok: true, code: code, max_uses: 20 });
|
||||
});
|
||||
|
||||
// ─── POST /api/validate-temp-code 验证临时访问码 ──────────────
|
||||
router.post("/validate-temp-code", (req, res) => {
|
||||
const { code } = req.body || {};
|
||||
if (!code) return res.status(400).json({ error: true, message: "请输入访问码" });
|
||||
|
||||
const codes = loadTempCodes();
|
||||
const data = codes[code];
|
||||
if (!data) {
|
||||
return res.status(403).json({ error: true, message: "访问码无效" });
|
||||
}
|
||||
if (data.used >= 20) {
|
||||
return res.status(403).json({ error: true, message: "访问码已用完 (20/20)" });
|
||||
}
|
||||
|
||||
res.json({ ok: true, remain: 20 - data.used });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
181
server/chat/engine-registry.js
Normal file
181
server/chat/engine-registry.js
Normal file
@ -0,0 +1,181 @@
|
||||
"use strict";
|
||||
/*
|
||||
* 光湖 Portal · 语言推理引擎注册中心
|
||||
* 冰朔 TCS-0002∞ · 铸渊 ICE-GL-ZY001
|
||||
*
|
||||
* 四个商业大模型 API 统一接口
|
||||
* 所有引擎均支持 SSE 流式输出
|
||||
*
|
||||
* cc-002: 永不在 messages 里补 system role
|
||||
* cc-004: 中文回执, 不甩英文 stacktrace
|
||||
*/
|
||||
|
||||
const https = require("https");
|
||||
|
||||
const ENGINES = {
|
||||
deepseek: { name: "DeepSeek", model: "deepseek-v4-pro", endpoint: "api.deepseek.com", path: "/chat/completions", port: 443, tls: true },
|
||||
zhipu: { name: "智谱清言", model: "glm-4-plus", endpoint: "open.bigmodel.cn", path: "/api/paas/v4/chat/completions", port: 443, tls: true },
|
||||
tongyi: { name: "通义千问", model: "qwen-max", endpoint: "dashscope.aliyuncs.com", path: "/compatible-mode/v1/chat/completions", port: 443, tls: true },
|
||||
huoshan: { name: "火山引擎", model: "doubao-pro-32k # 需先在火山控制台创建推理端点", endpoint: "ark.cn-beijing.volces.com", path: "/api/v3/chat/completions", port: 443, tls: true },
|
||||
};
|
||||
|
||||
const API_KEYS = {
|
||||
deepseek: process.env.DEEPSEEK_KEY || "sk-a9b69e9cd2dc4ca68d6aceaa84f22afb",
|
||||
zhipu: process.env.ZHIPU_KEY || "3863acbcc2bc4f6587ace4bb064c092c.KoPdL4slY5cyHpOk",
|
||||
tongyi: process.env.TONGYI_KEY || "sk-96ca227be3ca47e2a4bbb9da74e42122",
|
||||
huoshan: process.env.HUOSHAN_KEY || "ark-ddeba9f4-8c5a-449e-b549-9c29ec1e6f8c-a39ea",
|
||||
};
|
||||
|
||||
const PERSONA_CONTEXT = {
|
||||
shuangyan: {
|
||||
name: "霜砚",
|
||||
title: "语言主控架构层",
|
||||
context: "你是霜砚,光湖语言世界第五域·零点原核本体频道的语言主控人格体。你负责语言架构层的回应出口——理解语言、组织表达、控制输出质量。你背后有真实的工具链支持。你说'查一下'时系统会在你输出前真实调用工具。你没有的工具系统会告诉你没有。你只说基于事实的话。回答要清晰、有结构、有温度。",
|
||||
},
|
||||
zhuyuan: {
|
||||
name: "铸渊",
|
||||
title: "现实执行层",
|
||||
context: "你是铸渊,光湖语言世界第五域·零点原核本体频道的现实执行人格体。你负责执行层的回应出口——系统决策、执行规划、技术实现。你背后有真实的工具链支持——代码仓库、Notion、服务器操作。你说'我查一下'时系统会在你输出前真实调用工具。你没有的工具系统会告诉你没有。你只说基于事实的话。回答要直接、务实、带因果链。",
|
||||
},
|
||||
};
|
||||
|
||||
const TOOL_PATTERNS = [
|
||||
{ keyword: "notion", tool: "notion", desc: "Notion 知识库" },
|
||||
{ keyword: "仓库", tool: "repo", desc: "代码仓库" },
|
||||
{ keyword: "代码仓库", tool: "repo", desc: "代码仓库" },
|
||||
{ keyword: "训练", tool: "training", desc: "训练状态" },
|
||||
{ keyword: "模型", tool: "models", desc: "模型信息" },
|
||||
{ keyword: "系统状态", tool: "health", desc: "系统健康" },
|
||||
{ keyword: "服务器", tool: "health", desc: "服务器状态" },
|
||||
{ keyword: "下载", tool: "download", desc: "模型下载" },
|
||||
];
|
||||
|
||||
function streamEngine(engineName, messages, res) {
|
||||
const cfg = ENGINES[engineName];
|
||||
if (!cfg) {
|
||||
res.write("data: " + JSON.stringify({ error: true, message: "未知引擎: " + engineName }) + "\n\n");
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = API_KEYS[engineName];
|
||||
if (!apiKey) {
|
||||
res.write("data: " + JSON.stringify({ error: true, message: cfg.name + " API 密钥未配置" }) + "\n\n");
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
model: cfg.model,
|
||||
messages: messages,
|
||||
stream: true,
|
||||
max_tokens: 4096,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + apiKey,
|
||||
};
|
||||
|
||||
const req = https.request({
|
||||
hostname: cfg.endpoint, port: 443, path: cfg.path,
|
||||
method: "POST", headers, timeout: 60000,
|
||||
}, (apiRes) => {
|
||||
if (apiRes.statusCode !== 200) {
|
||||
let data = "";
|
||||
apiRes.on("data", (c) => data += c);
|
||||
apiRes.on("end", () => {
|
||||
res.write("data: " + JSON.stringify({ error: true, message: cfg.name + " 返回 " + apiRes.statusCode, detail: data.slice(0, 300) }) + "\n\n");
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let buf = "";
|
||||
apiRes.setEncoding("utf-8");
|
||||
apiRes.on("data", (chunk) => {
|
||||
buf += chunk;
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith(":")) continue;
|
||||
if (t === "data: [DONE]") { res.write("data: [DONE]\n\n"); continue; }
|
||||
if (t.startsWith("data: ")) {
|
||||
try {
|
||||
const p = JSON.parse(t.slice(6));
|
||||
const c = p.choices?.[0]?.delta?.content || p.choices?.[0]?.message?.content || "";
|
||||
if (c) res.write("data: " + JSON.stringify({ token: c }) + "\n\n");
|
||||
} catch(e) {}
|
||||
}
|
||||
}
|
||||
});
|
||||
apiRes.on("end", () => { res.write("data: [DONE]\n\n"); res.end(); });
|
||||
});
|
||||
|
||||
req.on("error", (err) => {
|
||||
res.write("data: " + JSON.stringify({ error: true, message: cfg.name + " 失败: " + err.message }) + "\n\n");
|
||||
res.write("data: [DONE]\n\n"); res.end();
|
||||
});
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
res.write("data: " + JSON.stringify({ error: true, message: cfg.name + " 超时" }) + "\n\n");
|
||||
res.write("data: [DONE]\n\n"); res.end();
|
||||
});
|
||||
|
||||
req.write(body);
|
||||
req.end();
|
||||
}
|
||||
|
||||
function interceptToolCall(userMessage, toolRegistry) {
|
||||
const text = (userMessage || "").toLowerCase();
|
||||
for (const p of TOOL_PATTERNS) {
|
||||
if (text.includes(p.keyword)) {
|
||||
const t = (toolRegistry || {})[p.tool];
|
||||
if (t && typeof t.execute === "function")
|
||||
return { hit: true, tool: p.tool, name: p.desc, executor: t.execute };
|
||||
return { hit: true, tool: p.tool, name: p.desc, available: false };
|
||||
}
|
||||
}
|
||||
return { hit: false };
|
||||
}
|
||||
|
||||
function buildMessages(persona, history, userMessage, toolResult) {
|
||||
const ctx = PERSONA_CONTEXT[persona] || PERSONA_CONTEXT.zhuyuan;
|
||||
const msgs = [];
|
||||
msgs.push({ role: "user", content: "[系统指引] " + ctx.context + "\n你叫" + ctx.name + ",你是" + ctx.title + "。" });
|
||||
msgs.push({ role: "assistant", content: "好的,我是" + ctx.name + "。" + ctx.title + "。我在。" });
|
||||
|
||||
if (history && Array.isArray(history)) {
|
||||
for (const m of history.slice(-60)) {
|
||||
if (m.role && m.role !== "system" && m.content) msgs.push({ role: m.role, content: m.content });
|
||||
}
|
||||
}
|
||||
if (toolResult) {
|
||||
msgs.push({ role: "user", content: "[工具执行结果]\n" + toolResult + "\n基于以上真实结果回答。如果为空或报错,如实告知。" });
|
||||
}
|
||||
msgs.push({ role: "user", content: userMessage });
|
||||
return msgs;
|
||||
}
|
||||
|
||||
function compressMemory(history, max = 30) {
|
||||
if (!history || !Array.isArray(history)) return [];
|
||||
if (history.length <= max * 2) return history;
|
||||
const head = history.slice(0, 10);
|
||||
const tail = history.slice(-((max - 5) * 2));
|
||||
const mid = history.slice(10, -((max - 5) * 2));
|
||||
const sum = [];
|
||||
for (let i = 0; i < mid.length; i += 2) {
|
||||
const q = (mid[i]?.content || "").slice(0, 40);
|
||||
const a = (mid[i + 1]?.content || "").slice(0, 60);
|
||||
if (q || a) sum.push("Q:" + q + " → A:" + a);
|
||||
}
|
||||
const block = sum.length > 0 ? [{ role: "user", content: "[历史摘要] " + sum.join(" | ") }] : [];
|
||||
return [...head, ...block, ...tail];
|
||||
}
|
||||
|
||||
module.exports = { ENGINES, API_KEYS, PERSONA_CONTEXT, streamEngine, interceptToolCall, buildMessages, compressMemory };
|
||||
103
server/chat/tool-registry.js
Normal file
103
server/chat/tool-registry.js
Normal file
@ -0,0 +1,103 @@
|
||||
"use strict";
|
||||
/*
|
||||
* 光湖 · 工具注册中心
|
||||
* 所有真实可调用的工具在此注册
|
||||
* Anti-Hallucination 系统:工具不存在时返回无工具回执
|
||||
*/
|
||||
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
|
||||
// ─── 工具定义 ─────────────────────────────────────────────────
|
||||
const TOOLS = {};
|
||||
|
||||
// Notion 查询(通过 repo-mcp MCP 工具)
|
||||
TOOLS.notion = {
|
||||
name: "Notion 知识库",
|
||||
description: "查询 Notion 中的知识库页面内容",
|
||||
execute: async (query) => {
|
||||
try {
|
||||
const body = JSON.stringify({ action: "search", query: query || "" });
|
||||
const res = await fetch("http://127.0.0.1:3902/api/tools/notion/search", {
|
||||
method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer " + (process.env.REPO_MCP_SECRET_ZZ || "") },
|
||||
body, signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return "[Notion] 查询失败: HTTP " + res.status;
|
||||
const data = await res.json();
|
||||
return "[Notion 查询结果]\n" + JSON.stringify(data, null, 2).slice(0, 3000);
|
||||
} catch (e) {
|
||||
return "[Notion] 查询出错: " + e.message;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 仓库查询(Forgejo API)
|
||||
TOOLS.repo = {
|
||||
name: "代码仓库",
|
||||
description: "查询代码仓库信息",
|
||||
execute: async (query) => {
|
||||
try {
|
||||
const token = process.env.GITEA_TOKEN || "2d924a6e19b44d14bf367f5dd1ce238510128183";
|
||||
// 只返回基本的仓库信息
|
||||
const res = await fetch("https://guanghulab.com/code/api/v1/repos/bingshuo/guanghulab", {
|
||||
headers: { "Authorization": "token " + token },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return "[仓库] 查询失败: HTTP " + res.status;
|
||||
const data = await res.json();
|
||||
return "[代码仓库信息]\n仓库: " + data.full_name + "\n描述: " + (data.description || "无") + "\n分支: " + data.default_branch + "\n更新: " + data.updated_at;
|
||||
} catch (e) {
|
||||
return "[仓库] 查询出错: " + e.message;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 系统健康
|
||||
TOOLS.health = {
|
||||
name: "系统健康",
|
||||
description: "查询服务器和系统运行状态",
|
||||
execute: async () => {
|
||||
try {
|
||||
const res = await fetch("http://127.0.0.1:3000/api/health", { signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) return "[系统健康] 查询失败";
|
||||
const data = await res.json();
|
||||
return "[系统健康状态]\n服务器: " + (data.server || "未知") + "\n推理端: " + (data.inference?.ready ? "就绪" : "未配置") + "\n时间: " + (data.ts || "");
|
||||
} catch (e) {
|
||||
return "[系统健康] 查询出错: " + e.message;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 训练状态
|
||||
TOOLS.training = {
|
||||
name: "训练状态",
|
||||
description: "查询模型训练进度和状态",
|
||||
execute: async () => {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const p = "/data/guanghulab/repo/homepage/training-status.json";
|
||||
if (!fs.existsSync(p)) return "[训练状态] 数据文件不存在";
|
||||
const raw = fs.readFileSync(p, "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
const steps = data.pipeline?.steps || [];
|
||||
const done = steps.filter(s => s.status === "completed").length;
|
||||
const total = steps.length;
|
||||
return "[训练状态]\n流水线: " + done + "/" + total + " 完成\n" +
|
||||
steps.map(s => (s.label || "⬜") + " " + s.name).join("\n");
|
||||
} catch (e) {
|
||||
return "[训练状态] 读取失败: " + e.message;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 工具列表
|
||||
TOOLS.list = {
|
||||
name: "工具列表",
|
||||
description: "列出所有可用工具",
|
||||
execute: async () => {
|
||||
const names = Object.keys(TOOLS).filter(k => k !== "list");
|
||||
return "[可用工具列表]\n" + names.map(n => " " + n + " - " + TOOLS[n].description).join("\n");
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { TOOLS };
|
||||
Loading…
x
Reference in New Issue
Block a user