Files
Fulfilled-Knowledge/Understand-Anything-main/understand-anything-plugin/packages/dashboard/scripts/rag-server.mjs
2026-05-27 15:40:32 +08:00

564 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const defaultStaticRoot = path.resolve(__dirname, "..", "..", "..", "..", "..", "wishfulfilled-dashboard");
const staticRoot = process.env.RAG_STATIC_ROOT || defaultStaticRoot;
const graphPath = process.env.RAG_GRAPH_PATH || path.join(staticRoot, "knowledge-graph.json");
const port = Number(process.env.PORT || process.env.RAG_PORT || 8080);
const host = process.env.HOST || "0.0.0.0";
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".woff": "font/woff",
".woff2": "font/woff2",
};
const SYNONYMS = {
回评: ["评价提交", "评价展示", "客服邀评", "评价结果追踪", "review submission", "review display", "review follow-up", "邀评", "留评"],
黑名单: ["blacklist", "blacklist_entities", "风险", "风控", "风险案件", "risk_cases", "risk_signals", "反欺诈", "拦截"],
测评: ["评价计划", "review plan", "评价提交", "额度统计", "测评单"],
免评: ["KOC", "KOL", "内容发布", "引流", "带货", "exemption"],
真实人: ["person", "person_profiles", "跨账号归并", "身份归并", "真实人归并"],
额度: ["4/4/12", "person_quota_ledgers", "额度台账", "预占", "额度与频控"],
对外API: ["对外 API 契约", "接口契约", "API 草案", "接口说明", "对外接口"],
工单: ["客服工单", "support_tickets", "客服跟进", "support_followups"],
风险: ["风险与反欺诈", "risk_signals", "risk_cases", "黑名单", "反欺诈"],
};
let graphCache = null;
let graphMtime = 0;
let chunksCache = [];
function sendJson(res, status, data) {
const body = JSON.stringify(data, null, 2);
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
});
res.end(body);
}
function normalizeText(value) {
if (Array.isArray(value)) return value.join(" ");
return typeof value === "string" ? value : "";
}
function stripHtml(text) {
return text
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}
function stripInjectedLineNumbers(text) {
const lines = text.split("\n");
let changed = 0;
const cleaned = lines.map((line) => {
const match = line.match(/^\s*\d+\|(.+)$/);
if (!match) return line;
changed += 1;
return match[1];
});
return changed >= Math.max(3, Math.floor(lines.length / 4)) ? cleaned.join("\n") : text;
}
function compactKey(value) {
return String(value || "")
.toLowerCase()
.replace(/[\s`*_>#|\-\[\]()()【】《》“”"',。、::;,.\/\\!?]+/g, "");
}
function normalizeForSearch(value) {
return stripHtml(stripInjectedLineNumbers(String(value || "")))
.toLowerCase()
.replace(/[\t\r]+/g, " ")
.replace(/[]/g, " ")
.replace(/[【】《》“”]/g, " ")
.replace(/[_*`>#|\[\]]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function unique(values) {
return Array.from(new Set(values.map((v) => String(v || "").trim()).filter(Boolean)));
}
function tokenizeText(text) {
const lower = normalizeForSearch(text);
const latin = lower.match(/[a-z0-9_.\/+-]{2,}/g) || [];
const cjkRuns = lower.match(/[\u4e00-\u9fa5]{2,}/g) || [];
const cjkChars = lower.match(/[\u4e00-\u9fa5]/g) || [];
const ngrams = [];
for (let i = 0; i < cjkChars.length - 1; i += 1) ngrams.push(`${cjkChars[i]}${cjkChars[i + 1]}`);
for (let i = 0; i < cjkChars.length - 2; i += 1) ngrams.push(`${cjkChars[i]}${cjkChars[i + 1]}${cjkChars[i + 2]}`);
return unique([...latin, ...cjkRuns, ...ngrams, ...cjkChars.filter((ch) => !/[是什么吗呢的了和与及或在中为]/.test(ch))]);
}
function detectQuestionType(query) {
const text = normalizeForSearch(query);
if (/api|接口|契约|endpoint|对外/.test(text)) return "api";
if (/数据|字段|表|对象|模型|schema|建表/.test(text)) return "data_model";
if (/流程|怎么走|如何|步骤|流转|处理/.test(text)) return "process";
if (/规则|限制|口径|额度|频控|条件/.test(text)) return "rule";
if (/谁|负责|职责|owner|角色/.test(text)) return "responsibility";
if (/测试|验收|用例|缺陷|上线/.test(text)) return "test";
if (/里程碑|阶段|计划|节点/.test(text)) return "milestone";
if (/是什么|定义|含义|意思|解释/.test(text)) return "definition";
return "general";
}
function expandTerms(tokens, raw) {
const compact = compactKey(raw);
const expanded = [];
for (const [term, values] of Object.entries(SYNONYMS)) {
const termCompact = compactKey(term);
if (compact.includes(termCompact) || tokens.some((token) => compactKey(token).includes(termCompact) || termCompact.includes(compactKey(token)))) {
expanded.push(term, ...values);
}
}
return unique(expanded);
}
function processQuery(raw) {
const normalized = normalizeForSearch(raw);
const compact = compactKey(raw);
const tokens = tokenizeText(raw);
const expandedTerms = expandTerms(tokens, raw);
const coreTerms = unique([String(raw || "").trim(), normalized, compact, ...tokens.filter((token) => token.length >= 2)]);
return { raw, normalized, compact, tokens, coreTerms, expandedTerms, questionType: detectQuestionType(raw) };
}
function loadGraph() {
const stat = fs.statSync(graphPath);
if (graphCache && stat.mtimeMs === graphMtime) return graphCache;
const graph = JSON.parse(fs.readFileSync(graphPath, "utf8"));
graphCache = graph;
graphMtime = stat.mtimeMs;
chunksCache = buildChunks(graph.nodes || []);
return graph;
}
function nodeContent(node) {
return stripHtml(stripInjectedLineNumbers(normalizeText(node?.knowledgeMeta?.content)));
}
function nodeLayer(node) {
return normalizeText(node?.knowledgeMeta?.category) || (node?.tags || []).find((tag) => /需求|技术|测试|里程碑/.test(tag)) || "";
}
function detectChunkType(heading, content) {
const text = `${heading}\n${content}`.toLowerCase();
if (/api|接口|契约|endpoint|对外/.test(text)) return "api_contract";
if (/数据模型|字段|表结构|对象|schema|建表/.test(text)) return "data_model";
if (/业务规则|规则|口径|额度|频控|限制/.test(text)) return "business_rule";
if (/流程|流转|步骤|时序|处理/.test(text)) return "process";
if (/faq|常见问题|是什么|问答/.test(text)) return "faq";
return "general";
}
function splitSections(text) {
const source = text.trim();
if (!source) return [];
const sections = [];
let currentHeading = "全文";
let buffer = [];
for (const line of source.split("\n")) {
const heading = line.match(/^\s{0,3}#{1,6}\s+(.+)$/)?.[1]?.trim();
if (heading) {
if (buffer.length > 0) sections.push({ heading: currentHeading, text: buffer.join("\n") });
currentHeading = heading;
buffer = [line];
} else {
buffer.push(line);
}
}
if (buffer.length > 0) sections.push({ heading: currentHeading, text: buffer.join("\n") });
return sections.length ? sections : [{ heading: "全文", text: source }];
}
function buildChunks(nodes) {
return nodes.flatMap((node) => {
const content = nodeContent(node);
const titleBlock = [node.name, node.summary, node.filePath, (node.tags || []).join(" ")].join("\n");
const sections = splitSections(content || titleBlock);
return sections.map((section, index) => {
const sectionText = section.text.trim() || titleBlock;
const normalizedContent = normalizeForSearch(sectionText);
return {
chunkId: `${node.id}#${index}`,
nodeId: node.id,
docTitle: node.name || node.id,
docPath: node.filePath || node.id,
sectionTitle: section.heading,
content: sectionText,
normalizedContent,
compactContent: compactKey(sectionText),
tags: node.tags || [],
summary: node.summary || "",
chunkType: detectChunkType(section.heading, sectionText),
layer: nodeLayer(node),
};
});
});
}
function countOccurrences(haystack, needle) {
if (!needle) return 0;
return haystack.split(needle).length - 1;
}
function fieldScore(field, query, fieldName, multiplier = 1) {
const normalized = normalizeForSearch(field);
const compact = compactKey(field);
const reasons = [];
let score = 0;
const rawNeedles = unique([query.normalized, String(query.raw || "").toLowerCase()].filter((v) => v.length >= 2));
for (const needle of rawNeedles) {
if (normalized.includes(needle)) {
const add = 70 * multiplier;
score += add;
reasons.push({ type: "exact", field: fieldName, message: `${fieldName} 原文命中:${needle}`, score: add });
}
}
if (query.compact && compact.includes(query.compact)) {
const add = 80 * multiplier;
score += add;
reasons.push({ type: "compact", field: fieldName, message: `${fieldName} 去标点命中`, score: add });
}
let tokenHits = 0;
for (const term of query.coreTerms) {
const normalizedTerm = normalizeForSearch(term);
const compactTerm = compactKey(term);
if (!normalizedTerm && !compactTerm) continue;
const occurrences = Math.max(countOccurrences(normalized, normalizedTerm), countOccurrences(compact, compactTerm));
if (occurrences > 0) {
tokenHits += 1;
score += Math.min(occurrences, 4) * Math.min(Math.max(compactTerm.length, normalizedTerm.length), 10) * multiplier;
}
}
if (tokenHits > 0) reasons.push({ type: "token", field: fieldName, message: `${fieldName} 关键词覆盖 ${tokenHits}`, score: tokenHits * multiplier });
return { score, reasons };
}
function semanticScore(chunk, query) {
const reasons = [];
const terms = unique([...query.tokens, ...query.expandedTerms.flatMap(tokenizeText)]).filter((term) => term.length >= 2);
if (!terms.length) return { score: 0, reasons };
const targetTokens = new Set(tokenizeText(`${chunk.docTitle} ${chunk.sectionTitle} ${chunk.normalizedContent}`));
let overlap = 0;
for (const term of terms) {
const compact = compactKey(term);
if (targetTokens.has(term) || chunk.compactContent.includes(compact) || compactKey(`${chunk.docTitle}${chunk.sectionTitle}`).includes(compact)) overlap += 1;
}
const ratio = overlap / Math.max(terms.length, 1);
const score = ratio * 90;
if (score > 0) reasons.push({ type: "semantic", message: `语义/同义词覆盖 ${(ratio * 100).toFixed(0)}%`, score });
return { score, reasons };
}
function directoryBoost(chunk, query) {
const p = chunk.docPath || "";
const base = p.includes("05_需求文档") ? 15 : p.includes("01_业务流程") ? 12 : p.includes("07_技术文档") ? 10 : p.includes("08_测试相关") ? 8 : p.includes("06_里程碑") ? 6 : p.includes("02_项目管理流程") ? 5 : p.includes("04_Agent检索") ? 4 : p.includes("03_规范与模板") ? 1 : 0;
if (query.questionType === "api" && p.includes("07_技术文档")) return base + 8;
if ((query.questionType === "definition" || query.questionType === "rule") && p.includes("05_需求文档")) return base + 5;
if (query.questionType === "test" && p.includes("08_测试相关")) return base + 12;
return base;
}
function chunkTypeBoost(chunk, query) {
if (query.questionType === "api" && chunk.chunkType === "api_contract") return 18;
if (query.questionType === "data_model" && chunk.chunkType === "data_model") return 18;
if (query.questionType === "process" && chunk.chunkType === "process") return 14;
if (query.questionType === "rule" && chunk.chunkType === "business_rule") return 14;
if (query.questionType === "definition" && (chunk.chunkType === "faq" || /概述|目标|说明/.test(chunk.sectionTitle))) return 10;
return 0;
}
function makeSnippet(text, query) {
const compact = String(text || "").replace(/\s+/g, " ").trim();
if (!compact) return "";
const lower = compact.toLowerCase();
const compactLower = compactKey(compact);
const terms = unique([query.normalized, query.raw, query.compact, ...query.coreTerms, ...query.expandedTerms]);
const hit = terms.map((term) => {
const plainIndex = lower.indexOf(normalizeForSearch(term));
if (plainIndex >= 0) return plainIndex;
const compactIndex = compactLower.indexOf(compactKey(term));
return compactIndex >= 0 ? Math.min(compactIndex, compact.length - 1) : -1;
}).filter((i) => i >= 0).sort((a, b) => a - b)[0];
const start = Math.max(0, (hit ?? 0) - 110);
const snippet = compact.slice(start, start + 420);
return `${start > 0 ? "…" : ""}${snippet}${start + 420 < compact.length ? "…" : ""}`;
}
function searchChunks(queryText, topK = 16) {
loadGraph();
const query = processQuery(queryText);
if (!query.normalized && !query.compact) return { query, hits: [] };
const weights = query.questionType === "api" ? { exact: 0.45, fuzzy: 0.25, semantic: 0.1 } : query.questionType === "definition" || query.questionType === "process" ? { exact: 0.25, fuzzy: 0.2, semantic: 0.3 } : { exact: 0.3, fuzzy: 0.2, semantic: 0.25 };
const hits = chunksCache.map((chunk) => {
const title = fieldScore(`${chunk.docTitle}\n${chunk.docPath}`, query, "docTitle", 4);
const section = fieldScore(chunk.sectionTitle, query, "sectionTitle", 6);
const content = fieldScore(chunk.normalizedContent, query, "content", 1);
const tags = fieldScore((chunk.tags || []).join(" "), query, "tags", 3);
const semantic = semanticScore(chunk, query);
const exact = title.score + section.score + tags.score + content.score;
const fuzzy = (chunk.compactContent.includes(query.compact) ? 80 : 0) + content.score * 0.2;
const titleBoost = title.score > 0 ? 12 : 0;
const sectionBoost = section.score > 0 ? 18 : 0;
const dirBoost = directoryBoost(chunk, query);
const typeBoost = chunkTypeBoost(chunk, query);
const score = exact * weights.exact + fuzzy * weights.fuzzy + semantic.score * weights.semantic + titleBoost + sectionBoost + dirBoost + typeBoost;
const reasons = [...title.reasons, ...section.reasons, ...tags.reasons, ...content.reasons.slice(0, 3), ...semantic.reasons];
if (dirBoost) reasons.push({ type: "directory", message: `目录权重:${(chunk.docPath || "").split("/")[0] || ""}`, score: dirBoost });
if (typeBoost) reasons.push({ type: "chunkType", message: `章节类型匹配:${chunk.chunkType}`, score: typeBoost });
return {
chunkId: chunk.chunkId,
nodeId: chunk.nodeId,
docTitle: chunk.docTitle,
docPath: chunk.docPath,
sectionTitle: chunk.sectionTitle,
chunkType: chunk.chunkType,
layer: chunk.layer,
score,
scores: { exact, fuzzy, semantic: semantic.score, titleBoost, sectionBoost, directoryBoost: dirBoost, chunkTypeBoost: typeBoost },
snippet: makeSnippet(chunk.content, query),
evidenceContent: chunk.content.slice(0, 2600),
reasons,
};
}).filter((hit) => hit.score > 10 && hit.snippet).sort((a, b) => b.score - a.score).reduce((acc, hit) => {
const key = `${hit.nodeId}:${hit.sectionTitle}`;
if (!acc.some((item) => `${item.nodeId}:${item.sectionTitle}` === key)) acc.push(hit);
return acc;
}, []).slice(0, topK);
return { query, hits };
}
function evidenceDecision(question, hits) {
if (!String(question || "").trim()) return { allowed: false, confidence: "none", reason: "请输入问题。" };
if (!hits.length) return { allowed: false, confidence: "none", reason: "没有检索到相关证据。" };
if (hits[0].score >= 75) return { allowed: true, confidence: "high", reason: "Top1 证据分数达到阈值。" };
if (hits[0].score >= 60 && (hits[1]?.score || 0) >= 50) return { allowed: true, confidence: "medium", reason: "多个证据片段达到中等相关。" };
return { allowed: false, confidence: "low", reason: "证据分数不足。" };
}
function localAnswer(question, hits) {
const decision = evidenceDecision(question, hits);
if (!decision.allowed) return "暂无该需求描述。";
const top = hits.slice(0, 5);
return [
`结论:已基于本地知识库检索到可用证据。${decision.reason}`,
"",
"相关依据:",
...top.map((hit, index) => `${index + 1}. ${hit.docTitle}${hit.sectionTitle ? ` / ${hit.sectionTitle}` : ""}\n ${hit.snippet}\n 来源:${hit.docPath}`),
].join("\n");
}
function detectProvider({ endpoint, apiKey, provider }) {
if (provider && provider !== "auto") return provider;
const raw = String(endpoint || "").toLowerCase();
if (String(apiKey || "").startsWith("plan-") || raw.includes("/plan/v1")) return "routin-plan";
return "openai";
}
function normalizeOpenAiEndpoint(endpoint) {
const raw = String(endpoint || "").trim();
if (!raw) return "";
if (/\/chat\/completions\/?$/.test(raw)) return raw;
return `${raw.replace(/\/+$/, "")}/chat/completions`;
}
function normalizePlanEndpoint(endpoint) {
const raw = String(endpoint || "").trim();
let base = raw || "https://api.routin.ai/plan/v1";
base = base.replace(/\/chat\/completions\/?$/, "").replace(/\/messages\/?$/, "").replace(/\/+$/, "");
if (!base.includes("/plan/v1")) base = base.replace(/\/v1$/, "/plan/v1");
return `${base}/messages`;
}
function extractPlanMessageText(data) {
if (data && typeof data === "object") {
const content = data.content;
if (Array.isArray(content)) {
const texts = content
.map((part) => typeof part === "string" ? part : part?.text)
.filter((part) => typeof part === "string" && part.trim());
if (texts.length) return texts.join("");
}
if (typeof content === "string") return content;
}
return "";
}
function extractAnswer(data, protocol) {
if (protocol === "routin-plan") {
const planText = extractPlanMessageText(data);
if (planText) return planText;
}
return data?.choices?.[0]?.message?.content
|| data?.message?.content
|| data?.response
|| data?.data?.answer
|| data?.answer
|| extractPlanMessageText(data);
}
async function callLlm({ endpoint, model, apiKey, provider = "auto", question, hits, timeoutMs = 60000 }) {
const protocol = detectProvider({ endpoint, apiKey, provider });
const requestEndpoint = protocol === "routin-plan" ? normalizePlanEndpoint(endpoint) : normalizeOpenAiEndpoint(endpoint);
const evidence = hits.slice(0, 8).map((hit, index) => `[${index + 1}]\n来源:${hit.docPath}\n章节:${hit.sectionTitle}\n内容:${hit.snippet}`).join("\n\n");
const systemPrompt = "你是如愿知识库问答助手。你只能基于提供的知识库片段回答,不得使用外部知识,不得编造文档中没有的内容。如果证据不足,请只回答:暂无该需求描述。回答末尾必须列出来源文件。";
const userPrompt = `用户问题:\n${question}\n\n检索证据:\n${evidence}\n\n请用中文结构化回答。`;
const messages = protocol === "routin-plan"
? [{ role: "user", content: `${systemPrompt}\n\n${userPrompt}` }]
: [{ role: "system", content: systemPrompt }, { role: "user", content: userPrompt }];
const body = protocol === "routin-plan"
? { model, messages, max_tokens: 4096, temperature: 0.7 }
: { model, messages, temperature: 0.1, stream: false };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
try {
const response = await fetch(requestEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify(body),
signal: controller.signal,
});
const text = await response.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch {}
if (!response.ok) {
const remoteMessage = data?.error?.message || data?.message || text.slice(0, 500);
const err = new Error(`模型接口返回 ${response.status} ${response.statusText}${remoteMessage ? `${remoteMessage}` : ""}`);
err.status = response.status;
err.endpoint = requestEndpoint;
err.elapsedMs = Date.now() - startedAt;
err.protocol = protocol;
throw err;
}
const answer = extractAnswer(data, protocol);
if (typeof answer !== "string" || !answer.trim()) {
const err = new Error("模型返回成功,但响应结构里没有可解析的回答内容。支持 OpenAI choices[0].message.content、RoutIn content[]/content、message.content / response / answer。原始返回" + text.slice(0, 500));
err.endpoint = requestEndpoint;
err.elapsedMs = Date.now() - startedAt;
err.protocol = protocol;
throw err;
}
return { answer: answer.trim(), endpoint: requestEndpoint, elapsedMs: Date.now() - startedAt, rawShape: Object.keys(data || {}), protocol };
} finally {
clearTimeout(timer);
}
}
async function readBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const raw = Buffer.concat(chunks).toString("utf8");
return raw ? JSON.parse(raw) : {};
}
async function handleApi(req, res, url) {
if (req.method === "OPTIONS") return sendJson(res, 200, { ok: true });
if (url.pathname === "/api/rag/health") {
const graph = loadGraph();
return sendJson(res, 200, { ok: true, graphPath, staticRoot, documentCount: (graph.nodes || []).length, chunkCount: chunksCache.length, llmProxy: true });
}
if (url.pathname === "/api/rag/search" && req.method === "POST") {
const body = await readBody(req);
const { query, hits } = searchChunks(body.query || "", body.topK || 16);
const decision = evidenceDecision(body.query || "", hits);
return sendJson(res, 200, { ok: true, query, decision, answer: localAnswer(body.query || "", hits), hits });
}
if (url.pathname === "/api/rag/answer" && req.method === "POST") {
const body = await readBody(req);
const requestStatus = { stage: "search", startedAt: new Date().toISOString() };
const { query, hits } = searchChunks(body.query || "", body.topK || 16);
const decision = evidenceDecision(body.query || "", hits);
if (!decision.allowed) return sendJson(res, 200, { ok: true, mode: "llm", requestStatus: { ...requestStatus, stage: "evidence_rejected" }, query, decision, answer: "暂无该需求描述。", hits });
if (!body.llm?.enabled) return sendJson(res, 200, { ok: true, mode: "local", requestStatus: { ...requestStatus, stage: "local_answer" }, query, decision, answer: localAnswer(body.query || "", hits), hits });
try {
const result = await callLlm({ endpoint: body.llm.endpoint, model: body.llm.model, apiKey: body.llm.apiKey, question: body.query || "", hits });
return sendJson(res, 200, { ok: true, mode: "llm", requestStatus: { ...requestStatus, stage: "llm_done", endpoint: result.endpoint, elapsedMs: result.elapsedMs, rawShape: result.rawShape }, query, decision, answer: result.answer, hits });
} catch (error) {
return sendJson(res, 502, {
ok: false,
mode: "llm",
requestStatus: { ...requestStatus, stage: "llm_failed", endpoint: error.endpoint || (detectProvider({ endpoint: body.llm?.endpoint, apiKey: body.llm?.apiKey, provider: body.llm?.provider }) === "routin-plan" ? normalizePlanEndpoint(body.llm?.endpoint) : normalizeOpenAiEndpoint(body.llm?.endpoint)), elapsedMs: error.elapsedMs, protocol: error.protocol },
query,
decision,
answer: localAnswer(body.query || "", hits),
hits,
error: {
message: error instanceof Error ? error.message : String(error),
name: error?.name,
status: error?.status,
},
});
}
}
if (url.pathname === "/api/llm/test" && req.method === "POST") {
const body = await readBody(req);
try {
const result = await callLlm({ endpoint: body.endpoint, model: body.model, apiKey: body.apiKey, question: "连接测试", hits: [{ docPath: "测试", sectionTitle: "连接测试", snippet: "请只回答:连接成功。", score: 100 }] });
return sendJson(res, 200, { ok: true, endpoint: result.endpoint, elapsedMs: result.elapsedMs, answer: result.answer });
} catch (error) {
return sendJson(res, 502, { ok: false, endpoint: error.endpoint || (detectProvider({ endpoint: body.endpoint, apiKey: body.apiKey, provider: body.provider }) === "routin-plan" ? normalizePlanEndpoint(body.endpoint) : normalizeOpenAiEndpoint(body.endpoint)), protocol: error.protocol, elapsedMs: error.elapsedMs, error: { message: error instanceof Error ? error.message : String(error), name: error?.name, status: error?.status } });
}
}
return sendJson(res, 404, { ok: false, error: "API not found" });
}
function serveStatic(req, res, url) {
let pathname = decodeURIComponent(url.pathname);
if (pathname === "/") pathname = "/index.html";
const requested = path.normalize(path.join(staticRoot, pathname));
if (!requested.startsWith(staticRoot)) {
res.writeHead(403);
res.end("Forbidden");
return;
}
const filePath = fs.existsSync(requested) && fs.statSync(requested).isFile() ? requested : path.join(staticRoot, "index.html");
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream", "Cache-Control": ext === ".html" ? "no-store" : "no-cache" });
fs.createReadStream(filePath).pipe(res);
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
try {
if (url.pathname.startsWith("/api/")) return await handleApi(req, res, url);
return serveStatic(req, res, url);
} catch (error) {
return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
}
});
server.listen(port, host, () => {
loadGraph();
console.log(`RAG dashboard server listening on http://${host}:${port}`);
console.log(`Static root: ${staticRoot}`);
console.log(`Graph path: ${graphPath}`);
});