kiku-agent 1.0.11 → 1.2.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 (221) hide show
  1. package/README.md +300 -495
  2. package/package.json +12 -11
  3. package/public/fonts/GoogleSans-Bold.ttf +0 -0
  4. package/public/fonts/GoogleSans-Bold.woff2 +0 -0
  5. package/public/fonts/GoogleSans-BoldItalic.ttf +0 -0
  6. package/public/fonts/GoogleSans-Italic.ttf +0 -0
  7. package/public/fonts/GoogleSans-Latin-400.woff2 +0 -0
  8. package/public/fonts/GoogleSans-Latin-500.woff2 +0 -0
  9. package/public/fonts/GoogleSans-Latin-600.woff2 +0 -0
  10. package/public/fonts/GoogleSans-Latin-700.woff2 +0 -0
  11. package/public/fonts/GoogleSans-Medium.ttf +0 -0
  12. package/public/fonts/GoogleSans-Medium.woff2 +0 -0
  13. package/public/fonts/GoogleSans-Regular.ttf +0 -0
  14. package/public/fonts/GoogleSans-Regular.woff2 +0 -0
  15. package/public/fonts/GoogleSans-Thai-400.woff2 +0 -0
  16. package/public/fonts/GoogleSans-Thai-500.woff2 +0 -0
  17. package/public/fonts/GoogleSans-Thai-600.woff2 +0 -0
  18. package/public/fonts/GoogleSans-Thai-700.woff2 +0 -0
  19. package/public/fonts/NotoSansThai-Bold.woff2 +0 -0
  20. package/public/fonts/NotoSansThai-Regular.woff2 +0 -0
  21. package/public/fonts/google-sans-official.css +900 -0
  22. package/public/images/0040jbadgy1id6am4yxqfj61s82dn1kx02.jpg +0 -0
  23. package/public/images/0040jbadgy1id6am708pbj62c0340qv602.jpg +0 -0
  24. package/public/images/0040jbadgy1id6amb81uqj62c0340x6q02.jpg +0 -0
  25. package/public/images/0040jbadgy1id6amcqte3j62c0340qv602.jpg +0 -0
  26. package/public/images/0040jbadgy1id6amdg3xcj62b232wqv502.jpg +0 -0
  27. package/public/images/0040jbadgy1id6amexv23j620v2pau0x02.jpg +0 -0
  28. package/public/images/0040jbadly8icq3if7twwj60qy0qy0uc02.jpg +0 -0
  29. package/public/images/008ET7Legy1ig9s89nri5j30i20n1gn4.jpg +0 -0
  30. package/public/images/008ET7Legy1ig9s8a2lmlj30i20ny409.jpg +0 -0
  31. package/public/images/008ET7Legy1ig9s8ay1hsj30i20nk3zv.jpg +0 -0
  32. package/public/images/008ET7Legy1ig9s8bab6sj30i20o7go1.jpg +0 -0
  33. package/public/images/008ET7Legy1ig9s8blqsxj30i20oiq4r.jpg +0 -0
  34. package/public/images/008ET7Legy1ig9s8cbxkzj30i20mxmzh.jpg +0 -0
  35. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-399915.jpg +0 -0
  36. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-424041.jpg +0 -0
  37. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-588166.jpg +0 -0
  38. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-672198.jpg +0 -0
  39. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-700522.jpg +0 -0
  40. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026820-839722.jpg +0 -0
  41. package/public/images/douyin-update/thumbs//346/212/226/351/237/2632026825-460586.jpg +0 -0
  42. package/public/images/douyin-update/thumbs//346/212/226/351/237/263202694-689407.jpg +0 -0
  43. package/public/images/douyin-update//346/212/226/351/237/2632026820-399915.mp4 +0 -0
  44. package/public/images/douyin-update//346/212/226/351/237/2632026820-424041.mp4 +0 -0
  45. package/public/images/douyin-update//346/212/226/351/237/2632026820-588166.mp4 +0 -0
  46. package/public/images/douyin-update//346/212/226/351/237/2632026820-672198.mp4 +0 -0
  47. package/public/images/douyin-update//346/212/226/351/237/2632026820-700522.mp4 +0 -0
  48. package/public/images/douyin-update//346/212/226/351/237/2632026820-839722.mp4 +0 -0
  49. package/public/images/douyin-update//346/212/226/351/237/2632026825-460586.mp4 +0 -0
  50. package/public/images/douyin-update//346/212/226/351/237/263202694-689407.mp4 +0 -0
  51. package/public/images/general/0040jbadgy1id6am4yxqfj61s82dn1kx02.jpg +0 -0
  52. package/public/images/general/0040jbadgy1id6am708pbj62c0340qv602.jpg +0 -0
  53. package/public/images/general/0040jbadgy1id6amb81uqj62c0340x6q02.jpg +0 -0
  54. package/public/images/general/0040jbadgy1id6amcqte3j62c0340qv602.jpg +0 -0
  55. package/public/images/general/0040jbadgy1id6amdg3xcj62b232wqv502.jpg +0 -0
  56. package/public/images/general/0040jbadgy1id6amexv23j620v2pau0x02.jpg +0 -0
  57. package/public/images/general/0040jbadly8icq3if7twwj60qy0qy0uc02.jpg +0 -0
  58. package/public/images/general/008ET7Legy1ig9s89nri5j30i20n1gn4.jpg +0 -0
  59. package/public/images/general/008ET7Legy1ig9s8a2lmlj30i20ny409.jpg +0 -0
  60. package/public/images/general/008ET7Legy1ig9s8ay1hsj30i20nk3zv.jpg +0 -0
  61. package/public/images/general/008ET7Legy1ig9s8bab6sj30i20o7go1.jpg +0 -0
  62. package/public/images/general/008ET7Legy1ig9s8blqsxj30i20oiq4r.jpg +0 -0
  63. package/public/images/general/008ET7Legy1ig9s8cbxkzj30i20mxmzh.jpg +0 -0
  64. package/public/images/general/jujingyi_portrait.jpg +0 -0
  65. package/public/images/jujingyi_portrait.jpg +0 -0
  66. package/public/images/series/a-millenniums-wait/poster.jpg +0 -0
  67. package/public/images/series/beauty-of-resilience/poster.jpg +0 -0
  68. package/public/images/series/detective-samoyeds/poster.jpg +0 -0
  69. package/public/images/series/everyday/poster.jpg +0 -0
  70. package/public/images/series/fu-rong-ancient-painting/poster.jpg +0 -0
  71. package/public/images/series/in-a-class-of-her-own/poster.jpg +0 -0
  72. package/public/images/series/in-blossom/poster.jpg +0 -0
  73. package/public/images/series/legend-of-yunxi/poster.jpg +0 -0
  74. package/public/images/series/lets-fight/poster-2.jpg +0 -0
  75. package/public/images/series/lets-fight/poster.jpg +0 -0
  76. package/public/images/series/love-emergency/poster.jpg +0 -0
  77. package/public/images/series/love-under-the-full-moon/poster.jpg +0 -0
  78. package/public/images/series/money-bastion/poster.jpg +0 -0
  79. package/public/images/series/mr-swimmer/poster.jpg +0 -0
  80. package/public/images/series/novoland-castle-in-the-sky/poster.jpg +0 -0
  81. package/public/images/series/please-give-me-a-pair-of-wings/poster.jpg +0 -0
  82. package/public/images/series/rebirth-for-you/poster.jpg +0 -0
  83. package/public/images/series/sword-and-fairy-4/poster.jpg +0 -0
  84. package/public/images/series/the-blooms-at-ruyi-pavilion/poster.jpg +0 -0
  85. package/public/images/series/the-eternal-fragrance/poster-2.jpg +0 -0
  86. package/public/images/series/the-eternal-fragrance/poster.jpg +0 -0
  87. package/public/images/series/the-fallen-flowers/poster.jpg +0 -0
  88. package/public/images/series/the-legend-of-white-snake/poster.jpg +0 -0
  89. package/public/images/series/veil-of-shadows/poster.jpg +0 -0
  90. package/public/images/series/wanhua-sin/poster-2.jpg +0 -0
  91. package/public/images/series/wanhua-sin/poster.jpg +0 -0
  92. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj10yogj62u03s0npe02.jpg +0 -0
  93. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj2pdvyj61li25jkjl02.jpg +0 -0
  94. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj71177j62672w9e8302.jpg +0 -0
  95. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj7twpfj62192197rw02.jpg +0 -0
  96. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj8jfu8j62c03401kx02.jpg +0 -0
  97. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkj9dhdsj622k2l77wh02.jpg +0 -0
  98. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkja31s4j62bw3407r802.jpg +0 -0
  99. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkjcf331j61402ey1kx02.jpg +0 -0
  100. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkjdx8gqj62602pihdt02.jpg +0 -0
  101. package/public/images/weibo-update/41/345/210/206/351/222/237/345/211/215/0040jbadgy1igrkjfvxnzj62432n57wh02.jpg +0 -0
  102. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3ixniv2yj627y4fxb2b02.jpg +0 -0
  103. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3ixsvbdaj625k2vfu1102.jpg +0 -0
  104. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3ixxxdaoj62c03501l202.jpg +0 -0
  105. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3ixzeef9j62c0340npd02.jpg +0 -0
  106. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3iy0vs6tj626t4rbhdu02.jpg +0 -0
  107. package/public/images/weibo-update/8-14-17-16/0040jbadgy1ig3iy21wwuj62bt34hx6q02.jpg +0 -0
  108. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwly4wr85j62z629y7wh02.jpg +0 -0
  109. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwly67oilj623u2t5npd02.jpg +0 -0
  110. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwly9qtw8j62c0340u0x02.jpg +0 -0
  111. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwlyem4m4j63402c01kz02.jpg +0 -0
  112. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwlygkoq4j627s2yeavd02.jpg +0 -0
  113. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwlyjh0daj62bw340nm102.jpg +0 -0
  114. package/public/images/weibo-update/8-8-17-39/0040jbadgy1ifwlylejlhj62bw3401kx02.jpg +0 -0
  115. package/public/images/weibo-update/today/0040jbadgy1igaiaa8b7fj627j2y67wh02.jpg +0 -0
  116. package/public/images/weibo-update/today/0040jbadgy1igaiagw3ezj61s02ebu0x02.jpg +0 -0
  117. package/public/images/weibo-update/today/0040jbadgy1igaiaivng3j61rv2ebx6p02.jpg +0 -0
  118. package/public/images/weibo-update/today/0040jbadgy1igaiajuixvj62bw340qv502.jpg +0 -0
  119. package/public/images/weibo-update/today/0040jbadgy1igaiapyartj62bw340hdt02.jpg +0 -0
  120. package/public/images/weibo-update/today/0040jbadgy1igaibkwu4ij61vy2iq1kx02.jpg +0 -0
  121. package/public/images/weibo-update/today/0040jbadgy1igaic1fvkpj61u92eo1ky02.jpg +0 -0
  122. package/public/images/weibo-update/today/0040jbadgy1igaic3cruhj62822ywb2902.jpg +0 -0
  123. package/public/manifest.json +25 -0
  124. package/public/mcp-logo-128.png +0 -0
  125. package/public/mcp-logo-64.png +0 -0
  126. package/public/mcp-logo.svg +16 -0
  127. package/server/agent/agentCore.js +542 -74
  128. package/server/agent/appPaths.js +1 -0
  129. package/server/agent/audioPlayer.js +4 -2
  130. package/server/agent/citations.js +294 -0
  131. package/server/agent/clarificationAnalytics.js +477 -0
  132. package/server/agent/clarifyingQuestions.js +1089 -0
  133. package/server/agent/cli.js +549 -125
  134. package/server/agent/commandFeatures.js +1 -0
  135. package/server/agent/commandRegistry.js +946 -879
  136. package/server/agent/commands.js +546 -272
  137. package/server/agent/config.js +581 -412
  138. package/server/agent/contextCompactor.js +2 -2
  139. package/server/agent/debate.js +235 -0
  140. package/server/agent/eventBus.js +3 -0
  141. package/server/agent/feedbackLearning.js +217 -0
  142. package/server/agent/llmClient.js +512 -394
  143. package/server/agent/mcpClient.js +182 -32
  144. package/server/agent/memory.js +283 -224
  145. package/server/agent/modelManager.js +79 -39
  146. package/server/agent/profiles.js +178 -0
  147. package/server/agent/publicTools.js +22 -0
  148. package/server/agent/queryDetails.js +68 -8
  149. package/server/agent/quizData.js +398 -384
  150. package/server/agent/research.js +121 -79
  151. package/server/agent/sanitizer.js +104 -13
  152. package/server/agent/session.js +319 -11
  153. package/server/agent/subagent.js +8 -3
  154. package/server/agent/suggestionTui.js +294 -0
  155. package/server/agent/suggestions.js +69 -3
  156. package/server/agent/telemetry.js +119 -0
  157. package/server/agent/terminalImage.js +146 -27
  158. package/server/agent/theme.js +17 -0
  159. package/server/agent/toolRouter.js +16 -1
  160. package/server/agent/tools.js +424 -225
  161. package/server/agent/ui.js +486 -64
  162. package/server/agent/version.js +2 -4
  163. package/server/agent/webSearch.js +111 -11
  164. package/server/agent/youtubeAuth.js +24 -6
  165. package/server/apiRouter.js +4222 -0
  166. package/server/mcp/data/discography.js +25 -16
  167. package/server/mcp/data/gallery.js +308 -20
  168. package/server/mcp/data/profile.js +74 -44
  169. package/server/mcp/data/timeline.js +1 -1
  170. package/server/mcp/data/works.js +51 -17
  171. package/server/mcp/dataAdapter.js +5 -3
  172. package/server/mcp/handler.js +5 -11
  173. package/server/mcp/prompts.js +86 -0
  174. package/server/mcp/security.js +18 -15
  175. package/server/mcp/server.js +7 -2
  176. package/server/mcp/tools.js +66 -2
  177. package/server/web-chat/core/browserLauncher.js +18 -0
  178. package/server/web-chat/core/connection.js +194 -0
  179. package/server/web-chat/core/conversationContext.js +82 -0
  180. package/server/web-chat/core/directCatalogEngine.js +196 -0
  181. package/server/web-chat/core/dramaKnowledge.js +142 -0
  182. package/server/web-chat/core/errorHandler.js +51 -0
  183. package/server/web-chat/core/followUpEngine.js +182 -0
  184. package/server/web-chat/core/futureEventGuard.js +80 -0
  185. package/server/web-chat/core/memorySnapshot.js +12 -0
  186. package/server/web-chat/core/security.js +146 -0
  187. package/server/web-chat/core/toolInspector.js +52 -0
  188. package/server/web-chat/router.js +120 -0
  189. package/server/web-chat/routes/chatRoute.js +397 -0
  190. package/server/web-chat/routes/clarifyConfigRoute.js +98 -0
  191. package/server/web-chat/routes/clarifyRoute.js +83 -0
  192. package/server/web-chat/routes/compareRoute.js +79 -0
  193. package/server/web-chat/routes/exportRoute.js +59 -0
  194. package/server/web-chat/routes/feedbackRoute.js +57 -0
  195. package/server/web-chat/routes/newsRoute.js +33 -0
  196. package/server/web-chat/routes/quizRoute.js +11 -0
  197. package/server/web-chat/routes/staticRoute.js +103 -0
  198. package/server/web-chat/routes/statusRoute.js +52 -0
  199. package/server/web-chat/routes/suggestRoute.js +77 -0
  200. package/server/web-chat/routes/timelineRoute.js +34 -0
  201. package/server/web-chat/routes/verifyRoute.js +57 -0
  202. package/server/web-chat/server.js +116 -0
  203. package/server/web-chat/ui/htmlBuilder.js +91 -0
  204. package/server/web-chat/ui/previews/agentBubble.preview.html +134 -0
  205. package/server/web-chat/ui/scripts/agentBubbleSystem.js +320 -0
  206. package/server/web-chat/ui/scripts/client.js +8397 -0
  207. package/server/web-chat/ui/scripts/kikuDb.js +177 -0
  208. package/server/web-chat/ui/scripts/suggestionsData.js +118 -0
  209. package/server/web-chat/ui/scripts/webchatI18n.js +291 -0
  210. package/server/web-chat/ui/styles/main.css +8568 -0
  211. package/server/web-chat/ui/templates/body.html +796 -0
  212. package/server/webChatHtml.js +1 -0
  213. package/server/webChatServer.js +6 -0
  214. package/src/data/discographyData.js +892 -866
  215. package/src/data/galleryData.js +1805 -561
  216. package/src/data/profileData.js +79 -2
  217. package/src/data/socialUpdatesData.js +106 -47
  218. package/src/data/timelineData.js +1 -1
  219. package/src/data/worksData.js +25 -4
  220. package/tokens.css +97 -0
  221. package/server/agent/updateManager.js +0 -456
