gitlink_help_center/api/server.js

133 lines
4.2 KiB
JavaScript

// api/server.js
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const { search } = require('./search/indexer');
const { streamChat } = require('./llm/deepseek');
const { addFeedback, getStats, getRecent } = require('./feedback/store');
const API_PORT = parseInt(process.env.API_PORT || '3000', 10);
const SERVE_PORT = parseInt(process.env.SERVE_PORT || '3001', 10);
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'gitlink_admin_2024';
const MAX_HISTORY = 10;
const SYSTEM_PROMPT = `你是 GitLink 帮助中心的 AI 助手。请基于提供的文档内容回答用户的问题。
要求:
1. 回答要简洁明了,直接回答用户的问题
2. 如果提供的文档中没有相关内容,请诚实说明
3. 回答时引用具体的操作步骤和页面位置`;
function buildMessages(query, history, searchResults) {
const messages = [{ role: 'system', content: SYSTEM_PROMPT }];
if (searchResults.length > 0) {
const context = searchResults.map((doc, i) =>
`[文档${i + 1}] 标题: ${doc.title}\n路径: ${doc.breadcrumb}\nURL: ${doc.url}\n内容:\n${doc.text}`
).join('\n\n---\n\n');
messages.push({
role: 'system',
content: `以下是相关的帮助中心文档内容:\n\n${context}`,
});
}
const recentHistory = history.slice(-MAX_HISTORY);
for (const msg of recentHistory) {
messages.push({ role: msg.role, content: msg.content });
}
messages.push({ role: 'user', content: query });
return messages;
}
const app = express();
app.use(express.json());
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
// Chat endpoint (SSE streaming)
app.post('/api/chat', async (req, res) => {
const { message, history = [] } = req.body;
if (!message || typeof message !== 'string') {
return res.status(400).json({ error: 'message is required' });
}
let searchResults = [];
try {
searchResults = search(message, 3);
} catch (err) {
console.error('Search error:', err.message);
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
if (searchResults.length > 0) {
const links = searchResults.map(doc => ({
title: doc.title,
path: encodeURI(doc.url),
breadcrumb: doc.breadcrumb,
}));
res.write(`data: ${JSON.stringify({ type: 'sources', links })}\n\n`);
}
try {
const messages = buildMessages(message, history, searchResults);
for await (const chunk of streamChat(messages)) {
res.write(`data: ${JSON.stringify({ type: 'content', text: chunk })}\n\n`);
}
res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
} catch (err) {
console.error('LLM error:', err.message);
res.write(`data: ${JSON.stringify({ type: 'error', message: 'AI 服务暂时不可用,请稍后重试' })}\n\n`);
}
res.end();
});
// Feedback: submit
app.post('/api/feedback', (req, res) => {
const { docPath, vote, suggestion } = req.body;
if (!docPath || !vote || !['yes', 'no'].includes(vote)) {
return res.status(400).json({ error: 'docPath and vote (yes/no) are required' });
}
const entry = addFeedback({ docPath, vote, suggestion });
res.json({ success: true, id: entry.id });
});
// Feedback: stats (admin)
app.get('/api/feedback/stats', (req, res) => {
const token = req.query.token || req.headers['x-admin-token'];
if (token !== ADMIN_TOKEN) {
return res.status(403).json({ error: 'unauthorized' });
}
res.json(getStats());
});
// Feedback: recent entries (admin)
app.get('/api/feedback/recent', (req, res) => {
const token = req.query.token || req.headers['x-admin-token'];
if (token !== ADMIN_TOKEN) {
return res.status(403).json({ error: 'unauthorized' });
}
const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
res.json(getRecent(limit));
});
// Proxy all other requests to Docusaurus serve
app.use('/', createProxyMiddleware({
target: `http://localhost:${SERVE_PORT}`,
changeOrigin: true,
ws: true,
}));
app.listen(API_PORT, () => {
console.log(`API server listening on port ${API_PORT}, proxying to Docusaurus on port ${SERVE_PORT}`);
});