kiku-agent 1.3.2 → 1.3.3

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,5 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
+ import { readFileSync } from 'node:fs';
3
4
  import { getWebChatHtml } from '../../webChatHtml.js';
4
5
  import { executeDirectCatalogKnowledge, makeInspectableToolPayload } from '../../webChatServer.js';
5
6
 
@@ -12,9 +13,9 @@ test('web chat tool payloads redact secrets and bound large values', () => {
12
13
  }, { maxArrayItems: 2 });
13
14
 
14
15
  assert.equal(payload.query, 'In Blossom');
15
- assert.equal(payload.apiKey, '[redacted]');
16
- assert.equal(payload.nested.token, '[redacted]');
17
- assert.deepEqual(payload.values, [0, 1, '… [ตัดอีก 3 รายการ]']);
16
+ assert.equal(payload.apiKey, '···');
17
+ assert.equal(payload.nested.token, '···');
18
+ assert.deepEqual(payload.values, [0, 1, '… ย่ออีก 3 รายการ']);
18
19
  });
19
20
 
20
21
  test('direct catalog fallback retains inspectable tool input and result', async () => {
@@ -27,6 +28,169 @@ test('direct catalog fallback retains inspectable tool input and result', async
27
28
  assert.ok(Number.isFinite(tool.durationMs));
28
29
  });
29
30
 
31
+ test('agent bare URLs render as clickable _blank links without double-wrapping', () => {
32
+ const src = readFileSync(new URL('../../web-chat/ui/scripts/client.js', import.meta.url), 'utf8');
33
+ const start = src.indexOf('function linkifyBareUrls(segment)');
34
+ assert.ok(start >= 0);
35
+ let depth = 0;
36
+ let end = -1;
37
+ for (let i = src.indexOf('{', start); i < src.length; i++) {
38
+ if (src[i] === '{') depth++;
39
+ else if (src[i] === '}') {
40
+ depth--;
41
+ if (depth === 0) {
42
+ end = i + 1;
43
+ break;
44
+ }
45
+ }
46
+ }
47
+ assert.ok(end > start);
48
+ const escapeHtml = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
49
+ const linkifyBareUrls = new Function('escapeHtml', src.slice(start, end) + '; return linkifyBareUrls;')(escapeHtml);
50
+
51
+ const linked = linkifyBareUrls('ชมได้ที่ https://www.youtube.com/watch?v=OMLpNyp8T7Q จบ.');
52
+ assert.match(linked, /<a href="https:\/\/www\.youtube\.com\/watch\?v=OMLpNyp8T7Q" target="_blank" rel="noopener noreferrer" class="md-link">/);
53
+
54
+ const intact = linkifyBareUrls('<a href="https://a.test">https://a.test</a>');
55
+ assert.equal((intact.match(/<a /g) || []).length, 1);
56
+
57
+ assert.equal(linkifyBareUrls('no link here'), 'no link here');
58
+ });
59
+
60
+ test('web chat loads KaTeX assets for math typesetting', () => {
61
+ const html = getWebChatHtml();
62
+ assert.match(html, /katex@0\.16\.11\/dist\/katex\.min\.css/);
63
+ assert.match(html, /katex@0\.16\.11\/dist\/katex\.min\.js/);
64
+ assert.match(html, /contrib\/auto-render\.min\.js/);
65
+ assert.match(html, /function typesetMathInBubble\(/);
66
+ assert.match(html, /renderMathInElement/);
67
+ assert.match(html, /KIKU_CLIENT_BUILD/);
68
+ });
69
+
70
+ test('math segments are shielded from markdown and restored as KaTeX targets', () => {
71
+ const src = readFileSync(new URL('../../web-chat/ui/scripts/client.js', import.meta.url), 'utf8');
72
+ const loadFn = (name) => {
73
+ const start = src.indexOf('function ' + name + '(');
74
+ assert.ok(start >= 0, name + ' must exist');
75
+ let depth = 0;
76
+ let end = -1;
77
+ for (let i = src.indexOf('{', start); i < src.length; i++) {
78
+ if (src[i] === '{') depth++;
79
+ else if (src[i] === '}') {
80
+ depth--;
81
+ if (depth === 0) {
82
+ end = i + 1;
83
+ break;
84
+ }
85
+ }
86
+ }
87
+ assert.ok(end > start);
88
+ return new Function(src.slice(start, end) + '; return ' + name + ';')();
89
+ };
90
+ const extractMathSegments = loadFn('extractMathSegments');
91
+ const restoreMathSegments = loadFn('restoreMathSegments');
92
+
93
+ const store = [];
94
+ const shielded = extractMathSegments('โจทย์ $\\lim_{x \\to 0} a * b$ และ $$x^2$$ จบ ส่วน `$y$` ในโค้ดไม่แตะ', store);
95
+ assert.equal(store.length, 2);
96
+ assert.ok(shielded.includes('§§§MATH0§§§'));
97
+ assert.ok(shielded.includes('§§§MATH1§§§'));
98
+ assert.ok(shielded.includes('`$y$`'), 'math inside inline code must stay untouched');
99
+ assert.ok(!shielded.includes('\\lim'), 'raw math must not leak into markdown processing');
100
+
101
+ const restored = restoreMathSegments(shielded, store);
102
+ assert.match(restored, /<span class="math-inline">\$\\lim/);
103
+ assert.match(restored, /<div class="math-display">\$\$x\^2\$\$<\/div>/);
104
+
105
+ assert.equal(extractMathSegments('ราคา $100 บาท', []), 'ราคา $100 บาท');
106
+ assert.equal(extractMathSegments('no math here', []).includes('§§§MATH'), false);
107
+ });
108
+
109
+ test('latexToUnicode fallback covers calculus symbols without KaTeX', () => {
110
+ const src = readFileSync(new URL('../../web-chat/ui/scripts/client.js', import.meta.url), 'utf8');
111
+ const loadBlock = (prefix) => {
112
+ const start = src.indexOf(prefix);
113
+ assert.ok(start >= 0, prefix + ' must exist');
114
+ let depth = 0;
115
+ let end = -1;
116
+ for (let i = src.indexOf('{', start); i < src.length; i++) {
117
+ if (src[i] === '{') depth++;
118
+ else if (src[i] === '}') {
119
+ depth--;
120
+ if (depth === 0) {
121
+ end = i + 1;
122
+ break;
123
+ }
124
+ }
125
+ }
126
+ assert.ok(end > start);
127
+ return src.slice(start, end);
128
+ };
129
+ const sandbox = loadBlock('const LATEX_SYMBOL_MAP =') + '\n' +
130
+ loadBlock('const LATEX_SUP_MAP =') + '\n' +
131
+ loadBlock('const LATEX_SUB_MAP =') + '\n' +
132
+ src.slice(src.indexOf('function latexReadBrace('), src.indexOf('function latexMapChars(')) + '\n' +
133
+ src.slice(src.indexOf('function latexMapChars('), src.indexOf('function latexToUnicode(')) + '\n' +
134
+ src.slice(src.indexOf('function latexToUnicode('), src.indexOf('window.latexToUnicode')) +
135
+ '\nreturn latexToUnicode;';
136
+ const latexToUnicode = new Function(sandbox)();
137
+ assert.equal(latexToUnicode('\\frac{1}{2}'), '(1)/(2)');
138
+ assert.equal(latexToUnicode('L = \\frac{1}{\\sqrt{e}}'), 'L = (1)/(√(e))');
139
+ assert.equal(latexToUnicode('1^\\infty'), '1^∞');
140
+ assert.equal(latexToUnicode('\\lim_{x \\to 0} \\cos x'), 'lim_(x → 0) cos x');
141
+ assert.equal(latexToUnicode('x_0 + x_{10}'), 'x₀ + x₁₀');
142
+ assert.match(readFileSync(new URL('../../web-chat/ui/scripts/client.js', import.meta.url), 'utf8'), /fallbackTypesetMath\(bubble\)/);
143
+ });
144
+
145
+ test('latexToUnicode fallback covers advanced math: matrices, accents, aliases', () => {
146
+ const src = readFileSync(new URL('../../web-chat/ui/scripts/client.js', import.meta.url), 'utf8');
147
+ const sandbox = src.slice(src.indexOf('const LATEX_SYMBOL_MAP ='), src.indexOf('window.latexToUnicode')) +
148
+ '\nreturn latexToUnicode;';
149
+ const latexToUnicode = new Function(sandbox)();
150
+ assert.equal(latexToUnicode('\\begin{matrix}a&b\\\\c&d\\end{matrix}'), 'a b c d');
151
+ assert.equal(latexToUnicode('\\binom{n}{k}'), '(n choose k)');
152
+ assert.equal(latexToUnicode('\\hat{\\theta}+\\vec{v}+\\bar{z}+\\dot{x}'), 'θ+v+z+x');
153
+ assert.equal(latexToUnicode('x\\le 0, y\\ge 1, a\\ne b'), 'x≤ 0, y≥ 1, a≠ b');
154
+ assert.equal(latexToUnicode('\\tag{1} x=1'), '(1) x=1');
155
+ assert.equal(latexToUnicode('\\sqrt{\\frac{1}{2}}'), '√((1)/(2))');
156
+ assert.equal(latexToUnicode('\\dfrac{a}{b}'), '(a)/(b)');
157
+ assert.equal(latexToUnicode('\\boxed{42} + \\cancel{x}'), '[42] + x');
158
+ assert.equal(latexToUnicode('\\text{อนันต์} + \\mathrm{d}x'), 'อนันต์ + dx');
159
+ assert.equal(latexToUnicode('x \\in \\mathbb{R}^n'), 'x ∈ ℝⁿ');
160
+ const noRaw = (s) => !/[\\][a-zA-Z]+/.test(s) && !/[{}]/.test(s);
161
+ for (const tex of [
162
+ '\\sum_{n=1}^{\\infty} n + \\prod_{i=1}^n i + \\int_0^1 x\\,dx',
163
+ '\\forall x \\exists y: x \\subset y \\cup z \\approx w',
164
+ '\\alpha\\beta\\gamma\\Delta\\Omega + \\clubsuit\\sharp\\flat',
165
+ '\\left( \\frac{a}{b} \\right) + \\big| x \\big| + A \\overset{!}{=} B',
166
+ ]) {
167
+ assert.ok(noRaw(latexToUnicode(tex)), 'raw latex left in: ' + latexToUnicode(tex));
168
+ }
169
+ });
170
+
171
+ test('math extractor shields bare display environments and auto-render knows them', () => {
172
+ const src = readFileSync(new URL('../../web-chat/ui/scripts/client.js', import.meta.url), 'utf8');
173
+ const loadFn = (name) => {
174
+ const start = src.indexOf('function ' + name + '(');
175
+ assert.ok(start >= 0, name + ' must exist');
176
+ const endMarker = name === 'extractMathSegments' ? 'function restoreMathSegments(' : 'const LATEX_SYMBOL_MAP =';
177
+ return new Function(src.slice(start, src.indexOf(endMarker)) + '; return ' + name + ';')();
178
+ };
179
+ const extractMathSegments = loadFn('extractMathSegments');
180
+ const restoreMathSegments = loadFn('restoreMathSegments');
181
+ const store = [];
182
+ const shielded = extractMathSegments('ผลคือ\n\\begin{align}x &= 1 \\\\ y &= 2\\end{align}\nจบ', store);
183
+ assert.equal(store.length, 1);
184
+ assert.equal(store[0].display, true);
185
+ assert.ok(shielded.includes('§§§MATH0§§§'));
186
+ const restored = restoreMathSegments(shielded, store);
187
+ assert.match(restored, /<div class="math-display">\\begin\{align\}/);
188
+ const html = getWebChatHtml();
189
+ for (const env of ['equation', 'align', 'gather', 'multline']) {
190
+ assert.ok(html.includes('\\\\begin{' + env + '}'), 'auto-render must know ' + env);
191
+ }
192
+ });
193
+
30
194
  test('web chat HTML isolates answer text and renders expandable tool details', () => {
31
195
  const html = getWebChatHtml();
32
196
 
@@ -106,6 +270,8 @@ test('renderFollowUpSuggestions creates intelligent contextual follow-up topic c
106
270
  const html = getWebChatHtml();
107
271
  assert.match(html, /function renderFollowUpSuggestions\(botGroup, answerText, userQuery, serverSuggestions\)/);
108
272
  assert.match(html, /follow-up-suggestions/);
273
+ assert.match(html, /typesetMathInBubble\(chip\)/, 'renderFollowUpSuggestions must typeset math in chips');
274
+ assert.match(html, /detectSpecializedOpenDomainFollowUps/, 'client must have specialized open-domain follow-up detector');
109
275
  assert.match(html, /💎 บทบาท Versace/);
110
276
  assert.match(html, /👗 แบรนด์อื่นๆ ทั้งหมด/);
111
277
  assert.match(html, /🌸 ตัวตนกับรูปลักษณ์/);
@@ -230,11 +396,19 @@ test('user bubble renders dedicated renderUserContent with high contrast white t
230
396
  assert.match(html, /color:\s*#ffffff\s*!important/);
231
397
  });
232
398
 
399
+ test('user bubble renders math formulas with typesetMathInBubble and dedicated KaTeX styles', () => {
400
+ const html = getWebChatHtml();
401
+ assert.match(html, /renderUserContent\(bubble,\s*text,\s*details\s*=\s*\{\}\)\s*\{[\s\S]*?typesetMathInBubble\(bubble\)/);
402
+ assert.match(html, /\.user-content-body\s*\.katex\s*,\s*\.bubble-user\s*\.katex/);
403
+ assert.match(html, /\.bubble-user\s*\.math-inline/);
404
+ assert.match(html, /\.bubble-user\s*\.math-display/);
405
+ });
406
+
233
407
  test('clarifying questions and interactive question cards are stably pinned without stream flickering', () => {
234
408
  const html = getWebChatHtml();
235
409
  assert.match(html, /\.clarify-slot/);
236
410
  assert.match(html, /clarifySlot\.className = 'clarify-slot'/);
237
- assert.match(html, /msgGroup\.insertBefore\(clarifySlot,\s*botBubble\)/);
411
+ assert.match(html, /msgGroup\.insertBefore\(clarifySlot,\s*botBubble(?:\.nextSibling)?\)/);
238
412
  assert.match(html, /dismissAgentInteractiveQuestion/);
239
413
  assert.match(html, /dismissedAgentQuestions/);
240
414
  assert.match(html, /!details\.isStreaming/);
@@ -253,7 +427,7 @@ test('subagent progress renders a dedicated live card with evidence report', ()
253
427
  assert.match(html, /subagent-report/);
254
428
  assert.match(html, /\.subagent-card\s*\{/);
255
429
  assert.match(html, /subagent-pulse/);
256
- assert.match(html, /run_deep_research: '🔍/);
430
+ assert.match(html, /run_deep_research: '▸/);
257
431
  });
258
432
 
259
433
  test('error handling renders dedicated retry error card and interactive retry badge', () => {
@@ -298,3 +472,80 @@ test('input box enhancements: slash commands menu, model chip, relocated attachm
298
472
  assert.match(html, /updateSendButtonState/);
299
473
  assert.match(html, /drag-over/);
300
474
  });
475
+
476
+ test('file upload system: attachments are validated, sent to /api/chat, and rendered with lightbox preview', () => {
477
+ const html = getWebChatHtml();
478
+ assert.match(html, /function safeRasterDataUrl\(/);
479
+ assert.match(html, /function addAttachmentFile\(/);
480
+ assert.match(html, /function renderAttachmentTray\(/);
481
+ assert.match(html, /function handleImageUpload\(/);
482
+ assert.match(html, /chat-attached-images-wrap/);
483
+ assert.match(html, /chat-attached-image/);
484
+ assert.match(html, /openImageLightbox/);
485
+ assert.match(html, /attachments:\s*attachmentsToSend/);
486
+ assert.match(html, /svg\+xml/);
487
+ assert.match(html, /avif/);
488
+ });
489
+
490
+ test('welcome hero gallery image picker and drag-and-drop system exist and are wired', () => {
491
+ const html = getWebChatHtml();
492
+ assert.match(html, /id="gallery-picker-modal"/);
493
+ assert.match(html, /id="gallery-picker-grid"/);
494
+ assert.match(html, /id="gallery-picker-search"/);
495
+ assert.match(html, /data-hero-slot="primary"/);
496
+ assert.match(html, /data-hero-slot="secondary"/);
497
+ assert.match(html, /data-hero-slot="poster"/);
498
+ assert.match(html, /function openHeroGalleryPicker\(/);
499
+ assert.match(html, /function closeHeroGalleryPicker\(/);
500
+ assert.match(html, /function filterGalleryPickerCategory\(/);
501
+ assert.match(html, /function handleGallerySearchInput\(/);
502
+ assert.match(html, /function applyHeroImage\(/);
503
+ assert.match(html, /function resetHeroImages\(/);
504
+ assert.match(html, /function scoreGalleryItem\(/);
505
+ assert.match(html, /function initWelcomeHeroGallery\(/);
506
+ assert.match(html, /kiku_hero_images/);
507
+ assert.match(html, /application\/x-kiku-hero-img/);
508
+ assert.match(html, /\.gallery-picker-dialog/);
509
+ assert.match(html, /\.gallery-picker-grid/);
510
+ assert.match(html, /\.hero-media-frame \.hero-slot-action/);
511
+ });
512
+
513
+ test('all gallery images exist on disk and staticRoute serves them without 404', async () => {
514
+ const { handleApiRequest } = await import('../../apiRouter.js');
515
+ const { handleStaticRoute } = await import('../../web-chat/routes/staticRoute.js');
516
+ const stream = await import('node:stream');
517
+
518
+ let body = '';
519
+ const req = {
520
+ method: 'GET',
521
+ url: '/api/v1/gallery?scope=all&limit=200',
522
+ headers: { host: 'localhost:3000' }
523
+ };
524
+ const res = {
525
+ writeHead: () => {},
526
+ end: (chunk) => { if (chunk) body += chunk; },
527
+ setHeader: () => {}
528
+ };
529
+ await handleApiRequest(req, res);
530
+ const json = JSON.parse(body);
531
+ assert.ok(Array.isArray(json.data) && json.data.length >= 70);
532
+
533
+ for (const item of json.data) {
534
+ const encodedUrl = encodeURI(item.image);
535
+ const mockReq = { url: encodedUrl };
536
+ let handledCode = 0;
537
+ const mockRes = new stream.Writable({
538
+ write(chunk, encoding, callback) { callback(); }
539
+ });
540
+ mockRes.writeHead = (code) => { handledCode = code; };
541
+ const u = new URL(mockReq.url, 'http://localhost:3002');
542
+ const handled = handleStaticRoute(mockReq, mockRes, u.pathname, 3002);
543
+ assert.strictEqual(handled, true, `Route must handle image ${item.image}`);
544
+ assert.strictEqual(handledCode, 200, `Image must return 200 OK for ${item.image}`);
545
+ }
546
+
547
+ const html = getWebChatHtml();
548
+ assert.match(html, /onerror="this\.onerror=null;const it=this\.closest/);
549
+ });
550
+
551
+
@@ -0,0 +1,94 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import http from 'node:http';
4
+ import { handleWebChatRequest } from '../../web-chat/router.js';
5
+
6
+ test('Web Chat MCP: GET /mcp returns protocol discovery JSON', async () => {
7
+ const server = http.createServer(async (req, res) => {
8
+ await handleWebChatRequest(req, res, { port: 3002 });
9
+ });
10
+
11
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
12
+ const port = server.address().port;
13
+
14
+ try {
15
+ const res = await fetch(`http://127.0.0.1:${port}/mcp`);
16
+ assert.equal(res.status, 200);
17
+ const data = await res.json();
18
+ assert.equal(data.protocol, 'mcp');
19
+ assert.equal(data.name, 'kiku');
20
+ assert.ok(data.capabilities?.toolsCount > 0);
21
+ } finally {
22
+ server.close();
23
+ }
24
+ });
25
+
26
+ test('Web Chat MCP: GET /api/mcp/status returns tools ecosystem and configs', async () => {
27
+ const server = http.createServer(async (req, res) => {
28
+ await handleWebChatRequest(req, res, { port: 3002 });
29
+ });
30
+
31
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
32
+ const port = server.address().port;
33
+
34
+ try {
35
+ const res = await fetch(`http://127.0.0.1:${port}/api/mcp/status`);
36
+ assert.equal(res.status, 200);
37
+ const data = await res.json();
38
+ assert.equal(data.status, 'ok');
39
+ assert.equal(data.mcpServer?.protocolVersion, '2025-11-25');
40
+ assert.ok(data.mcpServer?.supportedProtocolVersions?.includes('2024-11-05'));
41
+ assert.ok(data.mcpServer?.toolsCount >= 60);
42
+ assert.ok(data.totalTools >= 68);
43
+ assert.ok(data.categories?.ALL >= 68);
44
+ assert.ok(data.categories?.CATALOG > 0);
45
+ assert.ok(data.categories?.RESEARCH > 0);
46
+ assert.ok(Array.isArray(data.tools));
47
+ assert.ok(data.tools.length >= 68);
48
+ assert.ok(data.mcpServer?.clientConfigs?.claudeDesktop);
49
+ assert.ok(data.mcpServer?.clientConfigs?.cursor);
50
+ assert.ok(data.mcpServer?.clientConfigs?.windsurf);
51
+ assert.ok(data.mcpServer?.clientConfigs?.vscode);
52
+ assert.ok(data.mcpServer?.clientConfigs?.antigravity);
53
+ assert.ok(data.mcpServer?.clientConfigs?.cline);
54
+ assert.ok(data.mcpServer?.clientConfigs?.claudeCode);
55
+ assert.ok(data.mcpServer?.clientConfigs?.geminiCli);
56
+ assert.ok(data.mcpServer?.clientConfigs?.librechat);
57
+ assert.ok(data.mcpServer?.clientConfigs?.opencode);
58
+
59
+
60
+ assert.ok(data.mcpServer.clientConfigs.antigravity.http?.mcpServers?.kiku?.url);
61
+ assert.ok(data.mcpServer.clientConfigs.antigravity.stdio?.mcpServers?.kiku?.command);
62
+ assert.ok(data.mcpServer.clientConfigs.vscode.http?.servers?.kiku?.url);
63
+ assert.ok(data.mcpServer.clientConfigs.librechat.httpYaml?.includes('streamable-http'));
64
+ assert.ok(data.mcpServer.clientConfigs.opencode.http?.$schema);
65
+
66
+ const profileTool = data.tools.find(t => t.name === 'get_profile_and_fandom');
67
+ assert.ok(profileTool);
68
+ assert.equal(profileTool.category, 'CATALOG');
69
+ assert.equal(profileTool.isMcpExposed, true);
70
+ } finally {
71
+ server.close();
72
+ }
73
+ });
74
+
75
+ test('Web Chat MCP: GET /api/status includes mcp server capability', async () => {
76
+ const server = http.createServer(async (req, res) => {
77
+ await handleWebChatRequest(req, res, { port: 3002 });
78
+ });
79
+
80
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
81
+ const port = server.address().port;
82
+
83
+ try {
84
+ const res = await fetch(`http://127.0.0.1:${port}/api/status?light=1`);
85
+ assert.equal(res.status, 200);
86
+ const data = await res.json();
87
+ assert.equal(data.status, 'ok');
88
+ assert.ok(data.mcp);
89
+ assert.equal(data.mcp.endpoint, '/mcp');
90
+ assert.equal(data.mcp.online, true);
91
+ } finally {
92
+ server.close();
93
+ }
94
+ });
@@ -16,6 +16,7 @@ import {
16
16
  tokenizeForMatch,
17
17
  truncateByWords,
18
18
  } from '../websearch/extract.js';
19
+ import { buildPageResult } from '../webSearch.js';
19
20
 
20
21
  const ARTICLE_FIXTURE = `<!DOCTYPE html>
21
22
  <html lang="th">
@@ -160,6 +161,28 @@ test('extractMainContent flags thin single-page-app shells', async () => {
160
161
  assert.ok(result.quality.htmlChars > 15_000);
161
162
  });
162
163
 
164
+ test('buildPageResult reports login-walled/JS-shell pages as actionable errors, not hollow success', () => {
165
+ const empty = buildPageResult(
166
+ { markdown: ' ', meta: {}, engine: 'cheerio-readability', quality: { isThin: true, textChars: 0 } },
167
+ { finalUrl: 'https://weibo.com/u/3669102477', maxLength: 8000, query: '', cached: false }
168
+ );
169
+ assert.equal(empty.status, 'error');
170
+ assert.equal(empty.contentLength, 0);
171
+ assert.equal(empty.extraction.mode, 'no_content');
172
+ assert.match(empty.message, /m\.weibo\.cn/);
173
+ assert.match(empty.message, /search_chinese_web/);
174
+ assert.match(empty.message, /search_news/);
175
+ assert.match(empty.message, /Do NOT present this as the final answer/);
176
+ assert.ok(!('content' in empty) || empty.content === undefined, 'no dead-end sentence for the model to parrot');
177
+
178
+ const ok = buildPageResult(
179
+ { markdown: 'Hello world', meta: {}, engine: 'cheerio-readability', quality: { isThin: false } },
180
+ { finalUrl: 'https://example.com/a', maxLength: 8000, query: '', cached: false }
181
+ );
182
+ assert.equal(ok.status, 'success');
183
+ assert.equal(ok.content, 'Hello world');
184
+ });
185
+
163
186
  test('splitIntoPassages bounds sections by headings and size', () => {
164
187
  const markdown = [
165
188
  '## Section one',
@@ -8,6 +8,10 @@ import {
8
8
  detectLanguage,
9
9
  checkUrlLiveness,
10
10
  decodeBaiduUrl,
11
+ buildImageSearchQuery,
12
+ titleMentionsArtist,
13
+ isLowValueImageUrl,
14
+ imageHostKey,
11
15
  } from '../webSearch.js';
12
16
  import { executeTool, prepareToolArguments, toolHandlers, toolsSchema } from '../tools.js';
13
17
  import { dynamicToolRouter as toolRouter } from '../toolRouter.js';
@@ -151,24 +155,27 @@ test('search_encyclopedia requires a term and rejects unknown languages', async
151
155
  assert.match(badLang.message || JSON.stringify(badLang), /language/i);
152
156
  });
153
157
 
154
- test('chinese web tools are gated behind KIKU_ENABLE_CN_WEB_TOOLS', async () => {
158
+ test('chinese web tools are enabled by default and KIKU_ENABLE_CN_WEB_TOOLS=false gates them', async () => {
155
159
  const previous = process.env.KIKU_ENABLE_CN_WEB_TOOLS;
156
160
  try {
157
- delete process.env.KIKU_ENABLE_CN_WEB_TOOLS;
158
- for (const [handler, args] of [
159
- [toolHandlers.search_chinese_web, { query: '鞠婧祎' }],
160
- [toolHandlers.search_bilibili, { keyword: '鞠婧祎' }],
161
- [toolHandlers.search_web_images, { query: '鞠婧祎' }],
162
- ]) {
163
- const gated = await handler(args);
164
- assert.equal(gated.status, 'error');
165
- assert.equal(gated.gated, true);
166
- assert.match(gated.message, /KIKU_ENABLE_CN_WEB_TOOLS/);
161
+ for (const disableValue of ['false', '0']) {
162
+ process.env.KIKU_ENABLE_CN_WEB_TOOLS = disableValue;
163
+ for (const [handler, args] of [
164
+ [toolHandlers.search_chinese_web, { query: '鞠婧祎' }],
165
+ [toolHandlers.search_bilibili, { keyword: '鞠婧祎' }],
166
+ [toolHandlers.search_web_images, { query: '鞠婧祎' }],
167
+ ]) {
168
+ const gated = await handler(args);
169
+ assert.equal(gated.status, 'error');
170
+ assert.equal(gated.gated, true);
171
+ assert.match(gated.message, /KIKU_ENABLE_CN_WEB_TOOLS/);
172
+ }
167
173
  }
168
174
 
169
- process.env.KIKU_ENABLE_CN_WEB_TOOLS = 'true';
175
+ delete process.env.KIKU_ENABLE_CN_WEB_TOOLS;
170
176
  const missingQuery = await toolHandlers.search_chinese_web({});
171
177
  assert.equal(missingQuery.status, 'error');
178
+ assert.equal(missingQuery.gated, undefined);
172
179
  assert.match(missingQuery.message, /query parameter is required/);
173
180
  const missingKeyword = await toolHandlers.search_bilibili({});
174
181
  assert.equal(missingKeyword.status, 'error');
@@ -245,3 +252,30 @@ test('decodeBaiduUrl decodes Baidu encrypted objURL and fromURL into valid http
245
252
  });
246
253
 
247
254
 
255
+
256
+ test('buildImageSearchQuery always targets the artist for image searches', () => {
257
+ assert.match(buildImageSearchQuery('งานแฟนมีตล่าสุด'), /鞠婧祎/);
258
+ assert.match(buildImageSearchQuery('Ju Jingyi red carpet'), /鞠婧祎/);
259
+ const direct = buildImageSearchQuery('鞠婧祎 代言');
260
+ assert.match(direct, /鞠婧祎 代言/);
261
+ });
262
+
263
+ test('titleMentionsArtist separates artist results from keyword collisions', () => {
264
+ assert.equal(titleMentionsArtist('觅橘品牌代言人鞠婧祎'), true);
265
+ assert.equal(titleMentionsArtist('จวีจิ้งอี งานแฟนมีต'), true);
266
+ assert.equal(titleMentionsArtist('Ju Jingyi vogue gala red carpet'), true);
267
+ assert.equal(titleMentionsArtist('romand# red carpet 这支是什么神仙草莓色?'), false);
268
+ assert.equal(titleMentionsArtist('สุดหวานงานแฟนมีต “คิมม่อน”'), false);
269
+ assert.equal(titleMentionsArtist(''), false);
270
+ });
271
+
272
+ test('isLowValueImageUrl and imageHostKey guard junk sources', () => {
273
+ assert.equal(isLowValueImageUrl('data:image/png;base64,AAA'), true);
274
+ assert.equal(isLowValueImageUrl('https://cdn.example.com/icon.svg'), true);
275
+ assert.equal(isLowValueImageUrl('https://cdn.example.com/static/logo.png'), true);
276
+ assert.equal(isLowValueImageUrl('https://img0.baidu.com/it/u=1,2;3?fm=253'), false);
277
+ assert.equal(isLowValueImageUrl(''), true);
278
+
279
+ assert.equal(imageHostKey('https://img0.baidu.com/it/u=1?x=1'), 'img0.baidu.com/it/u=1');
280
+ assert.equal(imageHostKey('not a url'), 'not a url');
281
+ });