@@ -0,0 +1,51 @@
1
+ export function sendJson(res, statusCode, data, headers = {}) {
2
+ const jsonStr = JSON.stringify(data);
3
+ res.writeHead(statusCode, {
4
+ 'Content-Type': 'application/json; charset=utf-8',
5
+ ...headers
6
+ });
7
+ res.end(jsonStr);
8
+ }
9
+
10
+ export function sendError(res, statusCode, message, extra = {}) {
11
+ if (res.headersSent) return;
12
+ sendJson(res, statusCode, {
13
+ status: 'error',
14
+ error: message,
15
+ ...extra
16
+ });
17
+ }
18
+
19
+ export function parseJsonBody(req, maxBytes = 512 * 1024) {
20
+ return new Promise((resolve, reject) => {
21
+ let bodyStr = '';
22
+ let bytesCount = 0;
23
+
24
+ req.on('data', chunk => {
25
+ bytesCount += chunk.length;
26
+ if (bytesCount > maxBytes) {
27
+ req.destroy();
28
+ reject(new Error('Payload too large'));
29
+ return;
30
+ }
31
+ bodyStr += chunk;
32
+ });
33
+
34
+ req.on('end', () => {
35
+ if (!bodyStr || bodyStr.trim() === '') {
36
+ resolve({});
37
+ return;
38
+ }
39
+ try {
40
+ const parsed = JSON.parse(bodyStr);
41
+ resolve(parsed);
42
+ } catch (err) {
43
+ reject(new Error(`Invalid JSON: ${err.message}`));
44
+ }
45
+ });
46
+
47
+ req.on('error', err => {
48
+ reject(err);
49
+ });
50
+ });
51
+ }
@@ -0,0 +1,182 @@
1
+ function stripSuggestionMd(s) {
2
+ return String(s || '')
3
+ .replace(/\*\*(.+?)\*\*/g, '$1')
4
+ .replace(/__(.+?)__/g, '$1')
5
+ .replace(/^[\s#>*•\-–—\d.)\]]+/, '')
6
+ .replace(/[\s#*_\]]+$/, '')
7
+ .replace(/^(💡\s*)+/, '')
8
+ .trim();
9
+ }
10
+
11
+ function extractSuggestionItems(raw) {
12
+ let text = String(raw || '').replace(/\[SUGGESTED_FOLLOW_UPS\]|\[\/SUGGESTED_FOLLOW_UPS\]/gi, ' ');
13
+ const headerCut = text.match(/(suggested follow-?ups?|related questions?|คำถามแนะนำ|แนะนำถามต่อ)\s*[::]?\s*([\s\S]+)$/i);
14
+ if (headerCut && headerCut[2].trim().length > 3) text = headerCut[2];
15
+ text = text.replace(/\*\*/g, '').replace(/(^|[\s(["'])\*([^*?\n]+?)\*/g, '$1$2');
16
+ text = text
17
+ .replace(/\s+[•]\s+/g, '\n')
18
+ .replace(/\s+-\s+(?=[💡🌸🎬🎵🎭🌟💎👗⚖🔍📖🏆🐍🌺🦚🌿🎧💃🚀👑🏛🍊📺💄🎧🗡🌟🎪🎹])/g, '\n');
19
+ const items = [];
20
+ for (const chunk of text.split('\n')) {
21
+ const subs = chunk.split(/(?<=[??!!])\s*-\s+/);
22
+ for (const sub of subs) {
23
+ const cleaned = stripSuggestionMd(sub);
24
+ if (!cleaned || cleaned.length <= 3) continue;
25
+ if (cleaned.length > 140) {
26
+ const parts = cleaned.match(/[^??!!\n]{4,}[??!!]/g) || [];
27
+ if (parts.length > 0) {
28
+ for (const p of parts) {
29
+ const q = stripSuggestionMd(p);
30
+ if (q.length > 3 && q.length <= 140) items.push(q);
31
+ }
32
+ continue;
33
+ }
34
+ items.push(cleaned.slice(0, 140));
35
+ } else {
36
+ items.push(cleaned);
37
+ }
38
+ }
39
+ }
40
+ return items.slice(0, 4);
41
+ }
42
+
43
+ export function extractAgentFollowUpQuestions(text) {
44
+ if (!text || typeof text !== 'string') {
45
+ return { cleanText: text || '', suggestions: [] };
46
+ }
47
+
48
+ let cleanText = text;
49
+ const suggestions = [];
50
+
51
+
52
+ const blockMatch = cleanText.match(/\[SUGGESTED_FOLLOW_UPS\]([\s\S]*?)\[\/SUGGESTED_FOLLOW_UPS\]/i);
53
+ if (blockMatch) {
54
+ const rawList = blockMatch[1];
55
+ cleanText = cleanText.replace(blockMatch[0], '').trim();
56
+ const lines = extractSuggestionItems(rawList).filter(l => l.length > 3);
57
+
58
+ lines.slice(0, 4).forEach(q => {
59
+ suggestions.push({
60
+ label: '💡 ' + q,
61
+ query: q
62
+ });
63
+ });
64
+ }
65
+
66
+
67
+ if (suggestions.length === 0) {
68
+ const sectionMatch = cleanText.match(/(?:^|\n)[\-*•#\s]*(?:💡)?[\-*•#\s]*(?:แนะนำถามต่อ|คำถามแนะนำที่น่าสนใจ|คำถามที่อาจสนใจ|คำถามน่าสนใจ|Suggested Follow-ups?|Related Questions?)[::]?\s*([\s\S]+)/im);
69
+ if (sectionMatch) {
70
+ const rawList = sectionMatch[1];
71
+ const lines = extractSuggestionItems(rawList)
72
+ .filter(l => l.length > 3 && !l.startsWith('#') && !l.startsWith('>'));
73
+ if (lines.length > 0) {
74
+ cleanText = cleanText.replace(sectionMatch[0], '').trim();
75
+ lines.slice(0, 4).forEach(q => {
76
+ suggestions.push({
77
+ label: '💡 ' + q,
78
+ query: q
79
+ });
80
+ });
81
+ }
82
+ }
83
+ }
84
+
85
+
86
+ if (suggestions.length === 0) {
87
+ const lines = cleanText.split('\n');
88
+ const trailingChips = [];
89
+ let cutIndex = lines.length;
90
+ for (let i = lines.length - 1; i >= 0; i--) {
91
+ const line = lines[i].trim();
92
+ if (!line) continue;
93
+ const m = line.match(/^[-*•]?\s*💡\s*(.+)$/);
94
+ if (m) {
95
+ trailingChips.unshift(m[1].trim());
96
+ cutIndex = i;
97
+ } else {
98
+ break;
99
+ }
100
+ }
101
+ if (trailingChips.length > 0) {
102
+ cleanText = lines.slice(0, cutIndex).join('\n').trim();
103
+ extractSuggestionItems(trailingChips.join('\n')).slice(0, 4).forEach(q => {
104
+ suggestions.push({
105
+ label: '💡 ' + q,
106
+ query: q
107
+ });
108
+ });
109
+ }
110
+ }
111
+
112
+ return { cleanText, suggestions };
113
+ }
114
+
115
+ export function generateDynamicFollowUps({ query = '', answer = '', tools = [] } = {}) {
116
+ const combined = (query + ' ' + answer).toLowerCase();
117
+ const suggestions = [];
118
+
119
+
120
+ if (combined.includes('ฮวาเจียนหลิง') || combined.includes('in blossom') || combined.includes('花间令') || combined.includes('หยางไฉ่เวย') || combined.includes('ซ่างกวนจื่อ')) {
121
+ suggestions.push(
122
+ { label: '🌸 ตัวตนกับรูปลักษณ์', query: 'วิเคราะห์การถ่ายทอดตัวตนของหยางไฉ่เวยภายใต้รูปลักษณ์ซ่างกวนจื่อในฮวาเจียนลิ่ง' },
123
+ { label: '🎵 เพลงประกอบฮวาเจียนลิ่ง', query: 'เพลงประกอบซีรีส์ ฮวาเจียนลิ่ง (In Blossom OST) ทั้งหมดมีเพลงอะไรบ้าง' },
124
+ { label: '🎬 ปมเปลี่ยนใบหน้า', query: 'อธิบายว่าปมเปลี่ยนใบหน้าในฮวาเจียนลิ่งส่งผลต่อการสืบคดีและตัวตนของหยางไฉ่เวยอย่างไร' }
125
+ );
126
+ } else if (combined.includes('หยุนซี') || combined.includes('yunxi') || combined.includes('芸汐传')) {
127
+ suggestions.push(
128
+ { label: '🌿 ทักษะพิษและวิชาแพทย์', query: 'เล่าจุดเด่นวิชาแพทย์และทักษะการใช้พิษของหานหยุนซีในตำนานหยุนซี' },
129
+ { label: '🎵 เพลง 叹云兮 (Sigh of Yunxi)', query: 'เบื้องหลังและเสน่ห์ของเพลง 叹云兮 (Sigh of Yunxi) ขับร้องโดยจวีจิ้งอี' },
130
+ { label: '🌟 เคมีคู่กับหลงเฟยเย่', query: 'เคมีการแสดงระหว่าง จวีจิ้งอี และ จางเจ๋อฮั่น ในซีรีส์ตำนานหยุนซี' }
131
+ );
132
+ } else if (combined.includes('นางพญางูขาว') || combined.includes('white snake') || combined.includes('ไป๋ซู่เจิน')) {
133
+ suggestions.push(
134
+ { label: '🐍 เสน่ห์บทบาทไป๋ซู่เจิน', query: 'ความโดดเด่นและชุดแต่งกายสีขาวอันเป็นเอกลักษณ์ของไป๋ซู่เจิน 2019' },
135
+ { label: '🎵 เพลง 千年等一回', query: 'เพลง 千年等一回 เวอร์ชันจวีจิ้งอี มีความไพเราะและกระแสตอบรับอย่างไร' },
136
+ { label: '✨ ฉากตำนานสะพานขาด', query: 'เล่าฉากพบกันบนสะพานขาดระหว่างไป๋ซู่เจินและสวี่เซียน' }
137
+ );
138
+ } else if (combined.includes('veil of shadows') || combined.includes('月鳞绮纪') || combined.includes('ลู่หวู่อี้')) {
139
+ suggestions.push(
140
+ { label: '🗡️ บทบาทลู่หวู่อี้ 2026', query: 'บทบาท ลู่หวู่อี้ ในซีรีส์ Veil of Shadows (月鳞绮纪) มีความท้าทายอย่างไร' },
141
+ { label: '🎵 เพลงคู่ ไม่ทุกข์ตรม (不苦)', query: 'เบื้องหลังเพลง 不苦 (Not Suffering) ร้องคู่กับ เถียนเจียรุ่ย' },
142
+ { label: '📺 กำหนดการฉายและแพลตฟอร์ม', query: 'อัปเดตสถานะและแพลตฟอร์มการออกอากาศของ Veil of Shadows' }
143
+ );
144
+ } else if (combined.includes('versace') || combined.includes('แบรนด์') || combined.includes('แฟชั่น') || combined.includes('พรีเซนเตอร์')) {
145
+ suggestions.push(
146
+ { label: '💎 Versace Global Ambassador', query: 'เจาะลึกบทบาท จวีจิ้งอี กับตำแหน่ง Global Brand Ambassador ของ Versace' },
147
+ { label: '💄 สไตล์เมคอัพ Ju Jingyi', query: 'วิเคราะห์อิทธิพลของสไตล์การแต่งหน้า Ju Jingyi Makeup ที่ฮิตไปทั่วโลก' },
148
+ { label: '👗 แบรนด์พรีเซนเตอร์ทั้งหมด', query: 'สรุปแบรนด์ที่จวีจิ้งอีเป็นพรีเซนเตอร์และแอมบาสเดอร์ในปัจจุบัน' }
149
+ );
150
+ } else if (combined.includes('เพลง') || combined.includes('song') || combined.includes('singles') || combined.includes('find the way') || combined.includes('everyday')) {
151
+ suggestions.push(
152
+ { label: '🚀 ซิงเกิล FIND THE WAY 2026', query: 'เบื้องหลังเพลง FIND THE WAY และคอนเซปต์ใหม่ปี 2026' },
153
+ { label: '🎧 ซิงเกิลเดี่ยวและ OST ทั้งหมด', query: 'รวมเพลงเดี่ยวและเพลงประกอบซีรีส์ยอดนิยมของจวีจิ้งอี' },
154
+ { label: '💃 สเตจเต้น 红昭愿 ในตำนาน', query: 'สเตจการเต้นเพลง 红昭愿 (Hong Zhao Yuan) ชุดแดงที่เป็นตำนาน' }
155
+ );
156
+ } else {
157
+
158
+ const workTool = tools.find(t => t.name === 'search_works' || t.name === 'get_work_detail');
159
+ if (workTool && workTool.result?.works?.[0]) {
160
+ const w = workTool.result.works[0];
161
+ const title = w.titleTh || w.titleCn;
162
+ suggestions.push(
163
+ { label: `🎬 เจาะลึกบทบาทใน ${title}`, query: `เจาะลึกบทบาทและเบื้องหลังการแสดงของจวีจิ้งอีในเรื่อง ${title}` },
164
+ { label: `🎵 เพลงประกอบซีรีส์ ${title}`, query: `เพลงประกอบซีรีส์ ${title} ขับร้องโดยใครบ้าง` },
165
+ { label: `🏆 กระแสตอบรับของ ${title}`, query: `กระแสตอบรับ เรตติ้ง และรางวัลของซีรีส์ ${title}` }
166
+ );
167
+ } else {
168
+ suggestions.push(
169
+ { label: '🎬 แนะนำซีรีส์ยอดนิยมที่ไม่ควรพลาด', query: 'แนะนำซีรีส์ยอดนิยมและบทบาทมาสเตอร์พีซของจวีจิ้งอี' },
170
+ { label: '🎵 เพลงฮิตและสเตจแสดงสดเด่นๆ', query: 'รวมบทเพลงฮิตและสเตจการแสดงสดที่เป็นไวรัลของจวีจิ้งอี' },
171
+ { label: '💎 ก้าวสู่ศิลปินเดี่ยวและรางวัล', query: 'เส้นทางการเป็นศิลปินเดี่ยว การก่อตั้งสตูดิโอ และรางวัลสำคัญ' }
172
+ );
173
+ }
174
+ }
175
+
176
+ return suggestions.slice(0, 3);
177
+ }
178
+
179
+ export default {
180
+ extractAgentFollowUpQuestions,
181
+ generateDynamicFollowUps,
182
+ };
@@ -0,0 +1,80 @@
1
+ const MONTHS = new Map([
2
+ ['january', 1], ['jan', 1], ['มกราคม', 1], ['ม.ค.', 1],
3
+ ['february', 2], ['feb', 2], ['กุมภาพันธ์', 2], ['ก.พ.', 2],
4
+ ['march', 3], ['mar', 3], ['มีนาคม', 3], ['มี.ค.', 3],
5
+ ['april', 4], ['apr', 4], ['เมษายน', 4], ['เม.ย.', 4],
6
+ ['may', 5], ['พฤษภาคม', 5], ['พ.ค.', 5],
7
+ ['june', 6], ['jun', 6], ['มิถุนายน', 6], ['มิ.ย.', 6],
8
+ ['july', 7], ['jul', 7], ['กรกฎาคม', 7], ['ก.ค.', 7],
9
+ ['august', 8], ['aug', 8], ['สิงหาคม', 8], ['ส.ค.', 8],
10
+ ['september', 9], ['sep', 9], ['กันยายน', 9], ['ก.ย.', 9],
11
+ ['october', 10], ['oct', 10], ['ตุลาคม', 10], ['ต.ค.', 10],
12
+ ['november', 11], ['nov', 11], ['พฤศจิกายน', 11], ['พ.ย.', 11],
13
+ ['december', 12], ['dec', 12], ['ธันวาคม', 12], ['ธ.ค.', 12]
14
+ ]);
15
+
16
+ function normalizeYear(year) {
17
+ const value = Number(year);
18
+ return value > 2400 ? value - 543 : value;
19
+ }
20
+
21
+ export function findFutureDates(text, now = new Date()) {
22
+ const candidates = [];
23
+ const value = String(text || '');
24
+ const iso = /\b(20\d{2})[-/](\d{1,2})[-/](\d{1,2})\b/g;
25
+ const written = new RegExp(`\\b(\\d{1,2})\\s+(${[...MONTHS.keys()].map(key => key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s+(20\\d{2}|25\\d{2})\\b`, 'gi');
26
+ const yearOnly = /\b(20\d{2}|25\d{2})\b/g;
27
+ let match;
28
+
29
+ while ((match = iso.exec(value))) candidates.push(new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
30
+ while ((match = written.exec(value))) {
31
+ candidates.push(new Date(normalizeYear(match[3]), MONTHS.get(match[2].toLowerCase()) - 1, Number(match[1])));
32
+ }
33
+ while ((match = yearOnly.exec(value))) {
34
+ const year = normalizeYear(match[1]);
35
+ if (year > now.getFullYear()) candidates.push(new Date(year, 11, 31));
36
+ }
37
+
38
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
39
+ return candidates.filter(date => Number.isFinite(date.getTime()) && date > startOfToday);
40
+ }
41
+
42
+ function hasLiveVerification(tools = []) {
43
+ return tools.some(tool => /^(?:web_search|web_extract|fetch_webpage|verify_claim)$/.test(tool?.name || ''));
44
+ }
45
+
46
+ function redactUnverifiedFutureDates(text, now) {
47
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
48
+ const written = new RegExp(`(\\d{1,2})\\s+(${[...MONTHS.keys()].map(key => key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s+(20\\d{2}|25\\d{2})`, 'gi');
49
+ return String(text)
50
+ .replace(/\b(20\d{2})[-/](\d{1,2})[-/](\d{1,2})\b/g, (match, year, month, day) => {
51
+ const date = new Date(Number(year), Number(month) - 1, Number(day));
52
+ return date > startOfToday ? 'วันที่ในอนาคตที่ยังไม่ยืนยัน' : match;
53
+ })
54
+ .replace(written, (match, day, month, year) => {
55
+ const date = new Date(normalizeYear(year), MONTHS.get(String(month).toLowerCase()) - 1, Number(day));
56
+ return date > startOfToday ? 'วันที่ในอนาคตที่ยังไม่ยืนยัน' : match;
57
+ });
58
+ }
59
+
60
+ export function applyFutureEventGuard(answer, { tools = [], now = new Date() } = {}) {
61
+ let text = String(answer || '');
62
+ if (!text || findFutureDates(text, now).length === 0) return text;
63
+ const verified = hasLiveVerification(tools);
64
+ const warning = verified
65
+ ? '> **สถานะข้อมูลล่วงหน้า:** กำหนดการหรือรายชื่อผู้เข้าร่วมยังอาจเปลี่ยนแปลงได้ โปรดตรวจสอบประกาศทางการล่าสุดอีกครั้ง'
66
+ : '> **สถานะข้อมูลล่วงหน้า:** วันที่กล่าวถึงยังมาไม่ถึง และยังไม่ได้รับการยืนยันจากแหล่งข้อมูลสดในคำตอบนี้ กำหนดการและรายชื่อผู้เข้าร่วมจึงไม่ควรถูกมองว่าแน่นอน';
67
+
68
+ text = text
69
+ .replace(/(?:ได้รับเชิญ(?:ให้)?|ถูกเชิญให้)เข้าร่วมงานแน่นอน/g, 'มีรายงานเรื่องการเชิญเข้าร่วมงาน แต่สถานะยังอาจเปลี่ยนแปลงได้')
70
+ .replace(/(?:จะ|ยืนยันว่าจะ)เข้าร่วมงานแน่นอน/g, 'มีกำหนดเข้าร่วมตามข้อมูลที่เผยแพร่ล่าสุด แต่ยังอาจเปลี่ยนแปลงได้')
71
+ .replace(/\bwill definitely attend\b/gi, 'is scheduled to attend, subject to change');
72
+
73
+ if (!verified) {
74
+ text = redactUnverifiedFutureDates(text, now)
75
+ .replace(/ได้รับเชิญ(?:ให้)?เข้าร่วมงาน/g, 'มีรายงานที่ยังต้องตรวจสอบเรื่องการเชิญเข้าร่วมงาน')
76
+ .replace(/\bhas been invited\b/gi, 'has reportedly been invited, pending official confirmation');
77
+ }
78
+
79
+ return text.includes('สถานะข้อมูลล่วงหน้า:') ? text : `${warning}\n\n${text}`;
80
+ }
@@ -0,0 +1,12 @@
1
+ export function getAgentMemorySnapshot(agent, requestedSessionId = null) {
2
+ if (!agent) return null;
3
+ const memoryStatus = agent.memory?.getStatus?.() || {};
4
+ return {
5
+ sessionId: requestedSessionId || agent.session?.id || null,
6
+ messageCount: agent.session?.messages?.length || 0,
7
+ restoredMessageCount: agent.restoredMessageCount || 0,
8
+ longTermFacts: memoryStatus.factsCount || 0,
9
+ archivedSessions: memoryStatus.sessionsCount || 0,
10
+ persistent: !agent.memory?._storageError,
11
+ };
12
+ }
@@ -0,0 +1,146 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+
3
+ const rateLimitStore = new Map();
4
+ const RATE_LIMIT_WINDOW_MS = 60_000;
5
+ const MAX_CHAT_REQUESTS_PER_MINUTE = 60;
6
+
7
+ export function resolveAllowedOrigin(requestOrigin) {
8
+ if (!requestOrigin) return null;
9
+
10
+ try {
11
+ const parsed = new URL(requestOrigin);
12
+ const isDevelopment = process.env.NODE_ENV !== 'production';
13
+ if (isDevelopment && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1')) {
14
+ return requestOrigin;
15
+ }
16
+ } catch { }
17
+
18
+ const vercelOrigin = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : '';
19
+ const list = [process.env.WEBCHAT_CORS_ORIGINS || '', vercelOrigin, 'https://jujingyi-kikuuu.vercel.app']
20
+ .join(',')
21
+ .split(',')
22
+ .map(s => s.trim().toLowerCase())
23
+ .filter(Boolean);
24
+ if (list.includes(requestOrigin.toLowerCase())) return requestOrigin;
25
+
26
+ return null;
27
+ }
28
+
29
+ export function applySecurityHeaders(req, res) {
30
+ const headers = req?.headers || {};
31
+ const origin = headers['origin'];
32
+ const allowedOrigin = resolveAllowedOrigin(origin);
33
+
34
+ if (typeof res?.setHeader === 'function') {
35
+ if (origin && allowedOrigin) {
36
+ res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
37
+ res.setHeader('Vary', 'Origin');
38
+ }
39
+
40
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
41
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, Accept');
42
+ res.setHeader('X-Content-Type-Options', 'nosniff');
43
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
44
+ res.setHeader('Referrer-Policy', 'no-referrer');
45
+ res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
46
+ res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
47
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
48
+ res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
49
+ res.setHeader('Content-Security-Policy', "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; script-src 'self' 'unsafe-inline' https://www.youtube.com https://www.youtube-nocookie.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; media-src 'self' blob: https:; frame-src https://www.youtube.com https://www.youtube-nocookie.com; connect-src 'self' https:;");
50
+ }
51
+ return !origin || Boolean(allowedOrigin);
52
+ }
53
+
54
+ export function extractApiKey(req, urlObj = null) {
55
+ const headers = req?.headers || {};
56
+ let key = headers['x-api-key'];
57
+ if (!key) {
58
+ const authHeader = headers['authorization'];
59
+ if (authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
60
+ key = authHeader.slice(7).trim();
61
+ }
62
+ }
63
+ return key || null;
64
+ }
65
+
66
+ export function resolveClientIp(req) {
67
+ const directIp = req?.socket?.remoteAddress || '127.0.0.1';
68
+ const trustProxy = process.env.VERCEL === '1' || process.env.KIKU_TRUST_PROXY === 'true';
69
+ if (!trustProxy) return String(directIp);
70
+ const forwarded = req?.headers?.['x-forwarded-for'];
71
+ return typeof forwarded === 'string' && forwarded.trim()
72
+ ? forwarded.split(',')[0].trim()
73
+ : String(directIp);
74
+ }
75
+
76
+ function constantTimeEqual(left, right) {
77
+ const a = Buffer.from(String(left || ''));
78
+ const b = Buffer.from(String(right || ''));
79
+ return a.length === b.length && timingSafeEqual(a, b);
80
+ }
81
+
82
+ export function isWebChatAdmin(req) {
83
+ const expected = process.env.WEBCHAT_ADMIN_KEY || '';
84
+ if (!expected) return false;
85
+ return constantTimeEqual(extractApiKey(req), expected);
86
+ }
87
+
88
+ export function checkChatRateLimit(clientIp) {
89
+ const ip = clientIp || 'unknown';
90
+ const now = Date.now();
91
+ const entry = rateLimitStore.get(ip);
92
+
93
+ if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
94
+ rateLimitStore.set(ip, { count: 1, windowStart: now });
95
+ return { allowed: true, remaining: MAX_CHAT_REQUESTS_PER_MINUTE - 1 };
96
+ }
97
+
98
+ entry.count++;
99
+ if (entry.count > MAX_CHAT_REQUESTS_PER_MINUTE) {
100
+ const retryAfter = Math.ceil((RATE_LIMIT_WINDOW_MS - (now - entry.windowStart)) / 1000);
101
+ return { allowed: false, retryAfter };
102
+ }
103
+
104
+ return { allowed: true, remaining: MAX_CHAT_REQUESTS_PER_MINUTE - entry.count };
105
+ }
106
+
107
+ export async function checkChatRateLimitDistributed(clientIp) {
108
+ const redisUrl = process.env.UPSTASH_REDIS_REST_URL;
109
+ const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN;
110
+ if (!redisUrl || !redisToken) return checkChatRateLimit(clientIp);
111
+
112
+ const now = Date.now();
113
+ const identity = createHash('sha256').update(String(clientIp || 'unknown')).digest('hex');
114
+ const key = `webchat-rate:${identity}:${Math.floor(now / RATE_LIMIT_WINDOW_MS)}`;
115
+ try {
116
+ const response = await fetch(`${redisUrl.replace(/\/$/, '')}/pipeline`, {
117
+ method: 'POST',
118
+ headers: { Authorization: `Bearer ${redisToken}`, 'Content-Type': 'application/json' },
119
+ body: JSON.stringify([['INCR', key], ['EXPIRE', key, 120]]),
120
+ signal: AbortSignal.timeout(1500),
121
+ });
122
+ if (response.ok) {
123
+ const result = await response.json();
124
+ const count = Number(result?.[0]?.result) || 1;
125
+ return {
126
+ allowed: count <= MAX_CHAT_REQUESTS_PER_MINUTE,
127
+ remaining: Math.max(0, MAX_CHAT_REQUESTS_PER_MINUTE - count),
128
+ retryAfter: count <= MAX_CHAT_REQUESTS_PER_MINUTE ? 0 : 60,
129
+ };
130
+ }
131
+ } catch {}
132
+ return checkChatRateLimit(clientIp);
133
+ }
134
+
135
+
136
+ if (typeof setInterval !== 'undefined') {
137
+ const timer = setInterval(() => {
138
+ const now = Date.now();
139
+ for (const [ip, entry] of rateLimitStore) {
140
+ if (now - entry.windowStart > RATE_LIMIT_WINDOW_MS * 2) {
141
+ rateLimitStore.delete(ip);
142
+ }
143
+ }
144
+ }, 120_000);
145
+ timer.unref?.();
146
+ }
@@ -0,0 +1,52 @@
1
+ const SENSITIVE_TOOL_FIELD = /^(?:api[-_]?key|authorization|cookie|password|secret|token)$/i;
2
+
3
+ export function makeInspectableToolPayload(value, options = {}) {
4
+ const maxDepth = options.maxDepth ?? 6;
5
+ const maxArrayItems = options.maxArrayItems ?? 40;
6
+ const maxObjectKeys = options.maxObjectKeys ?? 80;
7
+ const maxStringLength = options.maxStringLength ?? 4000;
8
+ const maxSerializedLength = options.maxSerializedLength ?? 24000;
9
+ const seen = new WeakSet();
10
+
11
+ const visit = (item, depth) => {
12
+ if (item === null || typeof item === 'boolean' || typeof item === 'number') return item;
13
+ if (typeof item === 'bigint') return String(item);
14
+ if (typeof item === 'string') {
15
+ return item.length > maxStringLength
16
+ ? item.slice(0, maxStringLength) + '\n… [ตัดข้อความที่ยาวเกินไป]'
17
+ : item;
18
+ }
19
+ if (typeof item === 'undefined') return '[undefined]';
20
+ if (typeof item !== 'object') return String(item);
21
+ if (depth >= maxDepth) return '[ตัดข้อมูลที่ซ้อนลึกเกินไป]';
22
+ if (seen.has(item)) return '[อ้างอิงซ้ำ]';
23
+ seen.add(item);
24
+
25
+ if (Array.isArray(item)) {
26
+ const output = item.slice(0, maxArrayItems).map(entry => visit(entry, depth + 1));
27
+ if (item.length > maxArrayItems) {
28
+ output.push(`… [ตัดอีก ${item.length - maxArrayItems} รายการ]`);
29
+ }
30
+ return output;
31
+ }
32
+
33
+ const output = {};
34
+ const entries = Object.entries(item);
35
+ entries.slice(0, maxObjectKeys).forEach(([key, entry]) => {
36
+ output[key] = SENSITIVE_TOOL_FIELD.test(key) ? '[redacted]' : visit(entry, depth + 1);
37
+ });
38
+ if (entries.length > maxObjectKeys) {
39
+ output.__truncated__ = `ตัดอีก ${entries.length - maxObjectKeys} ฟิลด์`;
40
+ }
41
+ return output;
42
+ };
43
+
44
+ const safeValue = visit(value, 0);
45
+ const serialized = JSON.stringify(safeValue);
46
+ if (serialized.length <= maxSerializedLength) return safeValue;
47
+ return {
48
+ truncated: true,
49
+ preview: serialized.slice(0, maxSerializedLength) + '…',
50
+ note: 'ผลลัพธ์มีขนาดใหญ่ จึงแสดงเฉพาะตัวอย่าง'
51
+ };
52
+ }
@@ -0,0 +1,120 @@
1
+ import { handleStaticRoute } from './routes/staticRoute.js';
2
+ import { handleStatusRoute } from './routes/statusRoute.js';
3
+ import { handleChatRoute } from './routes/chatRoute.js';
4
+ import { handleSuggestionsRoute, handleAiSuggestRoute } from './routes/suggestRoute.js';
5
+ import { handleVerifyRoute } from './routes/verifyRoute.js';
6
+ import { handleNewsRoute } from './routes/newsRoute.js';
7
+ import { handleCompareRoute } from './routes/compareRoute.js';
8
+ import { handleQuizRoute } from './routes/quizRoute.js';
9
+ import { handleTimelineRoute } from './routes/timelineRoute.js';
10
+ import { handleExportRoute } from './routes/exportRoute.js';
11
+ import { handleClarifyRoute } from './routes/clarifyRoute.js';
12
+ import { handleFeedbackRoute, handleFeedbackStatsRoute } from './routes/feedbackRoute.js';
13
+ import { handleClarifyAnalyticsRoute, handleClarifyConfigRoute } from './routes/clarifyConfigRoute.js';
14
+ import { applySecurityHeaders, checkChatRateLimitDistributed, resolveClientIp } from './core/security.js';
15
+ import { sendError } from './core/errorHandler.js';
16
+
17
+ export async function handleWebChatRequest(req, res, options = {}) {
18
+ const port = Number(options.port || 3001);
19
+ const urlObj = new URL(req.url, `http://localhost:${port}`);
20
+ const pathname = urlObj.pathname;
21
+
22
+ const originAllowed = applySecurityHeaders(req, res);
23
+ if (!originAllowed) {
24
+ return sendError(res, 403, 'Origin is not allowed');
25
+ }
26
+
27
+ if (req.method === 'OPTIONS') {
28
+ res.writeHead(204);
29
+ res.end();
30
+ return;
31
+ }
32
+
33
+ if (pathname.startsWith('/api/')) {
34
+ const rate = await checkChatRateLimitDistributed(resolveClientIp(req));
35
+ if (!rate.allowed) {
36
+ res.setHeader('Retry-After', String(rate.retryAfter || 60));
37
+ return sendError(res, 429, `Rate limit exceeded. Please retry in ${rate.retryAfter || 60} seconds.`);
38
+ }
39
+ }
40
+
41
+ try {
42
+
43
+ if (handleStaticRoute(req, res, pathname, port)) {
44
+ return;
45
+ }
46
+
47
+ if (pathname === '/api/status') {
48
+ return await handleStatusRoute(req, res, urlObj, port);
49
+ }
50
+
51
+ if (pathname === '/api/suggestions') {
52
+ return await handleSuggestionsRoute(req, res);
53
+ }
54
+
55
+ if (pathname === '/api/ai-suggest' && req.method === 'POST') {
56
+ return await handleAiSuggestRoute(req, res);
57
+ }
58
+
59
+ if (pathname === '/api/quiz') {
60
+ return await handleQuizRoute(req, res);
61
+ }
62
+
63
+ if (pathname === '/api/v1/timeline') {
64
+ return await handleTimelineRoute(req, res, urlObj);
65
+ }
66
+
67
+ if (pathname === '/api/chat' && req.method === 'POST') {
68
+ return await handleChatRoute(req, res, urlObj);
69
+ }
70
+
71
+ if (pathname === '/api/export' && req.method === 'POST') {
72
+ return await handleExportRoute(req, res);
73
+ }
74
+
75
+ if (pathname === '/api/verify' && req.method === 'POST') {
76
+ return await handleVerifyRoute(req, res);
77
+ }
78
+
79
+ if (pathname === '/api/news' && req.method === 'POST') {
80
+ return await handleNewsRoute(req, res);
81
+ }
82
+
83
+ if (pathname === '/api/compare' && req.method === 'POST') {
84
+ return await handleCompareRoute(req, res);
85
+ }
86
+
87
+ if (pathname === '/api/clarify' && req.method === 'POST') {
88
+ return await handleClarifyRoute(req, res);
89
+ }
90
+
91
+ if (pathname === '/api/feedback' && req.method === 'POST') {
92
+ return await handleFeedbackRoute(req, res);
93
+ }
94
+
95
+ if (pathname === '/api/feedback/stats' && req.method === 'GET') {
96
+ return await handleFeedbackStatsRoute(req, res);
97
+ }
98
+
99
+ if (pathname === '/api/clarify/analytics' && req.method === 'GET') {
100
+ return await handleClarifyAnalyticsRoute(req, res, options);
101
+ }
102
+
103
+ if (pathname === '/api/clarify/config' && req.method === 'GET') {
104
+ return await handleClarifyConfigRoute(req, res, options, 'get');
105
+ }
106
+
107
+ if (pathname === '/api/clarify/config' && req.method === 'POST') {
108
+ return await handleClarifyConfigRoute(req, res, options, 'update');
109
+ }
110
+
111
+ if (pathname === '/api/clarify/config/reset' && req.method === 'POST') {
112
+ return await handleClarifyConfigRoute(req, res, options, 'reset');
113
+ }
114
+
115
+ res.writeHead(404, { 'Content-Type': 'application/json' });
116
+ res.end(JSON.stringify({ error: 'Not found' }));
117
+ } catch (err) {
118
+ sendError(res, 500, err.message);
119
+ }
120
+ }