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:
@@ -0,0 +1,3 @@
|
|||||||
|
config.json
|
||||||
|
node_modules/
|
||||||
|
data/
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 一步AI 聊天助手
|
||||||
|
|
||||||
|
基于 [一步API](https://yibuapi.com) 的 AI 聊天工具,兼容 OpenAI 接口格式,支持多种主流大模型。
|
||||||
|
|
||||||
|
## 支持模型
|
||||||
|
|
||||||
|
| 模型 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| GPT-5.2 | OpenAI 最新旗舰模型 |
|
||||||
|
| GPT-4o / GPT-4o Mini | OpenAI 多模态模型 |
|
||||||
|
| Claude Sonnet 4.6 / Opus 4.8 / Haiku 4.5 | Anthropic Claude 系列 |
|
||||||
|
| DeepSeek V3 / R1 | DeepSeek 推理模型 |
|
||||||
|
| Gemini 2.5 Pro | Google 旗舰模型 |
|
||||||
|
| Qwen Max | 通义千问旗舰模型 |
|
||||||
|
| Grok 3 | xAI 模型 |
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 配置 API Key
|
||||||
|
|
||||||
|
编辑 `config.json`,填入你的一步API密钥:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_key": "sk-你的真实API密钥",
|
||||||
|
"base_url": "https://api.yibuapi.com/v1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
然后访问 `http://localhost:8080`
|
||||||
|
|
||||||
|
## 架构说明
|
||||||
|
|
||||||
|
```
|
||||||
|
浏览器 ──→ server.js (Node代理) ──→ 一步API
|
||||||
|
│
|
||||||
|
config.json
|
||||||
|
(存储API Key)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **config.json** — 本地保存 API Key,不暴露到前端
|
||||||
|
- **server.js** — Node.js 代理服务器,读取配置并转发请求,支持 SSE 流式传输
|
||||||
|
- **index.html** — 聊天界面,通过同一域名调用代理 API
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- ✅ 流式响应,实时查看生成内容
|
||||||
|
- ✅ API Key 本地配置文件存储,不暴露前端
|
||||||
|
- ✅ Markdown 渲染 + 代码高亮
|
||||||
|
- ✅ 多对话管理,自动保存
|
||||||
|
- ✅ 深色/浅色主题切换
|
||||||
|
- ✅ 可调节 Temperature 和 Max Tokens
|
||||||
|
- ✅ 一键复制消息 / 重新生成
|
||||||
|
- ✅ 响应式设计,支持移动端
|
||||||
+1232
File diff suppressed because it is too large
Load Diff
@@ -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 API(url.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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user