kiku-agent 1.3.2 → 1.3.4

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 (152) hide show
  1. package/README.md +69 -12
  2. package/package.json +2 -1
  3. package/server/agent/agentCore.js +319 -46
  4. package/server/agent/appPaths.js +31 -0
  5. package/server/agent/audioPlayer.js +339 -12
  6. package/server/agent/autoContext.js +193 -0
  7. package/server/agent/catalogEntityResolver.js +170 -28
  8. package/server/agent/clarifyingQuestions.js +137 -35
  9. package/server/agent/cli.js +177 -48
  10. package/server/agent/cloudAdapters.js +116 -0
  11. package/server/agent/commandRegistry.js +409 -80
  12. package/server/agent/commands.js +1056 -125
  13. package/server/agent/config.js +1088 -690
  14. package/server/agent/contextCompactor.js +213 -21
  15. package/server/agent/cpuMetrics.js +199 -0
  16. package/server/agent/engineService.js +591 -0
  17. package/server/agent/eval/modules/evaluation/runner.js +4 -14
  18. package/server/agent/eval/modules/evaluation/scoring.js +15 -3
  19. package/server/agent/eval/modules/server/evalApi.js +5 -5
  20. package/server/agent/eval/modules/ui/evalUi.js +7 -4
  21. package/server/agent/eventBus.js +1 -0
  22. package/server/agent/ink/main.jsx +196 -191
  23. package/server/agent/jobManager.js +11 -1
  24. package/server/agent/lanDiscovery.js +721 -23
  25. package/server/agent/llmClient.js +317 -71
  26. package/server/agent/lmStudioWakeup.js +517 -65
  27. package/server/agent/locationService.js +797 -0
  28. package/server/agent/mcpQuizData.js +509 -0
  29. package/server/agent/memory/contextAllocator.js +19 -7
  30. package/server/agent/memory.js +3 -1
  31. package/server/agent/modelCompatibility.js +352 -0
  32. package/server/agent/modelDefaults.js +1 -0
  33. package/server/agent/modelManager.js +46 -2
  34. package/server/agent/newsWatch.js +9 -3
  35. package/server/agent/processManager.js +229 -0
  36. package/server/agent/prompts.js +174 -8
  37. package/server/agent/routingInterceptor.js +9 -1
  38. package/server/agent/sanitizer.js +41 -6
  39. package/server/agent/searchQueryPlanner.js +202 -0
  40. package/server/agent/session.js +59 -4
  41. package/server/agent/tests/agentFollowUps.test.js +128 -2
  42. package/server/agent/tests/agentMusicControl.test.js +245 -0
  43. package/server/agent/tests/apiRouter.test.js +1 -1
  44. package/server/agent/tests/autoContext.test.js +176 -0
  45. package/server/agent/tests/bilibiliEmbed.test.js +45 -0
  46. package/server/agent/tests/chatHardening.test.js +121 -0
  47. package/server/agent/tests/chineseWebSearchEscalation.test.js +62 -0
  48. package/server/agent/tests/clarifyingQuestions.test.js +33 -0
  49. package/server/agent/tests/cliAutomation.test.js +38 -1
  50. package/server/agent/tests/cloudProviders.test.js +91 -3
  51. package/server/agent/tests/commands.test.js +92 -3
  52. package/server/agent/tests/contextIntelligence.test.js +50 -3
  53. package/server/agent/tests/cpuMetrics.test.js +101 -0
  54. package/server/agent/tests/decisionAndSearchEnhancements.test.js +32 -0
  55. package/server/agent/tests/edgeCasesScenarios.test.js +82 -0
  56. package/server/agent/tests/engineService.test.js +436 -0
  57. package/server/agent/tests/engineUnload.test.js +94 -0
  58. package/server/agent/tests/envCommand.test.js +69 -0
  59. package/server/agent/tests/evalMetrics.test.js +8 -0
  60. package/server/agent/tests/fetchApi.test.js +99 -0
  61. package/server/agent/tests/hybridWebSearch.test.js +2 -0
  62. package/server/agent/tests/lanDiscovery.test.js +5 -0
  63. package/server/agent/tests/lanInterfaces.test.js +510 -0
  64. package/server/agent/tests/lmStudioWakeup.test.js +278 -0
  65. package/server/agent/tests/locationTool.test.js +278 -0
  66. package/server/agent/tests/memoryRoute.test.js +173 -0
  67. package/server/agent/tests/memoryUiNormalization.test.js +75 -0
  68. package/server/agent/tests/modelCompatibility.test.js +148 -0
  69. package/server/agent/tests/modelManager.test.js +32 -0
  70. package/server/agent/tests/musicControlUiBridge.test.js +113 -0
  71. package/server/agent/tests/nextGenArchitecture.test.js +4 -2
  72. package/server/agent/tests/openDomainPrompt.test.js +197 -0
  73. package/server/agent/tests/openDomainSearch.test.js +121 -0
  74. package/server/agent/tests/processManager.test.js +144 -0
  75. package/server/agent/tests/providerPersistence.test.js +10 -5
  76. package/server/agent/tests/quickActionsAndModals.test.js +180 -0
  77. package/server/agent/tests/searchQueryPlanner.test.js +108 -0
  78. package/server/agent/tests/securityAuditRemediation.test.js +16 -9
  79. package/server/agent/tests/securityAuditRemediations2.test.js +154 -0
  80. package/server/agent/tests/securityHardening.test.js +4 -4
  81. package/server/agent/tests/sessionAndCopy.test.js +39 -4
  82. package/server/agent/tests/sidebarControls.test.js +34 -0
  83. package/server/agent/tests/statefulMemory.test.js +8 -1
  84. package/server/agent/tests/textProtocolLoop.test.js +71 -0
  85. package/server/agent/tests/textToolParsing.test.js +48 -0
  86. package/server/agent/tests/toolRoutingRegressions.test.js +41 -0
  87. package/server/agent/tests/unifiedEndpoints.test.js +13 -6
  88. package/server/agent/tests/v110Features.test.js +1 -1
  89. package/server/agent/tests/vramEstimator.test.js +75 -0
  90. package/server/agent/tests/webChatDocs.test.js +199 -0
  91. package/server/agent/tests/webChatInferenceControl.test.js +298 -0
  92. package/server/agent/tests/webChatInspection.test.js +256 -5
  93. package/server/agent/tests/webChatMcp.test.js +94 -0
  94. package/server/agent/tests/webExtract.test.js +23 -0
  95. package/server/agent/tests/webSources.test.js +46 -12
  96. package/server/agent/textToolProtocol.js +173 -46
  97. package/server/agent/toolRouter.js +318 -44
  98. package/server/agent/tools.js +877 -96
  99. package/server/agent/ui.js +39 -34
  100. package/server/agent/vramService.js +35 -54
  101. package/server/agent/webSearch.js +441 -162
  102. package/server/agent/websearch/orchestrator.js +15 -5
  103. package/server/agent/websearch/resilience/cache.js +9 -2
  104. package/server/agent/websearch/security/ssrf.js +41 -10
  105. package/server/agent/youtubeAuth.js +5 -20
  106. package/server/apiRouter.js +5558 -4422
  107. package/server/mcp/data/discography.js +2 -2
  108. package/server/mcp/examples/antigravity.example.json +17 -0
  109. package/server/mcp/handler.js +8 -4
  110. package/server/mcp/security.js +220 -21
  111. package/server/mcp/server.js +2 -1
  112. package/server/mcp/tests/connection.test.js +14 -8
  113. package/server/mcp/tests/mcpKeyAuth.test.js +156 -0
  114. package/server/mcp/tools.js +7 -1
  115. package/server/supabase_migration_prefix.sql +102 -0
  116. package/server/supabase_schema.sql +18 -18
  117. package/server/web-chat/core/connection.js +179 -218
  118. package/server/web-chat/core/conversationContext.js +9 -2
  119. package/server/web-chat/core/directCatalogEngine.js +52 -28
  120. package/server/web-chat/core/dramaKnowledge.js +33 -22
  121. package/server/web-chat/core/followUpEngine.js +735 -81
  122. package/server/web-chat/core/futureEventGuard.js +5 -1
  123. package/server/web-chat/core/musicControlAction.js +70 -0
  124. package/server/web-chat/core/security.js +32 -21
  125. package/server/web-chat/core/toolInspector.js +6 -6
  126. package/server/web-chat/core/urlGuard.js +45 -0
  127. package/server/web-chat/router.js +204 -157
  128. package/server/web-chat/routes/chatRoute.js +885 -488
  129. package/server/web-chat/routes/engineRoute.js +270 -0
  130. package/server/web-chat/routes/exportRoute.js +1 -1
  131. package/server/web-chat/routes/lanDiscoveryRoute.js +17 -2
  132. package/server/web-chat/routes/lmStudioLoadRoute.js +172 -0
  133. package/server/web-chat/routes/mcpRoute.js +386 -0
  134. package/server/web-chat/routes/memoryRoute.js +253 -0
  135. package/server/web-chat/routes/providerRoute.js +49 -16
  136. package/server/web-chat/routes/staticRoute.js +306 -17
  137. package/server/web-chat/routes/statusRoute.js +194 -73
  138. package/server/web-chat/routes/suggestRoute.js +11 -3
  139. package/server/web-chat/routes/verifyRoute.js +26 -4
  140. package/server/web-chat/server.js +7 -6
  141. package/server/web-chat/ui/docsBuilder.js +3558 -0
  142. package/server/web-chat/ui/htmlBuilder.js +12 -0
  143. package/server/web-chat/ui/scripts/client.js +3401 -1302
  144. package/server/web-chat/ui/scripts/webchatI18n.js +155 -153
  145. package/server/web-chat/ui/settingsBuilder.js +7870 -0
  146. package/server/web-chat/ui/styles/lan-card.css +165 -0
  147. package/server/web-chat/ui/styles/main.css +4289 -447
  148. package/server/web-chat/ui/sw.js +64 -0
  149. package/server/web-chat/ui/templates/body.html +407 -281
  150. package/src/data/awardsData.js +146 -146
  151. package/src/data/musicVideoData.js +6 -4
  152. package/src/lib/vram/estimator.js +195 -19
