kiku-agent 1.2.0 → 1.3.1

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 (131) hide show
  1. package/README.md +459 -407
  2. package/package.json +32 -3
  3. package/server/agent/agentCore.js +635 -267
  4. package/server/agent/appPaths.js +2 -0
  5. package/server/agent/catalogEntityResolver.js +637 -0
  6. package/server/agent/citations.js +0 -21
  7. package/server/agent/clarificationAnalytics.js +1 -1
  8. package/server/agent/clarifyingQuestions.js +286 -216
  9. package/server/agent/cli.js +532 -73
  10. package/server/agent/commandFeatures.js +123 -24
  11. package/server/agent/commandRegistry.js +128 -62
  12. package/server/agent/commands.js +1410 -95
  13. package/server/agent/config.js +253 -163
  14. package/server/agent/contextCompactor.js +239 -25
  15. package/server/agent/dynamicCatalog.js +192 -0
  16. package/server/agent/envBootstrap.js +46 -0
  17. package/server/agent/envManager.js +317 -0
  18. package/server/agent/eventBus.js +3 -3
  19. package/server/agent/feedbackLearning.js +0 -8
  20. package/server/agent/gpuMetrics.js +188 -55
  21. package/server/agent/ingestionWorker.js +339 -0
  22. package/server/agent/ink/App.jsx +348 -0
  23. package/server/agent/ink/Panel.jsx +54 -0
  24. package/server/agent/ink/PromptInput.jsx +321 -0
  25. package/server/agent/ink/TaskPlanPanel.jsx +88 -0
  26. package/server/agent/ink/ThinkingPanel.jsx +68 -0
  27. package/server/agent/ink/ToolResult.jsx +133 -0
  28. package/server/agent/ink/main.jsx +195 -0
  29. package/server/agent/ink/useEventBus.js +218 -0
  30. package/server/agent/lanDiscovery.js +369 -0
  31. package/server/agent/llmClient.js +111 -39
  32. package/server/agent/lmStudioWakeup.js +144 -0
  33. package/server/agent/mcpClient.js +2 -2
  34. package/server/agent/memory/contextAllocator.js +214 -0
  35. package/server/agent/memory/hierarchicalStore.js +523 -0
  36. package/server/agent/memory/reflectionWorker.js +110 -0
  37. package/server/agent/memory.js +635 -494
  38. package/server/agent/modelConfig.js +487 -0
  39. package/server/agent/newsIntelligence.js +219 -0
  40. package/server/agent/newsWatch.js +85 -0
  41. package/server/agent/prompts.js +146 -0
  42. package/server/agent/publicTools.js +7 -0
  43. package/server/agent/queryDetails.js +62 -1
  44. package/server/agent/research.js +314 -35
  45. package/server/agent/routingInterceptor.js +77 -0
  46. package/server/agent/rumoredProjects.js +15 -0
  47. package/server/agent/sanitizer.js +117 -17
  48. package/server/agent/subagent.js +52 -9
  49. package/server/agent/suggestionTui.js +33 -13
  50. package/server/agent/suggestions.js +29 -13
  51. package/server/agent/telemetry/hardwareTiers.js +332 -0
  52. package/server/agent/telemetry/runtimeWatchdog.js +66 -0
  53. package/server/agent/telemetry/techPowerUpClient.js +682 -0
  54. package/server/agent/telemetry/techpowerup-mcp.json +22 -0
  55. package/server/agent/telemetry.js +0 -11
  56. package/server/agent/terminalImage.js +0 -1
  57. package/server/agent/textToolProtocol.js +190 -0
  58. package/server/agent/tokenTracker.js +220 -0
  59. package/server/agent/toolRouter.js +362 -216
  60. package/server/agent/tools.js +1155 -194
  61. package/server/agent/ui.js +144 -41
  62. package/server/agent/vramService.js +572 -0
  63. package/server/agent/webSearch.js +2279 -273
  64. package/server/agent/websearch/adapters/duckduckgo.js +97 -0
  65. package/server/agent/websearch/adapters/jina.js +77 -0
  66. package/server/agent/websearch/adapters/searxng.js +57 -0
  67. package/server/agent/websearch/core/canonical.js +89 -0
  68. package/server/agent/websearch/core/fusion.js +81 -0
  69. package/server/agent/websearch/core/types.js +29 -0
  70. package/server/agent/websearch/extract.js +605 -0
  71. package/server/agent/websearch/index.js +43 -0
  72. package/server/agent/websearch/orchestrator.js +266 -0
  73. package/server/agent/websearch/resilience/breaker.js +97 -0
  74. package/server/agent/websearch/resilience/cache.js +59 -0
  75. package/server/agent/websearch/resilience/limiter.js +93 -0
  76. package/server/agent/websearch/security/filter.js +36 -0
  77. package/server/agent/websearch/security/ssrf.js +289 -0
  78. package/server/agent/websearch/telemetry.js +71 -0
  79. package/server/agent/youtubeAuth.js +82 -73
  80. package/server/agent/youtubeTranscript.js +209 -0
  81. package/server/apiRouter.js +390 -190
  82. package/server/mcp/data/brands.js +5 -5
  83. package/server/mcp/data/discography.js +56 -45
  84. package/server/mcp/data/gallery.js +9 -0
  85. package/server/mcp/data/index.js +1 -0
  86. package/server/mcp/data/industryIndex.js +251 -0
  87. package/server/mcp/data/profile.js +5 -5
  88. package/server/mcp/data/timeline.js +2 -2
  89. package/server/mcp/data/works.js +2 -2
  90. package/server/mcp/handler.js +0 -2
  91. package/server/mcp/prompts.js +42 -2
  92. package/server/mcp/resources.js +5 -1
  93. package/server/mcp/security.js +25 -14
  94. package/server/mcp/server.js +92 -3
  95. package/server/mcp/tools.js +20 -11
  96. package/server/routes/agent.js +55 -0
  97. package/server/web-chat/core/connection.js +218 -194
  98. package/server/web-chat/core/conversationContext.js +1 -1
  99. package/server/web-chat/core/directCatalogEngine.js +190 -184
  100. package/server/web-chat/core/followUpEngine.js +88 -11
  101. package/server/web-chat/core/security.js +7 -4
  102. package/server/web-chat/router.js +54 -17
  103. package/server/web-chat/routes/bilibiliRoute.js +34 -0
  104. package/server/web-chat/routes/chatRoute.js +488 -397
  105. package/server/web-chat/routes/clarifyConfigRoute.js +1 -2
  106. package/server/web-chat/routes/exportRoute.js +2 -1
  107. package/server/web-chat/routes/lanDiscoveryRoute.js +90 -0
  108. package/server/web-chat/routes/newsRoute.js +36 -6
  109. package/server/web-chat/routes/providerRoute.js +87 -0
  110. package/server/web-chat/routes/staticRoute.js +61 -13
  111. package/server/web-chat/routes/statusRoute.js +23 -2
  112. package/server/web-chat/routes/vramRoute.js +22 -0
  113. package/server/web-chat/server.js +135 -106
  114. package/server/web-chat/ui/htmlBuilder.js +8 -8
  115. package/server/web-chat/ui/previews/modelSettings.preview.html +41 -0
  116. package/server/web-chat/ui/scripts/agentBubbleSystem.js +0 -5
  117. package/server/web-chat/ui/scripts/client.js +10526 -8397
  118. package/server/web-chat/ui/scripts/webchatI18n.js +190 -138
  119. package/server/web-chat/ui/styles/main.css +7471 -4246
  120. package/server/web-chat/ui/templates/body.html +305 -179
  121. package/src/data/brandsData.js +29 -6
  122. package/src/data/discographyData.js +918 -900
  123. package/src/data/galleryData.js +66 -0
  124. package/src/data/industryIndexData.js +292 -0
  125. package/src/data/musicVideoData.js +48 -5
  126. package/src/data/profileData.js +15 -1
  127. package/src/data/timelineData.js +4 -3
  128. package/src/data/worksData.js +536 -535
  129. package/src/lib/vram/estimator.js +464 -0
  130. package/src/lib/vram/modelRegistry.js +387 -0
  131. package/tokens.css +33 -2
