146 lines
5.9 KiB
JavaScript
146 lines
5.9 KiB
JavaScript
|
|
"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;
|