kiku-agent 1.2.0 → 1.3.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.
Files changed (100) hide show
  1. package/README.md +459 -407
  2. package/package.json +27 -3
  3. package/server/agent/agentCore.js +635 -267
  4. package/server/agent/appPaths.js +2 -0
  5. package/server/agent/catalogEntityResolver.js +629 -0
  6. package/server/agent/citations.js +0 -21
  7. package/server/agent/clarificationAnalytics.js +1 -1
  8. package/server/agent/clarifyingQuestions.js +286 -216
  9. package/server/agent/cli.js +528 -73
  10. package/server/agent/commandFeatures.js +123 -24
  11. package/server/agent/commandRegistry.js +128 -62
  12. package/server/agent/commands.js +1410 -95
  13. package/server/agent/config.js +253 -163
  14. package/server/agent/contextCompactor.js +239 -25
  15. package/server/agent/dynamicCatalog.js +192 -0
  16. package/server/agent/envBootstrap.js +46 -0
  17. package/server/agent/envManager.js +317 -0
  18. package/server/agent/eventBus.js +3 -3
  19. package/server/agent/feedbackLearning.js +0 -8
  20. package/server/agent/gpuMetrics.js +188 -55
  21. package/server/agent/ingestionWorker.js +339 -0
  22. package/server/agent/lanDiscovery.js +369 -0
  23. package/server/agent/llmClient.js +111 -39
  24. package/server/agent/lmStudioWakeup.js +144 -0
  25. package/server/agent/mcpClient.js +2 -2
  26. package/server/agent/memory.js +635 -494
  27. package/server/agent/modelConfig.js +487 -0
  28. package/server/agent/newsIntelligence.js +219 -0
  29. package/server/agent/newsWatch.js +85 -0
  30. package/server/agent/prompts.js +146 -0
  31. package/server/agent/publicTools.js +7 -0
  32. package/server/agent/queryDetails.js +62 -1
  33. package/server/agent/research.js +314 -35
  34. package/server/agent/routingInterceptor.js +77 -0
  35. package/server/agent/rumoredProjects.js +15 -0
  36. package/server/agent/sanitizer.js +117 -17
  37. package/server/agent/subagent.js +52 -9
  38. package/server/agent/suggestionTui.js +33 -13
  39. package/server/agent/suggestions.js +29 -13
  40. package/server/agent/telemetry.js +0 -11
  41. package/server/agent/terminalImage.js +0 -1
  42. package/server/agent/textToolProtocol.js +190 -0
  43. package/server/agent/tokenTracker.js +220 -0
  44. package/server/agent/toolRouter.js +362 -216
  45. package/server/agent/tools.js +1155 -194
  46. package/server/agent/ui.js +144 -41
  47. package/server/agent/vramService.js +572 -0
  48. package/server/agent/webSearch.js +2279 -273
  49. package/server/agent/youtubeAuth.js +82 -73
  50. package/server/agent/youtubeTranscript.js +209 -0
  51. package/server/apiRouter.js +383 -179
  52. package/server/mcp/data/brands.js +5 -5
  53. package/server/mcp/data/discography.js +56 -45
  54. package/server/mcp/data/gallery.js +9 -0
  55. package/server/mcp/data/index.js +1 -0
  56. package/server/mcp/data/industryIndex.js +251 -0
  57. package/server/mcp/data/profile.js +5 -5
  58. package/server/mcp/data/timeline.js +2 -2
  59. package/server/mcp/data/works.js +2 -2
  60. package/server/mcp/handler.js +0 -2
  61. package/server/mcp/prompts.js +42 -2
  62. package/server/mcp/resources.js +5 -1
  63. package/server/mcp/security.js +25 -14
  64. package/server/mcp/server.js +92 -3
  65. package/server/mcp/tools.js +20 -11
  66. package/server/web-chat/core/connection.js +218 -194
  67. package/server/web-chat/core/conversationContext.js +1 -1
  68. package/server/web-chat/core/directCatalogEngine.js +190 -184
  69. package/server/web-chat/core/followUpEngine.js +88 -11
  70. package/server/web-chat/core/security.js +7 -4
  71. package/server/web-chat/router.js +54 -17
  72. package/server/web-chat/routes/bilibiliRoute.js +34 -0
  73. package/server/web-chat/routes/chatRoute.js +488 -397
  74. package/server/web-chat/routes/clarifyConfigRoute.js +1 -2
  75. package/server/web-chat/routes/exportRoute.js +2 -1
  76. package/server/web-chat/routes/lanDiscoveryRoute.js +90 -0
  77. package/server/web-chat/routes/newsRoute.js +36 -6
  78. package/server/web-chat/routes/providerRoute.js +87 -0
  79. package/server/web-chat/routes/staticRoute.js +61 -13
  80. package/server/web-chat/routes/statusRoute.js +23 -2
  81. package/server/web-chat/routes/vramRoute.js +22 -0
  82. package/server/web-chat/server.js +135 -106
  83. package/server/web-chat/ui/htmlBuilder.js +8 -8
  84. package/server/web-chat/ui/previews/modelSettings.preview.html +41 -0
  85. package/server/web-chat/ui/scripts/agentBubbleSystem.js +0 -5
  86. package/server/web-chat/ui/scripts/client.js +10526 -8397
  87. package/server/web-chat/ui/scripts/webchatI18n.js +190 -138
  88. package/server/web-chat/ui/styles/main.css +7471 -4246
  89. package/server/web-chat/ui/templates/body.html +305 -179
  90. package/src/data/brandsData.js +29 -6
  91. package/src/data/discographyData.js +918 -900
  92. package/src/data/galleryData.js +66 -0
  93. package/src/data/industryIndexData.js +292 -0
  94. package/src/data/musicVideoData.js +48 -5
  95. package/src/data/profileData.js +15 -1
  96. package/src/data/timelineData.js +4 -3
  97. package/src/data/worksData.js +536 -535
  98. package/src/lib/vram/estimator.js +464 -0
  99. package/src/lib/vram/modelRegistry.js +387 -0
  100. package/tokens.css +33 -2