@@ -2,195 +2,201 @@ import { executeTool } from '../../agent/tools.js';
2
2
  import { makeInspectableToolPayload } from './toolInspector.js';
3
3
  import { generateDynamicFollowUps } from './followUpEngine.js';
4
4
  import { buildDramaSpecialistAnswer } from './dramaKnowledge.js';
5
-
6
- export async function executeDirectCatalogKnowledge(query, _persona = 'default', options = {}) {
7
- const toolsUsed = [];
8
- const q = String(query || '').toLowerCase();
9
- const conversationContext = options.conversationContext || null;
10
- let answerParts = [];
11
-
5
+ import { resolveQueryEntities, detectPromotionIntent } from '../../agent/catalogEntityResolver.js';
6
+
7
+ export async function executeDirectCatalogKnowledge(query, _persona = 'default', options = {}) {
8
+ const toolsUsed = [];
9
+ const q = String(query || '').toLowerCase();
10
+ const conversationContext = options.conversationContext || null;
11
+ let answerParts = [];
12
+
12
13
  const addToolExec = async (name, args) => {
13
- try {
14
- const t0 = Date.now();
15
- const res = await executeTool(name, args);
16
- const dur = Date.now() - t0;
17
- toolsUsed.push({
18
- name,
19
- input: makeInspectableToolPayload(args),
20
- result: makeInspectableToolPayload(res),
21
- resultCount: Array.isArray(res?.results || res?.works || res?.songs || res?.brands || res?.awards)
22
- ? (res.results || res.works || res.songs || res.brands || res.awards).length
23
- : 1,
24
- durationMs: dur
25
- });
26
- return res;
27
- } catch {
28
- return null;
29
- }
14
+ try {
15
+ const t0 = Date.now();
16
+ const res = await executeTool(name, args);
17
+ const dur = Date.now() - t0;
18
+ toolsUsed.push({
19
+ name,
20
+ input: makeInspectableToolPayload(args),
21
+ result: makeInspectableToolPayload(res),
22
+ resultCount: Array.isArray(res?.results || res?.works || res?.songs || res?.brands || res?.awards)
23
+ ? (res.results || res.works || res.songs || res.brands || res.awards).length
24
+ : 1,
25
+ durationMs: dur
26
+ });
27
+ return res;
28
+ } catch {
29
+ return null;
30
+ }
30
31
  };
31
32
 
32
33
  const specialistAnswer = await buildDramaSpecialistAnswer(query, addToolExec);
33
34
  if (specialistAnswer) answerParts.push(specialistAnswer);
34
35
 
36
+ const entityRes = resolveQueryEntities(query);
37
+
35
38
  if (!specialistAnswer && (q.includes('ประวัติ') || q.includes('who is') || q.includes('profile') || q.includes('slogan') || q.includes('ฉายา'))) {
36
- const profile = await addToolExec('get_profile_and_fandom', {});
37
- if (profile) {
38
- answerParts.push(`### 🌸 ประวัติและข้อมูลส่วนตัว: จวีจิ้งอี (Ju Jingyi / 鞠婧祎)\n\n` +
39
- `- **ชื่อภาษาจีน:** ${profile.profile?.nameCn || '鞠婧祎'} (${profile.profile?.pinyin || 'Jū Jìngyī'})\n` +
40
- `- **ชื่อสากล/ฉายา:** Kiku / เสี่ยวจวี (小鞠) / สาวงาม 4,000 ปี\n` +
41
- `- **วันเกิด:** ${profile.profile?.birthDate || '18 มิถุนายน 1994'}\n` +
42
- `- **บ้านเกิด:** ${profile.profile?.birthPlace || 'เมืองสุ้ยหนิง มณฑลเสฉวน ประเทศจีน'}\n` +
43
- `- **ชื่อกลุ่มแฟนคลับ (Fandom):** ${profile.fandom?.name || 'Miju (蜜橘 / ฮันนี่แทงเจอรีน)'}\n` +
44
- `- **สโลแกนประจำตัว:** "${profile.fandom?.slogan || '鞠世无双,与祎同行'}" (หนึ่งเดียวในใต้หล้า เคียงคู่จิ้งอีตลอดไป)\n` +
45
- `- **ความสามารถพิเศษ:** ขับร้อง, ไวโอลิน, เปียโน, กู่เจิง, เต้นรำ`);
46
- }
47
- }
48
-
49
- if (!specialistAnswer && (q.includes('七星彩') || q.includes('qi xiang cai') || q.includes('qi xing cai') || q.includes('ชีซิงไฉ่'))) {
50
- await addToolExec('search_works', { query: '七星彩' });
51
- answerParts.push(`### 🎬 ข้อมูลซีรีส์ 《七星彩》 (Qi Xing Cai / ชีซิงไฉ่)\n\n` +
52
- `**《七星彩》** เป็นโปรเจกต์ซีรีส์จีนโบราณฟอร์มยักษ์ (ระดับ S+) ผลิตโดย **Youku (优酷) และ Stellar Media (恒星引力传媒)** ดัดแปลงจากนวนิยายชื่อดังของ **หมิงเยว่ตัง (明月珰)**\n\n` +
53
- `#### 📖 เรื่องย่อและเนื้อหาหลัก:\n` +
54
- `- เล่าเรื่องราวของ **'จี้เฉิง' (纪澄)** บุตรสาวตระกูลพ่อค้าที่ครอบครัวประสบเคราะห์กรรม ถูกบีบให้แต่งงาน นางจึงต้องใช้ความฉลาด ปฏิภาณไหวพริบ และมารยาเอาตัวรอด เดินทางไปพึ่งพาญาติในตระกูลขุนนางใหญ่เพื่อหาคู่ครองที่พึ่งพาได้\n` +
55
- `- นางได้พบกับ **'เสิ่นเช่อ' (沈彻)** แม่ทัพหนุ่มผู้ชาญฉลาด ทั้งสองชิงไหวชิงพริบ ผลัดกันรุกผลัดกันรับจนก่อเกิดเป็นความผูกพันและร่วมมือกันกอบกู้วิกฤตตระกูลและบ้านเมือง\n\n` +
56
- `#### 🔥 ทำไมชาวเน็ตถึงให้ความสนใจอย่างมาก (เหตุผลที่เป็นกระแส):\n` +
57
- `1. **ค่ายผู้ผลิตระดับท็อป (Stellar Media / 恒星引力):** เป็นค่ายเดียวกับที่สร้างผลงานภาพสวยอลังการอย่าง *ของรักของข้า (Love Between Fairy and Devil)* และ *สื่อรักยี่สิบสี่เทศกาล (Fox Spirit Matchmaker)* ทำให้คอซีรีส์ตั้งตารองานภาพและคอสตูมเป็นอย่างมาก\n` +
58
- `2. **กระแสข่าวลือแคสติ้งนักแสดง (Casting Rumors / 选角瓜):** บทนางเอก 'จี้เฉิง' ต้องเป็นหญิงงามล่มเมืองที่ทั้งสวย ฉลาด ร้ายลึก และมีเสน่ห์ จึงมีการปล่อยข่าวลือในโซเชียลจีน (Weibo, Douban, Bilibili) จับคู่กับนักแสดงหญิงแถวหน้าหลายคน รวมถึงมีชื่อของ **'จวีจิ้งอี' (Ju Jingyi)**, หลูอวี้เสี่ยว, ซ่งจู่เอ๋อร์ ฯลฯ ถูกนำมาพูดถึงและถกเถียงกันอย่างกว้างขวาง\n` +
59
- `3. **สถานะปัจจุบัน:** ยังอยู่ในขั้นตอนการเตรียมการ/รอประกาศอย่างเป็นทางการ (ยังไม่มีการยืนยันรายชื่อนักแสดงทางการจากทาง Youku หรือสตูดิโอของศิลปิน)\n\n` +
60
- `> ⚠️ **ข้อควรระวัง (Disambiguation):** 《七星彩》 เป็นคนละผลงานกับซีรีส์และเพลง 《千香》 (The Eternal Fragrance / พันกลิ่นบุปผา) ที่จวีจิ้งอีนำแสดงร่วมกับ ซ่งเวยหลง นะคะ`);
61
- }
62
-
63
- if (!specialistAnswer && (q.includes('ซีรีส์') || q.includes('ละคร') || q.includes('หนัง') || q.includes('ผลงาน') || q.includes('ฮวาเจียนหลิง') || q.includes('หยุนซี') || q.includes('blossom') || q.includes('yunxi') || q.includes('fairy') || q.includes('บทบาท'))) {
64
- const worksRes = await addToolExec('search_works', { query: q.replace(/(ซีรีส์|ละคร|หนัง|ผลงาน|เรื่อง|เด่น|ของ|จวีจิ้งอี)/g, '').trim() });
65
- const works = worksRes?.works || [];
66
- if (works.length > 0) {
67
- let md = `### 🎬 ผลงานซีรีส์และภาพยนตร์ที่โดดเด่น (${works.length} เรื่อง)\n\n`;
68
- works.slice(0, 15).forEach(w => {
69
- md += `#### 🌟 **${w.titleTh || w.titleCn}** (${w.titleCn || ''})\n` +
70
- `- **บทบาทที่ได้รับ:** ${w.role || w.character || 'นักแสดงนำ'}\n` +
71
- `- **ปีที่ฉาย:** ${w.year || 'N/A'} • **ประเภท:** ${w.category || 'ซีรีส์โบราณ/โรแมนติก'}\n` +
72
- `- **เรื่องย่อ:** ${w.synopsis || w.description || 'ผลงานซีรีส์ชื่อดังของจวีจิ้งอี'}\n` +
73
- `- **ช่องทางรับชม:** ${w.streamingPlatform || w.platform || 'ไม่ระบุในแคตตาล็อก'}\n`;
74
- if (w.youtubeSearchUrl) {
75
- md += `- 🔗 [ดูตัวอย่างบน YouTube](${w.youtubeSearchUrl})\n\n`;
76
- } else {
77
- md += `\n`;
78
- }
79
- });
80
- answerParts.push(md);
81
- }
82
- }
83
-
84
- if (!specialistAnswer && (q.includes('เพลง') || q.includes('อัลบั้ม') || q.includes('ost') || q.includes('everyday') || q.includes('don\'t touch') || q.includes('ร้อง'))) {
85
- const discoRes = await addToolExec('search_discography', { query: q.replace(/(เพลง|อัลบั้ม|ost|ของ|จวีจิ้งอี)/g, '').trim() });
86
- const songs = discoRes?.songs || [];
87
- if (songs.length > 0) {
88
- let md = `### 🎵 ผลงานเพลงและเพลงประกอบซีรีส์ (Discography - ${songs.length} เพลง)\n\n`;
89
- songs.slice(0, 20).forEach(s => {
90
- md += `- **${s.titleTh || s.titleCn}** (${s.titleCn || ''}) — *ปี ${s.year || ''}* [ประเภท: ${s.work || 'Single'}]\n`;
91
- if (s.youtubeUrl) md += ` - 🔗 [ฟังบน YouTube](${s.youtubeUrl})\n`;
92
- });
93
- answerParts.push(md);
94
- }
95
- }
96
-
97
- if (!specialistAnswer && (q.includes('แบรนด์') || q.includes('พรีเซนเตอร์') || q.includes('ambassador') || q.includes('versace') || q.includes('luxury'))) {
98
- const brandsRes = await addToolExec('get_brand_endorsements', {});
99
- const brands = brandsRes?.brands || [];
100
- if (brands.length > 0) {
101
- let md = `### 👑 บทบาท Brand Ambassador และพรีเซนเตอร์ระดับโลก (${brands.length} แบรนด์)\n\n`;
102
- brands.slice(0, 15).forEach(b => {
103
- md += `- **${b.brand}** — ตำแหน่ง: **${b.role}** (${b.category || 'Luxury'}) [ปี ${b.year || 'ปัจจุบัน'}]\n`;
104
- });
105
- answerParts.push(md);
106
- }
107
- }
108
-
109
- if (!specialistAnswer && (q.includes('รางวัล') || q.includes('award') || q.includes('เกียรติยศ') || q.includes('weibo'))) {
110
- const awardsRes = await addToolExec('get_awards', {});
111
- const awards = awardsRes?.awards || [];
112
- if (awards.length > 0) {
113
- let md = `### 🏆 รางวัลและความสำเร็จสำคัญ (${awards.length} รางวัล)\n\n`;
114
- awards.slice(0, 20).forEach(a => {
115
- md += `- **${a.year || ''}**: ${a.award || a.title} (${a.ceremony || 'งานประกาศรางวัล'}) — *${a.category || ''}*\n`;
116
- });
117
- answerParts.push(md);
118
- }
119
- }
120
-
121
- if (!specialistAnswer && (q.includes('ไทม์ไลน์') || q.includes('timeline') || q.includes('snh48') || q.includes('เส้นทาง') || q.includes('อดีต'))) {
122
- const timelineRes = await addToolExec('get_career_timeline', {});
123
- const events = timelineRes?.timeline || [];
124
- if (events.length > 0) {
125
- let md = `### 🌸 ไทม์ไลน์เส้นทางอาชีพสำคัญ (Career Timeline - ${events.length} เหตุการณ์)\n\n`;
126
- events.slice(0, 20).forEach(e => {
127
- md += `- **ปี ${e.year}**: ${e.title} — ${e.description || ''}\n`;
128
- });
129
- answerParts.push(md);
130
- }
131
- }
132
-
39
+ const profile = await addToolExec('get_profile_and_fandom', {});
40
+ if (profile) {
41
+ answerParts.push(`### 🌸 ประวัติและข้อมูลส่วนตัว: จวีจิ้งอี (Ju Jingyi / 鞠婧祎)\n\n` +
42
+ `- **ชื่อภาษาจีน:** ${profile.profile?.nameCn || '鞠婧祎'} (${profile.profile?.pinyin || 'Jū Jìngyī'})\n` +
43
+ `- **ชื่อสากล/ฉายา:** Kiku / เสี่ยวจวี (小鞠) / สาวงาม 4,000 ปี\n` +
44
+ `- **วันเกิด:** ${profile.profile?.birthDate || '18 มิถุนายน 1994'}\n` +
45
+ `- **บ้านเกิด:** ${profile.profile?.birthPlace || 'เมืองสุ้ยหนิง มณฑลเสฉวน ประเทศจีน'}\n` +
46
+ `- **ชื่อกลุ่มแฟนคลับ (Fandom):** ${profile.fandom?.name || 'Miju (蜜橘 / ฮันนี่แทงเจอรีน)'}\n` +
47
+ `- **สโลแกนประจำตัว:** "${profile.fandom?.slogan || '鞠世无双,与祎同行'}" (หนึ่งเดียวในใต้หล้า เคียงคู่จิ้งอีตลอดไป)\n` +
48
+ `- **ความสามารถพิเศษ:** ขับร้อง, ไวโอลิน, เปียโน, กู่เจิง, เต้นรำ`);
49
+ }
50
+ }
51
+
52
+
53
+ if (!specialistAnswer && (entityRes.hasBrand || q.includes('แบรนด์') || q.includes('พรีเซนเตอร์') || q.includes('ambassador') || q.includes('โฆษณา') || q.includes('โปรโมท') || q.includes('โปรโมต') || entityRes.isPromotion)) {
54
+ const brandQuery = entityRes.primaryBrand ? (entityRes.primaryBrand.nameEn || entityRes.primaryBrand.id) : undefined;
55
+ const brandsRes = await addToolExec('get_brand_endorsements', brandQuery ? { query: brandQuery } : {});
56
+ const brands = brandsRes?.brands || [];
57
+ if (brands.length > 0) {
58
+ if (entityRes.primaryBrand) {
59
+ const b = brands[0];
60
+ let md = `### 👑 ข้อมูลแบรนด์: ${b.brand} (${b.year || 'ปัจจุบัน'})\n\n` +
61
+ `- **ตำแหน่ง:** ${b.title || 'Brand Ambassador'}\n` +
62
+ `- **หมวดหมู่:** ${b.category || 'Luxury'}\n` +
63
+ `- **ไฮไลต์สำคัญ:** ${b.highlight || 'พรีเซนเตอร์ระดับโลกของจวีจิ้งอี'}\n`;
64
+ if (b.bilibiliSearchUrl) md += `- 🔗 [ค้นหาคลิปโปรโมทบน Bilibili](${b.bilibiliSearchUrl})\n`;
65
+ if (b.youtubeCommercialSearch) md += `- 🔗 [ค้นหาโฆษณาบน YouTube](${b.youtubeCommercialSearch})\n`;
66
+ answerParts.push(md);
67
+ } else {
68
+ let md = `### 👑 บทบาท Brand Ambassador และพรีเซนเตอร์ระดับโลก (${brands.length} แบรนด์)\n\n`;
69
+ brands.slice(0, 15).forEach(b => {
70
+ md += `- **${b.brand}** — ตำแหน่ง: **${b.title}** (${b.category || 'Luxury'}) [ปี ${b.year || 'ปัจจุบัน'}]\n`;
71
+ if (b.bilibiliSearchUrl) md += ` - 🔗 [คลิปบน Bilibili](${b.bilibiliSearchUrl})\n`;
72
+ });
73
+ answerParts.push(md);
74
+ }
75
+ }
76
+ }
77
+
78
+
79
+ if (!specialistAnswer && (entityRes.hasWork || q.includes('ซีรีส์') || q.includes('ละคร') || q.includes('หนัง') || q.includes('ผลงาน') || q.includes('บทบาท'))) {
80
+ const workQuery = entityRes.primaryWork ? (entityRes.primaryWork.nameCn || entityRes.primaryWork.nameEn) : q.replace(/(ซีรีส์|ละคร|หนัง|ผลงาน|เรื่อง|เด่น|ของ|จวีจิ้งอี)/g, '').trim();
81
+ const worksRes = await addToolExec('search_works', { query: workQuery });
82
+ if (worksRes?.status === 'rumored_project_info' && worksRes?.rumoredProject) {
83
+ const r = worksRes.rumoredProject;
84
+ answerParts.push(`### 🎬 ข้อมูลซีรีส์ 《${r.titleCn}》 (${r.titleEn})\n\n` +
85
+ `**《${r.titleCn}》** เป็นโปรเจกต์ซีรีส์ที่มีกระแสข่าวลือแคสติ้ง (${r.status}):\n\n` +
86
+ `#### 📖 รายละเอียดโปรเจกต์:\n` +
87
+ `- **ผู้ผลิต:** ${r.producer || 'Youku x Stellar Media'}\n` +
88
+ `- **ต้นฉบับ:** ${r.originalNovel || 'นวนิยายจีน'}\n` +
89
+ `- **เนื้อหา:** ${r.details || ''}\n\n` +
90
+ `#### 🔥 เหตุผลที่เป็นกระแส:\n` +
91
+ `${r.buzzReason || ''}\n\n` +
92
+ `> ⚠️ **ข้อควรระวัง (Disambiguation):** ${r.disambiguation || ''}`);
93
+ } else {
94
+ const works = worksRes?.works || [];
95
+ if (works.length > 0) {
96
+ let md = `### 🎬 ผลงานซีรีส์และภาพยนตร์ที่โดดเด่น (${works.length} เรื่อง)\n\n`;
97
+ works.slice(0, 15).forEach(w => {
98
+ md += `#### 🌟 **${w.titleTh || w.titleCn}** (${w.titleCn || ''})\n` +
99
+ `- **บทบาทที่ได้รับ:** ${w.role || w.character || 'นักแสดงนำ'}\n` +
100
+ `- **ปีที่ฉาย:** ${w.year || 'N/A'} • **ประเภท:** ${w.category || 'ซีรีส์โบราณ/โรแมนติก'}\n` +
101
+ `- **เรื่องย่อ:** ${w.synopsis || w.description || 'ผลงานซีรีส์ชื่อดังของจวีจิ้งอี'}\n` +
102
+ `- **ช่องทางรับชม:** ${w.streamingPlatform || w.platform || 'ไม่ระบุในแคตตาล็อก'}\n`;
103
+ if (w.youtubeSearchUrl) {
104
+ md += `- 🔗 [ดูตัวอย่างบน YouTube](${w.youtubeSearchUrl})\n\n`;
105
+ } else {
106
+ md += `\n`;
107
+ }
108
+ });
109
+ answerParts.push(md);
110
+ }
111
+ }
112
+ }
113
+
114
+
115
+ if (!specialistAnswer && (entityRes.hasSong || q.includes('เพลง') || q.includes('อัลบั้ม') || q.includes('ost') || q.includes('ร้อง'))) {
116
+ const songQuery = entityRes.primarySong ? (entityRes.primarySong.nameCn || entityRes.primarySong.nameEn) : q.replace(/(เพลง|อัลบั้ม|ost|ของ|จวีจิ้งอี)/g, '').trim();
117
+ const discoRes = await addToolExec('search_discography', { query: songQuery });
118
+ const songs = discoRes?.songs || [];
119
+ if (songs.length > 0) {
120
+ let md = `### 🎵 ผลงานเพลงและเพลงประกอบซีรีส์ (Discography - ${songs.length} เพลง)\n\n`;
121
+ songs.slice(0, 20).forEach(s => {
122
+ md += `- **${s.titleTh || s.titleCn}** (${s.titleCn || ''}) — *ปี ${s.year || ''}* [ประเภท: ${s.work || 'Single'}]\n`;
123
+ if (s.youtubeUrl) md += ` - 🔗 [ฟังบน YouTube](${s.youtubeUrl})\n`;
124
+ });
125
+ answerParts.push(md);
126
+ }
127
+ }
128
+
129
+
130
+ if (!specialistAnswer && (entityRes.hasAward || q.includes('รางวัล') || q.includes('award') || q.includes('เกียรติยศ'))) {
131
+ const awardsRes = await addToolExec('get_awards', {});
132
+ const awards = awardsRes?.awards || [];
133
+ if (awards.length > 0) {
134
+ let md = `### 🏆 รางวัลและความสำเร็จสำคัญ (${awards.length} รางวัล)\n\n`;
135
+ awards.slice(0, 20).forEach(a => {
136
+ md += `- **${a.year || ''}**: ${a.award || a.title} (${a.ceremony || 'งานประกาศรางวัล'}) — *${a.category || ''}*\n`;
137
+ });
138
+ answerParts.push(md);
139
+ }
140
+ }
141
+
142
+
143
+ if (!specialistAnswer && (entityRes.hasTimeline || q.includes('ไทม์ไลน์') || q.includes('timeline') || q.includes('snh48') || q.includes('เส้นทาง') || q.includes('อดีต'))) {
144
+ const timelineRes = await addToolExec('get_career_timeline', {});
145
+ const events = timelineRes?.timeline || [];
146
+ if (events.length > 0) {
147
+ let md = `### 🌸 ไทม์ไลน์เส้นทางอาชีพสำคัญ (Career Timeline - ${events.length} เหตุการณ์)\n\n`;
148
+ events.slice(0, 20).forEach(e => {
149
+ md += `- **ปี ${e.year}**: ${e.title} — ${e.description || ''}\n`;
150
+ });
151
+ answerParts.push(md);
152
+ }
153
+ }
154
+
155
+
133
156
  if (!specialistAnswer && (q.includes('บทบาท') || q.includes('ตัวละคร') || q.includes('character') || q.includes('role'))) {
134
- const rolesRes = await addToolExec('get_character_roles', {});
135
- const roles = rolesRes?.roles || [];
136
- if (roles.length > 0) {
137
- let md = `### 🎭 โครงสร้างบทบาทและตัวละครเด่น (${roles.length} บทบาท)\n\n`;
138
- roles.slice(0, 15).forEach(r => {
139
- md += `- **${r.nameTh || r.nameCn}** (${r.nameCn || ''}) จากเรื่อง *${r.drama || ''}* — บุคลิก: ${r.personality || r.archetype || 'ตัวเอก'}\n`;
140
- });
141
- answerParts.push(md);
142
- }
143
- }
144
-
145
- // Handle Continuation Intents (เช่น "ดำเนินการต่อ...", "เจาะลึกด้วยเครื่องมือ")
146
- if (!specialistAnswer && (q.includes('ดำเนินการต่อ') || q.includes('ต่อยอด') || q.includes('เจาะลึก'))) {
147
- if (q.includes('ซีรีส์') || q.includes('ละคร') || q.includes('ฮวาเจียน') || q.includes('หยุนซี')) {
148
- if (!toolsUsed.some(t => t.name === 'search_works')) {
149
- await addToolExec('search_works', { query: 'In Blossom ฮวาเจียนลิ่ง' });
150
- }
151
- if (!toolsUsed.some(t => t.name === 'get_character_roles')) {
152
- await addToolExec('get_character_roles', {});
153
- }
154
- } else if (q.includes('เพลง') || q.includes('ost') || q.includes('ดนตรี')) {
155
- if (!toolsUsed.some(t => t.name === 'search_discography')) {
156
- await addToolExec('search_discography', { query: 'เพลง' });
157
- }
158
- if (!toolsUsed.some(t => t.name === 'get_music_videos')) {
159
- await addToolExec('get_music_videos', { query: 'MV' });
160
- }
161
- }
162
- }
163
-
164
- if (answerParts.length === 0) {
165
- const shouldPreserveExternalTopic = conversationContext?.contextual || conversationContext?.domain === 'fashion_event';
166
- if (shouldPreserveExternalTopic) {
167
- const topic = String(conversationContext?.anchor || query || 'หัวข้อก่อนหน้า').slice(0, 360);
168
- answerParts.push(`### อ้างอิงหัวข้อเดิม\n\n` +
169
- `คำถามนี้ต่อเนื่องจาก **${topic}**\n\n` +
170
- `ขณะนี้ระบบกำลังทำงานในโหมดฐานข้อมูลภายในและไม่มีข้อมูลสดที่เพียงพอสำหรับยืนยันรายละเอียดของหัวข้อนี้ จึงจะไม่เปลี่ยนไปตอบประวัติหรือผลงานของศิลปินแทน หากต้องการข้อสรุปที่แม่นยำ ควรตรวจสอบประกาศทางการล่าสุดของงานก่อนค่ะ`);
171
- } else {
172
- await addToolExec('get_profile_and_fandom', {});
173
- const worksRes = await addToolExec('search_works', { featuredOnly: true });
174
- let md = `### 🌸 ข้อมูลสรุป จวีจิ้งอี (Ju Jingyi / 鞠婧祎)\n\n`;
175
- md += `จวีจิ้งอี เป็นศิลปิน นักร้อง และนักแสดงหญิงชั้นนำของประเทศจีน เจ้าของฉายา "สาวงาม 4,000 ปี" และ Global Brand Ambassador ของ Versace\n\n`;
176
- md += `#### 🎬 ผลงานซีรีส์เด่น:\n`;
177
- (worksRes?.works || []).slice(0, 4).forEach(w => {
178
- md += `- **${w.titleTh || w.titleCn}** รับบทเป็น *${w.role || w.character}* (ปี ${w.year})\n`;
179
- });
180
- answerParts.push(md);
181
- }
182
- }
183
-
184
- const headerNotice = `> 💡 **โหมดฐานข้อมูลอัจฉริยะ (Direct Catalog Knowledge Mode)**\n` +
185
- `> *ระบบสืบค้นข้อมูลจาก Local Database ให้โดยตรงทันทีอย่างแม่นยำ (เปิด LM Studio เพื่อเปิดใช้งาน Generative AI)*\n\n`;
186
-
187
- const finalMarkdown = headerNotice + answerParts.join('\n\n');
188
- const suggestions = generateDynamicFollowUps({ query, answer: finalMarkdown, tools: toolsUsed });
189
-
190
- return {
191
- answer: finalMarkdown,
192
- thinking: 'ตรวจพบ LM Studio ออฟไลน์ -> เปิดใช้งาน Auto Direct Catalog Knowledge Engine...',
193
- tools: toolsUsed,
194
- suggestions
195
- };
196
- }
157
+ const rolesRes = await addToolExec('get_character_roles', {});
158
+ const roles = rolesRes?.roles || [];
159
+ if (roles.length > 0) {
160
+ let md = `### 🎭 โครงสร้างบทบาทและตัวละครเด่น (${roles.length} บทบาท)\n\n`;
161
+ roles.slice(0, 15).forEach(r => {
162
+ md += `- **${r.nameTh || r.nameCn}** (${r.nameCn || ''}) จากเรื่อง *${r.drama || ''}* — บุคลิก: ${r.personality || r.archetype || 'ตัวเอก'}\n`;
163
+ });
164
+ answerParts.push(md);
165
+ }
166
+ }
167
+
168
+ if (answerParts.length === 0) {
169
+ const shouldPreserveExternalTopic = conversationContext?.contextual || conversationContext?.domain === 'fashion_event';
170
+ if (shouldPreserveExternalTopic) {
171
+ const topic = String(conversationContext?.anchor || query || 'หัวข้อก่อนหน้า').slice(0, 360);
172
+ answerParts.push(`### อ้างอิงหัวข้อเดิม\n\n` +
173
+ `คำถามนี้ต่อเนื่องจาก **${topic}**\n\n` +
174
+ `ขณะนี้ระบบกำลังทำงานในโหมดฐานข้อมูลภายในและไม่มีข้อมูลสดที่เพียงพอสำหรับยืนยันรายละเอียดของหัวข้อนี้ จึงจะไม่เปลี่ยนไปตอบประวัติหรือผลงานของศิลปินแทน หากต้องการข้อสรุปที่แม่นยำ ควรตรวจสอบประกาศทางการล่าสุดของงานก่อนค่ะ`);
175
+ } else {
176
+ await addToolExec('get_profile_and_fandom', {});
177
+ const worksRes = await addToolExec('search_works', { featuredOnly: true });
178
+ const brandRes = await addToolExec('get_brand_endorsements', { isGlobal: true });
179
+ const topBrand = brandRes?.brands?.[0]?.brand || 'Global Brand Ambassador';
180
+ let md = `### 🌸 ข้อมูลสรุป จวีจิ้งอี (Ju Jingyi / 鞠婧祎)\n\n`;
181
+ md += `จวีจิ้งอี เป็นศิลปิน นักร้อง และนักแสดงหญิงชั้นนำของประเทศจีน เจ้าของฉายา "สาวงาม 4,000 ปี" และตัวแทนภาพลักษณ์แบรนด์ระดับสากล (${topBrand})\n\n`;
182
+ md += `#### 🎬 ผลงานซีรีส์เด่น:\n`;
183
+ (worksRes?.works || []).slice(0, 4).forEach(w => {
184
+ md += `- **${w.titleTh || w.titleCn}** รับบทเป็น *${w.role || w.character}* (ปี ${w.year})\n`;
185
+ });
186
+ answerParts.push(md);
187
+ }
188
+ }
189
+
190
+ const headerNotice = `> 💡 **โหมดฐานข้อมูลอัจฉริยะ (Direct Catalog Knowledge Mode)**\n` +
191
+ `> *ระบบสืบค้นข้อมูลจาก Local Database ให้โดยตรงทันทีอย่างแม่นยำ (เปิด LM Studio เพื่อเปิดใช้งาน Generative AI)*\n\n`;
192
+
193
+ const finalMarkdown = headerNotice + answerParts.join('\n\n');
194
+ const suggestions = generateDynamicFollowUps({ query, answer: finalMarkdown, tools: toolsUsed });
195
+
196
+ return {
197
+ answer: finalMarkdown,
198
+ thinking: 'ตรวจพบ LM Studio ออฟไลน์ -> เปิดใช้งาน Auto Direct Catalog Knowledge Engine...',
199
+ tools: toolsUsed,
200
+ suggestions
201
+ };
202
+ }
@@ -40,6 +40,40 @@ function extractSuggestionItems(raw) {
40
40
  return items.slice(0, 4);
41
41
  }
