feat: 大模型聊天工具

- 一步API接入,支持 Claude Fable 5 / Sonnet 4.6 / Opus 4.8
- Node.js 代理服务,API Key 通过 config.json 本地配置
- 流式响应 + Markdown 渲染 + 代码高亮
- 多对话管理,服务端持久化 + localStorage 双保险
- 深色/浅色主题,响应式布局

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jackyu66git
2026-07-10 14:49:48 +08:00
co-authored by Claude
commit 3633952c6b
4 changed files with 1507 additions and 0 deletions
+212
View File
@@ -0,0 +1,212 @@
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
// 读取配置文件
function loadConfig() {
const configPath = path.join(__dirname, 'config.json');
if (!fs.existsSync(configPath)) {
console.error('❌ 未找到 config.json,请创建配置文件');
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const apiKey = config.api_key;
if (!apiKey || apiKey === 'sk-你的API密钥') {
console.warn('⚠️ 请在 config.json 中配置真实的 API Key(当前为占位值)');
console.warn('⚠️ API 请求将会失败,但界面可以正常访问\n');
}
return { apiKey: apiKey || '', baseUrl: config.base_url || 'https://yibuapi.com/v1' };
}
const CONFIG = loadConfig();
console.log(`✅ API Key 已加载: ${CONFIG.apiKey.slice(0, 8)}...`);
console.log(`🌐 API Base URL: ${CONFIG.baseUrl}`);
// MIME 类型映射
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
};
const PORT = 8080;
const TARGET_URL = new URL(CONFIG.baseUrl);
// 读取请求体,带大小限制
function readBody(req, limit, callback) {
const chunks = [];
let size = 0;
req.on('data', chunk => {
size += chunk.length;
if (size > limit) {
req.destroy();
return callback(new Error('请求体超过大小限制'));
}
chunks.push(chunk);
});
req.on('end', () => callback(null, Buffer.concat(chunks)));
req.on('error', err => callback(err));
}
// 代理 API 请求
function proxyRequest(req, res) {
readBody(req, MAX_BODY_SIZE, (err, body) => {
if (err) {
res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ error: { message: err.message } }));
}
const options = {
hostname: TARGET_URL.hostname,
port: TARGET_URL.port || 443,
path: req.url,
method: req.method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CONFIG.apiKey}`,
'Content-Length': Buffer.byteLength(body),
},
};
const proxyReq = https.request(options, proxyRes => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
proxyRes.on('error', err => {
console.error('[代理] 响应流错误:', err.message);
if (!res.writableEnded) res.end();
});
});
proxyReq.on('error', err => {
console.error('[代理] 请求失败:', err.message);
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
}
res.end(JSON.stringify({ error: { message: `API 请求失败: ${err.message}` } }));
});
proxyReq.setTimeout(180000, () => {
proxyReq.destroy();
if (!res.headersSent) {
res.writeHead(504, { 'Content-Type': 'application/json; charset=utf-8' });
}
res.end(JSON.stringify({ error: { message: '请求超时,请重试' } }));
});
res.on('close', () => {
if (!proxyReq.destroyed) proxyReq.destroy();
});
proxyReq.write(body);
proxyReq.end();
});
}
// 对话数据存储
const DATA_DIR = path.join(__dirname, 'data');
const CONV_FILE = path.join(DATA_DIR, 'conversations.json');
const CONV_TMP = CONV_FILE + '.tmp';
function loadConversations() {
try {
if (fs.existsSync(CONV_FILE)) {
return JSON.parse(fs.readFileSync(CONV_FILE, 'utf-8'));
}
} catch(e) { console.error('读取对话数据失败:', e.message); }
return [];
}
// 原子写:先写 .tmp 再 rename,避免写盘崩溃导致文件损坏
function saveConversations(data) {
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(CONV_TMP, JSON.stringify(data), 'utf-8');
fs.renameSync(CONV_TMP, CONV_FILE);
}
// 静态文件服务
function serveStatic(req, res) {
let filePath = req.url === '/' ? '/index.html' : req.url;
filePath = path.join(__dirname, filePath);
// 安全检查:防止目录遍历(__dirname 追加 path.sep 避免前缀匹配绕过)
const root = __dirname + path.sep;
if (!path.resolve(filePath).startsWith(root)) {
res.writeHead(403);
res.end('403 Forbidden');
return;
}
const ext = path.extname(filePath);
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
fs.readFile(filePath, (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
res.writeHead(404);
res.end('404 Not Found');
} else {
res.writeHead(500);
res.end('500 Internal Server Error');
}
return;
}
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
});
}
// 创建服务器
const server = http.createServer((req, res) => {
// 使用 WHATWG URL APIurl.parse 在 Node 24 已弃用)
const parsed = new URL(req.url, 'http://localhost');
// API 代理
if (parsed.pathname.startsWith('/v1/')) {
return proxyRequest(req, res);
}
// 对话数据接口
if (parsed.pathname === '/api/conversations') {
if (req.method === 'GET') {
const data = loadConversations();
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify(data));
}
if (req.method === 'POST') {
readBody(req, MAX_BODY_SIZE, (err, raw) => {
if (err) {
res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, error: err.message }));
}
try {
const data = JSON.parse(raw.toString());
saveConversations(data);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: true }));
} catch(e) {
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: e.message }));
}
});
return;
}
}
// 静态文件
serveStatic(req, res);
});
server.listen(PORT, () => {
const convs = loadConversations();
console.log(`💬 已加载 ${convs.length} 个对话`);
console.log(`🚀 聊天工具已启动: http://localhost:${PORT}`);
console.log('📋 按 Ctrl+C 停止服务\n');
});