@@ -1,488 +1,885 @@
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
- }
1
+ import { randomUUID } from 'node:crypto';
2
+ import { resolveAgentConnection, currentAgent } from '../core/connection.js';
3
+ import { config, getProvider, normalizeInferenceSettings, getPersistedInferenceSettings } from '../../agent/config.js';
4
+ import { executeDirectCatalogKnowledge } from '../core/directCatalogEngine.js';
5
+ import { makeInspectableToolPayload } from '../core/toolInspector.js';
6
+ import { getAgentMemorySnapshot } from '../core/memorySnapshot.js';
7
+ import { parseJsonBody, sendJson, sendError } from '../core/errorHandler.js';
8
+ import { registerClarificationResolver } from './clarifyRoute.js';
9
+ import { extractAgentFollowUpQuestions, generateDynamicFollowUps, dedupeSuggestions, normalizeSuggestionKey } from '../core/followUpEngine.js';
10
+ import { resolveConversationContext } from '../core/conversationContext.js';
11
+ import { applyFutureEventGuard } from '../core/futureEventGuard.js';
12
+ import { extractApiKey, isWebChatAdmin } from '../core/security.js';
13
+ import { validateInferenceBaseUrl, isTrustedRequest, sanitizeInferenceError } from '../core/urlGuard.js';
14
+ import { assessDramaAnswerQuality } from '../core/dramaKnowledge.js';
15
+ import { isLowValueImageUrl, imageHostKey } from '../../agent/webSearch.js';
16
+ import { collectMusicControlActions, createMusicControlAction } from '../core/musicControlAction.js';
17
+ import { resolveQueryEntities } from '../../agent/catalogEntityResolver.js';
18
+
19
+ const MAX_QUERY_LENGTH = 32_000;
20
+ const MAX_CONTEXT_LIMIT = 131_072;
21
+
22
+ function stripInvalidYouTubeLinks(text) {
23
+ if (!text || typeof text !== 'string') return text || '';
24
+ return text.replace(/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{1,20})/gi, (m, id) => (id.length === 11 ? m : ''));
25
+ }
26
+
27
+ async function replaceLowQualityDramaAnswer({ query, answer, persona, conversationContext, toolsUsed }) {
28
+ const assessment = assessDramaAnswerQuality(query, answer);
29
+ if (!assessment.applicable || assessment.valid) return null;
30
+
31
+ const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
32
+ if (Array.isArray(toolsUsed)) toolsUsed.push(...fallback.tools);
33
+ return {
34
+ answer: fallback.answer,
35
+ suggestions: fallback.suggestions || [],
36
+ issues: assessment.issues
37
+ };
38
+ }
39
+
40
+ const CURATED_IMAGE_TOOL_NAMES = new Set(['search_news', 'search_chinese_web', 'web_search', 'get_gallery_photos']);
41
+
42
+ export function extractSearchImagesFromTools(tools = [], query = '') {
43
+ const images = [];
44
+ const seenUrls = new Set();
45
+ const seenKeys = new Set();
46
+ const add = (img, { trusted = false } = {}) => {
47
+ const url = img?.imageUrl || img?.thumbUrl || img?.url || img?.src || img?.image;
48
+ if (!url || typeof url !== 'string' || seenUrls.has(url)) return;
49
+ if (isLowValueImageUrl(url)) return;
50
+ const key = imageHostKey(url);
51
+ if (seenKeys.has(key)) return;
52
+ if (!trusted && img?.width && img.width < 200) return;
53
+ seenUrls.add(url);
54
+ seenKeys.add(key);
55
+ images.push({
56
+ title: img.title || img.alt || query || 'Ju Jingyi',
57
+ imageUrl: url,
58
+ thumbUrl: img.thumbUrl || img.imageUrl || url,
59
+ source: img.source || 'Search'
60
+ });
61
+ };
62
+
63
+
64
+ const orderedTools = [...tools].sort((a, b) =>
65
+ (a?.name === 'search_web_images' ? 0 : 1) - (b?.name === 'search_web_images' ? 0 : 1));
66
+
67
+ for (const t of orderedTools) {
68
+ const res = t?.result;
69
+ if (!res) continue;
70
+ const isDedicated = t.name === 'search_web_images';
71
+ if (!isDedicated && !CURATED_IMAGE_TOOL_NAMES.has(t.name)) continue;
72
+
73
+ if (isDedicated && Array.isArray(res.results)) {
74
+ res.results.forEach(img => add(img, { trusted: true }));
75
+ }
76
+ const curated = isDedicated || t.name === 'get_gallery_photos';
77
+ if (Array.isArray(res.images)) res.images.forEach(img => add(img, { trusted: curated }));
78
+ if (Array.isArray(res.photos)) res.photos.forEach(img => add(img, { trusted: curated }));
79
+ if (Array.isArray(res.works)) {
80
+ res.works.forEach(w => {
81
+ if (w.poster) add({ title: w.titleTh || w.titleCn || w.titleEn, imageUrl: w.poster, source: 'Series Poster' }, { trusted: true });
82
+ });
83
+ }
84
+ }
85
+
86
+ const rowAlignedCount = Math.floor(images.length / 3) * 3;
87
+ if (rowAlignedCount >= 3) {
88
+ return images.slice(0, Math.min(rowAlignedCount, 9));
89
+ }
90
+ return images.slice(0, 6);
91
+ }
92
+
93
+
94
+ export function sanitizeSubagentProgress(event = {}) {
95
+ const text = (value, max = 200) => String(value ?? '').slice(0, max);
96
+ const num = (value) => (Number.isFinite(Number(value)) ? Number(value) : undefined);
97
+ const out = { type: 'subagent_progress' };
98
+ if (['subagent', 'research', 'verify'].includes(event.source)) out.source = event.source;
99
+ else out.source = 'subagent';
100
+ if (['start', 'iteration', 'tool', 'worker-start', 'worker-complete', 'complete'].includes(event.type)) out.kind = event.type;
101
+ else out.kind = 'tool';
102
+ const subject = event.task ?? event.question ?? event.claim ?? '';
103
+ if (subject) out.subject = text(subject);
104
+ if (event.iteration !== undefined) out.iteration = num(event.iteration);
105
+ if (event.maxIterations !== undefined) out.maxIterations = num(event.maxIterations);
106
+ if (event.name) out.tool = text(event.name, 80);
107
+ if (event.label) out.label = text(event.label, 120);
108
+ if (event.id) out.workerId = text(event.id, 80);
109
+ if (event.workerId) out.workerId = text(event.workerId, 80);
110
+ if (event.sources !== undefined) out.sources = num(event.sources);
111
+ return out;
112
+ }
113
+
114
+
115
+ const CHAT_PERSONAS = new Set(['official', 'fan', 'researcher', 'default']);
116
+
117
+ const PERSONA_TONE_DIRECTIVES = {
118
+ official: `[SYSTEM DIRECTIVE (Agent Tone — Official Assistant): ตอบด้วยน้ำเสียงสุภาพ อบอุ่น เป็นกันเองแบบมืออาชีพ เน้นความถูกต้องของข้อเท็จจริง กระชับแต่ครบถ้วน เรียกผู้ใช้อย่างเป็นกันเองในฐานะเพื่อนแฟนคลับ]`,
119
+ fan: `[SYSTEM DIRECTIVE (Agent Tone — Miju Companion): ตอบด้วยน้ำเสียงสดใส สนุก เป็นกันเองเหมือนเพื่อนมี่จวี ใช้ภาษากันเองและอีโมจิได้พอประมาณ แต่ต้องรักษาความถูกต้องของข้อเท็จจริงจากผลลัพธ์เครื่องมือ ห้ามแต่งข้อมูลเพื่อให้สนุก]`,
120
+ researcher: `[SYSTEM DIRECTIVE (Agent Tone — Analytical Researcher): ตอบเชิงวิเคราะห์อย่างเป็นระบบ ใช้หัวข้อและโครงสร้างชัดเจน แยกข้อเท็จจริงจากบทวิเคราะห์ ให้ความลึกเชิงบริบทและเปรียบเทียบเมื่อเหมาะสม แต่ยังคงอ้างอิงเฉพาะข้อมูลที่มีหลักฐานจากเครื่องมือ]`,
121
+ };
122
+
123
+ const recentSuggestionKeys = new Map();
124
+
125
+ function getRecentSuggestionSet(sessionKey) {
126
+ let set = recentSuggestionKeys.get(sessionKey);
127
+ if (!set) {
128
+ set = new Set();
129
+ recentSuggestionKeys.set(sessionKey, set);
130
+ if (recentSuggestionKeys.size > 200) {
131
+ const oldest = recentSuggestionKeys.keys().next().value;
132
+ recentSuggestionKeys.delete(oldest);
133
+ }
134
+ }
135
+ return set;
136
+ }
137
+
138
+ function finalizeSuggestions(sessionKey, baseSuggestions, { query, answer, tools }) {
139
+ const recentKeys = getRecentSuggestionSet(sessionKey);
140
+ let out = dedupeSuggestions(Array.isArray(baseSuggestions) ? baseSuggestions : [], recentKeys);
141
+ if (out.length < 3) {
142
+ const dynamic = generateDynamicFollowUps({ query, answer, tools, recentKeys });
143
+ const localSeen = new Set(recentKeys);
144
+ for (const s of out) localSeen.add(normalizeSuggestionKey(s.query || s.label));
145
+ out = [...out, ...dedupeSuggestions(dynamic, localSeen)].slice(0, 3);
146
+ }
147
+ if (out.length) {
148
+ const set = getRecentSuggestionSet(sessionKey);
149
+ for (const s of out) {
150
+ set.add(normalizeSuggestionKey(s.query || s.label));
151
+ if (set.size > 60) {
152
+ const oldest = set.values().next().value;
153
+ set.delete(oldest);
154
+ }
155
+ }
156
+ }
157
+ return out;
158
+ }
159
+
160
+ export async function handleChatRoute(req, res, urlObj, { resolveConnection = resolveAgentConnection } = {}) {
161
+ let body;
162
+ try {
163
+ body = await parseJsonBody(req, 15 * 1024 * 1024);
164
+ } catch (err) {
165
+ return sendError(res, 400, `Invalid JSON body: ${err.message}`);
166
+ }
167
+
168
+ const rawAttachments = Array.isArray(body.attachments) ? body.attachments : [];
169
+ const attachments = rawAttachments.slice(0, 4).map(att => {
170
+ if (!att || typeof att !== 'object') return null;
171
+ const name = String(att.name || 'file').slice(0, 120);
172
+ const type = String(att.type || '').slice(0, 80);
173
+ const isImage = Boolean(att.isImage || type.startsWith('image/'));
174
+ const dataUrl = typeof att.dataUrl === 'string' && (att.dataUrl.startsWith('data:') || att.dataUrl.startsWith('http') || att.dataUrl.startsWith('blob:'))
175
+ ? att.dataUrl
176
+ : null;
177
+ return { name, size: Number(att.size) || 0, type, isImage, dataUrl };
178
+ }).filter(Boolean);
179
+
180
+ let query = String(body.query || '').trim();
181
+ if (!query && attachments.length > 0) {
182
+ query = 'ช่วยตรวจสอบและวิเคราะห์รูปภาพที่แนบมานี้ให้หน่อยค่ะ';
183
+ }
184
+ if (!query) {
185
+ return sendError(res, 400, 'Query is required');
186
+ }
187
+ if (query.length > MAX_QUERY_LENGTH) {
188
+ return sendError(res, 400, `Query too long (${query.length} chars, max ${MAX_QUERY_LENGTH})`);
189
+ }
190
+
191
+ const inference = normalizeInferenceSettings({ ...body, topP: body.topP ?? body.top_p, contextLimit: body.contextLimit ?? body.context_limit }, getPersistedInferenceSettings());
192
+ const rawPersona = inference.persona;
193
+ const persona = CHAT_PERSONAS.has(rawPersona) ? rawPersona : 'default';
194
+
195
+
196
+ const runTemperature = inference.temperature;
197
+ const runTopP = inference.topP;
198
+ const runContextLimit = inference.contextLimit;
199
+ const runMinP = inference.minP;
200
+ const runRepeatPenalty = inference.repeatPenalty;
201
+ const sessionKey = String(body.sessionId || 'global');
202
+ const wantsStream = inference.stream === true || (req.headers.accept && req.headers.accept.includes('text/event-stream'));
203
+
204
+ const apiKey = extractApiKey(req);
205
+
206
+
207
+ const rawBaseUrl = typeof body.baseUrl === 'string' && body.baseUrl.trim() ? body.baseUrl.trim() : null;
208
+ let guardedBaseUrl = null;
209
+ if (rawBaseUrl) {
210
+ const guard = await validateInferenceBaseUrl(rawBaseUrl, { trusted: isTrustedRequest(req) });
211
+ if (!guard.ok) {
212
+ return sendError(res, 400, `Invalid baseUrl: ${guard.reason}`);
213
+ }
214
+ guardedBaseUrl = guard.normalized;
215
+ }
216
+
217
+ const requestedProvider = String(body.provider || '').toLowerCase().trim();
218
+ const effectiveProvider = (requestedProvider && requestedProvider !== 'auto')
219
+ ? requestedProvider
220
+ : (currentAgent?.client?.provider || config.activeProvider || 'lmstudio');
221
+ if (!getProvider(effectiveProvider)) return sendError(res, 400, 'Unknown provider');
222
+ if (!['lmstudio', 'vllm', 'custom'].includes(effectiveProvider) && !isTrustedRequest(req)) {
223
+ return sendError(res, 403, 'Cloud inference requires local access or a valid WEBCHAT_ADMIN_KEY');
224
+ }
225
+
226
+ const abortController = new AbortController();
227
+ let heartbeat = null;
228
+ const cleanupHeartbeat = () => {
229
+ if (heartbeat) {
230
+ clearInterval(heartbeat);
231
+ heartbeat = null;
232
+ }
233
+ };
234
+ res.on('close', () => {
235
+ cleanupHeartbeat();
236
+ if (!res.writableEnded) {
237
+ abortController.abort();
238
+ }
239
+ });
240
+ const startTime = Date.now();
241
+ const clarificationIds = new Map();
242
+ const externalClarificationId = (internalId) => {
243
+ if (!clarificationIds.has(internalId)) clarificationIds.set(internalId, randomUUID());
244
+ return clarificationIds.get(internalId);
245
+ };
246
+ const conversationContext = resolveConversationContext(query, body.history);
247
+ const publicContext = {
248
+ contextual: conversationContext.contextual,
249
+ domain: conversationContext.domain
250
+ };
251
+
252
+ try {
253
+ const conn = await resolveConnection({
254
+ provider: body.provider,
255
+ model: body.model,
256
+ thinking: inference.thinking,
257
+ apiKey: isWebChatAdmin(req) ? undefined : apiKey,
258
+ callerIsTrusted: isTrustedRequest(req),
259
+ allowAutoStart: isTrustedRequest(req),
260
+ sessionId: body.sessionId,
261
+ history: body.history,
262
+ baseUrl: guardedBaseUrl || undefined,
263
+ }, { signal: abortController.signal });
264
+
265
+
266
+ let queryForAgent = conversationContext.resolvedQuery;
267
+ const imageAttachments = attachments.filter(a => a.isImage);
268
+ const otherAttachments = attachments.filter(a => !a.isImage);
269
+
270
+ if (imageAttachments.length > 0) {
271
+ const imgInfo = imageAttachments.map(img => `"${img.name}" (${img.type || 'image'})`).join(', ');
272
+ const imageContextHints = [];
273
+ for (const img of imageAttachments) {
274
+ const cleanName = (img.name || '').replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
275
+ if (cleanName) {
276
+ const res = resolveQueryEntities(cleanName);
277
+ if (res.primaryWork) {
278
+ imageContextHints.push(`ชื่อไฟล์ภาพ "${img.name}" สอดคล้องกับผลงานในแคตตาล็อก: "${res.primaryWork.nameCn || res.primaryWork.nameTh}"`);
279
+ } else if (res.primarySong) {
280
+ imageContextHints.push(`ชื่อไฟล์ภาพ "${img.name}" สอดคล้องกับบทเพลงในแคตตาล็อก: "${res.primarySong.nameCn || res.primarySong.titleEn}"`);
281
+ } else if (res.primaryBrand) {
282
+ imageContextHints.push(`ชื่อไฟล์ภาพ "${img.name}" สอดคล้องกับแบรนด์ในแคตตาล็อก: "${res.primaryBrand.nameCn || res.primaryBrand.nameEn}"`);
283
+ }
284
+ }
285
+ }
286
+ const hintText = imageContextHints.length > 0 ? `\n[IMAGE RECOGNITION HINTS: ${imageContextHints.join('; ')}]` : '';
287
+ queryForAgent = `${queryForAgent}\n\n[USER ATTACHMENT: ผู้ใช้ได้แนบรูปภาพจำนวน ${imageAttachments.length} รูป: ${imgInfo}]${hintText}`;
288
+ }
289
+ if (otherAttachments.length > 0) {
290
+ const fileInfo = otherAttachments.map(f => `"${f.name}" (${f.type || 'file'})`).join(', ');
291
+ const textFileSnippets = [];
292
+ for (const f of otherAttachments) {
293
+ const isText = (f.type && (f.type.startsWith('text/') || f.type.includes('json') || f.type.includes('javascript') || f.type.includes('csv') || f.type.includes('xml'))) ||
294
+ /\.(txt|json|csv|tsv|md|markdown|js|mjs|jsx|ts|tsx|py|html|xml|yaml|yml|log|css|sql|sh)$/i.test(f.name || '');
295
+ if (isText && f.dataUrl && typeof f.dataUrl === 'string') {
296
+ try {
297
+ const base64Part = f.dataUrl.replace(/^data:[^;]+;base64,/, '');
298
+ if (base64Part) {
299
+ const decoded = Buffer.from(base64Part, 'base64').toString('utf8');
300
+ const maxLen = 6000;
301
+ const content = decoded.length > maxLen
302
+ ? decoded.slice(0, maxLen) + `\n... [เนื้อหาไฟล์ถูกตัดทอนเนื่องจากยาวเกิน ${maxLen} ตัวอักษร]`
303
+ : decoded;
304
+ textFileSnippets.push(`\n[ATTACHED FILE CONTENT: "${f.name}"]:\n\`\`\`\n${content.trim()}\n\`\`\``);
305
+ }
306
+ } catch {}
307
+ }
308
+ }
309
+ const snippetText = textFileSnippets.join('\n');
310
+ queryForAgent = `${queryForAgent}\n\n[USER ATTACHMENT: ผู้ใช้ได้แนบไฟล์ข้อมูล: ${fileInfo}]${snippetText}`;
311
+ }
312
+
313
+ const systemDirectives = [
314
+ `[SYSTEM DIRECTIVE (Fact Grounding & Multi-Domain Retrieval):
315
+ 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) เท่านั้น ห้ามแต่งชื่อผลงาน ชื่อแบรนด์ ตัวเลข หรือลิงก์สื่อขึ้นเอง หากตรวจสอบเครื่องมือที่เกี่ยวข้องแล้วไม่พบ จึงแจ้งผู้ใช้ตามหลักฐานจริง
316
+ 2. เมื่อพูดถึงเพลงหรือแบรนด์ใด ให้ระบุ "ชื่อจริง" จากคลังข้อมูลกำกับในประโยคเสมอ (เช่น "เพลง 千年等一回", "แบรนด์ PRSR (帕莎眼镜)") และห้ามแนบ URL ที่ไม่ได้มาจากผลลัพธ์เครื่องมือโดยเด็ดขาด
317
+ 3. เงื่อนไขการเสนอเปิดเพลงใน Mini Player: ให้เสนอเปิดเพลงเฉพาะเมื่อผู้ใช้ถามเรื่องบทเพลง การฟังเพลง หรือ OST โดยตรงเท่านั้น และต้องระบุชื่อเพลงที่เจาะจงในประโยคเดียวกัน ห้ามเสนอเปิดเพลงหรือถามชวนเปิดเพลงเมื่อผู้ใช้ถามเรื่องซีรีส์ แบรนด์ ข่าวลือ หรือเนื้อหาทั่วไป
318
+ 4. การแยกแยะชื่อเรื่อง แบรนด์ และข่าวลือ (Entity Disambiguation & Anti-Hallucination): ตรวจสอบชื่อเฉพาะให้ตรงกับเอนทิตีที่ถูกต้อง (เช่น เมื่อค้นหาแคมเปญโฆษณาหรือแบรนด์ ให้ตรวจสอบด้วย get_brand_endorsements และค้นหาคลิปบน Bilibili ควบคู่กับชื่อภาษาจีนของแบรนด์ เช่น 帕莎/PRSR เสมอ; และห้ามเดาหรือจับคู่คำค้นหาที่ออกเสียงคล้ายกันเข้ากับผลงานอื่นในคลังโดยไม่มีหลักฐานเด็ดขาด)
319
+ 5. หากผู้ใช้ส่งคำว่า "ดำเนินการต่อ" หรือตอบตกลงต่อคำถามก่อนหน้า ให้ลงมือทำสิ่งที่เสนอไว้ทันทีด้วยข้อมูลจริงจากเครื่องมือ อย่าถามคำถามเดิมซ้ำอีก
320
+ 6. ข้อมูลที่เกี่ยวกับแบรนด์ สินค้า แคมเปญโฆษณา และสื่อบนแพลตฟอร์มโซเชียล (Bilibili, YouTube, Weibo): ให้ใช้ get_brand_endorsements ควบคู่กับ search_bilibili หรือ get_media_links เสมอ โดยระบุคำค้นภาษาจีนของศิลปินและแบรนด์อย่างครบถ้วน
321
+ 7. การควบคุมเครื่องเล่นเพลงผ่านคำสั่ง (Agent Music Playback Control): เมื่อผู้ใช้สั่งให้เปิดเพลง, หยุดเพลง, เล่นต่อ, ปิดเพลง, ข้ามเพลง, ย้อนเพลง, ปรับระดับเสียง, ปิด/เปิดเสียง, เลื่อนตำแหน่งวินาที/นาที, ตั้งค่าเล่นซ้ำ, สลับโหมดสุ่มเพลง หรือเปิด/ปิดวิดีโอ MV ให้เรียกใช้เครื่องมือควบคุมเพลงที่เกี่ยวข้อง (play_music, pause_music, resume_music, stop_music, next_track, prev_track, set_volume, mute_music, unmute_music, seek_music, set_repeat_mode, set_shuffle_mode, toggle_video_mode) ทันทีในเทิร์นนี้
322
+ 8. คำถามนอกเรื่อง (Open-Domain): หากคำถามไม่เกี่ยวกับจวีจิ้งอี ผลงาน เพลง แบรนด์ หรือแฟนด้อมเลย ให้ค้นหาด้วยเครื่องมือเว็บ (web_search, web_extract, fetch_webpage, search_encyclopedia, search_news) เท่านั้น ห้ามใช้เครื่องมือแคตตาล็อกของศิลปิน (search_works, search_discography, get_profile_and_fandom ฯลฯ) และตอบเฉพาะจากผลลัพธ์เว็บเท่านั้น]`,
323
+
324
+ `[SYSTEM DIRECTIVE (Answer Quality for Drama Analysis):
325
+ 1. แยก "ข้อเท็จจริงจากเครื่องมือ" ออกจาก "บทวิเคราะห์" ให้ชัดเจน ห้ามนำเรื่องย่อไปสรุปเป็นเสียงวิจารณ์หรือการยอมรับของวงการ
326
+ 2. ห้ามเหมารวมว่าผลงานใหม่กว่าแปลว่าลึกกว่า สมจริงกว่า หรือแสดงดีกว่า ให้เทียบจากภาระของบท แรงขับของตัวละคร แนวเรื่อง และฉากที่มีหลักฐานเท่านั้น
327
+ 3. ห้ามใช้คำยกย่องแบบไม่มีหลักฐาน เช่น "ระดับแนวหน้า", "ไม่มีใครเทียบได้", "ก้าวข้ามไอดอลอย่างสมบูรณ์" หรือ "ได้รับรางวัลมากมาย"
328
+ 4. คะแนนในแคตตาล็อกที่ไม่มีชื่อแหล่งและเกณฑ์คะแนนเป็นเพียง metadata ภายใน ห้ามใช้ยืนยันคุณภาพและไม่ต้องแสดงในบทวิเคราะห์
329
+ 5. In Blossom: จวีจิ้งอีแสดงตัวตนของหยางไฉ่เวยภายใต้รูปลักษณ์ซ่างกวนจื่อหลัง premise การเปลี่ยนใบหน้า ไม่ใช่สองตัวละครอิสระที่สลับเล่นตลอดเรื่อง
330
+ 6. The Legend of White Snake: ไป๋ซู่เจินคือวิญญาณ/ปีศาจงูขาวที่แปลงเป็นหญิงมนุษย์ ส่วนสวี่เซียนคือมนุษย์ชาย ห้ามสลับเพศหรือเผ่าพันธุ์
331
+ 7. หากเป็นคำสั่ง "ดำเนินการต่อ" ให้ตอบเฉพาะมุมใหม่ที่ผู้ใช้ขอ ห้ามคัดลอกคำตอบก่อนหน้ามาตอบซ้ำ
332
+ 8. การวิเคราะห์เชิงลึกและเจาะลึก (Deep-dive Analysis): สำหรับคำถามที่ต้องการการเจาะลึก วิเคราะห์การแสดง บทบาท หรือเนื้อหาเรื่องราว ให้ใช้ search_works, get_work_detail, get_character_roles, get_career_timeline หรือ web_search ห้ามเรียกใช้ get_gallery_photos เว้นแต่ผู้ใช้จะระบุอย่างชัดเจนว่าต้องการดูรูปภาพ ภาพนิ่ง หรือแฟชั่นเซ็ตประกอบ]`,
333
+
334
+ `[SYSTEM DIRECTIVE (Topic & Future-event Safety):
335
+ 1. คำถามสั้นที่มีสรรพนามหรืออ้างถึง "เรื่องนี้/งานนี้/ทำไม/น่าจับตามอง" ต้องยึดหัวข้อหลักล่าสุดในบทสนทนาก่อน ห้ามเปลี่ยนไปตอบเรื่องศิลปินเพียงเพราะ Persona หรือฐานข้อมูลเน้นศิลปิน
336
+ 2. วันนี้คือ ${conversationContext.today}. เหตุการณ์หลังวันนี้เป็นข้อมูลอนาคต: ใช้คำว่า "มีกำหนด", "มีรายงาน", หรือ "ยังไม่ยืนยัน" ตามหลักฐาน ห้ามฟันธงว่าได้รับเชิญหรือจะเข้าร่วมแน่นอนโดยไม่มีแหล่งทางการที่เป็นปัจจุบัน
337
+ 3. ห้ามนำวัน เดือน รายชื่อแขก หรือสถานะการเข้าร่วมจากงานคนละปีมาใช้กับฉบับปีปัจจุบัน หากต้องระบุวันที่แน่นอนหรือผู้เข้าร่วมงานอนาคต ให้ตรวจสอบด้วยเครื่องมือเว็บก่อน]`,
338
+
339
+ `[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:
340
+ [SUGGESTED_FOLLOW_UPS]
341
+ - [คำถามแนะนำที่ 1 ที่เชื่อมโยงกับเรื่องที่เพิ่งเล่า]
342
+ - [คำถามแนะนำที่ 2 ที่เชื่อมโยงกับเรื่องที่เพิ่งเล่า]
343
+ - [คำถามแนะนำที่ 3 ที่เชื่อมโยงกับเรื่องที่เพิ่งเล่า]
344
+ [/SUGGESTED_FOLLOW_UPS]
345
+ 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.
346
+ Topic fidelity rule: if the user's question is NOT about Ju Jingyi, her works, music, brands, or fandom, the follow-up questions MUST stay on the user's actual topic — do NOT redirect or steer the user back to Ju Jingyi topics.]`,
347
+ ];
348
+
349
+ if (imageAttachments.length > 0) {
350
+ const imgNames = imageAttachments.map(img => `"${img.name}"`).join(', ');
351
+ systemDirectives.push(`[SYSTEM DIRECTIVE (User Image Attachments Received):
352
+ 1. ผู้ใช้ได้แนบรูปภาพเข้ามาในการสนทนานี้: ${imgNames}
353
+ 2. ระบบได้รับรูปภาพของผู้ใช้เรียบร้อยแล้วอย่างสมบูรณ์
354
+ 3. หากชื่อไฟล์ คำถาม หรือบริบทสอดคล้องกับผลงาน ละคร เพลง ลุคงานอีเวนต์ หรือแบรนด์ในแคตตาล็อก ให้เชื่อมโยงและนำเสนอข้อมูลหรือเรียกใช้เครื่องมือค้นหาผลงาน (search_works), รูปภาพแกลเลอรี (get_gallery_photos), หรือค้นหาบทบาท (get_character_roles) มาสนับสนุนคำตอบอย่างเป็นมิตร
355
+ 4. หากชื่อไฟล์และคำถามไม่มีบริบทเฉพาะเจาะจง ให้ทักทายตอบรับรูปภาพอย่างอบอุ่น ชี้แจงอย่างสุภาพว่าปัจจุบันระบบเน้นการวิเคราะห์ข้อมูลและแคตตาล็อก จึงแนะนำให้ผู้ใช้ระบุชื่อเรื่อง บทบาท หรือสิ่งที่ต้องการค้นหาเกี่ยวกับรูปภาพนี้ เพื่อที่ระบบจะได้ช่วยดึงข้อมูลและภาพจากแกลเลอรีมาให้ชมได้ตรงจุด]`);
356
+ }
357
+
358
+
359
+ if (persona !== 'default' && PERSONA_TONE_DIRECTIVES[persona]) {
360
+ systemDirectives.push(PERSONA_TONE_DIRECTIVES[persona]);
361
+ }
362
+
363
+ if (wantsStream) {
364
+ res.writeHead(200, {
365
+ 'Content-Type': 'text/event-stream',
366
+ 'Cache-Control': 'no-cache, no-transform',
367
+ 'Connection': 'keep-alive',
368
+ 'X-Accel-Buffering': 'no'
369
+ });
370
+
371
+ heartbeat = setInterval(() => {
372
+ if (res.writableEnded || res.destroyed) {
373
+ cleanupHeartbeat();
374
+ return;
375
+ }
376
+ try {
377
+ res.write(': ping\n\n');
378
+ } catch {
379
+ cleanupHeartbeat();
380
+ }
381
+ }, 15_000);
382
+ heartbeat.unref?.();
383
+ res.once('close', cleanupHeartbeat);
384
+ res.once('finish', cleanupHeartbeat);
385
+ const sendSse = (eventObj) => {
386
+ if (res.writableEnded || res.destroyed) return;
387
+ try { res.write(`data: ${JSON.stringify(eventObj)}\n\n`); } catch { cleanupHeartbeat(); }
388
+ };
389
+
390
+ if (conn.ready && conn.agent) {
391
+ try {
392
+ const isResearch = /^\/research(?:\s|$)/i.test(query.trim());
393
+ const isVerify = /^\/verify(?:\s|$)/i.test(query.trim());
394
+ if (isResearch || isVerify) {
395
+ const researchTarget = query.trim().replace(/^\/(?:research|verify)\s*/i, '').trim();
396
+ if (!researchTarget) {
397
+ const guideMessage = isVerify
398
+ ? `⚖️ **ระบบตรวจสอบข้อเท็จจริง (Fact-Check & Verify)**\n\nกรุณาระบุข้อกล่าวอ้างหรือข่าวลือที่ต้องการให้ AI ตรวจสอบ เช่น:\n- \`/verify จวีจิ้งอีเป็น Brand Ambassador ของ FRED Paris ในปี 2024-2026 จริงหรือไม่\`\n- \`/verify เพลง 0.2s เป็นซิงเกิลวันเกิดที่ปล่อยเมื่อวันที่ 18 มิถุนายน 2024\`\n\nระบบจะกระจายงานไปยัง 3 Subagent Workers เพื่อตรวจสอบข้ามแคตตาล็อกทางการ ข่าวสื่อออนไลน์ และวิเคราะห์ข้อขัดแย้งเพื่อสรุปคำตัดสิน (VERIFIED / FALSE / CONFLICTING) พร้อมระบุแหล่งอ้างอิงค่ะ`
399
+ : `🔍 **ระบบวิจัยเชิงลึก (Deep Research Engine)**\n\nกรุณาระบุคำถามหรือหัวข้อที่ต้องการให้วิจัย เช่น:\n- \`/research รางวัลและเกียรติยศทั้งหมดที่จวีจิ้งอีได้รับในระดับประเทศและสากล\`\n- \`/research วิเคราะห์บทบาทหยางไฉ่เวยและซ่างกวนจื่อในซีรีส์ In Blossom\`\n\nระบบจะกระจายงานไปยัง 3 Subagent Workers ทำงานคู่ขนานกันเพื่อสำรวจคลังผลงาน ข่าวสาร และสังเคราะห์รายงานเจาะลึกค่ะ`;
400
+
401
+ sendSse({ type: 'token', chunk: guideMessage });
402
+ sendSse({
403
+ type: 'final',
404
+ answer: guideMessage,
405
+ suggestions: isVerify ? [
406
+ { label: '💎 ตรวจสอบ: แอมบาสเดอร์ FRED Paris', query: '/verify จวีจิ้งอีเป็น Brand Ambassador ของ FRED Paris ในปี 2024-2026 จริงหรือไม่' },
407
+ { label: '🎂 ตรวจสอบ: ซิงเกิลวันเกิด 0.2s', query: '/verify เพลง 0.2s เป็นซิงเกิลวันเกิดที่ปล่อยเมื่อวันที่ 18 มิถุนายน 2024' },
408
+ { label: '🐰 ตรวจสอบ: พรีเซนเตอร์ TuTuCare', query: '/verify จวีจิ้งอีเคยเป็นพรีเซนเตอร์ของแบรนด์ TuTuCare และ Lafang' }
409
+ ] : [
410
+ { label: '🏆 วิจัย: รางวัลและเกียรติยศสากล', query: '/research รางวัลและเกียรติยศทั้งหมดที่จวีจิ้งอีได้รับในระดับประเทศและสากล' },
411
+ { label: '🎭 วิจัย: พัฒนาการด้านการแสดง', query: '/research วิเคราะห์พัฒนาการด้านการแสดงและบทบาทเด่นของจวีจิ้งอี' },
412
+ { label: '🌸 วิจัย: เจาะลึก In Blossom', query: '/research บทบาทหยางไฉ่เวยและซ่างกวนจื่อในซีรีส์ฮวาเจียนเลิ่ง (In Blossom)' }
413
+ ],
414
+ searchImages: [],
415
+ attachments: attachments.map(a => ({ name: a.name, size: a.size, type: a.type, isImage: a.isImage })),
416
+ durationMs: Date.now() - startTime,
417
+ citations: [],
418
+ context: publicContext,
419
+ memory: getAgentMemorySnapshot(conn.agent, body.sessionId)
420
+ });
421
+ cleanupHeartbeat();
422
+ res.write('data: [DONE]\n\n');
423
+ res.end();
424
+ return;
425
+ }
426
+
427
+ const toolsUsed = [];
428
+ sendSse({
429
+ type: 'subagent_progress',
430
+ source: isVerify ? 'verify' : 'research',
431
+ kind: 'start',
432
+ subject: researchTarget,
433
+ workers: 3
434
+ });
435
+
436
+ const forwardProgress = (event) => {
437
+ sendSse(sanitizeSubagentProgress({
438
+ source: isVerify ? 'verify' : 'research',
439
+ subject: researchTarget,
440
+ ...event
441
+ }));
442
+ };
443
+
444
+ const researchResult = await conn.agent.runResearch(researchTarget, {
445
+ mode: isVerify ? 'verify' : 'research',
446
+ signal: abortController.signal,
447
+ onProgress: forwardProgress,
448
+ });
449
+
450
+ const allWorkerTools = (researchResult.reports || []).flatMap(r => r.toolCalls || []).map(tc => {
451
+ const name = typeof tc === 'string' ? tc : (tc.name || 'tool');
452
+ const input = typeof tc === 'object' && tc.args ? tc.args : {};
453
+ const result = typeof tc === 'object' && tc.result ? tc.result : { status: 'ok' };
454
+ const durationMs = typeof tc === 'object' && tc.duration ? tc.duration : 0;
455
+ return { name, input, result, durationMs, status: 'done' };
456
+ });
457
+ toolsUsed.push(...allWorkerTools);
458
+
459
+ let finalAnswer = researchResult.answer || '';
460
+ if (!finalAnswer.trim()) {
461
+ finalAnswer = 'ไม่พบข้อมูลเพียงพอสำหรับการวิจัยในหัวข้อนี้ค่ะ';
462
+ }
463
+
464
+ sendSse({ type: 'token', chunk: finalAnswer });
465
+
466
+ let suggestions = [];
467
+ const extracted = extractAgentFollowUpQuestions(finalAnswer);
468
+ finalAnswer = stripInvalidYouTubeLinks(extracted.cleanText);
469
+ suggestions = extracted.suggestions;
470
+ suggestions = finalizeSuggestions(sessionKey, suggestions, { query, answer: finalAnswer, tools: toolsUsed });
471
+
472
+ const searchImages = extractSearchImagesFromTools(toolsUsed, researchTarget);
473
+
474
+ try {
475
+ if (conn.agent.session?.createUserMessage) {
476
+ conn.agent.session.createUserMessage(query);
477
+ }
478
+ if (conn.agent.session?.createAssistantMessage) {
479
+ conn.agent.session.createAssistantMessage({ content: finalAnswer });
480
+ }
481
+ if (Array.isArray(conn.agent.conversationHistory)) {
482
+ conn.agent.conversationHistory.push({ role: 'user', content: query });
483
+ conn.agent.conversationHistory.push({ role: 'assistant', content: finalAnswer });
484
+ }
485
+ } catch {}
486
+
487
+ sendSse({
488
+ type: 'final',
489
+ answer: finalAnswer,
490
+ suggestions,
491
+ searchImages,
492
+ attachments: attachments.map(a => ({ name: a.name, size: a.size, type: a.type, isImage: a.isImage })),
493
+ durationMs: Date.now() - startTime,
494
+ citations: (researchResult.sources || []).map((s, i) => ({ id: i + 1, url: s, title: s })),
495
+ context: publicContext,
496
+ memory: getAgentMemorySnapshot(conn.agent, body.sessionId)
497
+ });
498
+ cleanupHeartbeat();
499
+ res.write('data: [DONE]\n\n');
500
+ res.end();
501
+ return;
502
+ }
503
+
504
+ const toolsUsed = [];
505
+ let reasoningStarted = false;
506
+ const runRes = await conn.agent.run(queryForAgent, {
507
+ signal: abortController.signal,
508
+ clientLocation: body.clientLocation,
509
+ systemDirectives,
510
+ temperature: runTemperature,
511
+ topP: runTopP,
512
+ contextLimit: runContextLimit,
513
+ minP: runMinP,
514
+ repeatPenalty: runRepeatPenalty,
515
+ onReasoningDelta: () => {
516
+ if (reasoningStarted) return;
517
+ reasoningStarted = true;
518
+ sendSse({ type: 'reasoning', chunk: 'กำลังวิเคราะห์บริบทและวางแผนคำตอบ' });
519
+ },
520
+ onToolStart: (name, input) => {
521
+ toolsUsed.push({ name, input });
522
+ sendSse({ type: 'tool_start', name, input: makeInspectableToolPayload(input) });
523
+ },
524
+ onToolRemove: (name) => {
525
+ const idx = toolsUsed.findIndex(t => t.name === name);
526
+ if (idx !== -1) toolsUsed.splice(idx, 1);
527
+ sendSse({ type: 'tool_remove', name });
528
+ },
529
+ onToolResult: (name, result, durationMs) => {
530
+ const existing = (toolsUsed.findLast ? toolsUsed.findLast(t => t.name === name && t.result === undefined) : [...toolsUsed].reverse().find(t => t.name === name && t.result === undefined)) || toolsUsed.find(t => t.name === name);
531
+ if (existing) { existing.result = result; existing.durationMs = durationMs; }
532
+ sendSse({
533
+ type: 'tool_result',
534
+ name,
535
+ durationMs,
536
+ resultCount: result?.count ?? 0,
537
+ result: makeInspectableToolPayload(result)
538
+ });
539
+ const uiAction = createMusicControlAction(name, result);
540
+ if (uiAction) sendSse(uiAction);
541
+ if (name === 'get_user_location' && (result?.prompt_client_permission || result?.requires_client_permission)) {
542
+ sendSse({
543
+ type: 'location_permission_request',
544
+ reason: result.reason || 'weather_forecast',
545
+ estimatedLocation: result.estimatedLocation || null,
546
+ quickCityChoices: result.quickCityChoices || []
547
+ });
548
+ }
549
+ },
550
+ onSubagentProgress: (event) => {
551
+ sendSse(sanitizeSubagentProgress(event));
552
+ },
553
+ onTokenDelta: (chunk) => {
554
+ sendSse({ type: 'token', chunk });
555
+ },
556
+ ...(conversationContext.contextual ? {} : {
557
+ onClarifyingQuestions: (questions, queryId) => {
558
+ sendSse({ type: 'clarifying_questions', questions, queryId: externalClarificationId(queryId) });
559
+ },
560
+ onClarifyingAnswer: (queryId) => {
561
+ return new Promise((resolve) => {
562
+ registerClarificationResolver(externalClarificationId(queryId), resolve);
563
+ });
564
+ }
565
+ })
566
+ });
567
+
568
+ let finalAnswer = runRes?.answer || String(runRes || '');
569
+ finalAnswer = finalAnswer.replace(/The user has provided a complex instruction set:[\s\S]*?(?=\n\n|\n[A-Zก-๙]|$)/i, '').trim();
570
+ finalAnswer = finalAnswer.replace(/^\[SYSTEM (?:INSTRUCTION|DIRECTIVE):[\s\S]*?\]\s*/i, '').trim();
571
+
572
+ let suggestions = [];
573
+ const extracted = extractAgentFollowUpQuestions(finalAnswer);
574
+ finalAnswer = stripInvalidYouTubeLinks(extracted.cleanText);
575
+ finalAnswer = applyFutureEventGuard(finalAnswer, { tools: toolsUsed });
576
+ suggestions = extracted.suggestions;
577
+
578
+ if (!finalAnswer.trim() || (imageAttachments.length > 0 && finalAnswer.includes('ขออภัยค่ะ ไม่สามารถประมวลผลคำตอบได้'))) {
579
+ if (imageAttachments.length > 0) {
580
+ finalAnswer = 'ได้รับรูปภาพเรียบร้อยแล้วค่ะ แต่เนื่องจากระบบปัจจุบันเน้นการวิเคราะห์ข้อมูลและแคตตาล็อกเป็นหลัก จึงยังไม่รองรับการวิเคราะห์พิกเซลภาพโดยตรง คุณสามารถพิมพ์บอกชื่อผลงาน ละคร ลุคงาน หรือสิ่งที่ต้องการค้นหาเกี่ยวกับภาพนี้ เพื่อให้ฉันช่วยค้นหาข้อมูลและรูปภาพจากคลังข้อมูลได้เลยนะคะ';
581
+ } else {
582
+ const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
583
+ finalAnswer = applyFutureEventGuard(fallback.answer, { tools: fallback.tools });
584
+ if (!suggestions.length) suggestions = fallback.suggestions || [];
585
+ }
586
+ }
587
+
588
+ const qualityFallback = await replaceLowQualityDramaAnswer({
589
+ query,
590
+ answer: finalAnswer,
591
+ persona,
592
+ conversationContext,
593
+ toolsUsed
594
+ });
595
+ if (qualityFallback) {
596
+ finalAnswer = applyFutureEventGuard(qualityFallback.answer, { tools: toolsUsed });
597
+ suggestions = qualityFallback.suggestions;
598
+ }
599
+
600
+ suggestions = finalizeSuggestions(sessionKey, suggestions, { query, answer: finalAnswer, tools: toolsUsed });
601
+
602
+ const searchImages = extractSearchImagesFromTools(toolsUsed, query);
603
+
604
+ sendSse({
605
+ type: 'final',
606
+ answer: finalAnswer,
607
+ suggestions,
608
+ searchImages,
609
+ attachments: attachments.map(a => ({ name: a.name, size: a.size, type: a.type, isImage: a.isImage })),
610
+ durationMs: Date.now() - startTime,
611
+ tokens: conn.agent?.lastTelemetry?.tokens || conn.agent?.session?.lastQueryTokens || null,
612
+ metrics: conn.agent?.lastQueryMetrics || null,
613
+ costUsd: conn.agent?.lastTelemetry?.costUsd ?? conn.agent?.session?.lastQueryCostUsd ?? 0,
614
+ citations: conn.agent?.lastCitations || [],
615
+ context: publicContext,
616
+ memory: getAgentMemorySnapshot(conn.agent, body.sessionId)
617
+ });
618
+ cleanupHeartbeat();
619
+ res.write('data: [DONE]\n\n');
620
+ res.end();
621
+ return;
622
+ } catch (streamErr) {
623
+ if (abortController.signal.aborted || streamErr?.name === 'AbortError') {
624
+ cleanupHeartbeat();
625
+ if (!res.writableEnded) {
626
+ res.write('data: [DONE]\n\n');
627
+ res.end();
628
+ }
629
+ return;
630
+ }
631
+ sendSse({ type: 'error', message: sanitizeInferenceError(streamErr, isTrustedRequest(req)), fallback: true });
632
+ const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
633
+ const fallbackImages = extractSearchImagesFromTools(fallback.tools, query);
634
+ sendSse({
635
+ type: 'final',
636
+ answer: applyFutureEventGuard(fallback.answer, { tools: fallback.tools }),
637
+ suggestions: finalizeSuggestions(sessionKey, fallback.suggestions || [], { query, answer: fallback.answer, tools: fallback.tools }),
638
+ searchImages: fallbackImages,
639
+ durationMs: Date.now() - startTime,
640
+ citations: [],
641
+ context: publicContext,
642
+ memory: { sessionId: body.sessionId || null, messageCount: body.history?.length || 0, persistent: false }
643
+ });
644
+ cleanupHeartbeat();
645
+ res.write('data: [DONE]\n\n');
646
+ res.end();
647
+ return;
648
+ }
649
+ }
650
+
651
+ sendSse({ type: 'error', message: sanitizeInferenceError(conn.error, isTrustedRequest(req)), fallback: true });
652
+
653
+ const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
654
+ const guardedFallbackAnswer = applyFutureEventGuard(fallback.answer, { tools: fallback.tools });
655
+ const fallbackImages = extractSearchImagesFromTools(fallback.tools, query);
656
+ for (const t of fallback.tools) {
657
+ sendSse({ type: 'tool_start', name: t.name, input: t.input });
658
+ sendSse({ type: 'tool_result', name: t.name, durationMs: t.durationMs, resultCount: t.resultCount, result: t.result });
659
+ }
660
+ sendSse({ type: 'token', chunk: guardedFallbackAnswer });
661
+ sendSse({
662
+ type: 'final',
663
+ answer: guardedFallbackAnswer,
664
+ suggestions: finalizeSuggestions(sessionKey, fallback.suggestions || [], { query, answer: guardedFallbackAnswer, tools: fallback.tools }),
665
+ searchImages: fallbackImages,
666
+ durationMs: Date.now() - startTime,
667
+ citations: [],
668
+ context: publicContext,
669
+ memory: { sessionId: body.sessionId || null, messageCount: body.history?.length || 0, persistent: false }
670
+ });
671
+ cleanupHeartbeat();
672
+ res.write('data: [DONE]\n\n');
673
+ res.end();
674
+ return;
675
+ }
676
+
677
+ if (conn.ready && conn.agent) {
678
+ const isResearch = /^\/research(?:\s|$)/i.test(query.trim());
679
+ const isVerify = /^\/verify(?:\s|$)/i.test(query.trim());
680
+ if (isResearch || isVerify) {
681
+ const researchTarget = query.trim().replace(/^\/(?:research|verify)\s*/i, '').trim();
682
+ if (!researchTarget) {
683
+ const guideMessage = isVerify
684
+ ? `⚖️ กรุณาระบุข้อกล่าวอ้างหรือข่าวลือที่ต้องการให้ AI ตรวจสอบ เช่น: /verify จวีจิ้งอีเป็น Brand Ambassador ของ FRED Paris`
685
+ : `🔍 กรุณาระบุคำถามหรือหัวข้อที่ต้องการให้วิจัย เช่น: /research รางวัลและเกียรติยศทั้งหมดของจวีจิ้งอี`;
686
+ return sendJson(res, 200, {
687
+ status: 'success',
688
+ answer: guideMessage,
689
+ suggestions: [],
690
+ sources: [],
691
+ subagents: [],
692
+ tools: [],
693
+ durationMs: Date.now() - startTime
694
+ });
695
+ }
696
+
697
+ try {
698
+ const subagentActivity = [];
699
+ const forwardProgress = (event) => {
700
+ subagentActivity.push(sanitizeSubagentProgress({
701
+ source: isVerify ? 'verify' : 'research',
702
+ subject: researchTarget,
703
+ ...event
704
+ }));
705
+ };
706
+
707
+ const researchResult = await conn.agent.runResearch(researchTarget, {
708
+ mode: isVerify ? 'verify' : 'research',
709
+ signal: abortController.signal,
710
+ onProgress: forwardProgress,
711
+ });
712
+
713
+ let finalAnswer = researchResult?.answer || '';
714
+ if (!finalAnswer.trim()) {
715
+ finalAnswer = isVerify
716
+ ? 'ไม่สามารถตรวจสอบข้อเท็จจริงในประเด็นนี้ได้แน่ชัดค่ะ'
717
+ : 'ไม่พบข้อมูลเพียงพอสำหรับการวิจัยในหัวข้อนี้ค่ะ';
718
+ }
719
+
720
+ try {
721
+ if (conn.agent.session?.createUserMessage) {
722
+ conn.agent.session.createUserMessage(query);
723
+ }
724
+ if (conn.agent.session?.createAssistantMessage) {
725
+ conn.agent.session.createAssistantMessage({ content: finalAnswer });
726
+ }
727
+ if (Array.isArray(conn.agent.conversationHistory)) {
728
+ conn.agent.conversationHistory.push({ role: 'user', content: query });
729
+ conn.agent.conversationHistory.push({ role: 'assistant', content: finalAnswer });
730
+ }
731
+ } catch {}
732
+
733
+ const allWorkerTools = (researchResult.reports || []).flatMap(r => r.toolCalls || []).map(tc => {
734
+ const name = typeof tc === 'string' ? tc : (tc.name || 'tool');
735
+ const input = typeof tc === 'object' && tc.args ? tc.args : {};
736
+ const result = typeof tc === 'object' && tc.result ? tc.result : { status: 'ok' };
737
+ return { name, input, result, status: 'done' };
738
+ });
739
+
740
+ return sendJson(res, 200, {
741
+ status: 'success',
742
+ answer: finalAnswer,
743
+ sources: researchResult.sources || [],
744
+ subagents: subagentActivity,
745
+ tools: allWorkerTools,
746
+ durationMs: Date.now() - startTime
747
+ });
748
+ } catch (researchErr) {
749
+ if (abortController.signal.aborted || researchErr?.name === 'AbortError') return;
750
+ return sendJson(res, 200, {
751
+ status: 'error',
752
+ answer: sanitizeInferenceError(researchErr, isTrustedRequest(req)),
753
+ sources: [],
754
+ subagents: [],
755
+ tools: [],
756
+ durationMs: Date.now() - startTime
757
+ });
758
+ }
759
+ }
760
+
761
+ let answer = '';
762
+ let thinking = '';
763
+ let thinkingDurationMs = 0;
764
+ const toolsUsed = [];
765
+ const subagentActivity = [];
766
+
767
+ let runRes;
768
+ try {
769
+ runRes = await conn.agent.run(queryForAgent, {
770
+ signal: abortController.signal,
771
+ systemDirectives,
772
+ temperature: runTemperature,
773
+ topP: runTopP,
774
+ contextLimit: runContextLimit,
775
+ minP: runMinP,
776
+ repeatPenalty: runRepeatPenalty,
777
+ onReasoningDelta: () => { thinking = 'วิเคราะห์บริบทและวางแผนคำตอบแล้ว'; },
778
+ onReasoningEnd: () => { thinkingDurationMs = Date.now() - startTime; },
779
+ onSubagentProgress: (event) => {
780
+ subagentActivity.push(sanitizeSubagentProgress(event));
781
+ },
782
+ onToolStart: (name, input) => {
783
+ toolsUsed.push({ name, input: makeInspectableToolPayload(input), status: 'running' });
784
+ },
785
+ onToolResult: (name, result, dur) => {
786
+ let tool = null;
787
+ for (let index = toolsUsed.length - 1; index >= 0; index--) {
788
+ if (toolsUsed[index].name === name && toolsUsed[index].status === 'running') {
789
+ tool = toolsUsed[index];
790
+ break;
791
+ }
792
+ }
793
+ if (!tool) {
794
+ tool = { name };
795
+ toolsUsed.push(tool);
796
+ }
797
+ Object.assign(tool, {
798
+ status: 'done',
799
+ result: makeInspectableToolPayload(result),
800
+ resultCount: result?.count ?? 0,
801
+ durationMs: dur
802
+ });
803
+ }
804
+ });
805
+ } catch (agentErr) {
806
+ if (abortController.signal.aborted || agentErr?.name === 'AbortError') {
807
+ if (!res.writableEnded) {
808
+ return sendError(res, 499, 'Request aborted by client');
809
+ }
810
+ return;
811
+ }
812
+ throw agentErr;
813
+ }
814
+
815
+ answer = runRes?.answer || String(runRes || '');
816
+ let suggestions = [];
817
+ const extracted = extractAgentFollowUpQuestions(answer);
818
+ answer = stripInvalidYouTubeLinks(extracted.cleanText);
819
+ answer = applyFutureEventGuard(answer, { tools: toolsUsed });
820
+ suggestions = extracted.suggestions;
821
+
822
+ if (!answer.trim() || (imageAttachments.length > 0 && answer.includes('ขออภัยค่ะ ไม่สามารถประมวลผลคำตอบได้'))) {
823
+ if (imageAttachments.length > 0) {
824
+ answer = 'ได้รับรูปภาพเรียบร้อยแล้วค่ะ แต่เนื่องจากระบบปัจจุบันเน้นการวิเคราะห์ข้อมูลและแคตตาล็อกเป็นหลัก จึงยังไม่รองรับการวิเคราะห์พิกเซลภาพโดยตรง คุณสามารถพิมพ์บอกชื่อผลงาน ละคร ลุคงาน หรือสิ่งที่ต้องการค้นหาเกี่ยวกับภาพนี้ เพื่อให้ฉันช่วยค้นหาข้อมูลและรูปภาพจากคลังข้อมูลได้เลยนะคะ';
825
+ } else {
826
+ const fallback = await executeDirectCatalogKnowledge(query, persona, { conversationContext });
827
+ answer = applyFutureEventGuard(fallback.answer, { tools: fallback.tools });
828
+ if (!suggestions.length) suggestions = fallback.suggestions || [];
829
+ }
830
+ }
831
+
832
+ const qualityFallback = await replaceLowQualityDramaAnswer({
833
+ query,
834
+ answer,
835
+ persona,
836
+ conversationContext,
837
+ toolsUsed
838
+ });
839
+ if (qualityFallback) {
840
+ answer = applyFutureEventGuard(qualityFallback.answer, { tools: toolsUsed });
841
+ suggestions = qualityFallback.suggestions;
842
+ }
843
+
844
+ suggestions = finalizeSuggestions(sessionKey, suggestions, { query, answer, tools: toolsUsed });
845
+
846
+ const searchImages = extractSearchImagesFromTools(toolsUsed, query);
847
+
848
+ return sendJson(res, 200, {
849
+ status: 'ok',
850
+ query,
851
+ persona,
852
+ answer,
853
+ suggestions,
854
+ searchImages,
855
+ attachments: attachments.map(a => ({ name: a.name, size: a.size, type: a.type, isImage: a.isImage })),
856
+ thinking,
857
+ thinkingDurationMs,
858
+ tools: toolsUsed,
859
+ subagents: subagentActivity,
860
+ tokens: conn.agent?.lastTelemetry?.tokens || conn.agent?.session?.lastQueryTokens || null,
861
+ metrics: conn.agent?.lastQueryMetrics || null,
862
+ costUsd: conn.agent?.lastTelemetry?.costUsd ?? conn.agent?.session?.lastQueryCostUsd ?? 0,
863
+ citations: conn.agent?.lastCitations || [],
864
+ context: publicContext,
865
+ memory: getAgentMemorySnapshot(conn.agent, body.sessionId),
866
+ uiActions: collectMusicControlActions(toolsUsed),
867
+ durationMs: Date.now() - startTime
868
+ });
869
+ }
870
+
871
+ return sendError(res, 502, sanitizeInferenceError(conn.error, isTrustedRequest(req)));
872
+
873
+ } catch (err) {
874
+ if (res.headersSent) {
875
+ if (!res.writableEnded && !res.destroyed) {
876
+ res.write('data: ' + JSON.stringify({ type: 'error', message: sanitizeInferenceError(err, isTrustedRequest(req)) }) + '\n\n');
877
+ res.end('data: [DONE]\n\n');
878
+ }
879
+ return;
880
+ }
881
+ return sendError(res, err.statusCode || 502, sanitizeInferenceError(err, isTrustedRequest(req)));
882
+ } finally {
883
+ if (heartbeat) clearInterval(heartbeat);
884
+ }
885
+ }