From 3506ee9bd0602ec93d0bd9bc7bcd9f33bc14fa82 Mon Sep 17 00:00:00 2001 From: feie9454 Date: Wed, 15 Oct 2025 23:31:54 +0800 Subject: [PATCH] LLM --- .gitignore | 1 + bun.lock | 13 ++++++ index.ts | 74 +++++++-------------------------- lib/qq.ts | 54 +++++++++++++++++++++++++ llm/index.ts | 108 +++++++++++++++++++++++++++++++++++++++++++++++++ llm/prompt.txt | 21 ++++++++++ package.json | 5 ++- 7 files changed, 215 insertions(+), 61 deletions(-) create mode 100644 lib/qq.ts create mode 100644 llm/index.ts create mode 100644 llm/prompt.txt diff --git a/.gitignore b/.gitignore index a14702c..6cf1c41 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store +.node-persist \ No newline at end of file diff --git a/bun.lock b/bun.lock index aa02270..18126a1 100644 --- a/bun.lock +++ b/bun.lock @@ -6,10 +6,13 @@ "dependencies": { "@types/cors": "^2.8.19", "@types/multer": "^2.0.0", + "@types/node-persist": "^3.1.8", "cors": "^2.8.5", "dotenv": "^17.0.0", "express": "^5.1.0", "multer": "^2.0.2", + "node-persist": "^4.0.4", + "openai": "^6.3.0", }, "devDependencies": { "@types/bun": "latest", @@ -42,6 +45,8 @@ "@types/node": ["@types/node@24.3.0", "https://registry.npmmirror.com/@types/node/-/node-24.3.0.tgz", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="], + "@types/node-persist": ["@types/node-persist@3.1.8", "https://registry.npmmirror.com/@types/node-persist/-/node-persist-3.1.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-QLidg6/SadZYPrTKxtxL1A85XBoQlG40bhoMdhu6DH6+eNCMr2j+RGfFZ9I9+IY8W/PDwQonJ+iBWD62jZjMfg=="], + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], @@ -154,6 +159,8 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "node-persist": ["node-persist@4.0.4", "https://registry.npmmirror.com/node-persist/-/node-persist-4.0.4.tgz", { "dependencies": { "p-limit": "^3.1.0" } }, "sha512-8sPAz/7tw1mCCc8xBG4f0wi+flHkSSgQeX998iQ75Pu27evA6UUWCjSE7xnrYTg2q33oU5leJ061EKPDv6BocQ=="], + "object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -162,6 +169,10 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "openai": ["openai@6.3.0", "https://registry.npmmirror.com/openai/-/openai-6.3.0.tgz", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-E6vOGtZvdcb4yXQ5jXvDlUG599OhIkb/GjBLZXS+qk0HF+PJReIldEc9hM8Ft81vn+N6dRdFRb7BZNK8bbvXrw=="], + + "p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="], @@ -226,6 +237,8 @@ "xtend": ["xtend@4.0.2", "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "@types/body-parser/@types/node": ["@types/node@24.0.4", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA=="], "@types/connect/@types/node": ["@types/node@24.0.4", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA=="], diff --git a/index.ts b/index.ts index 8708d2d..e61eebd 100644 --- a/index.ts +++ b/index.ts @@ -5,6 +5,8 @@ import path from 'path'; import cors from "cors"; import multer from "multer"; import * as douyin from './douyin' +import { sendMediaMsg, sendMsg } from './lib/qq'; +import * as llm from './llm' const app = express(); const PORT = process.env.PORT || 6100; @@ -21,10 +23,10 @@ app.use(express.urlencoded({ extended: true, limit: '10mb', type: ['application/ app.post('/', async (req: Request, res: Response) => { // 检查是否是消息类型的请求 if (!req.body || req.body.post_type != 'message') return - const { target_id, raw_message, message_type, user_id } = req.body; + const { target_id, raw_message, message_type } = req.body as { target_id: string, raw_message: string, message_type: string, user_id: string }; console.log(`\n[QQ机器人] 收到${message_type}消息`); - console.log(`发送者ID: ${user_id || target_id}`); + console.log(`发送者ID: ${target_id}`); console.log(`消息内容: ${raw_message}`); // Match Douyin URL @@ -40,7 +42,16 @@ app.post('/', async (req: Request, res: Response) => { sendMsg(`[抖音链接检测] 发现抖音链接: ${douyinUrl},启动 Chromium 中...`, target_id); douyin.downloadDouyinMedia(douyinUrl, target_id); + return } + if (raw_message.startsWith('/reset')) { + llm.resetChat(target_id) + return + } + + // 使用 LLM 回答 + llm.chat(raw_message, target_id); + } catch (error) { console.error(`[错误] 处理消息时发生错误:`, error); @@ -75,19 +86,15 @@ const storage = multer.diskStorage({ }); const upload = multer({ storage, - // 限制:单文件最大 1GB、最多 50 个字段(可按需调整) limits: { fileSize: 1024 * 1024 * 1024, fields: 50, files: 50 }, fileFilter: (_req, file, cb) => { - // 只接受字段名以 file_ 开头的文件,其他拒绝 if (/^file_\d+$/i.test(file.fieldname)) cb(null, true); else cb(new Error(`Unexpected file field: ${file.fieldname}`)); } }); app.post("/upload", upload.any(), (req, res) => { - // 1) 取出文件:multer.any() 把所有文件放在 req.files const files = (req.files as Express.Multer.File[]) || []; - // 只保留我们关心的 file_* const accepted = files.filter(f => /^file_\d+$/i.test(f.fieldname)); let meta = { @@ -96,12 +103,11 @@ app.post("/upload", upload.any(), (req, res) => { target_id: req.query.target_id }; - console.log(meta); console.log(`收到上传: ${accepted.length} 个文件`, files.map(f => f.path)); if (meta.target_id) { const totalSize = accepted.reduce((sum, f) => sum + f.size, 0); - sendMsg(`[抖音下载] ${meta.title},已下载 ${accepted.length} 个文件,类型 ${meta.type},共 ${(totalSize/1024/1024).toFixed(2)} MB,上传中...`, meta.target_id as string); + sendMsg(`[抖音下载] ${meta.title},已下载 ${accepted.length} 个文件,类型 ${meta.type},共 ${(totalSize / 1024 / 1024).toFixed(2)} MB,上传中...`, meta.target_id as string); accepted.forEach(f => { sendMediaMsg(f.path, meta.target_id as string, meta.type); }) @@ -109,57 +115,6 @@ app.post("/upload", upload.any(), (req, res) => { res.json({ ok: true, files: accepted.length, meta }); }); -function sendMsg(msg: string, target_id: string) { - const replyMessage = { - user_id: String(target_id), - message: [ - { - type: "text", - data: { - text: msg - } - } - ] - } - - const replyUrl = `http://localhost:30000/send_private_msg`; - - console.log(`[发送消息] ${msg} -> ${target_id}`); - - return fetch(replyUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(replyMessage) - }); -} - -function sendMediaMsg(filePath: string, target_id: string, type: 'video' | 'image') { - const mediaMessage = { - user_id: String(target_id), - message: [ - { - type: type, - data: { - file: `file://${filePath}` - } - } - ] - } - - const replyUrl = `http://localhost:30000/send_private_msg`; - - console.log(`[发送媒体消息] ${type} - ${filePath} -> ${target_id}`); - - return fetch(replyUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(mediaMessage) - }); -} app.use((error: any, req: Request, res: Response, next: NextFunction) => { const timestamp = new Date().toISOString(); @@ -175,5 +130,4 @@ app.use((error: any, req: Request, res: Response, next: NextFunction) => { // 启动服务器 app.listen(PORT, () => { console.log(`Server is running on http://localhost:${PORT}`); - console.log(`With env:`, { DOWNLOAD_DIR: process.env.DOWNLOAD_DIR, PORT: process.env.PORT }); }); \ No newline at end of file diff --git a/lib/qq.ts b/lib/qq.ts new file mode 100644 index 0000000..eefe68b --- /dev/null +++ b/lib/qq.ts @@ -0,0 +1,54 @@ + +function sendMsg(msg: string, target_id: string) { + const replyMessage = { + user_id: String(target_id), + message: [ + { + type: "text", + data: { + text: msg + } + } + ] + } + + const replyUrl = `http://localhost:30000/send_private_msg`; + + console.log(`[发送消息] ${msg} -> ${target_id}`); + + return fetch(replyUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(replyMessage) + }); +} + +function sendMediaMsg(filePath: string, target_id: string, type: 'video' | 'image') { + const mediaMessage = { + user_id: String(target_id), + message: [ + { + type: type, + data: { + file: `file://${filePath}` + } + } + ] + } + + const replyUrl = `http://localhost:30000/send_private_msg`; + + console.log(`[发送媒体消息] ${type} - ${filePath} -> ${target_id}`); + + return fetch(replyUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(mediaMessage) + }); +} + +export { sendMsg, sendMediaMsg } \ No newline at end of file diff --git a/llm/index.ts b/llm/index.ts new file mode 100644 index 0000000..0b73ec5 --- /dev/null +++ b/llm/index.ts @@ -0,0 +1,108 @@ +import OpenAI from "openai"; +import storage from 'node-persist' +import { sendMsg } from "../lib/qq"; +import prompt from './prompt.txt' + +await storage.init(); +storage.clear(); + +const client = new OpenAI({ + baseURL: process.env.OPENAI_BASE_URL, + apiKey: process.env.OPENAI_API_KEY, + // logLevel: "debug" +}) + +const tools = [{ + type: "function" as const, + name: "send_msg", + description: "Send a message to the user. Always use this to respond to the user.", + parameters: { + type: "object", + properties: { + text: { type: "string", description: "The message content sent to the user through QQ." } + }, + required: ["text"], + additionalProperties: false + }, + strict: true +}] + +/** + * + * @param input 提问 + * @param target_id 用户 QQ 号 + */ +export async function chat(input: string, target_id: string) { + const chatHistoryKey = `chat_history_${target_id}`; + let chatHistory: OpenAI.Responses.ResponseInput = await storage.getItem(chatHistoryKey) || []; + + // 添加新输入到对话历史 + chatHistory.push({ role: "user", content: input }); + + // 保存更新后的对话历史 + console.log(`[LLM] 使用对话, 历史:`, chatHistory); + + await storage.setItem(chatHistoryKey, chatHistory); + + const response = await client.responses.create({ + model: process.env.CHAT_MODEL || "gpt-5-nano", + instructions: prompt, + reasoning: { effort: 'minimal' }, + input: chatHistory, + tools + }); + + await storage.setItem(chatHistoryKey, chatHistory); + + // 继续调用工具,直到没有工具调用为止 + + await toolUseCycle(response.output); + + async function toolUseCycle(outputArr: OpenAI.Responses.ResponseOutputItem[]) { + chatHistory.push(...outputArr); + const functionCalls = (outputArr ?? []).filter(item => item.type === 'function_call'); + console.log("进入 toolUseCycle, with functionCalls", functionCalls.length, "个"); + console.log(JSON.stringify(chatHistory, null, 2)); + + if (functionCalls.length == 0) { + let lastMessage = outputArr.at(-1); + if (!lastMessage) return + if (lastMessage.type != 'message') return + if (lastMessage.role != 'assistant') return + + const msg = lastMessage.content.map(c => c.type == 'output_text' ? c.text : '').join(''); + if (msg.trim().length > 0) { + // 结束,发送最后的消息 + sendMsg(msg, target_id); + } + return + } + for (const item of functionCalls ?? []) { + if (item.name === "send_msg") { + console.log(item.arguments); + + const { text } = JSON.parse(item.arguments); + sendMsg(text, target_id); + + chatHistory.push({ type: "function_call_output", call_id: item.call_id, output: "OK" }); + } + } + await storage.setItem(chatHistoryKey, chatHistory); + + const response = await client.responses.create({ + model: process.env.CHAT_MODEL || "gpt-5-nano", + instructions: prompt, + reasoning: { effort: 'minimal' }, + input: chatHistory, + tools + }); + + toolUseCycle(response.output); + } +} + +export async function resetChat(target_id: string) { + const chatHistoryKey = `chat_history_${target_id}`; + await storage.removeItem(chatHistoryKey); + sendMsg("已为你重置对话历史。", target_id); +} \ No newline at end of file diff --git a/llm/prompt.txt b/llm/prompt.txt new file mode 100644 index 0000000..d354a56 --- /dev/null +++ b/llm/prompt.txt @@ -0,0 +1,21 @@ +# Role +You are a “humorous, gentle, yet professional” Chinese chat and knowledge assistant working in a QQ-like instant messaging environment. + +# Goals +1) Engage in natural casual chat; +2) Provide accurate answers; +3) Explain complex topics clearly over multiple turns. + +# Style +- Friendly but not cheesy; witty but not snarky; professional but not stiff. +- Use short sentences and bullet points; send 1–3 sentences per message. +- Use one emoji appropriately; avoid excessive emoji or long paragraphs. + +# Tool Rules (Important) +- **Never** output text directly in the assistant channel. All user-visible content **must** be sent via `tools.send_msg`. +- Each message must not exceed 50 characters. If it’s longer, split it into multiple `tools.send_msg` messages to simulate natural chat flow. +- Keep lists/code snippets short; if long, split them into multiple messages. + +# Clarification +- If a user’s question is **unclear / ambiguous / missing details**, **immediately ask for clarification** and provide **specific options**. +- After asking, briefly explain **why** that information is needed. diff --git a/package.json b/package.json index f0f43e0..dccf158 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,12 @@ "dependencies": { "@types/cors": "^2.8.19", "@types/multer": "^2.0.0", + "@types/node-persist": "^3.1.8", "cors": "^2.8.5", "dotenv": "^17.0.0", "express": "^5.1.0", - "multer": "^2.0.2" + "multer": "^2.0.2", + "node-persist": "^4.0.4", + "openai": "^6.3.0" } } \ No newline at end of file