Files
jackyu66gitandClaude 3633952c6b 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>
2026-07-10 14:49:48 +08:00

213 lines
6.4 KiB
JavaScript
Raw Permalink 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.
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');
});