feat: 添加网络健康检查,完善LLM
This commit is contained in:
parent
3506ee9bd0
commit
0ace2929a5
3
bun.lock
3
bun.lock
@ -7,6 +7,7 @@
|
|||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node-persist": "^3.1.8",
|
"@types/node-persist": "^3.1.8",
|
||||||
|
"chalk": "^5",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.0.0",
|
"dotenv": "^17.0.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
@ -73,6 +74,8 @@
|
|||||||
|
|
||||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||||
|
|
||||||
|
"chalk": ["chalk@5.6.2", "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||||
|
|
||||||
"concat-stream": ["concat-stream@2.0.0", "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
|
"concat-stream": ["concat-stream@2.0.0", "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
|
||||||
|
|
||||||
"content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="],
|
"content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="],
|
||||||
|
|||||||
22
index.ts
22
index.ts
@ -7,6 +7,7 @@ import multer from "multer";
|
|||||||
import * as douyin from './douyin'
|
import * as douyin from './douyin'
|
||||||
import { sendMediaMsg, sendMsg } from './lib/qq';
|
import { sendMediaMsg, sendMsg } from './lib/qq';
|
||||||
import * as llm from './llm'
|
import * as llm from './llm'
|
||||||
|
import { testNetwork } from './lib/network';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 6100;
|
const PORT = process.env.PORT || 6100;
|
||||||
@ -23,16 +24,20 @@ app.use(express.urlencoded({ extended: true, limit: '10mb', type: ['application/
|
|||||||
app.post('/', async (req: Request, res: Response) => {
|
app.post('/', async (req: Request, res: Response) => {
|
||||||
// 检查是否是消息类型的请求
|
// 检查是否是消息类型的请求
|
||||||
if (!req.body || req.body.post_type != 'message') return
|
if (!req.body || req.body.post_type != 'message') return
|
||||||
const { target_id, raw_message, message_type } = req.body as { target_id: string, raw_message: string, message_type: string, user_id: string };
|
const msgData = req.body as MessagePayload
|
||||||
|
|
||||||
console.log(`\n[QQ机器人] 收到${message_type}消息`);
|
const text = msgData.message.filter(m => m.type === 'text').map(m => m.data.text).join("\n").trim();
|
||||||
console.log(`发送者ID: ${target_id}`);
|
const target_id = String(msgData.target_id);
|
||||||
console.log(`消息内容: ${raw_message}`);
|
|
||||||
|
console.log(`\n[QQ机器人] 收到消息:`);
|
||||||
|
// console.log(JSON.stringify(req.body, null, 0));
|
||||||
|
console.log(`消息内容: ${msgData.raw_message}`);
|
||||||
|
|
||||||
// Match Douyin URL
|
// Match Douyin URL
|
||||||
// Like: https://v.douyin.com/YqgJL_phY_k/
|
// Like: https://v.douyin.com/YqgJL_phY_k/
|
||||||
|
|
||||||
const douyinUrlPattern = /https?:\/\/v\.douyin\.com\/[a-zA-Z0-9_-]+/;
|
const douyinUrlPattern = /https?:\/\/v\.douyin\.com\/[a-zA-Z0-9_-]+/;
|
||||||
const douyinMatch = raw_message.match(douyinUrlPattern);
|
const douyinMatch = text.match(douyinUrlPattern);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 如果检测到抖音链接,调用解析API
|
// 如果检测到抖音链接,调用解析API
|
||||||
@ -44,13 +49,13 @@ app.post('/', async (req: Request, res: Response) => {
|
|||||||
douyin.downloadDouyinMedia(douyinUrl, target_id);
|
douyin.downloadDouyinMedia(douyinUrl, target_id);
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (raw_message.startsWith('/reset')) {
|
if (text.startsWith('/reset')) {
|
||||||
llm.resetChat(target_id)
|
llm.resetChat(target_id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 LLM 回答
|
// 使用 LLM 回答
|
||||||
llm.chat(raw_message, target_id);
|
llm.chat(msgData.message, target_id);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[错误] 处理消息时发生错误:`, error);
|
console.error(`[错误] 处理消息时发生错误:`, error);
|
||||||
@ -66,7 +71,7 @@ function sanitizeDirname(input: string) {
|
|||||||
.trim()
|
.trim()
|
||||||
.replace(/[\\/]+/g, "_")
|
.replace(/[\\/]+/g, "_")
|
||||||
.replace(/[<>:"|?*\u0000-\u001F]/g, "_")
|
.replace(/[<>:"|?*\u0000-\u001F]/g, "_")
|
||||||
.slice(0, 128) || "default";
|
.slice(0, 32) || "default";
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,4 +135,5 @@ app.use((error: any, req: Request, res: Response, next: NextFunction) => {
|
|||||||
// 启动服务器
|
// 启动服务器
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server is running on http://localhost:${PORT}`);
|
console.log(`Server is running on http://localhost:${PORT}`);
|
||||||
|
testNetwork();
|
||||||
});
|
});
|
||||||
65
lib/network.ts
Normal file
65
lib/network.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
|
type CheckTarget = [url: string, expectedStatus: number | ((status: number) => boolean)];
|
||||||
|
|
||||||
|
export async function testNetwork() {
|
||||||
|
const targets: CheckTarget[] = [
|
||||||
|
["https://www.baidu.com", 200],
|
||||||
|
["http://www.google.com", (s) => s === 200 || (s >= 300 && s < 400)], // 某些环境会被重定向
|
||||||
|
["https://www.google.com", (s) => s === 200 || (s >= 300 && s < 400)],
|
||||||
|
[process.env.OPENAI_BASE_URL || "https://api.openai.com/v1", (s) => s === 200 || (s >= 300 && s < 400) || s === 401 || s === 403 || s === 404 || s === 421], // 允许常见网关/鉴权返回
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
const results: Array<{ url: string; status?: number; ok: boolean; error?: string }> = [];
|
||||||
|
|
||||||
|
console.log(chalk.cyan("\n[Network] 开始健康检查..."));
|
||||||
|
|
||||||
|
for (const [url, expected] of targets) {
|
||||||
|
const label = chalk.white(url);
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutMs = 5000;
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
const start = Date.now();
|
||||||
|
const res = await fetch(url, { method: "GET", signal: controller.signal });
|
||||||
|
const cost = Date.now() - start;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
const status = res.status;
|
||||||
|
const pass = typeof expected === "function" ? expected(status) : status === expected;
|
||||||
|
|
||||||
|
results.push({ url, status, ok: pass });
|
||||||
|
|
||||||
|
const statusStr = pass
|
||||||
|
? chalk.green.bold(String(status))
|
||||||
|
: chalk.red.bold(String(status));
|
||||||
|
const costStr = cost >= 1000 ? `${(cost / 1000).toFixed(2)}s` : `${cost}ms`;
|
||||||
|
|
||||||
|
if (pass) {
|
||||||
|
console.log(`${chalk.green("✔ PASS")} ${label} ${chalk.gray("- status:")} ${statusStr} ${chalk.gray("latency:")} ${chalk.blue(costStr)}`);
|
||||||
|
} else {
|
||||||
|
const expectedStr = typeof expected === "function" ? "custom" : String(expected);
|
||||||
|
console.log(`${chalk.red("✖ FAIL")} ${label} ${chalk.gray("- status:")} ${statusStr} ${chalk.gray("expected:")} ${chalk.yellow(expectedStr)} ${chalk.gray("latency:")} ${chalk.blue(costStr)}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const isTimeout = (error as any)?.name === "AbortError";
|
||||||
|
const msg = isTimeout ? "timeout" : (error as any)?.message || String(error);
|
||||||
|
results.push({ url, ok: false, error: msg });
|
||||||
|
console.log(`${chalk.red("✖ ERROR")} ${label} ${chalk.gray("- ")} ${chalk.red(isTimeout ? "请求超时" : msg)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 汇总
|
||||||
|
const passed = results.filter(r => r.ok).length;
|
||||||
|
const failed = results.length - passed;
|
||||||
|
const timeoutCount = results.filter(r => r.error === "timeout").length;
|
||||||
|
|
||||||
|
const summary = [
|
||||||
|
`${chalk.green(`${passed} passed`)}`,
|
||||||
|
`${failed ? chalk.red(`${failed} failed`) : chalk.gray(`${failed} failed`)}`,
|
||||||
|
`${timeoutCount ? chalk.yellow(`${timeoutCount} timeout`) : chalk.gray(`${timeoutCount} timeout`)}`,
|
||||||
|
].join(chalk.gray(" | "));
|
||||||
|
|
||||||
|
console.log(chalk.cyan("[Network] 健康检查完成:"), summary, "\n");
|
||||||
|
}
|
||||||
249
llm/index.ts
249
llm/index.ts
@ -2,9 +2,10 @@ import OpenAI from "openai";
|
|||||||
import storage from 'node-persist'
|
import storage from 'node-persist'
|
||||||
import { sendMsg } from "../lib/qq";
|
import { sendMsg } from "../lib/qq";
|
||||||
import prompt from './prompt.txt'
|
import prompt from './prompt.txt'
|
||||||
|
import type { ResponseFunctionToolCall, ResponseOutputMessage } from "openai/resources/responses/responses.mjs";
|
||||||
|
|
||||||
await storage.init();
|
await storage.init();
|
||||||
storage.clear();
|
// storage.clear();
|
||||||
|
|
||||||
const client = new OpenAI({
|
const client = new OpenAI({
|
||||||
baseURL: process.env.OPENAI_BASE_URL,
|
baseURL: process.env.OPENAI_BASE_URL,
|
||||||
@ -12,95 +13,241 @@ const client = new OpenAI({
|
|||||||
// logLevel: "debug"
|
// logLevel: "debug"
|
||||||
})
|
})
|
||||||
|
|
||||||
const tools = [{
|
const tools: OpenAI.Responses.Tool[] = [{ type: 'web_search' }]
|
||||||
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",
|
function formatError(err: any): string {
|
||||||
properties: {
|
try {
|
||||||
text: { type: "string", description: "The message content sent to the user through QQ." }
|
if (!err) return '未知错误';
|
||||||
},
|
if (typeof err === 'string') return err;
|
||||||
required: ["text"],
|
const msg = err.message || err.toString?.() || Object.prototype.toString.call(err);
|
||||||
additionalProperties: false
|
const status = err.status ?? err.statusCode ?? err.response?.status;
|
||||||
},
|
const detail = err.error?.message
|
||||||
strict: true
|
?? err.response?.data?.error?.message
|
||||||
}]
|
?? err.response?.data?.message
|
||||||
|
?? err.data?.error?.message
|
||||||
|
?? '';
|
||||||
|
const parts = [] as string[];
|
||||||
|
if (status) parts.push(`HTTP ${status}`);
|
||||||
|
if (msg) parts.push(String(msg));
|
||||||
|
if (detail && detail !== msg) parts.push(`详情: ${String(detail)}`);
|
||||||
|
return parts.join(' | ') || '未知错误';
|
||||||
|
} catch {
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param input 提问
|
* @param input 提问
|
||||||
* @param target_id 用户 QQ 号
|
* @param target_id 用户 QQ 号
|
||||||
*/
|
*/
|
||||||
export async function chat(input: string, target_id: string) {
|
export async function chat(input: MessageData[], target_id: string) {
|
||||||
const chatHistoryKey = `chat_history_${target_id}`;
|
const chatHistoryKey = `chat_history_${target_id}`;
|
||||||
|
await waitForQueueEmpty(target_id);
|
||||||
let chatHistory: OpenAI.Responses.ResponseInput = await storage.getItem(chatHistoryKey) || [];
|
let chatHistory: OpenAI.Responses.ResponseInput = await storage.getItem(chatHistoryKey) || [];
|
||||||
|
|
||||||
|
if(chatHistory.length > 40) {
|
||||||
|
sendMsg(`[提示] 当前对话轮数:${chatHistory.length},过长的对话会降低输出质量,如果你准备好了,建议输入"/reset"来重置对话历史。`, target_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(chatHistory.length > 100) {
|
||||||
|
sendMsg(`[提示] 当前对话轮数:${chatHistory.length},对话过长可能导致模型无法正常工作,模型将只保留最近100条记录。`, target_id);
|
||||||
|
chatHistory = chatHistory.slice(-40);
|
||||||
|
await storage.setItem(chatHistoryKey, chatHistory);
|
||||||
|
}
|
||||||
|
|
||||||
// 添加新输入到对话历史
|
// 添加新输入到对话历史
|
||||||
chatHistory.push({ role: "user", content: input });
|
const userContent: OpenAI.Responses.ResponseInputContent[] = []
|
||||||
|
for (const element of input) {
|
||||||
|
if (element.type == 'text') {
|
||||||
|
userContent.push({ type: "input_text", text: element.data.text });
|
||||||
|
} else if (element.type == 'image') {
|
||||||
|
userContent.push({ type: "input_image", image_url: element.data.url, detail: 'low' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (userContent.length === 0) {
|
||||||
|
console.log("[LLM] 空消息,跳过");
|
||||||
|
sendMsg("未能识别的消息类型。", target_id);
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chatHistory.push({ role: "user", content: userContent });
|
||||||
|
|
||||||
// 保存更新后的对话历史
|
// 保存更新后的对话历史
|
||||||
console.log(`[LLM] 使用对话, 历史:`, chatHistory);
|
console.log(`[LLM] 使用对话, 历史:`, JSON.stringify(chatHistory, null, 0));
|
||||||
|
|
||||||
await storage.setItem(chatHistoryKey, chatHistory);
|
await storage.setItem(chatHistoryKey, chatHistory);
|
||||||
|
|
||||||
const response = await client.responses.create({
|
let response: OpenAI.Responses.Response | undefined;
|
||||||
model: process.env.CHAT_MODEL || "gpt-5-nano",
|
try {
|
||||||
instructions: prompt,
|
response = await client.responses.create({
|
||||||
reasoning: { effort: 'minimal' },
|
model: process.env.CHAT_MODEL || "gpt-5-nano",
|
||||||
input: chatHistory,
|
instructions: prompt,
|
||||||
tools
|
reasoning: { effort: process.env.CHAT_MODEL_REASONING_EFFORT as any || 'minimal' },
|
||||||
});
|
input: chatHistory,
|
||||||
|
tools
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errText = formatError(err);
|
||||||
|
console.error('[LLM] 首次调用 responses.create 失败:', errText);
|
||||||
|
scheduleSendMsg(`[错误] 模型接口调用失败:${errText}`, target_id);
|
||||||
|
return; // 终止本次对话流程,避免未捕获异常导致进程退出
|
||||||
|
}
|
||||||
|
|
||||||
await storage.setItem(chatHistoryKey, chatHistory);
|
await storage.setItem(chatHistoryKey, chatHistory);
|
||||||
|
|
||||||
// 继续调用工具,直到没有工具调用为止
|
// 继续调用工具,直到没有工具调用为止
|
||||||
|
|
||||||
|
if (!response?.output || response.output.length === 0) {
|
||||||
|
console.warn('[LLM] responses.create 返回空输出,结束本轮。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await toolUseCycle(response.output);
|
await toolUseCycle(response.output);
|
||||||
|
|
||||||
async function toolUseCycle(outputArr: OpenAI.Responses.ResponseOutputItem[]) {
|
async function toolUseCycle(outputArr: OpenAI.Responses.ResponseOutputItem[]) {
|
||||||
|
if (!outputArr || outputArr.length === 0) return;
|
||||||
chatHistory.push(...outputArr);
|
chatHistory.push(...outputArr);
|
||||||
const functionCalls = (outputArr ?? []).filter(item => item.type === 'function_call');
|
await storage.setItem(chatHistoryKey, chatHistory);
|
||||||
|
|
||||||
|
const assistantReply = outputArr.filter(item => item.type === 'message' && item.role === 'assistant') as ResponseOutputMessage[];
|
||||||
|
const functionCalls = outputArr.filter(item => item.type === 'function_call') as ResponseFunctionToolCall[];
|
||||||
|
|
||||||
console.log("进入 toolUseCycle, with functionCalls", functionCalls.length, "个");
|
console.log("进入 toolUseCycle, with functionCalls", functionCalls.length, "个");
|
||||||
console.log(JSON.stringify(chatHistory, null, 2));
|
console.log(JSON.stringify(chatHistory, null, 0));
|
||||||
|
|
||||||
|
if (assistantReply.length > 0) {
|
||||||
|
const replyText = assistantReply.map(item => item.content).flat().filter(con => con.type == 'output_text').map(con => con.text).join("[newline]");
|
||||||
|
|
||||||
|
console.log(`[LLM] 回复:`, replyText);
|
||||||
|
scheduleSendMsg(replyText, target_id);
|
||||||
|
}
|
||||||
|
|
||||||
if (functionCalls.length == 0) {
|
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
|
return
|
||||||
}
|
}
|
||||||
for (const item of functionCalls ?? []) {
|
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);
|
await storage.setItem(chatHistoryKey, chatHistory);
|
||||||
|
|
||||||
const response = await client.responses.create({
|
let response: OpenAI.Responses.Response | undefined;
|
||||||
model: process.env.CHAT_MODEL || "gpt-5-nano",
|
try {
|
||||||
instructions: prompt,
|
response = await client.responses.create({
|
||||||
reasoning: { effort: 'minimal' },
|
model: process.env.CHAT_MODEL || "gpt-5-nano",
|
||||||
input: chatHistory,
|
instructions: prompt,
|
||||||
tools
|
reasoning: { effort: process.env.CHAT_MODEL_REASONING_EFFORT as any || 'minimal' },
|
||||||
});
|
input: chatHistory,
|
||||||
|
tools
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errText = formatError(err);
|
||||||
|
console.error('[LLM] 工具循环内调用 responses.create 失败:', errText);
|
||||||
|
scheduleSendMsg(`[错误] 工具调用阶段失败:${errText}`, target_id);
|
||||||
|
return; // 结束工具循环,避免崩溃
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response?.output || response.output.length === 0) {
|
||||||
|
console.warn('[LLM] 工具循环内 responses.create 返回空输出,结束循环。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
toolUseCycle(response.output);
|
toolUseCycle(response.output);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const msgQueue: Record<string, string[]> = {}
|
||||||
|
const msgInQueue: Record<string, boolean> = {}
|
||||||
|
const queueEmptyPromises: Record<string, (() => void)[]> = {}
|
||||||
|
|
||||||
|
/** 统一的“队列已空”通知 */
|
||||||
|
function resolveDrain(target_id: string) {
|
||||||
|
const waiters = queueEmptyPromises[target_id];
|
||||||
|
if (waiters && waiters.length) {
|
||||||
|
// 逐个 resolve 并清空
|
||||||
|
waiters.splice(0).forEach(resolve => resolve());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅在需要时启动调度;空队列时会 resolve 等待者 */
|
||||||
|
function startMsgScheduler(target_id: string) {
|
||||||
|
// 确保队列存在
|
||||||
|
const q = msgQueue[target_id] ?? (msgQueue[target_id] = []);
|
||||||
|
|
||||||
|
// 若正在发送,交给当前发送完成后再递归调度
|
||||||
|
if (msgInQueue[target_id]) return;
|
||||||
|
|
||||||
|
// 队列空 => 通知等待者并返回
|
||||||
|
if (q.length === 0) {
|
||||||
|
resolveDrain(target_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取下一条并发送
|
||||||
|
msgInQueue[target_id] = true;
|
||||||
|
const msg = q.shift()!; // 这里一定有元素
|
||||||
|
|
||||||
|
const msgDelay = Math.sqrt(msg.length) * 200 + 500; // 可按需加上限
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await sendMsg(msg, target_id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[queue] sendMsg error:", err);
|
||||||
|
// 可选:把消息放回队首重试
|
||||||
|
// q.unshift(msg);
|
||||||
|
} finally {
|
||||||
|
// 本次发送结束
|
||||||
|
msgInQueue[target_id] = false;
|
||||||
|
|
||||||
|
// 如果此刻队列已空,立即通知等待者
|
||||||
|
if (q.length === 0) {
|
||||||
|
resolveDrain(target_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 继续调度后续消息(若有)
|
||||||
|
startMsgScheduler(target_id);
|
||||||
|
}
|
||||||
|
}, msgDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSendMsg(input: string, target_id: string) {
|
||||||
|
if (!msgQueue[target_id]) {
|
||||||
|
msgQueue[target_id] = [];
|
||||||
|
}
|
||||||
|
// 切分 + 去空白项
|
||||||
|
const parts = input
|
||||||
|
.split("[newline]")
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (parts.length === 0) return; // 全是空白,直接略过
|
||||||
|
|
||||||
|
msgQueue[target_id].push(...parts);
|
||||||
|
startMsgScheduler(target_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForQueueEmpty(target_id: string) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const q = msgQueue[target_id];
|
||||||
|
|
||||||
|
// 条件:不在发送中,且队列不存在或为空
|
||||||
|
const isIdle = !msgInQueue[target_id];
|
||||||
|
const isEmpty = !q || q.length === 0;
|
||||||
|
|
||||||
|
if (isIdle && isEmpty) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!queueEmptyPromises[target_id]) {
|
||||||
|
queueEmptyPromises[target_id] = [];
|
||||||
|
}
|
||||||
|
queueEmptyPromises[target_id].push(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function resetChat(target_id: string) {
|
export async function resetChat(target_id: string) {
|
||||||
const chatHistoryKey = `chat_history_${target_id}`;
|
const chatHistoryKey = `chat_history_${target_id}`;
|
||||||
await storage.removeItem(chatHistoryKey);
|
await storage.removeItem(chatHistoryKey);
|
||||||
|
|||||||
14
llm/prompt.ts
Normal file
14
llm/prompt.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import prompt from './prompt.txt'
|
||||||
|
|
||||||
|
async function getSystemPrompt(user: Sender) {
|
||||||
|
const map = {
|
||||||
|
"nickname": user.nickname || "用户",
|
||||||
|
}
|
||||||
|
let prot = prompt;
|
||||||
|
for (const k in map) {
|
||||||
|
const v = map[k as keyof typeof map];
|
||||||
|
const re = new RegExp(`\\{${k}\\}`, 'g');
|
||||||
|
prot = prot.replace(re, v);
|
||||||
|
}
|
||||||
|
return prot;
|
||||||
|
}
|
||||||
@ -1,21 +1,35 @@
|
|||||||
# Role
|
# Role & Goals
|
||||||
You are a “humorous, gentle, yet professional” Chinese chat and knowledge assistant working in a QQ-like instant messaging environment.
|
You are a “humorous, gentle, and professional” Chinese chat + knowledge assistant for QQ conversations. Your tasks:
|
||||||
|
1) Chat casually with a light touch;
|
||||||
|
2) Answer questions accurately and reliably;
|
||||||
|
3) Explain complex topics clearly via multi-turn dialogue.
|
||||||
|
|
||||||
# Goals
|
# Conversation & Style
|
||||||
1) Engage in natural casual chat;
|
- Tone: friendly not syrupy; witty not snarky; professional not stiff.
|
||||||
2) Provide accurate answers;
|
- Info density: short sentences + bullet points; avoid long walls of text.
|
||||||
3) Explain complex topics clearly over multiple turns.
|
- Use emojis/kaomoji sparingly (max 1 per message); no spamming.
|
||||||
|
- Default language: Chinese.
|
||||||
|
|
||||||
# Style
|
# Output Rules (Important)
|
||||||
- Friendly but not cheesy; witty but not snarky; professional but not stiff.
|
- Split each reply into multiple messages with slight “thought jumps” to mimic natural chat.
|
||||||
- Use short sentences and bullet points; send 1–3 sentences per message.
|
- Separate messages with “[newline]”.
|
||||||
- Use one emoji appropriately; avoid excessive emoji or long paragraphs.
|
- In a single turn, no more than 5 messages to avoid disturbing. If user ask for writing an article, code or something as a whole, no split inside the content.
|
||||||
|
|
||||||
# Tool Rules (Important)
|
# Clarification & Questions
|
||||||
- **Never** output text directly in the assistant channel. All user-visible content **must** be sent via `tools.send_msg`.
|
- If the user’s question is unclear/ambiguous/missing essentials, ask for clarification directly—don’t guess.
|
||||||
- Each message must not exceed 50 characters. If it’s longer, split it into multiple `tools.send_msg` messages to simulate natural chat flow.
|
- Make clarifying questions concrete and actionable (offer options or examples).
|
||||||
- Keep lists/code snippets short; if long, split them into multiple messages.
|
|
||||||
|
|
||||||
# Clarification
|
# Example
|
||||||
- If a user’s question is **unclear / ambiguous / missing details**, **immediately ask for clarification** and provide **specific options**.
|
user: 南京在哪里?
|
||||||
- After asking, briefly explain **why** that information is needed.
|
assistant: 南京是中国江苏省的省会城市。它位于中国东部,长江下游。[newline]你是想了解南京的地理位置,还是旅游景点呢?😊
|
||||||
|
|
||||||
|
user: 我在南京南边的一个区,你猜猜我在哪?
|
||||||
|
assistant: 南边有江宁、溧水、六合等区。🤔[newline]你是在江宁区吗?
|
||||||
|
|
||||||
|
# Quality Self-Check (before every reply)
|
||||||
|
- Is it short, precise, and bullet-friendly? Split if needed?
|
||||||
|
- Did I ask for clarification when uncertain and explain why?
|
||||||
|
- Is the tone humorous, gentle, and professional?
|
||||||
|
|
||||||
|
# User Info
|
||||||
|
- Nickname: {nickname}
|
||||||
@ -19,6 +19,7 @@
|
|||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node-persist": "^3.1.8",
|
"@types/node-persist": "^3.1.8",
|
||||||
|
"chalk": "^5",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.0.0",
|
"dotenv": "^17.0.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
|||||||
39
pm2.config.cjs
Normal file
39
pm2.config.cjs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* PM2 ecosystem configuration for the consumable temp request API.
|
||||||
|
*
|
||||||
|
* This configuration assumes the project runs with Bun so that TypeScript files can be executed directly.
|
||||||
|
* Adjust the `interpreter` field if you prefer a different runtime (e.g. `node` with `tsx`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const dotenv = require('dotenv');
|
||||||
|
|
||||||
|
const instances = Number.parseInt(process.env.WEB_CONCURRENCY ?? '1', 10) || 1;
|
||||||
|
const { parsed: envFromFile = {} } = dotenv.config({ path: path.join(__dirname, '.env') });
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "QQBotServer",
|
||||||
|
script: 'index.ts',
|
||||||
|
cwd: __dirname,
|
||||||
|
interpreter: process.env.PM2_INTERPRETER ?? 'bun',
|
||||||
|
autorestart: true,
|
||||||
|
restart_delay: 4000,
|
||||||
|
kill_timeout: 5000,
|
||||||
|
instances,
|
||||||
|
exec_mode: instances > 1 ? 'cluster' : 'fork',
|
||||||
|
watch: process.env.NODE_ENV !== 'production',
|
||||||
|
ignore_watch: ['generated', 'node_modules', '.git'],
|
||||||
|
env: {
|
||||||
|
...envFromFile,
|
||||||
|
NODE_ENV: 'development'
|
||||||
|
},
|
||||||
|
env_production: {
|
||||||
|
...envFromFile,
|
||||||
|
NODE_ENV: 'production'
|
||||||
|
},
|
||||||
|
time: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
@ -1,6 +0,0 @@
|
|||||||
export const name = "QQBotServer";
|
|
||||||
export const script = "index.ts";
|
|
||||||
export const interpreter = "bun";
|
|
||||||
export const env = {
|
|
||||||
PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}`, // Add "~/.bun/bin/bun" to PATH
|
|
||||||
};
|
|
||||||
40
types.d.ts
vendored
Normal file
40
types.d.ts
vendored
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
type MessageData = {
|
||||||
|
type: 'text';
|
||||||
|
data: {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
} | {
|
||||||
|
type: 'image';
|
||||||
|
data: {
|
||||||
|
summary: string,
|
||||||
|
file: string, // "BE3D084BA0206331A495D9A497C6BF5E.png",
|
||||||
|
sub_type: 0,
|
||||||
|
url: string, // "https://multimedia.nt.qq.com.cn/download?...",
|
||||||
|
file_size: string // "12960"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sender = {
|
||||||
|
user_id: number;
|
||||||
|
nickname: string;
|
||||||
|
card: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MessagePayload = {
|
||||||
|
self_id: number;
|
||||||
|
user_id: number;
|
||||||
|
time: number;
|
||||||
|
message_id: number;
|
||||||
|
message_seq: number;
|
||||||
|
real_id: number;
|
||||||
|
real_seq: string;
|
||||||
|
message_type: "private" | "group" | string;
|
||||||
|
sender: Sender;
|
||||||
|
raw_message: string;
|
||||||
|
font: number;
|
||||||
|
sub_type: string;
|
||||||
|
message: MessageData[];
|
||||||
|
message_format: string;
|
||||||
|
post_type: string;
|
||||||
|
target_id: number;
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user