42
42
 
43
+ /** Collapse a suggestion to a comparable key: no emoji, punctuation, case or spacing noise.
44
+ * \p{M} keeps Thai vowels/tone marks intact (they are combining marks, not letters). */
45
+ export function normalizeSuggestionKey(s) {
46
+ return String(s || '')
47
+ .toLowerCase()
48
+ .replace(/[\p{Extended_Pictographic}\uFE0F]/gu, ' ')
49
+ .replace(/[^\p{L}\p{N}\p{M}]/gu, '')
50
+ .trim();
51
+ }
52
+
53
+ /** A suggestion is a duplicate when identical, or a long prefix of one already shown. */
54
+ export function isDuplicateSuggestionKey(key, seenKeys) {
55
+ if (!key || seenKeys.has(key)) return true;
56
+ for (const existing of seenKeys) {
57
+ if (key.length >= 18 && existing.length >= 18 && (key.startsWith(existing) || existing.startsWith(key))) {
58
+ return true;
59
+ }
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /** Drop suggestions whose key duplicates something in seenKeys (or earlier in the list). */
65
+ export function dedupeSuggestions(suggestions, seenKeys) {
66
+ const seen = seenKeys instanceof Set ? new Set(seenKeys) : new Set();
67
+ const out = [];
68
+ for (const item of Array.isArray(suggestions) ? suggestions : []) {
69
+ const key = normalizeSuggestionKey(item && (item.query || item.label));
70
+ if (isDuplicateSuggestionKey(key, seen)) continue;
71
+ seen.add(key);
72
+ out.push(item);
73
+ }
74
+ return out;
75
+ }
76
+
43
77
  export function extractAgentFollowUpQuestions(text) {
44
78
  if (!text || typeof text !== 'string') {
45
79
  return { cleanText: text || '', suggestions: [] };
@@ -109,10 +143,10 @@ export function extractAgentFollowUpQuestions(text) {
109
143
  }
110
144
  }
111
145
 
112
- return { cleanText, suggestions };
146
+ return { cleanText, suggestions: dedupeSuggestions(suggestions, new Set()) };
113
147
  }
114
148
 
115
- export function generateDynamicFollowUps({ query = '', answer = '', tools = [] } = {}) {
149
+ export function generateDynamicFollowUps({ query = '', answer = '', tools = [], recentKeys = null } = {}) {
116
150
  const combined = (query + ' ' + answer).toLowerCase();
117
151
  const suggestions = [];
118
152
 
@@ -141,9 +175,18 @@ export function generateDynamicFollowUps({ query = '', answer = '', tools = [] }
141
175
  { label: '🎵 เพลงคู่ ไม่ทุกข์ตรม (不苦)', query: 'เบื้องหลังเพลง 不苦 (Not Suffering) ร้องคู่กับ เถียนเจียรุ่ย' },
142
176
  { label: '📺 กำหนดการฉายและแพลตฟอร์ม', query: 'อัปเดตสถานะและแพลตฟอร์มการออกอากาศของ Veil of Shadows' }
143
177
  );
144
- } else if (combined.includes('versace') || combined.includes('แบรนด์') || combined.includes('แฟชั่น') || combined.includes('พรีเซนเตอร์')) {
178
+ } else if (combined.includes('prsr') || combined.includes('pasha') || combined.includes('帕莎')) {
145
179
  suggestions.push(
146
- { label: '💎 Versace Global Ambassador', query: 'เจาะลึกบทบาท จวีจิ้งอี กับตำแหน่ง Global Brand Ambassador ของ Versace' },
180
+ { label: '👓 PRSR Global Ambassador', query: 'เจาะลึกบทบาท Global Brand Ambassador ของ PRSR (Pasha) และลุคไวรัลใน Milan Fashion Week' },
181
+ { label: '📺 คลิปโปรโมท PRSR บน Bilibili', query: 'ค้นหาคลิปโปรโมทและเบื้องหลังแว่นตาแฟชั่น PRSR บน Bilibili' },
182
+ { label: '👗 แบรนด์พรีเซนเตอร์ทั้งหมด', query: 'สรุปแบรนด์ที่จวีจิ้งอีเป็นพรีเซนเตอร์และแอมบาสเดอร์ในปัจจุบัน' }
183
+ );
184
+ } else if (combined.includes('versace') || combined.includes('แบรนด์') || combined.includes('แฟชั่น') || combined.includes('พรีเซนเตอร์') || combined.includes('โปรโมท') || combined.includes('โปรโมต') || combined.includes('commercial')) {
185
+ const brandTool = tools.find(t => t.name === 'get_brand_endorsements');
186
+ const firstBrand = brandTool?.result?.brands?.[0];
187
+ const brandTitle = firstBrand?.brand || 'Versace';
188
+ suggestions.push(
189
+ { label: `💎 ${brandTitle}`, query: `เจาะลึกบทบาท จวีจิ้งอี กับตำแหน่ง ${firstBrand?.title || 'Brand Ambassador'} ของ ${brandTitle}` },
147
190
  { label: '💄 สไตล์เมคอัพ Ju Jingyi', query: 'วิเคราะห์อิทธิพลของสไตล์การแต่งหน้า Ju Jingyi Makeup ที่ฮิตไปทั่วโลก' },
148
191
  { label: '👗 แบรนด์พรีเซนเตอร์ทั้งหมด', query: 'สรุปแบรนด์ที่จวีจิ้งอีเป็นพรีเซนเตอร์และแอมบาสเดอร์ในปัจจุบัน' }
149
192
  );
@@ -154,7 +197,7 @@ export function generateDynamicFollowUps({ query = '', answer = '', tools = [] }
154
197
  { label: '💃 สเตจเต้น 红昭愿 ในตำนาน', query: 'สเตจการเต้นเพลง 红昭愿 (Hong Zhao Yuan) ชุดแดงที่เป็นตำนาน' }
155
198
  );
156
199
  } else {
157
-
200
+
158
201
  const workTool = tools.find(t => t.name === 'search_works' || t.name === 'get_work_detail');
159
202
  if (workTool && workTool.result?.works?.[0]) {
160
203
  const w = workTool.result.works[0];
@@ -165,17 +208,51 @@ export function generateDynamicFollowUps({ query = '', answer = '', tools = [] }
165
208
  { label: `🏆 กระแสตอบรับของ ${title}`, query: `กระแสตอบรับ เรตติ้ง และรางวัลของซีรีส์ ${title}` }
166
209
  );
167
210
  } else {
168
- suggestions.push(
169
- { label: '🎬 แนะนำซีรีส์ยอดนิยมที่ไม่ควรพลาด', query: 'แนะนำซีรีส์ยอดนิยมและบทบาทมาสเตอร์พีซของจวีจิ้งอี' },
170
- { label: '🎵 เพลงฮิตและสเตจแสดงสดเด่นๆ', query: 'รวมบทเพลงฮิตและสเตจการแสดงสดที่เป็นไวรัลของจวีจิ้งอี' },
171
- { label: '💎 ก้าวสู่ศิลปินเดี่ยวและรางวัล', query: 'เส้นทางการเป็นศิลปินเดี่ยว การก่อตั้งสตูดิโอ และรางวัลสำคัญ' }
172
- );
211
+ suggestions.push(...GENERIC_FOLLOW_UP_POOL.slice(0, 3));
173
212
  }
174
213
  }
175
214
 
176
- return suggestions.slice(0, 3);
215
+ const seen = recentKeys instanceof Set ? recentKeys : null;
216
+ if (!seen) return suggestions.slice(0, 3);
217
+
218
+ // Cross-turn freshness: drop questions already shown recently in this session.
219
+ const fresh = dedupeSuggestions(suggestions, seen);
220
+ if (fresh.length >= 2) return fresh.slice(0, 3);
221
+
222
+ // The topic bucket itself was all recently shown — rotate through the generic
223
+ // pool (cursor advances every call) so each turn proposes NEW questions.
224
+ const rotated = [
225
+ ...GENERIC_FOLLOW_UP_POOL.slice(followUpPoolCursor),
226
+ ...GENERIC_FOLLOW_UP_POOL.slice(0, followUpPoolCursor),
227
+ ];
228
+ followUpPoolCursor = (followUpPoolCursor + 1) % GENERIC_FOLLOW_UP_POOL.length;
229
+ const freshGeneric = dedupeSuggestions(rotated, seen);
230
+ const out = [...fresh, ...freshGeneric].slice(0, 3);
231
+ if (out.length >= 2) return out;
232
+
233
+ // Pool fully consumed for this session — better to recycle the least-recent
234
+ // questions than to return nothing.
235
+ return dedupeSuggestions(rotated, new Set()).slice(0, 3);
177
236
  }
178
237
 
238
+ const GENERIC_FOLLOW_UP_POOL = Object.freeze([
239
+ { label: '🎬 แนะนำซีรีส์ยอดนิยมที่ไม่ควรพลาด', query: 'แนะนำซีรีส์ยอดนิยมและบทบาทมาสเตอร์พีซของจวีจิ้งอี' },
240
+ { label: '🎵 เพลงฮิตและสเตจแสดงสดเด่นๆ', query: 'รวมบทเพลงฮิตและสเตจการแสดงสดที่เป็นไวรัลของจวีจิ้งอี' },
241
+ { label: '💎 ก้าวสู่ศิลปินเดี่ยวและรางวัล', query: 'เส้นทางการเป็นศิลปินเดี่ยว การก่อตั้งสตูดิโอ และรางวัลสำคัญ' },
242
+ { label: '🏆 รางวัลและผลการประกวด', query: 'รางวัลและผลการประกวดสำคัญของจวีจิ้งอีตั้งแต่ยุค SNH48' },
243
+ { label: '📅 ไทม์ไลน์อาชีพ 2013-2026', query: 'ไทม์ไลน์อาชีพของจวีจิ้งอีตั้งแต่เดบิวต์ SNH48 จนถึงยุคศิลปินเดี่ยว' },
244
+ { label: '🎤 สเตจไวรัลที่ต้องดู', query: 'สเตจการแสดงสดของจวีจิ้งอีที่กลายเป็นไวรัลมากที่สุด' },
245
+ { label: '📸 แฟชั่นและลุคเด่น', query: 'ลุคแฟชั่นและงานพรีเซนเตอร์แบรนด์ที่โดดเด่นของจวีจิ้งอี' },
246
+ { label: '📺 ผลงานซีรีส์ล่าสุด', query: 'ผลงานซีรีส์ล่าสุดและโปรเจกต์ที่กำลังจะมาของจวีจิ้งอี' },
247
+ { label: '🎬 ฉากไวรัลในซีรีส์', query: 'ฉากที่ไวรัลที่สุดในซีรีส์ที่จวีจิ้งอีแสดง' },
248
+ { label: '🌟 การแสดงที่ได้รับคำชม', query: 'การแสดงของจวีจิ้งอีที่ได้รับคำชมจากนักวิจารณ์และผู้ชม' },
249
+ { label: '🎹 บทเพลงที่ขับร้องเอง', query: 'รวมบทเพลงที่จวีจิ้งอีขับร้องเองทั้งหมด' },
250
+ { label: '🌏 แฟนคลับสากล', query: 'แฟนคลับสากลและชื่อเสียงระดับโลกของจวีจิ้งอี' },
251
+ { label: '🧧 อีเวนต์และกำหนดการ', query: 'กำหนดการงานเทศกาลและอีเวนต์ของจวีจิ้งอี' },
252
+ { label: '🌺 ยุค SNH48 ก่อนเดี่ยว', query: 'ช่วงเวลาสำคัญของจวีจิ้งอีในยุค SNH48 ก่อนออกมาเดี่ยว' },
253
+ ]);
254
+ let followUpPoolCursor = 0;
255
+
179
256
  export default {
180
257
  extractAgentFollowUpQuestions,
181
258
  generateDynamicFollowUps,
@@ -10,7 +10,7 @@ export function resolveAllowedOrigin(requestOrigin) {
10
10
  try {
11
11
  const parsed = new URL(requestOrigin);
12
12
  const isDevelopment = process.env.NODE_ENV !== 'production';
13
- if (isDevelopment && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1')) {
13
+ if (isDevelopment && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') && (parsed.protocol === 'http:' || parsed.protocol === 'https:')) {
14
14
  return requestOrigin;
15
15
  }
16
16
  } catch { }
@@ -41,12 +41,12 @@ export function applySecurityHeaders(req, res) {
41
41
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, Accept');
42
42
  res.setHeader('X-Content-Type-Options', 'nosniff');
43
43
  res.setHeader('X-Frame-Options', 'SAMEORIGIN');
44
- res.setHeader('Referrer-Policy', 'no-referrer');
44
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
45
45
  res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
46
- res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
46
+ res.setHeader('Permissions-Policy', 'camera=(), microphone=(self), geolocation=(), payment=(), usb=()');
47
47
  res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
48
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:;");
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 https://player.bilibili.com https://www.bilibili.com; connect-src 'self' http://localhost:* http://127.0.0.1:* https:;");
50
50
  }
51
51
  return !origin || Boolean(allowedOrigin);
52
52
  }
@@ -80,6 +80,9 @@ function constantTimeEqual(left, right) {
80
80
  }
81
81
 
82
82
  export function isWebChatAdmin(req) {
83
+ // Explicit opt-in only: never infer test mode from NODE_ENV/--test flags,
84
+ // otherwise a stray NODE_ENV=test in production would disable admin auth.
85
+ if (process.env.WEBCHAT_ALLOW_TEST_ADMIN === 'true' && process.env.NODE_ENV !== 'production') return true;
83
86
  const expected = process.env.WEBCHAT_ADMIN_KEY || '';
84
87
  if (!expected) return false;
85
88
  return constantTimeEqual(extractApiKey(req), expected);