@@ -1,397 +1,488 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { resolveAgentConnection } from '../core/connection.js';
3
- import { executeDirectCatalogKnowledge } from '../core/directCatalogEngine.js';
4
- import { makeInspectableToolPayload } from '../core/toolInspector.js';
5
- import { getAgentMemorySnapshot } from '../core/memorySnapshot.js';
6
- import { parseJsonBody, sendJson, sendError } from '../core/errorHandler.js';
7
- import { registerClarificationResolver } from './clarifyRoute.js';
8
- import { extractAgentFollowUpQuestions, generateDynamicFollowUps } from '../core/followUpEngine.js';
9
- import { resolveConversationContext } from '../core/conversationContext.js';
10
- import { applyFutureEventGuard } from '../core/futureEventGuard.js';
11
- import { extractApiKey } from '../core/security.js';
12
- import { assessDramaAnswerQuality } from '../core/dramaKnowledge.js';
13
-
14
- function stripInvalidYouTubeLinks(text) {
15
- if (!text || typeof text !== 'string') return text || '';
16
- return text.replace(/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{1,20})/gi, (m, id) => (id.length === 11 ? m : ''));
17
- }
18
-
19
- async function replaceLowQualityDramaAnswer({ query, answer, persona, conversationContext, toolsUsed }) {
20
- const assessment = assessDramaAnswerQuality(query, answer);
21
- if (!assessment.applicable || assessment.valid) return null;
22
-
23
- const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
24
- if (Array.isArray(toolsUsed)) toolsUsed.push(...fallback.tools);
25
- return {
26
- answer: fallback.answer,
27
- suggestions: fallback.suggestions || [],
28
- issues: assessment.issues
29
- };
30
- }
31
-
32
-
33
- export function sanitizeSubagentProgress(event = {}) {
34
- const text = (value, max = 200) => String(value ?? '').slice(0, max);
35
- const num = (value) => (Number.isFinite(Number(value)) ? Number(value) : undefined);
36
- const out = { type: 'subagent_progress' };
37
- if (['subagent', 'research', 'verify'].includes(event.source)) out.source = event.source;
38
- else out.source = 'subagent';
39
- if (['start', 'iteration', 'tool', 'worker-start', 'worker-complete', 'complete'].includes(event.type)) out.kind = event.type;
40
- else out.kind = 'tool';
41
- const subject = event.task ?? event.question ?? event.claim ?? '';
42
- if (subject) out.subject = text(subject);
43
- if (event.iteration !== undefined) out.iteration = num(event.iteration);
44
- if (event.maxIterations !== undefined) out.maxIterations = num(event.maxIterations);
45
- if (event.name) out.tool = text(event.name, 80);
46
- if (event.label) out.label = text(event.label, 120);
47
- if (event.id) out.workerId = text(event.id, 80);
48
- if (event.workerId) out.workerId = text(event.workerId, 80);
49
- if (event.sources !== undefined) out.sources = num(event.sources);
50
- return out;
51
- }
52
-
53
- export async function handleChatRoute(req, res, urlObj) {
54
- let body;
55
- try {
56
- body = await parseJsonBody(req);
57
- } catch (err) {
58
- return sendError(res, 400, `Invalid JSON body: ${err.message}`);
59
- }
60
-
61
- const query = String(body.query || '').trim();
62
-
63
- const persona = 'default';
64
- const wantsStream = body.stream === true || (req.headers.accept && req.headers.accept.includes('text/event-stream'));
65
-
66
- if (!query) {
67
- return sendError(res, 400, 'Query is required');
68
- }
69
-
70
- const apiKey = extractApiKey(req);
71
- const abortController = new AbortController();
72
- req.on('close', () => {
73
- if (!res.writableEnded) {
74
- abortController.abort();
75
- }
76
- });
77
-
78
- const startTime = Date.now();
79
- const clarificationIds = new Map();
80
- const externalClarificationId = (internalId) => {
81
- if (!clarificationIds.has(internalId)) clarificationIds.set(internalId, randomUUID());
82
- return clarificationIds.get(internalId);
83
- };
84
- const conversationContext = resolveConversationContext(query, body.history);
85
- const publicContext = {
86
- contextual: conversationContext.contextual,
87
- domain: conversationContext.domain
88
- };
89
-
90
- try {
91
- const conn = await resolveAgentConnection({
92
- provider: body.provider,
93
- model: body.model,
94
- thinking: body.thinking,
95
- apiKey,
96
- sessionId: body.sessionId,
97
- history: body.history,
98
- }, { signal: abortController.signal });
99
-
100
- let augmentedQuery = conversationContext.resolvedQuery;
101
-
102
- augmentedQuery += `\n\n[SYSTEM DIRECTIVE (Fact Grounding & Music Playback):
103
- 1. อ้างอิงเฉพาะเพลง ซีรีส์ และข้อมูลที่ได้จากผลลัพธ์เครื่องมือ (search_works, search_discography, get_song_detail, get_character_roles, get_awards, get_career_timeline) เท่านั้น ห้ามแต่งชื่อเพลง ชื่อซีรีส์ ตัวเลข หรือลิงก์ YouTube ขึ้นเอง หากเครื่องมือไม่พบข้อมูล ให้บอกผู้ใช้ตามตรงว่าไม่พบข้อมูลในคลัง
104
- 2. เมื่อพูดถึงเพลงใด ให้ระบุ "ชื่อเพลงจริง" จากคลังข้อมูลกำกับในประโยคเสมอ (เช่น "เพลง 千年等一回") และห้ามแนบ URL YouTube ใดๆ ที่ไม่ได้มาจากผลลัพธ์เครื่องมือโดยเด็ดขาด
105
- 3. เงื่อนไขการเสนอเปิดเพลงใน Mini Player: ให้เสนอเปิดเพลงเฉพาะเมื่อผู้ใช้ถามเรื่องบทเพลง การฟังเพลง หรือ OST โดยตรงเท่านั้น และต้องระบุชื่อเพลงที่เจาะจงในประโยคเดียวกัน ห้ามเสนอเปิดเพลงหรือถามชวนเปิดเพลงเมื่อผู้ใช้ถามเรื่องซีรีส์ ข่าวลือ หรือเนื้อหาทั่วไป
106
- 4. การแยกแยะชื่อเรื่องและข่าวลือแคสติ้ง (Entity Disambiguation & Anti-Hallucination): ห้ามเดาหรือจับคู่คำค้นหาที่ออกเสียงคล้ายกันเข้ากับผลงานในคลังโดยไม่มีหลักฐานเด็ดขาด (เช่น คำว่า "Qi Xiang Cai" หรือ "七星彩" (ชีซิงไฉ่) คือโปรเจกต์ซีรีส์จีนโบราณของ Youku x Stellar Media ดัดแปลงจากนิยายของหมิงเยว่ตัง ที่มีกระแสข่าวลือแคสติ้ง ไม่ใช่เพลงหรือซีรีส์ 《千香》 (The Eternal Fragrance / พันกลิ่นบุปผา) และไม่มีความเกี่ยวข้องกัน ห้ามอ้างว่าเป็นเพลง OST หรือผลงานในคลังเด็ดขาด)
107
- 5. หากผู้ใช้ส่งคำว่า "ดำเนินการต่อ" หรือตอบตกลงต่อคำถามก่อนหน้า ให้ลงมือทำสิ่งที่เสนอไว้ทันทีด้วยข้อมูลจริงจากเครื่องมือ (เช่น สรุปเพลงที่เสนอพร้อมชื่อจริงและรายละเอียด) อย่าถามคำถามเดิมซ้ำอีก
108
- 6. ข้อมูลที่อ่อนไหวต่อเวลา (ผลงานล่าสุด, กำหนดการ, โซเชียล, ข่าว, ข่าวลือแคสติ้ง, รางวัล, แบรนด์) ให้ลำดับการใช้ข้อมูลสดจาก fetch_api/global_search/verify_claim/web_search แทนข้อมูลที่อาจเก่าในคลัง เพื่อหลีกเลี่ยงคำตอบที่ล้าหลังหรือเข้าใจผิด]`;
109
-
110
- augmentedQuery += `\n\n[SYSTEM DIRECTIVE (Answer Quality for Drama Analysis):
111
- 1. แยก "ข้อเท็จจริงจากเครื่องมือ" ออกจาก "บทวิเคราะห์" ให้ชัดเจน ห้ามนำเรื่องย่อไปสรุปเป็นเสียงวิจารณ์หรือการยอมรับของวงการ
112
- 2. ห้ามเหมารวมว่าผลงานใหม่กว่าแปลว่าลึกกว่า สมจริงกว่า หรือแสดงดีกว่า ให้เทียบจากภาระของบท แรงขับของตัวละคร แนวเรื่อง และฉากที่มีหลักฐานเท่านั้น
113
- 3. ห้ามใช้คำยกย่องแบบไม่มีหลักฐาน เช่น "ระดับแนวหน้า", "ไม่มีใครเทียบได้", "ก้าวข้ามไอดอลอย่างสมบูรณ์" หรือ "ได้รับรางวัลมากมาย"
114
- 4. คะแนนในแคตตาล็อกที่ไม่มีชื่อแหล่งและเกณฑ์คะแนนเป็นเพียง metadata ภายใน ห้ามใช้ยืนยันคุณภาพและไม่ต้องแสดงในบทวิเคราะห์
115
- 5. In Blossom: จวีจิ้งอีแสดงตัวตนของหยางไฉ่เวยภายใต้รูปลักษณ์ซ่างกวนจื่อหลัง premise การเปลี่ยนใบหน้า ไม่ใช่สองตัวละครอิสระที่สลับเล่นตลอดเรื่อง
116
- 6. The Legend of White Snake: ไป๋ซู่เจินคือวิญญาณ/ปีศาจงูขาวที่แปลงเป็นหญิงมนุษย์ ส่วนสวี่เซียนคือมนุษย์ชาย ห้ามสลับเพศหรือเผ่าพันธุ์
117
- 7. หากเป็นคำสั่ง "ดำเนินการต่อ" ให้ตอบเฉพาะมุมใหม่ที่ผู้ใช้ขอ ห้ามคัดลอกคำตอบก่อนหน้ามาตอบซ้ำ]`;
118
-
119
- augmentedQuery += `\n\n[SYSTEM DIRECTIVE (Topic & Future-event Safety):
120
- 1. คำถามสั้นที่มีสรรพนามหรืออ้างถึง "เรื่องนี้/งานนี้/ทำไม/น่าจับตามอง" ต้องยึดหัวข้อหลักล่าสุดในบทสนทนาก่อน ห้ามเปลี่ยนไปตอบเรื่องศิลปินเพียงเพราะ Persona หรือฐานข้อมูลเน้นศิลปิน
121
- 2. วันนี้คือ ${conversationContext.today}. เหตุการณ์หลังวันนี้เป็นข้อมูลอนาคต: ใช้คำว่า "มีกำหนด", "มีรายงาน", หรือ "ยังไม่ยืนยัน" ตามหลักฐาน ห้ามฟันธงว่าได้รับเชิญหรือจะเข้าร่วมแน่นอนโดยไม่มีแหล่งทางการที่เป็นปัจจุบัน
122
- 3. ห้ามนำวัน เดือน รายชื่อแขก หรือสถานะการเข้าร่วมจากงานคนละปีมาใช้กับฉบับปีปัจจุบัน หากต้องระบุวันที่แน่นอนหรือผู้เข้าร่วมงานอนาคต ให้ตรวจสอบด้วยเครื่องมือเว็บก่อน]`;
123
-
124
- augmentedQuery += `\n\n[SYSTEM DIRECTIVE: After completing your answer, proactively formulate and suggest 2-3 engaging, creative, and relevant follow-up questions tailored specifically to what you just discussed for the user to explore deeper. Place them at the very end of your response strictly formatted as:
125
- [SUGGESTED_FOLLOW_UPS]
126
- - [คำถามแนะนำที่ 1 ที่เชื่อมโยงกับเรื่องที่เพิ่งเล่า]
127
- - [คำถามแนะนำที่ 2 ที่เชื่อมโยงกับเรื่องที่เพิ่งเล่า]
128
- - [คำถามแนะนำที่ 3 ที่เชื่อมโยงกับเรื่องที่เพิ่งเล่า]
129
- [/SUGGESTED_FOLLOW_UPS]
130
- Rules for the block: one question per line, plain text only — no markdown bold/italic, no extra headers, no lead-in sentences, no numbering inside the block.]`;
131
-
132
- if (wantsStream) {
133
- res.writeHead(200, {
134
- 'Content-Type': 'text/event-stream',
135
- 'Cache-Control': 'no-cache, no-transform',
136
- 'Connection': 'keep-alive',
137
- 'X-Accel-Buffering': 'no'
138
- });
139
-
140
- const sendSse = (eventObj) => {
141
- res.write(`data: ${JSON.stringify(eventObj)}\n\n`);
142
- };
143
-
144
- if (conn.ready && conn.agent) {
145
- try {
146
- const toolsUsed = [];
147
- let reasoningStarted = false;
148
- const runRes = await conn.agent.run(augmentedQuery, {
149
- signal: abortController.signal,
150
- onReasoningDelta: () => {
151
- if (reasoningStarted) return;
152
- reasoningStarted = true;
153
- sendSse({ type: 'reasoning', chunk: 'กำลังวิเคราะห์บริบทและวางแผนคำตอบ' });
154
- },
155
- onToolStart: (name, input) => {
156
- toolsUsed.push({ name, input });
157
- sendSse({ type: 'tool_start', name, input: makeInspectableToolPayload(input) });
158
- },
159
- onToolResult: (name, result, durationMs) => {
160
- const existing = toolsUsed.find(t => t.name === name);
161
- if (existing) { existing.result = result; existing.durationMs = durationMs; }
162
- sendSse({
163
- type: 'tool_result',
164
- name,
165
- durationMs,
166
- resultCount: result?.count ?? 0,
167
- result: makeInspectableToolPayload(result)
168
- });
169
- },
170
- onSubagentProgress: (event) => {
171
- sendSse(sanitizeSubagentProgress(event));
172
- },
173
- onTokenDelta: (chunk) => {
174
- sendSse({ type: 'token', chunk });
175
- },
176
- ...(conversationContext.contextual ? {} : {
177
- onClarifyingQuestions: (questions, queryId) => {
178
- sendSse({ type: 'clarifying_questions', questions, queryId: externalClarificationId(queryId) });
179
- },
180
- onClarifyingAnswer: (queryId) => {
181
- return new Promise((resolve) => {
182
- registerClarificationResolver(externalClarificationId(queryId), resolve);
183
- });
184
- }
185
- })
186
- });
187
-
188
- let finalAnswer = runRes?.answer || String(runRes || '');
189
- finalAnswer = finalAnswer.replace(/The user has provided a complex instruction set:[\s\S]*?(?=\n\n|\n[A-Zก-๙]|$)/i, '').trim();
190
- finalAnswer = finalAnswer.replace(/^\[SYSTEM (?:INSTRUCTION|DIRECTIVE):[\s\S]*?\]\s*/i, '').trim();
191
-
192
- let suggestions = [];
193
- const extracted = extractAgentFollowUpQuestions(finalAnswer);
194
- finalAnswer = stripInvalidYouTubeLinks(extracted.cleanText);
195
- finalAnswer = applyFutureEventGuard(finalAnswer, { tools: toolsUsed });
196
- suggestions = extracted.suggestions;
197
-
198
- if (!finalAnswer.trim()) {
199
- const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
200
- finalAnswer = applyFutureEventGuard(fallback.answer, { tools: fallback.tools });
201
- if (!suggestions.length) suggestions = fallback.suggestions || [];
202
- }
203
-
204
- const qualityFallback = await replaceLowQualityDramaAnswer({
205
- query,
206
- answer: finalAnswer,
207
- persona,
208
- conversationContext,
209
- toolsUsed
210
- });
211
- if (qualityFallback) {
212
- finalAnswer = applyFutureEventGuard(qualityFallback.answer, { tools: toolsUsed });
213
- suggestions = qualityFallback.suggestions;
214
- }
215
-
216
- if (!suggestions.length) {
217
- suggestions = generateDynamicFollowUps({ query, answer: finalAnswer, tools: toolsUsed });
218
- }
219
-
220
- sendSse({
221
- type: 'final',
222
- answer: finalAnswer,
223
- suggestions,
224
- durationMs: Date.now() - startTime,
225
- tokens: conn.agent?.lastTelemetry?.tokens || conn.agent?.session?.lastQueryTokens || null,
226
- costUsd: conn.agent?.lastTelemetry?.costUsd ?? conn.agent?.session?.lastQueryCostUsd ?? 0,
227
- citations: conn.agent?.lastCitations || [],
228
- context: publicContext,
229
- memory: getAgentMemorySnapshot(conn.agent, body.sessionId)
230
- });
231
- res.write('data: [DONE]\n\n');
232
- res.end();
233
- return;
234
- } catch (streamErr) {
235
- if (abortController.signal.aborted || streamErr?.name === 'AbortError') {
236
- if (!res.writableEnded) {
237
- res.write('data: [DONE]\n\n');
238
- res.end();
239
- }
240
- return;
241
- }
242
- const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
243
- sendSse({
244
- type: 'final',
245
- answer: applyFutureEventGuard(fallback.answer, { tools: fallback.tools }),
246
- suggestions: fallback.suggestions,
247
- durationMs: Date.now() - startTime,
248
- citations: [],
249
- context: publicContext,
250
- memory: { sessionId: body.sessionId || null, messageCount: body.history?.length || 0, persistent: false }
251
- });
252
- res.write('data: [DONE]\n\n');
253
- res.end();
254
- return;
255
- }
256
- }
257
-
258
- sendSse({ type: 'reasoning', chunk: '⚡ ตรวจพบ LM Studio ออฟไลน์ -> เปิดใช้งาน Auto Direct Catalog Knowledge Engine...' });
259
-
260
- const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
261
- const guardedFallbackAnswer = applyFutureEventGuard(fallback.answer, { tools: fallback.tools });
262
- for (const t of fallback.tools) {
263
- sendSse({ type: 'tool_start', name: t.name, input: t.input });
264
- sendSse({ type: 'tool_result', name: t.name, durationMs: t.durationMs, resultCount: t.resultCount, result: t.result });
265
- }
266
- sendSse({ type: 'token', chunk: guardedFallbackAnswer });
267
- sendSse({
268
- type: 'final',
269
- answer: guardedFallbackAnswer,
270
- suggestions: fallback.suggestions,
271
- durationMs: Date.now() - startTime,
272
- citations: [],
273
- context: publicContext,
274
- memory: { sessionId: body.sessionId || null, messageCount: body.history?.length || 0, persistent: false }
275
- });
276
- res.write('data: [DONE]\n\n');
277
- res.end();
278
- return;
279
- }
280
-
281
- if (conn.ready && conn.agent) {
282
- let answer = '';
283
- let thinking = '';
284
- let thinkingDurationMs = 0;
285
- const toolsUsed = [];
286
- const subagentActivity = [];
287
-
288
- let runRes;
289
- try {
290
- runRes = await conn.agent.run(augmentedQuery, {
291
- signal: abortController.signal,
292
- onReasoningDelta: () => { thinking = 'วิเคราะห์บริบทและวางแผนคำตอบแล้ว'; },
293
- onReasoningEnd: () => { thinkingDurationMs = Date.now() - startTime; },
294
- onSubagentProgress: (event) => {
295
- subagentActivity.push(sanitizeSubagentProgress(event));
296
- },
297
- onToolStart: (name, input) => {
298
- toolsUsed.push({ name, input: makeInspectableToolPayload(input), status: 'running' });
299
- },
300
- onToolResult: (name, result, dur) => {
301
- let tool = null;
302
- for (let index = toolsUsed.length - 1; index >= 0; index--) {
303
- if (toolsUsed[index].name === name && toolsUsed[index].status === 'running') {
304
- tool = toolsUsed[index];
305
- break;
306
- }
307
- }
308
- if (!tool) {
309
- tool = { name };
310
- toolsUsed.push(tool);
311
- }
312
- Object.assign(tool, {
313
- status: 'done',
314
- result: makeInspectableToolPayload(result),
315
- resultCount: result?.count ?? 0,
316
- durationMs: dur
317
- });
318
- }
319
- });
320
- } catch (agentErr) {
321
- if (abortController.signal.aborted || agentErr?.name === 'AbortError') {
322
- if (!res.writableEnded) {
323
- return sendError(res, 499, 'Request aborted by client');
324
- }
325
- return;
326
- }
327
- throw agentErr;
328
- }
329
-
330
- answer = runRes?.answer || String(runRes || '');
331
- let suggestions = [];
332
- const extracted = extractAgentFollowUpQuestions(answer);
333
- answer = stripInvalidYouTubeLinks(extracted.cleanText);
334
- answer = applyFutureEventGuard(answer, { tools: toolsUsed });
335
- suggestions = extracted.suggestions;
336
-
337
- if (!answer.trim()) {
338
- const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
339
- answer = applyFutureEventGuard(fallback.answer, { tools: fallback.tools });
340
- if (!suggestions.length) suggestions = fallback.suggestions || [];
341
- }
342
-
343
- const qualityFallback = await replaceLowQualityDramaAnswer({
344
- query,
345
- answer,
346
- persona,
347
- conversationContext,
348
- toolsUsed
349
- });
350
- if (qualityFallback) {
351
- answer = applyFutureEventGuard(qualityFallback.answer, { tools: toolsUsed });
352
- suggestions = qualityFallback.suggestions;
353
- }
354
-
355
- if (!suggestions.length) {
356
- suggestions = generateDynamicFollowUps({ query, answer, tools: toolsUsed });
357
- }
358
-
359
- return sendJson(res, 200, {
360
- status: 'ok',
361
- query,
362
- persona,
363
- answer,
364
- suggestions,
365
- thinking,
366
- thinkingDurationMs,
367
- tools: toolsUsed,
368
- subagents: subagentActivity,
369
- tokens: conn.agent?.lastTelemetry?.tokens || conn.agent?.session?.lastQueryTokens || null,
370
- costUsd: conn.agent?.lastTelemetry?.costUsd ?? conn.agent?.session?.lastQueryCostUsd ?? 0,
371
- citations: conn.agent?.lastCitations || [],
372
- context: publicContext,
373
- memory: getAgentMemorySnapshot(conn.agent, body.sessionId),
374
- durationMs: Date.now() - startTime
375
- });
376
- }
377
-
378
- const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
379
- return sendJson(res, 200, {
380
- status: 'ok',
381
- query,
382
- persona,
383
- answer: applyFutureEventGuard(fallback.answer, { tools: fallback.tools }),
384
- suggestions: fallback.suggestions || [],
385
- thinking: fallback.thinking,
386
- thinkingDurationMs: Date.now() - startTime,
387
- tools: fallback.tools,
388
- citations: [],
389
- context: publicContext,
390
- memory: { sessionId: body.sessionId || null, messageCount: body.history?.length || 0, persistent: false },
391
- durationMs: Date.now() - startTime
392
- });
393
-
394
- } catch (err) {
395
- return sendError(res, 500, err.message);
396
- }
397
- }
1
+ import { randomUUID } from 'node:crypto';
2
+ import { resolveAgentConnection } from '../core/connection.js';
3
+ import { executeDirectCatalogKnowledge } from '../core/directCatalogEngine.js';
4
+ import { makeInspectableToolPayload } from '../core/toolInspector.js';
5
+ import { getAgentMemorySnapshot } from '../core/memorySnapshot.js';
6
+ import { parseJsonBody, sendJson, sendError } from '../core/errorHandler.js';
7
+ import { registerClarificationResolver } from './clarifyRoute.js';
8
+ import { extractAgentFollowUpQuestions, generateDynamicFollowUps, dedupeSuggestions, normalizeSuggestionKey } from '../core/followUpEngine.js';
9
+ import { resolveConversationContext } from '../core/conversationContext.js';
10
+ import { applyFutureEventGuard } from '../core/futureEventGuard.js';
11
+ import { extractApiKey } from '../core/security.js';
12
+ import { assessDramaAnswerQuality } from '../core/dramaKnowledge.js';
13
+
14
+ function stripInvalidYouTubeLinks(text) {
15
+ if (!text || typeof text !== 'string') return text || '';
16
+ return text.replace(/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{1,20})/gi, (m, id) => (id.length === 11 ? m : ''));
17
+ }
18
+
19
+ async function replaceLowQualityDramaAnswer({ query, answer, persona, conversationContext, toolsUsed }) {
20
+ const assessment = assessDramaAnswerQuality(query, answer);
21
+ if (!assessment.applicable || assessment.valid) return null;
22
+
23
+ const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
24
+ if (Array.isArray(toolsUsed)) toolsUsed.push(...fallback.tools);
25
+ return {
26
+ answer: fallback.answer,
27
+ suggestions: fallback.suggestions || [],
28
+ issues: assessment.issues
29
+ };
30
+ }
31
+
32
+ export function extractSearchImagesFromTools(tools = [], query = '') {
33
+ const images = [];
34
+ const seen = new Set();
35
+ const add = (img) => {
36
+ const url = img?.imageUrl || img?.thumbUrl || img?.url || img?.src || img?.image;
37
+ if (!url || typeof url !== 'string' || seen.has(url)) return;
38
+ seen.add(url);
39
+ images.push({
40
+ title: img.title || img.alt || query || 'Ju Jingyi',
41
+ imageUrl: url,
42
+ thumbUrl: img.thumbUrl || img.imageUrl || url,
43
+ source: img.source || 'Search'
44
+ });
45
+ };
46
+
47
+ for (const t of tools) {
48
+ const res = t?.result;
49
+ if (!res) continue;
50
+ if (Array.isArray(res.images)) res.images.forEach(add);
51
+ if (Array.isArray(res.photos)) res.photos.forEach(add);
52
+ if (t.name === 'search_web_images' && Array.isArray(res.results)) res.results.forEach(add);
53
+ if (Array.isArray(res.works)) {
54
+ res.works.forEach(w => {
55
+ if (w.poster) add({ title: w.titleTh || w.titleCn || w.titleEn, imageUrl: w.poster, source: 'Series Poster' });
56
+ });
57
+ }
58
+ }
59
+
60
+ const rowAlignedCount = Math.floor(images.length / 3) * 3;
61
+ if (rowAlignedCount >= 3) {
62
+ return images.slice(0, Math.min(rowAlignedCount, 9));
63
+ }
64
+ return images.slice(0, 6);
65
+ }
66
+
67
+
68
+ export function sanitizeSubagentProgress(event = {}) {
69
+ const text = (value, max = 200) => String(value ?? '').slice(0, max);
70
+ const num = (value) => (Number.isFinite(Number(value)) ? Number(value) : undefined);
71
+ const out = { type: 'subagent_progress' };
72
+ if (['subagent', 'research', 'verify'].includes(event.source)) out.source = event.source;
73
+ else out.source = 'subagent';
74
+ if (['start', 'iteration', 'tool', 'worker-start', 'worker-complete', 'complete'].includes(event.type)) out.kind = event.type;
75
+ else out.kind = 'tool';
76
+ const subject = event.task ?? event.question ?? event.claim ?? '';
77
+ if (subject) out.subject = text(subject);
78
+ if (event.iteration !== undefined) out.iteration = num(event.iteration);
79
+ if (event.maxIterations !== undefined) out.maxIterations = num(event.maxIterations);
80
+ if (event.name) out.tool = text(event.name, 80);
81
+ if (event.label) out.label = text(event.label, 120);
82
+ if (event.id) out.workerId = text(event.id, 80);
83
+ if (event.workerId) out.workerId = text(event.workerId, 80);
84
+ if (event.sources !== undefined) out.sources = num(event.sources);
85
+ return out;
86
+ }
87
+
88
+
89
+ const recentSuggestionKeys = new Map();
90
+
91
+ function getRecentSuggestionSet(sessionKey) {
92
+ let set = recentSuggestionKeys.get(sessionKey);
93
+ if (!set) {
94
+ set = new Set();
95
+ recentSuggestionKeys.set(sessionKey, set);
96
+ if (recentSuggestionKeys.size > 200) {
97
+ const oldest = recentSuggestionKeys.keys().next().value;
98
+ recentSuggestionKeys.delete(oldest);
99
+ }
100
+ }
101
+ return set;
102
+ }
103
+
104
+ function finalizeSuggestions(sessionKey, baseSuggestions, { query, answer, tools }) {
105
+ const recentKeys = getRecentSuggestionSet(sessionKey);
106
+ let out = dedupeSuggestions(Array.isArray(baseSuggestions) ? baseSuggestions : [], recentKeys);
107
+ if (out.length < 3) {
108
+ const dynamic = generateDynamicFollowUps({ query, answer, tools, recentKeys });
109
+ const localSeen = new Set(recentKeys);
110
+ for (const s of out) localSeen.add(normalizeSuggestionKey(s.query || s.label));
111
+ out = [...out, ...dedupeSuggestions(dynamic, localSeen)].slice(0, 3);
112
+ }
113
+ if (out.length) {
114
+ const set = getRecentSuggestionSet(sessionKey);
115
+ for (const s of out) {
116
+ set.add(normalizeSuggestionKey(s.query || s.label));
117
+ if (set.size > 60) {
118
+ const oldest = set.values().next().value;
119
+ set.delete(oldest);
120
+ }
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+
126
+ export async function handleChatRoute(req, res, urlObj) {
127
+ let body;
128
+ try {
129
+ body = await parseJsonBody(req);
130
+ } catch (err) {
131
+ return sendError(res, 400, `Invalid JSON body: ${err.message}`);
132
+ }
133
+
134
+ const query = String(body.query || '').trim();
135
+
136
+ const persona = 'default';
137
+ const sessionKey = String(body.sessionId || 'global');
138
+ const wantsStream = body.stream === true || (req.headers.accept && req.headers.accept.includes('text/event-stream'));
139
+
140
+ if (!query) {
141
+ return sendError(res, 400, 'Query is required');
142
+ }
143
+
144
+ const apiKey = extractApiKey(req);
145
+ const abortController = new AbortController();
146
+ req.on('close', () => {
147
+ if (!res.writableEnded) {
148
+ abortController.abort();
149
+ }
150
+ });
151
+
152
+ const startTime = Date.now();
153
+ const clarificationIds = new Map();
154
+ const externalClarificationId = (internalId) => {
155
+ if (!clarificationIds.has(internalId)) clarificationIds.set(internalId, randomUUID());
156
+ return clarificationIds.get(internalId);
157
+ };
158
+ const conversationContext = resolveConversationContext(query, body.history);
159
+ const publicContext = {
160
+ contextual: conversationContext.contextual,
161
+ domain: conversationContext.domain
162
+ };
163
+
164
+ try {
165
+ const conn = await resolveAgentConnection({
166
+ provider: body.provider,
167
+ model: body.model,
168
+ thinking: body.thinking,
169
+ apiKey,
170
+ sessionId: body.sessionId,
171
+ history: body.history,
172
+ baseUrl: typeof body.baseUrl === 'string' && body.baseUrl.trim() ? body.baseUrl.trim() : undefined,
173
+ }, { signal: abortController.signal });
174
+
175
+
176
+ const queryForAgent = conversationContext.resolvedQuery;
177
+ const systemDirectives = [
178
+ `[SYSTEM DIRECTIVE (Fact Grounding & Multi-Domain Retrieval):
179
+ 1. อ้างอิงเฉพาะเพลง ซีรีส์ แบรนด์พรีเซนเตอร์ และข้อมูลจริงที่ได้จากผลลัพธ์เครื่องมือ (search_works, search_discography, get_brand_endorsements, get_character_roles, get_awards, get_career_timeline, search_bilibili, search_chinese_web, web_search, get_media_links) เท่านั้น ห้ามแต่งชื่อผลงาน ชื่อแบรนด์ ตัวเลข หรือลิงก์สื่อขึ้นเอง หากตรวจสอบเครื่องมือที่เกี่ยวข้องแล้วไม่พบ จึงแจ้งผู้ใช้ตามหลักฐานจริง
180
+ 2. เมื่อพูดถึงเพลงหรือแบรนด์ใด ให้ระบุ "ชื่อจริง" จากคลังข้อมูลกำกับในประโยคเสมอ (เช่น "เพลง 千年等一回", "แบรนด์ PRSR (帕莎眼镜)") และห้ามแนบ URL ที่ไม่ได้มาจากผลลัพธ์เครื่องมือโดยเด็ดขาด
181
+ 3. เงื่อนไขการเสนอเปิดเพลงใน Mini Player: ให้เสนอเปิดเพลงเฉพาะเมื่อผู้ใช้ถามเรื่องบทเพลง การฟังเพลง หรือ OST โดยตรงเท่านั้น และต้องระบุชื่อเพลงที่เจาะจงในประโยคเดียวกัน ห้ามเสนอเปิดเพลงหรือถามชวนเปิดเพลงเมื่อผู้ใช้ถามเรื่องซีรีส์ แบรนด์ ข่าวลือ หรือเนื้อหาทั่วไป
182
+ 4. การแยกแยะชื่อเรื่อง แบรนด์ และข่าวลือ (Entity Disambiguation & Anti-Hallucination): ตรวจสอบชื่อเฉพาะให้ตรงกับเอนทิตีที่ถูกต้อง (เช่น เมื่อค้นหาแคมเปญโฆษณาหรือแบรนด์ ให้ตรวจสอบด้วย get_brand_endorsements และค้นหาคลิปบน Bilibili ควบคู่กับชื่อภาษาจีนของแบรนด์ เช่น 帕莎/PRSR เสมอ; และห้ามเดาหรือจับคู่คำค้นหาที่ออกเสียงคล้ายกันเข้ากับผลงานอื่นในคลังโดยไม่มีหลักฐานเด็ดขาด)
183
+ 5. หากผู้ใช้ส่งคำว่า "ดำเนินการต่อ" หรือตอบตกลงต่อคำถามก่อนหน้า ให้ลงมือทำสิ่งที่เสนอไว้ทันทีด้วยข้อมูลจริงจากเครื่องมือ อย่าถามคำถามเดิมซ้ำอีก
184
+ 6. ข้อมูลที่เกี่ยวกับแบรนด์ สินค้า แคมเปญโฆษณา และสื่อบนแพลตฟอร์มโซเชียล (Bilibili, YouTube, Weibo): ให้ใช้ get_brand_endorsements ควบคู่กับ search_bilibili หรือ get_media_links เสมอ โดยระบุคำค้นภาษาจีนของศิลปินและแบรนด์อย่างครบถ้วน]`,
185
+
186
+ `[SYSTEM DIRECTIVE (Answer Quality for Drama Analysis):
187
+ 1. แยก "ข้อเท็จจริงจากเครื่องมือ" ออกจาก "บทวิเคราะห์" ให้ชัดเจน ห้ามนำเรื่องย่อไปสรุปเป็นเสียงวิจารณ์หรือการยอมรับของวงการ
188
+ 2. ห้ามเหมารวมว่าผลงานใหม่กว่าแปลว่าลึกกว่า สมจริงกว่า หรือแสดงดีกว่า ให้เทียบจากภาระของบท แรงขับของตัวละคร แนวเรื่อง และฉากที่มีหลักฐานเท่านั้น
189
+ 3. ห้ามใช้คำยกย่องแบบไม่มีหลักฐาน เช่น "ระดับแนวหน้า", "ไม่มีใครเทียบได้", "ก้าวข้ามไอดอลอย่างสมบูรณ์" หรือ "ได้รับรางวัลมากมาย"
190
+ 4. คะแนนในแคตตาล็อกที่ไม่มีชื่อแหล่งและเกณฑ์คะแนนเป็นเพียง metadata ภายใน ห้ามใช้ยืนยันคุณภาพและไม่ต้องแสดงในบทวิเคราะห์
191
+ 5. In Blossom: จวีจิ้งอีแสดงตัวตนของหยางไฉ่เวยภายใต้รูปลักษณ์ซ่างกวนจื่อหลัง premise การเปลี่ยนใบหน้า ไม่ใช่สองตัวละครอิสระที่สลับเล่นตลอดเรื่อง
192
+ 6. The Legend of White Snake: ไป๋ซู่เจินคือวิญญาณ/ปีศาจงูขาวที่แปลงเป็นหญิงมนุษย์ ส่วนสวี่เซียนคือมนุษย์ชาย ห้ามสลับเพศหรือเผ่าพันธุ์
193
+ 7. หากเป็นคำสั่ง "ดำเนินการต่อ" ให้ตอบเฉพาะมุมใหม่ที่ผู้ใช้ขอ ห้ามคัดลอกคำตอบก่อนหน้ามาตอบซ้ำ]`,
194
+
195
+ `[SYSTEM DIRECTIVE (Topic & Future-event Safety):
196
+ 1. คำถามสั้นที่มีสรรพนามหรืออ้างถึง "เรื่องนี้/งานนี้/ทำไม/น่าจับตามอง" ต้องยึดหัวข้อหลักล่าสุดในบทสนทนาก่อน ห้ามเปลี่ยนไปตอบเรื่องศิลปินเพียงเพราะ Persona หรือฐานข้อมูลเน้นศิลปิน
197
+ 2. วันนี้คือ ${conversationContext.today}. เหตุการณ์หลังวันนี้เป็นข้อมูลอนาคต: ใช้คำว่า "มีกำหนด", "มีรายงาน", หรือ "ยังไม่ยืนยัน" ตามหลักฐาน ห้ามฟันธงว่าได้รับเชิญหรือจะเข้าร่วมแน่นอนโดยไม่มีแหล่งทางการที่เป็นปัจจุบัน
198
+ 3. ห้ามนำวัน เดือน รายชื่อแขก หรือสถานะการเข้าร่วมจากงานคนละปีมาใช้กับฉบับปีปัจจุบัน หากต้องระบุวันที่แน่นอนหรือผู้เข้าร่วมงานอนาคต ให้ตรวจสอบด้วยเครื่องมือเว็บก่อน]`,
199
+
200
+ `[SYSTEM DIRECTIVE: After completing your answer, proactively formulate and suggest 2-3 engaging, creative, and relevant follow-up questions tailored specifically to what you just discussed for the user to explore deeper. Place them at the very end of your response strictly formatted as:
201
+ [SUGGESTED_FOLLOW_UPS]
202
+ - [คำถามแนะนำที่ 1 ที่เชื่อมโยงกับเรื่องที่เพิ่งเล่า]
203
+ - [คำถามแนะนำที่ 2 ที่เชื่อมโยงกับเรื่องที่เพิ่งเล่า]
204
+ - [คำถามแนะนำที่ 3 ที่เชื่อมโยงกับเรื่องที่เพิ่งเล่า]
205
+ [/SUGGESTED_FOLLOW_UPS]
206
+ Rules for the block: one question per line, plain text only — no markdown bold/italic, no extra headers, no lead-in sentences, no numbering inside the block.]`,
207
+ ];
208
+
209
+ if (wantsStream) {
210
+ res.writeHead(200, {
211
+ 'Content-Type': 'text/event-stream',
212
+ 'Cache-Control': 'no-cache, no-transform',
213
+ 'Connection': 'keep-alive',
214
+ 'X-Accel-Buffering': 'no'
215
+ });
216
+
217
+ const sendSse = (eventObj) => {
218
+ res.write(`data: ${JSON.stringify(eventObj)}\n\n`);
219
+ };
220
+
221
+ if (conn.ready && conn.agent) {
222
+ try {
223
+ const toolsUsed = [];
224
+ let reasoningStarted = false;
225
+ const runRes = await conn.agent.run(queryForAgent, {
226
+ signal: abortController.signal,
227
+ systemDirectives,
228
+ onReasoningDelta: () => {
229
+ if (reasoningStarted) return;
230
+ reasoningStarted = true;
231
+ sendSse({ type: 'reasoning', chunk: 'กำลังวิเคราะห์บริบทและวางแผนคำตอบ' });
232
+ },
233
+ onToolStart: (name, input) => {
234
+ toolsUsed.push({ name, input });
235
+ sendSse({ type: 'tool_start', name, input: makeInspectableToolPayload(input) });
236
+ },
237
+ onToolResult: (name, result, durationMs) => {
238
+ const existing = toolsUsed.find(t => t.name === name);
239
+ if (existing) { existing.result = result; existing.durationMs = durationMs; }
240
+ sendSse({
241
+ type: 'tool_result',
242
+ name,
243
+ durationMs,
244
+ resultCount: result?.count ?? 0,
245
+ result: makeInspectableToolPayload(result)
246
+ });
247
+ },
248
+ onSubagentProgress: (event) => {
249
+ sendSse(sanitizeSubagentProgress(event));
250
+ },
251
+ onTokenDelta: (chunk) => {
252
+ sendSse({ type: 'token', chunk });
253
+ },
254
+ ...(conversationContext.contextual ? {} : {
255
+ onClarifyingQuestions: (questions, queryId) => {
256
+ sendSse({ type: 'clarifying_questions', questions, queryId: externalClarificationId(queryId) });
257
+ },
258
+ onClarifyingAnswer: (queryId) => {
259
+ return new Promise((resolve) => {
260
+ registerClarificationResolver(externalClarificationId(queryId), resolve);
261
+ });
262
+ }
263
+ })
264
+ });
265
+
266
+ let finalAnswer = runRes?.answer || String(runRes || '');
267
+ finalAnswer = finalAnswer.replace(/The user has provided a complex instruction set:[\s\S]*?(?=\n\n|\n[A-Zก-๙]|$)/i, '').trim();
268
+ finalAnswer = finalAnswer.replace(/^\[SYSTEM (?:INSTRUCTION|DIRECTIVE):[\s\S]*?\]\s*/i, '').trim();
269
+
270
+ let suggestions = [];
271
+ const extracted = extractAgentFollowUpQuestions(finalAnswer);
272
+ finalAnswer = stripInvalidYouTubeLinks(extracted.cleanText);
273
+ finalAnswer = applyFutureEventGuard(finalAnswer, { tools: toolsUsed });
274
+ suggestions = extracted.suggestions;
275
+
276
+ if (!finalAnswer.trim()) {
277
+ const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
278
+ finalAnswer = applyFutureEventGuard(fallback.answer, { tools: fallback.tools });
279
+ if (!suggestions.length) suggestions = fallback.suggestions || [];
280
+ }
281
+
282
+ const qualityFallback = await replaceLowQualityDramaAnswer({
283
+ query,
284
+ answer: finalAnswer,
285
+ persona,
286
+ conversationContext,
287
+ toolsUsed
288
+ });
289
+ if (qualityFallback) {
290
+ finalAnswer = applyFutureEventGuard(qualityFallback.answer, { tools: toolsUsed });
291
+ suggestions = qualityFallback.suggestions;
292
+ }
293
+
294
+ suggestions = finalizeSuggestions(sessionKey, suggestions, { query, answer: finalAnswer, tools: toolsUsed });
295
+
296
+ const searchImages = extractSearchImagesFromTools(toolsUsed, query);
297
+
298
+ sendSse({
299
+ type: 'final',
300
+ answer: finalAnswer,
301
+ suggestions,
302
+ searchImages,
303
+ durationMs: Date.now() - startTime,
304
+ tokens: conn.agent?.lastTelemetry?.tokens || conn.agent?.session?.lastQueryTokens || null,
305
+ metrics: conn.agent?.lastQueryMetrics || null,
306
+ costUsd: conn.agent?.lastTelemetry?.costUsd ?? conn.agent?.session?.lastQueryCostUsd ?? 0,
307
+ citations: conn.agent?.lastCitations || [],
308
+ context: publicContext,
309
+ memory: getAgentMemorySnapshot(conn.agent, body.sessionId)
310
+ });
311
+ res.write('data: [DONE]\n\n');
312
+ res.end();
313
+ return;
314
+ } catch (streamErr) {
315
+ if (abortController.signal.aborted || streamErr?.name === 'AbortError') {
316
+ if (!res.writableEnded) {
317
+ res.write('data: [DONE]\n\n');
318
+ res.end();
319
+ }
320
+ return;
321
+ }
322
+ const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
323
+ const fallbackImages = extractSearchImagesFromTools(fallback.tools, query);
324
+ sendSse({
325
+ type: 'final',
326
+ answer: applyFutureEventGuard(fallback.answer, { tools: fallback.tools }),
327
+ suggestions: finalizeSuggestions(sessionKey, fallback.suggestions || [], { query, answer: fallback.answer, tools: fallback.tools }),
328
+ searchImages: fallbackImages,
329
+ durationMs: Date.now() - startTime,
330
+ citations: [],
331
+ context: publicContext,
332
+ memory: { sessionId: body.sessionId || null, messageCount: body.history?.length || 0, persistent: false }
333
+ });
334
+ res.write('data: [DONE]\n\n');
335
+ res.end();
336
+ return;
337
+ }
338
+ }
339
+
340
+ sendSse({ type: 'reasoning', chunk: '⚡ ตรวจพบ LM Studio ออฟไลน์ -> เปิดใช้งาน Auto Direct Catalog Knowledge Engine...' });
341
+
342
+ const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
343
+ const guardedFallbackAnswer = applyFutureEventGuard(fallback.answer, { tools: fallback.tools });
344
+ const fallbackImages = extractSearchImagesFromTools(fallback.tools, query);
345
+ for (const t of fallback.tools) {
346
+ sendSse({ type: 'tool_start', name: t.name, input: t.input });
347
+ sendSse({ type: 'tool_result', name: t.name, durationMs: t.durationMs, resultCount: t.resultCount, result: t.result });
348
+ }
349
+ sendSse({ type: 'token', chunk: guardedFallbackAnswer });
350
+ sendSse({
351
+ type: 'final',
352
+ answer: guardedFallbackAnswer,
353
+ suggestions: finalizeSuggestions(sessionKey, fallback.suggestions || [], { query, answer: guardedFallbackAnswer, tools: fallback.tools }),
354
+ searchImages: fallbackImages,
355
+ durationMs: Date.now() - startTime,
356
+ citations: [],
357
+ context: publicContext,
358
+ memory: { sessionId: body.sessionId || null, messageCount: body.history?.length || 0, persistent: false }
359
+ });
360
+ res.write('data: [DONE]\n\n');
361
+ res.end();
362
+ return;
363
+ }
364
+
365
+ if (conn.ready && conn.agent) {
366
+ let answer = '';
367
+ let thinking = '';
368
+ let thinkingDurationMs = 0;
369
+ const toolsUsed = [];
370
+ const subagentActivity = [];
371
+
372
+ let runRes;
373
+ try {
374
+ runRes = await conn.agent.run(queryForAgent, {
375
+ signal: abortController.signal,
376
+ systemDirectives,
377
+ onReasoningDelta: () => { thinking = 'วิเคราะห์บริบทและวางแผนคำตอบแล้ว'; },
378
+ onReasoningEnd: () => { thinkingDurationMs = Date.now() - startTime; },
379
+ onSubagentProgress: (event) => {
380
+ subagentActivity.push(sanitizeSubagentProgress(event));
381
+ },
382
+ onToolStart: (name, input) => {
383
+ toolsUsed.push({ name, input: makeInspectableToolPayload(input), status: 'running' });
384
+ },
385
+ onToolResult: (name, result, dur) => {
386
+ let tool = null;
387
+ for (let index = toolsUsed.length - 1; index >= 0; index--) {
388
+ if (toolsUsed[index].name === name && toolsUsed[index].status === 'running') {
389
+ tool = toolsUsed[index];
390
+ break;
391
+ }
392
+ }
393
+ if (!tool) {
394
+ tool = { name };
395
+ toolsUsed.push(tool);
396
+ }
397
+ Object.assign(tool, {
398
+ status: 'done',
399
+ result: makeInspectableToolPayload(result),
400
+ resultCount: result?.count ?? 0,
401
+ durationMs: dur
402
+ });
403
+ }
404
+ });
405
+ } catch (agentErr) {
406
+ if (abortController.signal.aborted || agentErr?.name === 'AbortError') {
407
+ if (!res.writableEnded) {
408
+ return sendError(res, 499, 'Request aborted by client');
409
+ }
410
+ return;
411
+ }
412
+ throw agentErr;
413
+ }
414
+
415
+ answer = runRes?.answer || String(runRes || '');
416
+ let suggestions = [];
417
+ const extracted = extractAgentFollowUpQuestions(answer);
418
+ answer = stripInvalidYouTubeLinks(extracted.cleanText);
419
+ answer = applyFutureEventGuard(answer, { tools: toolsUsed });
420
+ suggestions = extracted.suggestions;
421
+
422
+ if (!answer.trim()) {
423
+ const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
424
+ answer = applyFutureEventGuard(fallback.answer, { tools: fallback.tools });
425
+ if (!suggestions.length) suggestions = fallback.suggestions || [];
426
+ }
427
+
428
+ const qualityFallback = await replaceLowQualityDramaAnswer({
429
+ query,
430
+ answer,
431
+ persona,
432
+ conversationContext,
433
+ toolsUsed
434
+ });
435
+ if (qualityFallback) {
436
+ answer = applyFutureEventGuard(qualityFallback.answer, { tools: toolsUsed });
437
+ suggestions = qualityFallback.suggestions;
438
+ }
439
+
440
+ if (!suggestions.length) {
441
+ suggestions = finalizeSuggestions(sessionKey, suggestions, { query, answer, tools: toolsUsed });
442
+ }
443
+
444
+ const searchImages = extractSearchImagesFromTools(toolsUsed, query);
445
+
446
+ return sendJson(res, 200, {
447
+ status: 'ok',
448
+ query,
449
+ persona,
450
+ answer,
451
+ suggestions,
452
+ searchImages,
453
+ thinking,
454
+ thinkingDurationMs,
455
+ tools: toolsUsed,
456
+ subagents: subagentActivity,
457
+ tokens: conn.agent?.lastTelemetry?.tokens || conn.agent?.session?.lastQueryTokens || null,
458
+ metrics: conn.agent?.lastQueryMetrics || null,
459
+ costUsd: conn.agent?.lastTelemetry?.costUsd ?? conn.agent?.session?.lastQueryCostUsd ?? 0,
460
+ citations: conn.agent?.lastCitations || [],
461
+ context: publicContext,
462
+ memory: getAgentMemorySnapshot(conn.agent, body.sessionId),
463
+ durationMs: Date.now() - startTime
464
+ });
465
+ }
466
+
467
+ const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
468
+ const searchImages = extractSearchImagesFromTools(fallback.tools, query);
469
+ return sendJson(res, 200, {
470
+ status: 'ok',
471
+ query,
472
+ persona,
473
+ answer: applyFutureEventGuard(fallback.answer, { tools: fallback.tools }),
474
+ suggestions: finalizeSuggestions(sessionKey, fallback.suggestions || [], { query, answer: fallback.answer, tools: fallback.tools }),
475
+ searchImages,
476
+ thinking: fallback.thinking,
477
+ thinkingDurationMs: Date.now() - startTime,
478
+ tools: fallback.tools,
479
+ citations: [],
480
+ context: publicContext,
481
+ memory: { sessionId: body.sessionId || null, messageCount: body.history?.length || 0, persistent: false },
482
+ durationMs: Date.now() - startTime
483
+ });
484
+
485
+ } catch (err) {
486
+ return sendError(res, 500, err.message);
487
+ }
488
+ }