mioku-plugin-chat 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,779 @@
1
+ import type {
2
+ AIInstance,
3
+ SessionToolDefinition,
4
+ } from "mioku";
5
+ import { logger } from "mioki";
6
+ import type { AITool } from "mioku";
7
+ import type {
8
+ ToolContext,
9
+ ChatMessage,
10
+ TargetMessage,
11
+ ChatResult,
12
+ } from "../types";
13
+ import type { HumanizeEngine } from "../humanize";
14
+ import type { PromptContext } from "./prompt";
15
+ import type { SkillSessionManager } from "../manage/skill-session";
16
+ import { createTools } from "./tools";
17
+ import { buildSystemPrompt } from "./prompt";
18
+ import {
19
+ isExternalSkillAllowed,
20
+ isSkillAllowedForRole,
21
+ } from "./external-skills";
22
+ import {
23
+ consumeCompleteStreamUnits,
24
+ splitOutgoingUnits,
25
+ } from "./media/markdown-message";
26
+ import {
27
+ attachImagesToCurrentUserMessages,
28
+ buildStructuredUserMessages,
29
+ GroupStructuredHistoryManager,
30
+ type StructuredUserInput,
31
+ } from "../manage/group-structured-history";
32
+
33
+ interface StructuredHistoryRunContext {
34
+ manager: GroupStructuredHistoryManager;
35
+ ttlMs: number;
36
+ currentUserInputs: StructuredUserInput[];
37
+ }
38
+
39
+ interface ChatRuntimeRunOptions {
40
+ extraTools?: AITool[];
41
+ }
42
+
43
+ /**
44
+ * Run a single chat turn using a fresh tool loop inside the current request.
45
+ */
46
+ export async function runChat(
47
+ ai: AIInstance,
48
+ toolCtx: ToolContext,
49
+ history: ChatMessage[],
50
+ targetMessage: TargetMessage,
51
+ promptCtx: Omit<
52
+ PromptContext,
53
+ "activeSkillsInfo" | "chatHistory" | "targetMessage"
54
+ >,
55
+ humanize: HumanizeEngine,
56
+ skillManager: SkillSessionManager,
57
+ structuredHistory?: StructuredHistoryRunContext,
58
+ runtimeOptions?: ChatRuntimeRunOptions,
59
+ ): Promise<ChatResult> {
60
+ const { tools: chatTools } = createTools(toolCtx, skillManager);
61
+ const skillTools = skillManager.getTools(toolCtx.sessionId);
62
+ const activeSkillsInfo = skillManager.getActiveSkillsInfo(
63
+ toolCtx.sessionId,
64
+ (skillName: string) => {
65
+ if (
66
+ !toolCtx.config.enableExternalSkills ||
67
+ !isExternalSkillAllowed(toolCtx.config, skillName)
68
+ ) {
69
+ return false;
70
+ }
71
+ const skill = toolCtx.aiService.getSkill(skillName);
72
+ return isSkillAllowedForRole(skill, toolCtx.triggerSkillRole);
73
+ },
74
+ );
75
+ const prompt = buildSystemPrompt({
76
+ ...promptCtx,
77
+ triggerSkillRole: toolCtx.triggerSkillRole,
78
+ activeSkillsInfo: activeSkillsInfo || undefined,
79
+ chatHistory: history,
80
+ targetMessage,
81
+ emojiAgent: humanize.emojiAgent,
82
+ skillManager,
83
+ sessionId: toolCtx.sessionId,
84
+ });
85
+
86
+ logger.info(
87
+ `[chat-engine] Session ${toolCtx.sessionId} | target: ${targetMessage.userName}(${targetMessage.userId}): "${targetMessage.content}"`,
88
+ );
89
+ if (toolCtx.config.debug) {
90
+ logger.info("[chat-engine] === Prompt ===");
91
+ logger.info(prompt);
92
+ logger.info("[chat-engine] === End Prompt ===");
93
+ }
94
+
95
+ const hasStructuredHistory =
96
+ Boolean(toolCtx.groupId) &&
97
+ Boolean(structuredHistory) &&
98
+ structuredHistory!.currentUserInputs.length > 0;
99
+ const cachedHistory = hasStructuredHistory
100
+ ? structuredHistory!.manager.getMessages(
101
+ toolCtx.sessionId,
102
+ structuredHistory!.ttlMs,
103
+ )
104
+ : [];
105
+ const currentUserMessages = hasStructuredHistory
106
+ ? buildStructuredUserMessages(structuredHistory!.currentUserInputs)
107
+ : [];
108
+ const directImageUrls = toolCtx.config.isMultimodal
109
+ ? toolCtx.pendingImageUrls
110
+ : undefined;
111
+ const usageId = `chat:${toolCtx.sessionId}:${Date.now()}:${Math.random()
112
+ .toString(36)
113
+ .slice(2, 10)}`;
114
+ const systemPromptTokens = estimateTextTokens(prompt);
115
+ const chatHistoryTokens = estimateChatHistoryTokens(history);
116
+ const currentUserTokens = estimateMessageContentTokens(
117
+ buildCurrentMessages(
118
+ "",
119
+ targetMessage,
120
+ [],
121
+ currentUserMessages,
122
+ directImageUrls,
123
+ ).filter((message) => message.role !== "system"),
124
+ );
125
+ const usageContext = {
126
+ usageId,
127
+ source: "chat",
128
+ botId: getEventNumber(toolCtx.event, "self_id"),
129
+ groupId: toolCtx.groupId,
130
+ groupName: getEventString(toolCtx.event, "group_name"),
131
+ userId: toolCtx.userId,
132
+ userName: targetMessage.userName,
133
+ sessionId: toolCtx.sessionId,
134
+ };
135
+
136
+ if (hasStructuredHistory) {
137
+ structuredHistory!.manager.touch(
138
+ toolCtx.sessionId,
139
+ structuredHistory!.ttlMs,
140
+ );
141
+ }
142
+
143
+ const streamEnabled = Boolean(toolCtx.config.stream);
144
+ const streamedMessages: string[] = [];
145
+ const streamThinkFilter = createThinkTagStreamFilter();
146
+ let streamBuffer = "";
147
+ let streamUnitIndex = 0;
148
+
149
+ const emitStreamSegment = async (
150
+ segment: string,
151
+ unitIndex: number,
152
+ ): Promise<void> => {
153
+ const text = cleanMarkers(segment)
154
+ .replace(/\[meme:[^\]]+\]/gi, "")
155
+ .replace(/\r/g, "")
156
+ .trim();
157
+ if (!text || text === "---") {
158
+ return;
159
+ }
160
+
161
+ if (toolCtx.onTextContent) {
162
+ await toolCtx.onTextContent(text, unitIndex, unitIndex + 1);
163
+ toolCtx.sentMessageIndices ??= new Set<number>();
164
+ toolCtx.sentMessageIndices.add(unitIndex);
165
+ }
166
+ streamedMessages.push(text);
167
+ };
168
+
169
+ const flushStreamBuffer = async (force: boolean): Promise<void> => {
170
+ while (true) {
171
+ const { units, rest } = consumeCompleteStreamUnits(streamBuffer, force);
172
+ if (units.length === 0) {
173
+ streamBuffer = rest;
174
+ break;
175
+ }
176
+
177
+ streamBuffer = rest;
178
+ for (const unit of units) {
179
+ const unitIndex = streamUnitIndex;
180
+ streamUnitIndex += 1;
181
+ if (unit.trim()) {
182
+ await emitStreamSegment(unit, unitIndex);
183
+ }
184
+ }
185
+
186
+ if (!force) {
187
+ break;
188
+ }
189
+ }
190
+ };
191
+
192
+ const runComplete = () =>
193
+ ai.complete({
194
+ model: toolCtx.config.model,
195
+ messages: buildCurrentMessages(
196
+ prompt,
197
+ targetMessage,
198
+ cachedHistory,
199
+ currentUserMessages,
200
+ directImageUrls,
201
+ ),
202
+ usageContext,
203
+ usageContextTokens: chatHistoryTokens,
204
+ usageBreakdown: {
205
+ systemPromptTokens,
206
+ chatHistoryTokens,
207
+ otherContextTokens: currentUserTokens,
208
+ },
209
+ executableToolsProvider: () =>
210
+ buildSessionTools(
211
+ chatTools,
212
+ skillManager.getTools(toolCtx.sessionId),
213
+ toolCtx,
214
+ runtimeOptions?.extraTools,
215
+ ),
216
+ temperature: toolCtx.config.temperature,
217
+ maxIterations: toolCtx.config.maxIterations,
218
+ stream: streamEnabled,
219
+ onTextDelta: streamEnabled
220
+ ? async (delta) => {
221
+ streamBuffer += streamThinkFilter.push(delta, false);
222
+ await flushStreamBuffer(false);
223
+ }
224
+ : undefined,
225
+ });
226
+ const response = ai.withUsageContext
227
+ ? await ai.withUsageContext(usageContext, runComplete)
228
+ : await runComplete();
229
+
230
+ if (streamEnabled) {
231
+ streamBuffer += streamThinkFilter.push("", true);
232
+ await flushStreamBuffer(true);
233
+ }
234
+
235
+ if (toolCtx.config.debug) {
236
+ logger.info("[chat-engine] === Raw AI Reply ===");
237
+ logger.info(response.content || "(empty)");
238
+ logger.info("[chat-engine] === End Raw AI Reply ===");
239
+ }
240
+
241
+ const allToolCalls = response.allToolCalls || [];
242
+
243
+ if (toolCtx.config.debug && response.reasoning) {
244
+ logger.info(`[chat-engine] AI reasoning: ${response.reasoning}`);
245
+ }
246
+
247
+ if (toolCtx.config.debug && allToolCalls.length > 0) {
248
+ for (const toolCall of allToolCalls) {
249
+ const resultPreview = JSON.stringify(toolCall.result);
250
+ logger.info(
251
+ `[chat-engine] Tool call: ${toolCall.name}(${JSON.stringify(toolCall.arguments).substring(0, 100)})`,
252
+ );
253
+ logger.info(
254
+ `[chat-engine] Tool result: ${toolCall.name} -> ${resultPreview ? resultPreview.substring(0, 300) : "undefined"}`,
255
+ );
256
+ }
257
+ }
258
+
259
+ if (shouldEndSession(allToolCalls)) {
260
+ persistStructuredHistory(
261
+ structuredHistory,
262
+ toolCtx.sessionId,
263
+ response.turnMessages,
264
+ currentUserMessages,
265
+ [],
266
+ );
267
+ logger.info(`[chat-engine] Session ${toolCtx.sessionId} ended by tool`);
268
+ toolCtx.aiService.finalizeUsage?.(usageId, {
269
+ sentUserMessages: 0,
270
+ sentAssistantMessages: 0,
271
+ });
272
+ return {
273
+ messages: [],
274
+ pendingAt: [],
275
+ pendingPoke: [],
276
+ pendingQuote: undefined,
277
+ toolCalls: allToolCalls.map((toolCall) => ({
278
+ name: toolCall.name,
279
+ args: toolCall.arguments,
280
+ result: toolCall.result,
281
+ })),
282
+ emojiPath: null,
283
+ protocolMessages: response.turnMessages,
284
+ };
285
+ }
286
+
287
+ const failedToolCalls = allToolCalls.filter((toolCall) =>
288
+ isToolErrorResult(toolCall.result),
289
+ );
290
+ let cleanedText = cleanMarkers(response.content || "");
291
+ if (failedToolCalls.length > 0) {
292
+ cleanedText = await generateToolFailureReply(
293
+ ai,
294
+ toolCtx,
295
+ prompt,
296
+ targetMessage,
297
+ failedToolCalls,
298
+ );
299
+ }
300
+
301
+ let emojiPath: string | null = null;
302
+ let finalText = cleanedText;
303
+ if (cleanedText.trim()) {
304
+ const memeResult = await humanize.emojiAgent.processMemeResponse(
305
+ cleanedText,
306
+ toolCtx.sessionId,
307
+ );
308
+ if (memeResult.success && memeResult.emojiPath) {
309
+ emojiPath = memeResult.emojiPath;
310
+ finalText = memeResult.cleanedText || cleanedText;
311
+ }
312
+ }
313
+
314
+ const finalMessages = splitOutgoingUnits(finalText).filter(
315
+ (unit) => unit.trim() && unit.trim() !== "---",
316
+ );
317
+ const sentAssistantMessages = streamEnabled
318
+ ? streamedMessages.length
319
+ : finalMessages.length;
320
+ toolCtx.aiService.finalizeUsage?.(usageId, {
321
+ sentUserMessages: 1,
322
+ sentAssistantMessages,
323
+ });
324
+
325
+ logger.info(
326
+ `[chat-engine] Session ${toolCtx.sessionId} done | ${finalMessages.length} msg(s), ${allToolCalls.length} tool call(s)`,
327
+ );
328
+
329
+ persistStructuredHistory(
330
+ structuredHistory,
331
+ toolCtx.sessionId,
332
+ response.turnMessages,
333
+ currentUserMessages,
334
+ finalMessages,
335
+ );
336
+
337
+ return {
338
+ messages: streamEnabled ? [] : finalMessages,
339
+ pendingAt: [],
340
+ pendingPoke: [],
341
+ pendingQuote: undefined,
342
+ toolCalls: allToolCalls.map((toolCall) => ({
343
+ name: toolCall.name,
344
+ args: toolCall.arguments,
345
+ result: toolCall.result,
346
+ })),
347
+ emojiPath,
348
+ protocolMessages: response.turnMessages,
349
+ };
350
+ }
351
+
352
+ function estimateChatHistoryTokens(history: ChatMessage[]): number {
353
+ if (history.length === 0) return 0;
354
+ return estimateTextTokens(
355
+ history
356
+ .map((message) =>
357
+ [
358
+ message.userName,
359
+ message.userId,
360
+ message.userRole,
361
+ message.userTitle,
362
+ message.messageId,
363
+ message.content,
364
+ ]
365
+ .filter((value) => value !== undefined && value !== null)
366
+ .join(" "),
367
+ )
368
+ .join("\n"),
369
+ );
370
+ }
371
+
372
+ function estimateMessageContentTokens(messages: Array<{ content?: unknown }>): number {
373
+ return messages.reduce(
374
+ (sum, message) => sum + estimateContentTokens(message.content),
375
+ 0,
376
+ );
377
+ }
378
+
379
+ function estimateContentTokens(content: unknown): number {
380
+ if (typeof content === "string") {
381
+ return estimateTextTokens(content);
382
+ }
383
+ if (!Array.isArray(content)) {
384
+ return 0;
385
+ }
386
+ return content.reduce((sum, item) => {
387
+ if (!item || typeof item !== "object") return sum;
388
+ const record = item as Record<string, unknown>;
389
+ if (typeof record.text === "string") {
390
+ return sum + estimateTextTokens(record.text);
391
+ }
392
+ if (record.type === "image_url") {
393
+ return sum + 85;
394
+ }
395
+ return sum + estimateTextTokens(JSON.stringify(record));
396
+ }, 0);
397
+ }
398
+
399
+ function estimateTextTokens(text: string): number {
400
+ const normalized = text.trim();
401
+ if (!normalized) return 0;
402
+ const cjkChars =
403
+ normalized.match(/[\u3400-\u9fff\u3040-\u30ff]/g)?.length || 0;
404
+ const latinWords = normalized.match(/[A-Za-z0-9_]+/g)?.length || 0;
405
+ const symbols = Math.max(0, normalized.length - cjkChars);
406
+ return Math.max(
407
+ 1,
408
+ Math.ceil(cjkChars * 0.6 + latinWords * 1.3 + symbols / 6),
409
+ );
410
+ }
411
+
412
+ function buildCurrentMessages(
413
+ prompt: string,
414
+ targetMessage: TargetMessage,
415
+ cachedHistory: any[] = [],
416
+ currentUserMessages: any[] = [],
417
+ pendingImageUrls?: string[],
418
+ ): any[] {
419
+ const messages: any[] = [{ role: "system", content: prompt }];
420
+ messages.push(...cachedHistory);
421
+
422
+ if (currentUserMessages.length > 0) {
423
+ messages.push(
424
+ ...attachImagesToCurrentUserMessages(
425
+ currentUserMessages,
426
+ pendingImageUrls,
427
+ ),
428
+ );
429
+ return messages;
430
+ }
431
+
432
+ const hasImages = Boolean(pendingImageUrls && pendingImageUrls.length > 0);
433
+
434
+ if (!hasImages) {
435
+ messages.push({
436
+ role: "user",
437
+ content: targetMessage.content,
438
+ });
439
+ return messages;
440
+ }
441
+
442
+ const userContent: any[] = [{ type: "text", text: targetMessage.content }];
443
+ for (const url of pendingImageUrls || []) {
444
+ userContent.push({ type: "image_url", image_url: { url } });
445
+ }
446
+
447
+ messages.push({
448
+ role: "user",
449
+ content: userContent,
450
+ });
451
+ return messages;
452
+ }
453
+
454
+ function buildSessionTools(
455
+ chatTools: AITool[],
456
+ skillTools: Map<string, AITool>,
457
+ toolCtx: ToolContext,
458
+ extraTools: AITool[] = [],
459
+ ): SessionToolDefinition[] {
460
+ const tools: SessionToolDefinition[] = [];
461
+ const runtimeContext = createExternalSkillRuntimeContext(toolCtx);
462
+
463
+ for (const tool of chatTools) {
464
+ tools.push({
465
+ name: tool.name,
466
+ tool: {
467
+ ...tool,
468
+ handler: (args: any) => tool.handler(args, runtimeContext),
469
+ },
470
+ });
471
+ }
472
+
473
+ for (const tool of extraTools) {
474
+ tools.push({
475
+ name: tool.name,
476
+ tool: {
477
+ ...tool,
478
+ handler: (args: any) => tool.handler(args, runtimeContext),
479
+ },
480
+ });
481
+ }
482
+
483
+ for (const [name, tool] of skillTools) {
484
+ const skillName = name.split(".")[0] || "";
485
+ const skill = toolCtx.aiService.getSkill(skillName);
486
+ if (
487
+ !toolCtx.config.enableExternalSkills ||
488
+ !isExternalSkillAllowed(toolCtx.config, skillName) ||
489
+ !isSkillAllowedForRole(skill, toolCtx.triggerSkillRole)
490
+ ) {
491
+ continue;
492
+ }
493
+
494
+ tools.push({
495
+ name,
496
+ tool: {
497
+ ...tool,
498
+ handler: (args: any) => tool.handler(args, runtimeContext),
499
+ },
500
+ });
501
+ }
502
+
503
+ return tools;
504
+ }
505
+
506
+ function createExternalSkillRuntimeContext(toolCtx: ToolContext): any {
507
+ const rawEvent = toolCtx.event || {};
508
+ return {
509
+ ctx: toolCtx.ctx,
510
+ event: rawEvent,
511
+ rawEvent,
512
+ session_id: toolCtx.sessionId,
513
+ trigger_role: toolCtx.triggerSkillRole,
514
+ };
515
+ }
516
+
517
+ function shouldEndSession(
518
+ toolCalls: Array<{ name: string; result: any }>,
519
+ ): boolean {
520
+ return toolCalls.some((toolCall) => {
521
+ if (toolCall.name !== "end_session") {
522
+ return false;
523
+ }
524
+
525
+ const result = toolCall.result;
526
+ return Boolean(result && typeof result === "object" && result.ended);
527
+ });
528
+ }
529
+
530
+ function persistStructuredHistory(
531
+ structuredHistory: StructuredHistoryRunContext | undefined,
532
+ sessionId: string,
533
+ protocolMessages: any[] | undefined,
534
+ currentUserMessages: any[],
535
+ finalMessages: string[],
536
+ ): void {
537
+ if (!structuredHistory || currentUserMessages.length === 0) {
538
+ return;
539
+ }
540
+
541
+ const messages = [...currentUserMessages];
542
+ const protocol = [...(protocolMessages || [])];
543
+
544
+ if (protocol.length > 0) {
545
+ const last = protocol[protocol.length - 1];
546
+ if (isPlainAssistantMessage(last)) {
547
+ protocol.pop();
548
+ }
549
+ }
550
+
551
+ messages.push(...protocol);
552
+ for (const msg of finalMessages) {
553
+ messages.push({
554
+ role: "assistant",
555
+ content: msg,
556
+ });
557
+ }
558
+
559
+ structuredHistory.manager.append(
560
+ sessionId,
561
+ messages,
562
+ structuredHistory.ttlMs,
563
+ );
564
+ }
565
+
566
+ function isPlainAssistantMessage(message: any): boolean {
567
+ if (!message || message.role !== "assistant") {
568
+ return false;
569
+ }
570
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
571
+ return toolCalls.length === 0;
572
+ }
573
+
574
+ /**
575
+ * Remove action markers from text for storage/display.
576
+ * Note: ALL markers are preserved here - they'll be parsed by parseLineMarkers in index.ts
577
+ */
578
+ function cleanMarkers(text: string): string {
579
+ let cleaned = stripThinkBlocks(text).trim();
580
+
581
+ cleaned = cleaned
582
+ .replace(/<Ai>\s*<think>[\s\S]*?<\/Ai>/gi, "")
583
+ .replace(/<||DSML||tool_calls>[\s\S]*?<\/||DSML||tool_calls>/gi, "")
584
+ .replace(/<||DSML||invoke[^>]*>[\s\S]*?<\/||DSML||invoke>/gi, "")
585
+ .replace(/<||DSML||parameter[^>]*>[\s\S]*?<\/||DSML||parameter>/gi, "");
586
+
587
+ return cleaned;
588
+ }
589
+
590
+ function stripThinkBlocks(text: string): string {
591
+ let source = String(text || "");
592
+ let output = "";
593
+
594
+ while (source) {
595
+ const open = findThinkOpenTag(source);
596
+ if (!open) {
597
+ output += source;
598
+ break;
599
+ }
600
+
601
+ output += source.slice(0, open.index);
602
+ const afterOpen = source.slice(open.end);
603
+ const close = findThinkCloseTag(afterOpen);
604
+ if (!close) {
605
+ break;
606
+ }
607
+
608
+ source = afterOpen.slice(close.end);
609
+ }
610
+
611
+ return output.replace(/<\/think\s*>/gi, "");
612
+ }
613
+
614
+ function createThinkTagStreamFilter() {
615
+ let buffer = "";
616
+ let insideThink = false;
617
+
618
+ return {
619
+ push(delta: string, force: boolean): string {
620
+ buffer += delta;
621
+ let output = "";
622
+
623
+ while (buffer) {
624
+ if (insideThink) {
625
+ const close = findThinkCloseTag(buffer);
626
+ if (!close) {
627
+ buffer = force ? "" : keepTagPrefixSuffix(buffer, "</think>");
628
+ break;
629
+ }
630
+
631
+ buffer = buffer.slice(close.end);
632
+ insideThink = false;
633
+ continue;
634
+ }
635
+
636
+ const open = findThinkOpenTag(buffer);
637
+ if (!open) {
638
+ const keep = force ? "" : keepTagPrefixSuffix(buffer, "<think");
639
+ output += buffer.slice(0, buffer.length - keep.length);
640
+ buffer = keep;
641
+ break;
642
+ }
643
+
644
+ output += buffer.slice(0, open.index);
645
+ buffer = buffer.slice(open.end);
646
+ insideThink = true;
647
+ }
648
+
649
+ return output.replace(/<\/think\s*>/gi, "");
650
+ },
651
+ };
652
+ }
653
+
654
+ function findThinkOpenTag(text: string): { index: number; end: number } | null {
655
+ const lower = text.toLowerCase();
656
+ const index = lower.indexOf("<think");
657
+ if (index < 0) {
658
+ return null;
659
+ }
660
+
661
+ const afterName = text[index + "<think".length];
662
+ if (afterName && !/[\s>]/.test(afterName)) {
663
+ const next = findThinkOpenTag(text.slice(index + 1));
664
+ return next
665
+ ? { index: index + 1 + next.index, end: index + 1 + next.end }
666
+ : null;
667
+ }
668
+
669
+ const closeIndex = text.indexOf(">", index + "<think".length);
670
+ if (closeIndex < 0) {
671
+ return { index, end: text.length };
672
+ }
673
+
674
+ return { index, end: closeIndex + 1 };
675
+ }
676
+
677
+ function findThinkCloseTag(
678
+ text: string,
679
+ ): { index: number; end: number } | null {
680
+ const match = /<\/think\s*>/i.exec(text);
681
+ return match
682
+ ? { index: match.index, end: match.index + match[0].length }
683
+ : null;
684
+ }
685
+
686
+ function keepTagPrefixSuffix(text: string, tagPrefix: string): string {
687
+ const maxLength = Math.min(text.length, tagPrefix.length - 1);
688
+ const lowerText = text.toLowerCase();
689
+ const lowerPrefix = tagPrefix.toLowerCase();
690
+
691
+ for (let length = maxLength; length > 0; length--) {
692
+ const suffix = lowerText.slice(-length);
693
+ if (lowerPrefix.startsWith(suffix)) {
694
+ return text.slice(-length);
695
+ }
696
+ }
697
+
698
+ return "";
699
+ }
700
+
701
+ function isToolErrorResult(result: any): boolean {
702
+ if (!result || typeof result !== "object") return false;
703
+ if (result.error) return true;
704
+ return result.success === false;
705
+ }
706
+
707
+ async function generateToolFailureReply(
708
+ ai: AIInstance,
709
+ toolCtx: ToolContext,
710
+ chatSystemPrompt: string,
711
+ targetMessage: TargetMessage,
712
+ failedToolCalls: Array<{ name: string; result: any }>,
713
+ ): Promise<string> {
714
+ const failedSummary = failedToolCalls
715
+ .map((item) => {
716
+ const raw =
717
+ typeof item.result === "string"
718
+ ? item.result
719
+ : JSON.stringify(item.result);
720
+ return `- ${item.name}: ${raw}`;
721
+ })
722
+ .join("\n");
723
+ const userPrompt = `用户原始消息:${targetMessage.content}
724
+
725
+ 补充上下文:你刚才尝试调用工具,但以下工具失败了:
726
+ ${failedSummary}
727
+
728
+ 请基于当前会话的人设与语气,给用户一条自然、简短的回复。
729
+ 要求:
730
+ - 可以简要提到“刚刚没查到/调用失败”,但不要泄露内部系统细节。
731
+ - 给出可执行的下一步建议(如补充关键词、提供更具体链接、稍后再试)。
732
+ - 直接输出最终回复文本,不要解释你在做什么。`;
733
+
734
+ try {
735
+ const retry = await ai.complete({
736
+ model: toolCtx.config.model,
737
+ messages: [
738
+ { role: "system", content: chatSystemPrompt },
739
+ { role: "user", content: userPrompt },
740
+ ],
741
+ usageContext: {
742
+ source: "chat.tool-failure",
743
+ botId: getEventNumber(toolCtx.event, "self_id"),
744
+ groupId: toolCtx.groupId,
745
+ groupName: getEventString(toolCtx.event, "group_name"),
746
+ userId: toolCtx.userId,
747
+ userName: targetMessage.userName,
748
+ sessionId: toolCtx.sessionId,
749
+ },
750
+ temperature: Math.max(0.2, Math.min(0.8, toolCtx.config.temperature)),
751
+ max_tokens: 120,
752
+ });
753
+
754
+ const text = cleanMarkers(retry.content || "");
755
+ if (text) {
756
+ return text;
757
+ }
758
+ } catch (err) {
759
+ logger.warn(
760
+ `[chat-engine] Failed to generate tool-failure fallback reply: ${err}`,
761
+ );
762
+ }
763
+
764
+ return "我刚刚查这条信息时出了点问题,你可以换个关键词再试试,或者给我更具体一点的线索。";
765
+ }
766
+
767
+ function getEventNumber(event: unknown, key: string): number | undefined {
768
+ if (!event || typeof event !== "object") return undefined;
769
+ const value = (event as Record<string, unknown>)[key];
770
+ return typeof value === "number" && Number.isFinite(value)
771
+ ? value
772
+ : undefined;
773
+ }
774
+
775
+ function getEventString(event: unknown, key: string): string | undefined {
776
+ if (!event || typeof event !== "object") return undefined;
777
+ const value = (event as Record<string, unknown>)[key];
778
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
779
+ }