kiku-agent 1.0.0 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +293 -122
  2. package/package.json +60 -61
  3. package/server/agent/agentCore.js +892 -782
  4. package/server/agent/appPaths.js +21 -0
  5. package/server/agent/audioPlayer.js +739 -673
  6. package/server/agent/cli.js +1414 -1111
  7. package/server/agent/commandRegistry.js +74 -58
  8. package/server/agent/commands.js +1694 -788
  9. package/server/agent/config.js +264 -90
  10. package/server/agent/contextCompactor.js +79 -0
  11. package/server/agent/doctor.js +69 -0
  12. package/server/agent/eventBus.js +107 -103
  13. package/server/agent/gpuMetrics.js +189 -0
  14. package/server/agent/llmClient.js +369 -200
  15. package/server/agent/mcpClient.js +364 -0
  16. package/server/agent/memory.js +670 -566
  17. package/server/agent/modelManager.js +77 -0
  18. package/server/agent/research.js +202 -0
  19. package/server/agent/session.js +244 -239
  20. package/server/agent/subagent.js +116 -0
  21. package/server/agent/theme.js +125 -124
  22. package/server/agent/toolRouter.js +201 -0
  23. package/server/agent/tools.js +1679 -1528
  24. package/server/agent/ui.js +760 -469
  25. package/server/agent/youtubeAuth.js +144 -65
  26. package/server/mcp/claude-desktop-config.example.json +1 -1
  27. package/server/mcp/data/discography.js +36 -36
  28. package/server/mcp/dataAdapter.js +102 -0
  29. package/server/mcp/examples/claude-code.mcp.example.json +19 -0
  30. package/server/mcp/examples/claude-desktop.example.json +11 -0
  31. package/server/mcp/examples/cline.example.json +13 -0
  32. package/server/mcp/examples/cursor.example.json +14 -0
  33. package/server/mcp/examples/gemini-cli.example.json +17 -0
  34. package/server/mcp/examples/opencode.example.json +21 -0
  35. package/server/mcp/examples/vscode.example.json +19 -0
  36. package/server/mcp/examples/windsurf.example.json +14 -0
  37. package/server/mcp/handler.js +85 -159
  38. package/server/mcp/security.js +140 -138
  39. package/server/mcp/server.js +129 -100
  40. package/server/mcp/stdio.js +4 -2
@@ -1,1528 +1,1679 @@
1
- import { profileData } from '../../src/data/profileData.js';
2
- import { worksData } from '../../src/data/worksData.js';
3
- import { discographyData } from '../../src/data/discographyData.js';
4
- import { musicVideoData } from '../../src/data/musicVideoData.js';
5
- import { brandsData } from '../../src/data/brandsData.js';
6
- import { socialUpdatesData } from '../../src/data/socialUpdatesData.js';
7
- import { galleryData } from '../../src/data/galleryData.js';
8
- import { awardsData } from '../../src/data/awardsData.js';
9
- import { timelineData } from '../../src/data/timelineData.js';
10
- import { searchWeb, fetchWebpage, fetchJson, screenshotWebpage, webExtract } from './webSearch.js';
11
- import { readFile, readdir, stat } from 'node:fs/promises';
12
- import { join, relative, extname, resolve } from 'node:path';
13
- import { audioPlayer } from './audioPlayer.js';
14
- import { sanitizeToolArgs } from './sanitizer.js';
15
-
16
- export function buildYouTubeSearchUrl(query) {
17
- return `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`;
18
- }
19
-
20
- export function buildYouTubeWatchUrl(videoId) {
21
- return videoId ? `https://www.youtube.com/watch?v=${videoId}` : null;
22
- }
23
-
24
- export function buildBilibiliSearchUrl(query) {
25
- return `https://search.bilibili.com/all?keyword=${encodeURIComponent(query)}`;
26
- }
27
-
28
- export function buildQqMusicSearchUrl(query) {
29
- return `https://y.qq.com/n/ryqq/search?w=${encodeURIComponent(query)}`;
30
- }
31
-
32
- export function buildYoukuSearchUrl(query) {
33
- return `https://so.youku.com/search_video/q_${encodeURIComponent(query)}`;
34
- }
35
-
36
- function getCleanDiscography() {
37
- const map = new Map();
38
- const allTracks = [
39
- ...(discographyData.latestReleases || []),
40
- ...(discographyData.ostList || []),
41
- ...(discographyData.singles || [])
42
- ];
43
-
44
- for (const track of allTracks) {
45
- const key = track.titleCn || track.titleTh;
46
- if (!map.has(key)) {
47
- const ytId = track.youtubeId || null;
48
- const searchTerms = `Ju Jingyi 鞠婧祎 ${track.titleCn || track.titleTh || track.titleEn || ''}`;
49
- map.set(key, {
50
- id: track.id,
51
- titleTh: track.titleTh,
52
- titleCn: track.titleCn,
53
- titleEn: track.titleEn || '',
54
- year: track.year,
55
- work: track.work || "Solo Single",
56
- duration: track.duration || track.trackDuration || '',
57
- theme: track.theme || "Pop / Ballad",
58
- youtubeId: ytId,
59
- youtubeUrl: track.youtubeUrl || buildYouTubeWatchUrl(ytId),
60
- youtubeSearchUrl: buildYouTubeSearchUrl(searchTerms),
61
- qqMusicSearchUrl: buildQqMusicSearchUrl(`鞠婧祎 ${track.titleCn || track.titleTh}`),
62
- bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${track.titleCn || track.titleTh}`)
63
- });
64
- }
65
- }
66
-
67
- return Array.from(map.values()).sort((a, b) => (b.year || 0) - (a.year || 0));
68
- }
69
-
70
- function getAllMusicVideos() {
71
- if (Array.isArray(musicVideoData)) {
72
- return musicVideoData;
73
- }
74
- return [
75
- ...(musicVideoData.officialMvs || []).map(m => ({ ...m, category: 'Official MV' })),
76
- ...(musicVideoData.stagePerformances || []).map(m => ({ ...m, category: 'Stage Performance' }))
77
- ];
78
- }
79
-
80
- export const toolsSchema = [
81
- {
82
- type: "function",
83
- function: {
84
- name: "get_profile_and_fandom",
85
- description: "Get Ju Jingyi's biographical profile, Chinese/English/Thai names, birthday, birthplace, Miju (蜜橘) fan community, official slogan, and socials.",
86
- parameters: {
87
- type: "object",
88
- properties: {},
89
- required: []
90
- }
91
- }
92
- },
93
- {
94
- type: "function",
95
- function: {
96
- name: "get_quotes",
97
- description: "Retrieve inspiring quotes and famous statements made by Ju Jingyi (with Thai translation and Chinese text).",
98
- parameters: {
99
- type: "object",
100
- properties: {
101
- tag: { type: "string", description: "Optional tag (mindset, growth, acting, fandom)" }
102
- },
103
- required: []
104
- }
105
- }
106
- },
107
- {
108
- type: "function",
109
- function: {
110
- name: "search_works",
111
- description: "Search Ju Jingyi's filmography (TV dramas, web series, movies, and 2026/2025 upcoming projects) with official streaming links and YouTube trailer search links.",
112
- parameters: {
113
- type: "object",
114
- properties: {
115
- query: { type: "string", description: "Search keyword for drama title or character name." },
116
- year: { type: "number", description: "Specific release year (e.g. 2026, 2025, 2024, 2023). Leave empty to get newest." },
117
- category: { type: "string", description: "Category filter (e.g. 'series', 'movie', 'upcoming')." },
118
- featuredOnly: { type: "boolean", description: "Filter only featured major hit dramas." }
119
- },
120
- required: []
121
- }
122
- }
123
- },
124
- {
125
- type: "function",
126
- function: {
127
- name: "get_work_detail",
128
- description: "Get full details of a specific drama or film by ID or title (synopsis, role, OST with YouTube links, episode count, platform, rating).",
129
- parameters: {
130
- type: "object",
131
- properties: {
132
- workIdOrTitle: { type: "string", description: "ID or title of drama (e.g. 'veil-of-shadows', 'in-blossom', 'legend-of-white-snake')" }
133
- },
134
- required: ["workIdOrTitle"]
135
- }
136
- }
137
- },
138
- {
139
- type: "function",
140
- function: {
141
- name: "get_character_roles",
142
- description: "List and search famous fictional character roles portrayed by Ju Jingyi in dramas and movies (e.g. Yang Caiwei, Lu Wuyi, Bai Suzhen, Han Yunxi).",
143
- parameters: {
144
- type: "object",
145
- properties: {
146
- query: { type: "string", description: "Optional role name or drama title keyword" }
147
- },
148
- required: []
149
- }
150
- }
151
- },
152
- {
153
- type: "function",
154
- function: {
155
- name: "search_discography",
156
- description: "Search Ju Jingyi's songs, singles, and OST drama soundtracks. Returns verified direct YouTube watch links (https://www.youtube.com/watch?v=...), real YouTube search queries (https://www.youtube.com/results?search_query=...), and music streaming links.",
157
- parameters: {
158
- type: "object",
159
- properties: {
160
- query: { type: "string", description: "Song title keyword (Thai, Chinese, or English)" },
161
- year: { type: "number", description: "Release year" },
162
- type: { type: "string", enum: ["all", "ost", "single"] }
163
- },
164
- required: []
165
- }
166
- }
167
- },
168
- {
169
- type: "function",
170
- function: {
171
- name: "get_music_videos",
172
- description: "Retrieve music videos (MV), stage performances, and dance practices with verified direct YouTube links and YouTube search query URLs.",
173
- parameters: {
174
- type: "object",
175
- properties: {
176
- category: { type: "string", description: "Category (Official MV, Stage Performance, OST, Single)" },
177
- query: { type: "string", description: "Keyword matching title, song, or event" }
178
- },
179
- required: []
180
- }
181
- }
182
- },
183
- {
184
- type: "function",
185
- function: {
186
- name: "get_media_links",
187
- description: "Get direct YouTube video watch links (watch?v=...), official YouTube search queries (results?search_query=...), Bilibili links, and streaming URLs for any Ju Jingyi song, MV, live stage, or drama trailer.",
188
- parameters: {
189
- type: "object",
190
- properties: {
191
- query: { type: "string", description: "Song, drama, stage, or MV name (e.g. 'FIND THE WAY', 'Rain Love', 'Hong Zhao Yuan', 'In Blossom', 'Veil of Shadows', 'Luo Hua Cheng Ni')" },
192
- category: { type: "string", enum: ["all", "mv", "stage", "ost", "single", "trailer"], description: "Optional media type filter" }
193
- },
194
- required: ["query"]
195
- }
196
- }
197
- },
198
- {
199
- type: "function",
200
- function: {
201
- name: "get_brand_endorsements",
202
- description: "Retrieve brand endorsements, commercial ambassadorships, luxury fashion, and beauty partnerships of Ju Jingyi.",
203
- parameters: {
204
- type: "object",
205
- properties: {
206
- isGlobal: { type: "boolean", description: "Filter for Global Brand Ambassador roles" },
207
- year: { type: "number", description: "Year of endorsement (e.g. 2026, 2025, 2024). Leave empty for latest." },
208
- category: { type: "string", description: "Category filter (Luxury, Beauty, FMCG, Tech)" },
209
- query: { type: "string", description: "Brand name keyword or search term" }
210
- },
211
- required: []
212
- }
213
- }
214
- },
215
- {
216
- type: "function",
217
- function: {
218
- name: "get_career_timeline",
219
- description: "Explore Ju Jingyi's career milestones and chronological eras (SNH48 Era, Solo Studio, Breakthrough Dramas, Present).",
220
- parameters: {
221
- type: "object",
222
- properties: {
223
- year: { type: "number", description: "Year filter (2013-2026)" },
224
- era: { type: "string", description: "Era keyword" }
225
- },
226
- required: []
227
- }
228
- }
229
- },
230
- {
231
- type: "function",
232
- function: {
233
- name: "get_awards",
234
- description: "Query awards and honors received by Ju Jingyi across music, television, and cultural influence.",
235
- parameters: {
236
- type: "object",
237
- properties: {
238
- category: { type: "string", description: "Category (Acting, Music, Honor)" },
239
- year: { type: "number", description: "Year filter" }
240
- },
241
- required: []
242
- }
243
- }
244
- },
245
- {
246
- type: "function",
247
- function: {
248
- name: "get_social_feed",
249
- description: "Get recent social media updates from Weibo and Douyin.",
250
- parameters: {
251
- type: "object",
252
- properties: {
253
- platform: { type: "string", enum: ["all", "weibo", "douyin"] },
254
- limit: { type: "number", description: "Number of posts (default 4)" }
255
- },
256
- required: []
257
- }
258
- }
259
- },
260
- {
261
- type: "function",
262
- function: {
263
- name: "get_gallery_photos",
264
- description: "Search visual photo gallery of Ju Jingyi (red carpet, photoshoot, drama stills).",
265
- parameters: {
266
- type: "object",
267
- properties: {
268
- category: { type: "string", description: "Category (fashion-magazine, red-carpet, drama-stills)" },
269
- limit: { type: "number", description: "Number of photos (default 4)" }
270
- },
271
- required: []
272
- }
273
- }
274
- },
275
- {
276
- type: "function",
277
- function: {
278
- name: "global_search",
279
- description: "Universal search across ALL Ju Jingyi entities (works, songs, roles, brands, awards, videos) with YouTube and media links.",
280
- parameters: {
281
- type: "object",
282
- properties: {
283
- query: { type: "string", description: "Search term to match across all collections" }
284
- },
285
- required: ["query"]
286
- }
287
- }
288
- },
289
- {
290
- type: "function",
291
- function: {
292
- name: "web_search",
293
- description: "Search the LIVE internet for real-time news, recent updates, social media trends, Weibo topics, or external info about Ju Jingyi and entertainment topics.",
294
- parameters: {
295
- type: "object",
296
- properties: {
297
- query: { type: "string", description: "Search query keyword" }
298
- },
299
- required: ["query"]
300
- }
301
- }
302
- },
303
- {
304
- type: "function",
305
- function: {
306
- name: "fetch_webpage",
307
- description: "Fetch and read the text content of a specific public web page URL. Returns extracted text — read the content field carefully and extract specific facts (names, dates, titles, descriptions) for your answer. NOTE: This cannot read SPA/React sites — use fetch_api for jujingyi-kikuuu.vercel.app.",
308
- parameters: {
309
- type: "object",
310
- properties: {
311
- url: { type: "string", description: "The full http/https URL of the web page to read" }
312
- },
313
- required: ["url"]
314
- }
315
- }
316
- },
317
- {
318
- type: "function",
319
- function: {
320
- name: "fetch_api",
321
- description: "Query the Ju Jingyi Official REST API (jujingyi-kikuuu.vercel.app/api/v1) for structured JSON data. Use this instead of fetch_webpage when the user shares a link to the platform site, or when you need live/verified data from the official API.",
322
- parameters: {
323
- type: "object",
324
- properties: {
325
- endpoint: { type: "string", description: "API endpoint path (e.g. 'profile', 'works', 'music', 'commercial', 'milestones', 'honors', 'social', 'search', 'stats', 'quotes', 'fandom', 'character-roles', 'gallery')" },
326
- query: { type: "string", description: "Optional search/filter query parameter" },
327
- baseUrl: { type: "string", description: "Optional override base URL (defaults to https://jujingyi-kikuuu.vercel.app/api/v1)" }
328
- },
329
- required: ["endpoint"]
330
- }
331
- }
332
- },
333
- {
334
- type: "function",
335
- function: {
336
- name: "fetch_json",
337
- description: "Fetch data from any REST API or JSON endpoint. Returns parsed JSON data with status code, headers, and content. Use for public APIs, weather, translation, definitions, or any JSON-based service.",
338
- parameters: {
339
- type: "object",
340
- properties: {
341
- url: { type: "string", description: "The full HTTP/HTTPS URL of the JSON API endpoint" },
342
- method: { type: "string", enum: ["GET", "POST", "PUT", "PATCH", "DELETE"], description: "HTTP method (default GET)" },
343
- headers: { type: "object", description: "Optional custom headers as key-value pairs" },
344
- body: { description: "Optional request body (for POST/PUT/PATCH). Can be a JSON object or string." }
345
- },
346
- required: ["url"]
347
- }
348
- }
349
- },
350
- {
351
- type: "function",
352
- function: {
353
- name: "read_local_file",
354
- description: "Read the contents of a local file in the project directory. Use for reading source code, config files, data files, markdown docs, etc. Returns the text content with file metadata.",
355
- parameters: {
356
- type: "object",
357
- properties: {
358
- path: { type: "string", description: "Relative file path from project root (e.g. 'package.json', 'src/App.jsx', 'README.md')" },
359
- maxLines: { type: "number", description: "Maximum number of lines to read (default 200). Use for large files." }
360
- },
361
- required: ["path"]
362
- }
363
- }
364
- },
365
- {
366
- type: "function",
367
- function: {
368
- name: "list_directory",
369
- description: "List files and subdirectories in a project directory. Use to explore project structure, find files, or understand codebase layout.",
370
- parameters: {
371
- type: "object",
372
- properties: {
373
- path: { type: "string", description: "Relative directory path from project root (e.g. 'src', 'server/agent', '.')" },
374
- recursive: { type: "boolean", description: "If true, list recursively (max 3 levels deep). Default false." }
375
- },
376
- required: []
377
- }
378
- }
379
- },
380
- {
381
- type: "function",
382
- function: {
383
- name: "screenshot_webpage",
384
- description: "Take a screenshot of a webpage using Chrome headless. Returns base64 PNG image data. Requires Google Chrome installed. Use for visual verification of pages, layouts, or UI elements.",
385
- parameters: {
386
- type: "object",
387
- properties: {
388
- url: { type: "string", description: "The full HTTP/HTTPS URL to screenshot" },
389
- width: { type: "number", description: "Viewport width in pixels (default 1280)" },
390
- height: { type: "number", description: "Viewport height in pixels (default 800)" }
391
- },
392
- required: ["url"]
393
- }
394
- }
395
- },
396
- {
397
- type: "function",
398
- function: {
399
- name: "web_extract",
400
- description: "Auto-crawl: search the web AND fetch+extract content from top results in one call. Returns structured page data (meta, headings, paragraphs, links, images) from each result. Use when you need comprehensive info from multiple sources on a topic.",
401
- parameters: {
402
- type: "object",
403
- properties: {
404
- query: { type: "string", description: "Search query to find and extract content about" },
405
- maxResults: { type: "number", description: "Max pages to fetch (default 3, max 5). More = slower but more comprehensive." },
406
- allowedDomains: { type: "array", items: { type: "string" }, description: "Only fetch from these domains (e.g. ['wikipedia.org', 'youtube.com']). Empty = all domains." }
407
- },
408
- required: ["query"]
409
- }
410
- }
411
- },
412
- {
413
- type: "function",
414
- function: {
415
- name: "get_system_stats",
416
- description: "Get high-level summary metrics of the entire Ju Jingyi database.",
417
- parameters: {
418
- type: "object",
419
- properties: {},
420
- required: []
421
- }
422
- }
423
- },
424
- {
425
- type: "function",
426
- function: {
427
- name: "play_music",
428
- description: "Play a specific song, OST, or music video audio directly in the user's terminal.",
429
- parameters: {
430
- type: "object",
431
- properties: {
432
- query: {
433
- type: "string",
434
- description: "The name of the song and artist (e.g., 'Ju Jingyi FIND THE WAY', 'In Blossom OST Ju Jingyi')"
435
- }
436
- },
437
- required: ["query"]
438
- }
439
- }
440
- },
441
- {
442
- type: "function",
443
- function: {
444
- name: "stop_music",
445
- description: "Stop the currently playing music.",
446
- parameters: { type: "object", properties: {} }
447
- }
448
- },
449
- {
450
- type: "function",
451
- function: {
452
- name: "mute_music",
453
- description: "Mute the currently playing music.",
454
- parameters: { type: "object", properties: {} }
455
- }
456
- },
457
- {
458
- type: "function",
459
- function: {
460
- name: "unmute_music",
461
- description: "Unmute the currently playing music.",
462
- parameters: { type: "object", properties: {} }
463
- }
464
- },
465
- {
466
- type: "function",
467
- function: {
468
- name: "pause_music",
469
- description: "Pause the currently playing music.",
470
- parameters: { type: "object", properties: {} }
471
- }
472
- },
473
- {
474
- type: "function",
475
- function: {
476
- name: "resume_music",
477
- description: "Resume paused music.",
478
- parameters: { type: "object", properties: {} }
479
- }
480
- },
481
- {
482
- type: "function",
483
- function: {
484
- name: "next_track",
485
- description: "Skip to the next track in the queue.",
486
- parameters: { type: "object", properties: {} }
487
- }
488
- },
489
- {
490
- type: "function",
491
- function: {
492
- name: "prev_track",
493
- description: "Go back to the previous track in the queue.",
494
- parameters: { type: "object", properties: {} }
495
- }
496
- },
497
- {
498
- type: "function",
499
- function: {
500
- name: "add_to_queue",
501
- description: "Add a song to the playback queue.",
502
- parameters: {
503
- type: "object",
504
- properties: {
505
- query: {
506
- type: "string",
507
- description: "The name of the song and artist to queue."
508
- }
509
- },
510
- required: ["query"]
511
- }
512
- }
513
- },
514
- {
515
- type: "function",
516
- function: {
517
- name: "remove_from_queue",
518
- description: "Remove a song from the queue by index.",
519
- parameters: {
520
- type: "object",
521
- properties: {
522
- index: {
523
- type: "number",
524
- description: "0-based index of the song in the queue to remove."
525
- }
526
- },
527
- required: ["index"]
528
- }
529
- }
530
- },
531
- {
532
- type: "function",
533
- function: {
534
- name: "clear_queue",
535
- description: "Clear the entire playback queue and stop music.",
536
- parameters: { type: "object", properties: {} }
537
- }
538
- },
539
- {
540
- type: "function",
541
- function: {
542
- name: "get_queue",
543
- description: "Get the current playback queue list.",
544
- parameters: { type: "object", properties: {} }
545
- }
546
- },
547
- {
548
- type: "function",
549
- function: {
550
- name: "search_youtube",
551
- description: "Search YouTube for music videos or audio and return top results.",
552
- parameters: {
553
- type: "object",
554
- properties: {
555
- query: {
556
- type: "string",
557
- description: "Search query for YouTube."
558
- }
559
- },
560
- required: ["query"]
561
- }
562
- }
563
- },
564
- {
565
- type: "function",
566
- function: {
567
- name: "get_current_track",
568
- description: "Get information about the currently playing or paused track.",
569
- parameters: { type: "object", properties: {} }
570
- }
571
- },
572
- {
573
- type: "function",
574
- function: {
575
- name: "get_music_status",
576
- description: "Get full music player status including playing state, current track, and queue info.",
577
- parameters: { type: "object", properties: {} }
578
- }
579
- }
580
- ];
581
-
582
- export const toolHandlers = {
583
- get_profile_and_fandom: async () => {
584
- const d = profileData.details || {};
585
- return {
586
- status: "success",
587
- profile: {
588
- nameTh: profileData.nameTh,
589
- nameCn: profileData.nameCn,
590
- nameEn: profileData.nameEn,
591
- nickname: profileData.nickname,
592
- birthDate: d.birthDate,
593
- birthPlace: d.birthPlace,
594
- height: d.height,
595
- bloodType: d.bloodType,
596
- zodiac: d.zodiac,
597
- education: d.education,
598
- debutDate: d.debutDate,
599
- graduationDate: d.graduationDate,
600
- currentStatus: d.currentStatus,
601
- hobbies: d.hobbies,
602
- favoriteFoods: d.favoriteFoods,
603
- talentsSummary: d.talentsSummary,
604
- fandomName: d.fandomName || "มี่จวี (Miju / 蜜橘)",
605
- fandomColor: d.fandomColorName || "ฟ้าคริสตัล & ชมพูซากุระ",
606
- slogan: profileData.motto || "保持热爱,奔赴山海",
607
- youtubeSearchUrl: buildYouTubeSearchUrl("Ju Jingyi 鞠婧祎")
608
- }
609
- };
610
- },
611
-
612
- get_quotes: async ({ tag } = {}) => {
613
- let quotes = profileData.quotes || [];
614
- if (tag) {
615
- const qLower = tag.toLowerCase();
616
- quotes = quotes.filter(q =>
617
- (q.tag && q.tag.toLowerCase().includes(qLower)) ||
618
- (q.textTh && q.textTh.toLowerCase().includes(qLower)) ||
619
- (q.textCn && q.textCn.includes(tag))
620
- );
621
- }
622
- return {
623
- status: "success",
624
- count: quotes.length,
625
- quotes: quotes.slice(0, 5).map(q => ({
626
- textTh: q.textTh,
627
- textCn: q.textCn,
628
- tag: q.tag,
629
- context: q.context
630
- }))
631
- };
632
- },
633
-
634
- search_works: async ({ query, year, category, featuredOnly } = {}) => {
635
- let results = [...worksData];
636
-
637
- if (year) results = results.filter(w => w.year === Number(year));
638
- if (category && category !== 'all') results = results.filter(w => w.category === category);
639
-
640
- const genericArtistKeywords = ['ju jingyi', 'jujingyi', 'จวีจิ้งอี', 'kiku', 'เสี่ยวจวี', 'kikuuu', 'ศิลปิน', 'ดารา', 'งานล่าสุด', 'ผลงานล่าสุด', 'ล่าสุด', 'latest', 'newest', 'recent', 'upcoming', 'ละคร'];
641
- const isGenericArtistQuery = query && genericArtistKeywords.some(k => query.toLowerCase().trim() === k);
642
-
643
- if (query && !isGenericArtistQuery) {
644
- const q = query.toLowerCase().trim();
645
- results = results.filter(w =>
646
- (w.titleTh && w.titleTh.toLowerCase().includes(q)) ||
647
- (w.titleEn && w.titleEn.toLowerCase().includes(q)) ||
648
- (w.titleCn && w.titleCn.includes(query)) ||
649
- (w.role && w.role.toLowerCase().includes(q)) ||
650
- (w.roleCn && w.roleCn.includes(query))
651
- );
652
- }
653
-
654
- if (featuredOnly && results.some(w => !!w.featured)) {
655
- results = results.filter(w => !!w.featured);
656
- }
657
-
658
- results.sort((a, b) => (b.year || 0) - (a.year || 0));
659
-
660
- return {
661
- status: "success",
662
- count: results.length,
663
- works: results.slice(0, 6).map(w => {
664
- const titleSearch = `Ju Jingyi 鞠婧祎 ${w.titleCn || w.titleEn || w.titleTh}`;
665
- const themeSongYtId = w.themeSong?.youtubeId || null;
666
- return {
667
- id: w.id,
668
- titleTh: w.titleTh,
669
- titleCn: w.titleCn,
670
- titleEn: w.titleEn,
671
- year: w.year,
672
- category: w.category,
673
- role: w.role,
674
- roleCn: w.roleCn,
675
- episodes: w.episodes,
676
- rating: w.rating,
677
- platform: w.streamPlatform,
678
- streamUrl: w.streamUrl || null,
679
- youtubeTrailerSearch: buildYouTubeSearchUrl(`${titleSearch} Trailer Official`),
680
- youkuSearchUrl: buildYoukuSearchUrl(w.titleCn || w.titleEn),
681
- themeSong: w.themeSong ? {
682
- title: `${w.themeSong.titleTh || ''} (${w.themeSong.titleCn || ''})`,
683
- youtubeUrl: themeSongYtId ? buildYouTubeWatchUrl(themeSongYtId) : null,
684
- youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.themeSong.titleCn || w.themeSong.titleTh} OST`)
685
- } : null
686
- };
687
- })
688
- };
689
- },
690
-
691
- get_work_detail: async ({ workIdOrTitle }) => {
692
- if (!workIdOrTitle) return { status: "error", message: "workIdOrTitle parameter is required." };
693
- const q = workIdOrTitle.toLowerCase().trim();
694
- const work = worksData.find(w =>
695
- w.id.toLowerCase() === q ||
696
- w.titleTh.toLowerCase().includes(q) ||
697
- (w.titleEn && w.titleEn.toLowerCase().includes(q)) ||
698
- (w.titleCn && w.titleCn.includes(workIdOrTitle))
699
- );
700
-
701
- if (!work) {
702
- return { status: "not_found", message: `No work found for "${workIdOrTitle}".` };
703
- }
704
-
705
- const titleSearch = `Ju Jingyi 鞠婧祎 ${work.titleCn || work.titleEn || work.titleTh}`;
706
- const themeSongYtId = work.themeSong?.youtubeId || null;
707
-
708
- return {
709
- status: "success",
710
- work: {
711
- id: work.id,
712
- titleTh: work.titleTh,
713
- titleCn: work.titleCn,
714
- titleEn: work.titleEn,
715
- year: work.year,
716
- category: work.category,
717
- role: work.role,
718
- episodes: work.episodes,
719
- rating: work.rating,
720
- platform: work.streamPlatform,
721
- streamUrl: work.streamUrl || null,
722
- youtubeTrailerSearch: buildYouTubeSearchUrl(`${titleSearch} Trailer Official`),
723
- youkuSearchUrl: buildYoukuSearchUrl(work.titleCn || work.titleEn),
724
- bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${work.titleCn}`),
725
- synopsis: work.synopsis?.slice(0, 250) + (work.synopsis?.length > 250 ? '...' : ''),
726
- highlights: (work.highlights || []).slice(0, 3),
727
- themeSong: work.themeSong ? {
728
- title: `${work.themeSong.titleTh || ''} (${work.themeSong.titleCn || ''})`,
729
- youtubeUrl: themeSongYtId ? buildYouTubeWatchUrl(themeSongYtId) : null,
730
- youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${work.themeSong.titleCn || work.themeSong.titleTh} OST`)
731
- } : null
732
- }
733
- };
734
- },
735
-
736
- get_character_roles: async ({ query } = {}) => {
737
- const roles = worksData.map(w => ({
738
- workTitle: `${w.titleTh} (${w.titleCn})`,
739
- year: w.year,
740
- role: w.role,
741
- roleCn: w.roleCn,
742
- youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.roleCn || w.role} ${w.titleCn}`)
743
- }));
744
-
745
- let filtered = roles;
746
- const genericArtistKeywords = ['ju jingyi', 'jujingyi', 'จวีจิ้งอี', 'kiku', 'เสี่ยวจวี', 'kikuuu'];
747
- const isGeneric = query && genericArtistKeywords.some(k => query.toLowerCase().trim() === k);
748
-
749
- if (query && !isGeneric) {
750
- const q = query.toLowerCase().trim();
751
- filtered = roles.filter(r =>
752
- (r.role && r.role.toLowerCase().includes(q)) ||
753
- (r.roleCn && r.roleCn.includes(query)) ||
754
- (r.workTitle && r.workTitle.toLowerCase().includes(q))
755
- );
756
- }
757
-
758
- filtered.sort((a, b) => (b.year || 0) - (a.year || 0));
759
-
760
- return {
761
- status: "success",
762
- count: filtered.length,
763
- roles: filtered.slice(0, 8)
764
- };
765
- },
766
-
767
- search_discography: async ({ query, year, type } = {}) => {
768
- let list = getCleanDiscography();
769
-
770
- if (year) list = list.filter(s => s.year === Number(year));
771
- if (type === 'ost') list = list.filter(s => s.work && s.work !== "Solo Single");
772
- if (type === 'single') list = list.filter(s => !s.work || s.work === "Solo Single");
773
-
774
- const genericArtistKeywords = ['ju jingyi', 'jujingyi', 'จวีจิ้งอี', 'kiku', 'เสี่ยวจวี', 'kikuuu', 'เพลง', 'เพลงทั้งหมด', 'เพลงใหม่', 'music', 'song', 'songs'];
775
- const isGeneric = query && genericArtistKeywords.some(k => query.toLowerCase().trim() === k);
776
-
777
- if (query && !isGeneric) {
778
- const q = query.toLowerCase().trim();
779
- list = list.filter(s =>
780
- (s.titleTh && s.titleTh.toLowerCase().includes(q)) ||
781
- (s.titleCn && s.titleCn.includes(query)) ||
782
- (s.titleEn && s.titleEn.toLowerCase().includes(q)) ||
783
- (s.work && s.work.toLowerCase().includes(q))
784
- );
785
- }
786
-
787
- return {
788
- status: list.length > 0 ? "success" : "not_found",
789
- message: list.length > 0 ? undefined : `No matching discography entries for "${query || 'this filter'}".`,
790
- count: list.length,
791
- songs: list.slice(0, 8).map(s => ({
792
- id: s.id,
793
- title: `${s.titleTh} (${s.titleCn})`,
794
- titleEn: s.titleEn,
795
- year: s.year,
796
- type: s.work,
797
- duration: s.duration,
798
- youtubeUrl: s.youtubeUrl || (s.youtubeId ? buildYouTubeWatchUrl(s.youtubeId) : null),
799
- youtubeSearchUrl: s.youtubeSearchUrl || buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${s.titleCn || s.titleTh}`),
800
- qqMusicSearchUrl: s.qqMusicSearchUrl || buildQqMusicSearchUrl(`鞠婧祎 ${s.titleCn || s.titleTh}`),
801
- bilibiliSearchUrl: s.bilibiliSearchUrl || buildBilibiliSearchUrl(`鞠婧祎 ${s.titleCn || s.titleTh}`)
802
- }))
803
- };
804
- },
805
-
806
- get_music_videos: async ({ category, query } = {}) => {
807
- const allMVs = getAllMusicVideos();
808
-
809
- let results = allMVs;
810
- if (category) {
811
- const catLower = category.toLowerCase();
812
- results = results.filter(m => {
813
- const mCat = (m.category || '').toLowerCase();
814
- if (catLower.includes('mv') || catLower.includes('official')) return mCat.includes('mv') || mCat === 'single' || mCat === 'ost';
815
- if (catLower.includes('stage') || catLower.includes('performance')) return mCat.includes('stage');
816
- if (catLower.includes('ost')) return mCat === 'ost';
817
- if (catLower.includes('single')) return mCat === 'single';
818
- return mCat.includes(catLower);
819
- });
820
- }
821
-
822
- const genericArtistKeywords = ['ju jingyi', 'jujingyi', 'จวีจิ้งอี', 'kiku', 'เสี่ยวจวี', 'kikuuu', 'mv', 'music video', 'วิดีโอ', 'คลิป'];
823
- const isGeneric = query && genericArtistKeywords.some(k => query.toLowerCase().trim() === k);
824
-
825
- if (query && !isGeneric) {
826
- const q = query.toLowerCase().trim();
827
- results = results.filter(m =>
828
- (m.titleTh && m.titleTh.toLowerCase().includes(q)) ||
829
- (m.titleCn && m.titleCn.includes(query)) ||
830
- (m.titlePinyin && m.titlePinyin.toLowerCase().includes(q)) ||
831
- (m.dramaName && m.dramaName.toLowerCase().includes(q)) ||
832
- (m.description && m.description.toLowerCase().includes(q))
833
- );
834
- }
835
-
836
- return {
837
- status: "success",
838
- count: results.length,
839
- videos: results.slice(0, 8).map(m => {
840
- const ytId = m.youtubeId || null;
841
- const searchTitle = `Ju Jingyi 鞠婧祎 ${m.titleCn || m.titleTh} ${m.category === 'Stage-Performance' ? 'Stage Live' : 'Official MV'}`;
842
- return {
843
- id: m.id,
844
- title: `${m.titleTh} (${m.titleCn})`,
845
- category: m.category,
846
- year: m.releaseYear || m.year,
847
- drama: m.dramaName || null,
848
- duration: m.duration || null,
849
- description: m.description || null,
850
- isFeatured: !!m.isFeatured,
851
- youtubeUrl: ytId ? buildYouTubeWatchUrl(ytId) : null,
852
- youtubeSearchUrl: buildYouTubeSearchUrl(searchTitle),
853
- bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${m.titleCn || m.titleTh}`)
854
- };
855
- })
856
- };
857
- },
858
-
859
- get_media_links: async ({ query, category } = {}) => {
860
- if (!query) return { status: "error", message: "query parameter is required." };
861
- const q = query.toLowerCase().trim();
862
-
863
- const mvs = getAllMusicVideos();
864
- const disc = getCleanDiscography();
865
- const works = worksData;
866
-
867
- const matchedVideos = mvs.filter(m =>
868
- (m.titleTh && m.titleTh.toLowerCase().includes(q)) ||
869
- (m.titleCn && m.titleCn.includes(query)) ||
870
- (m.titlePinyin && m.titlePinyin.toLowerCase().includes(q)) ||
871
- (m.dramaName && m.dramaName.toLowerCase().includes(q))
872
- ).map(m => ({
873
- type: m.category || 'MV',
874
- title: `${m.titleTh} (${m.titleCn})`,
875
- year: m.releaseYear || m.year,
876
- duration: m.duration,
877
- youtubeUrl: m.youtubeId ? buildYouTubeWatchUrl(m.youtubeId) : null,
878
- youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${m.titleCn || m.titleTh} ${m.category === 'Stage-Performance' ? 'Stage' : 'Official MV'}`),
879
- bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${m.titleCn || m.titleTh}`)
880
- }));
881
-
882
- const matchedSongs = disc.filter(s =>
883
- (s.titleTh && s.titleTh.toLowerCase().includes(q)) ||
884
- (s.titleCn && s.titleCn.includes(query)) ||
885
- (s.titleEn && s.titleEn.toLowerCase().includes(q)) ||
886
- (s.work && s.work.toLowerCase().includes(q))
887
- ).map(s => ({
888
- type: s.work || 'Song',
889
- title: `${s.titleTh} (${s.titleCn})`,
890
- year: s.year,
891
- duration: s.duration,
892
- youtubeUrl: s.youtubeUrl || (s.youtubeId ? buildYouTubeWatchUrl(s.youtubeId) : null),
893
- youtubeSearchUrl: s.youtubeSearchUrl || buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${s.titleCn || s.titleTh}`),
894
- qqMusicSearchUrl: s.qqMusicSearchUrl || buildQqMusicSearchUrl(`鞠婧祎 ${s.titleCn || s.titleTh}`),
895
- bilibiliSearchUrl: s.bilibiliSearchUrl || buildBilibiliSearchUrl(`鞠婧祎 ${s.titleCn || s.titleTh}`)
896
- }));
897
-
898
- const matchedWorks = works.filter(w =>
899
- (w.titleTh && w.titleTh.toLowerCase().includes(q)) ||
900
- (w.titleCn && w.titleCn.includes(query)) ||
901
- (w.titleEn && w.titleEn.toLowerCase().includes(q))
902
- ).map(w => ({
903
- type: 'Drama / Series',
904
- title: `${w.titleTh} (${w.titleCn})`,
905
- year: w.year,
906
- platform: w.streamPlatform,
907
- streamUrl: w.streamUrl || null,
908
- youtubeTrailerSearch: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.titleCn || w.titleEn} Trailer Official`),
909
- youkuSearchUrl: buildYoukuSearchUrl(w.titleCn || w.titleEn),
910
- themeSong: w.themeSong ? {
911
- title: `${w.themeSong.titleTh || ''} (${w.themeSong.titleCn || ''})`,
912
- youtubeUrl: w.themeSong.youtubeId ? buildYouTubeWatchUrl(w.themeSong.youtubeId) : null,
913
- youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.themeSong.titleCn || w.themeSong.titleTh} OST`)
914
- } : null
915
- }));
916
-
917
- const generalYouTubeSearch = buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${query}`);
918
- const generalBilibiliSearch = buildBilibiliSearchUrl(`鞠婧祎 ${query}`);
919
-
920
- return {
921
- status: "success",
922
- query,
923
- fallbackYouTubeSearch: generalYouTubeSearch,
924
- fallbackBilibiliSearch: generalBilibiliSearch,
925
- results: {
926
- musicVideos: matchedVideos.slice(0, 5),
927
- songs: matchedSongs.slice(0, 5),
928
- works: matchedWorks.slice(0, 3)
929
- }
930
- };
931
- },
932
-
933
- get_brand_endorsements: async ({ isGlobal, category, year, query } = {}) => {
934
- let list = Array.isArray(brandsData) ? [...brandsData] : [...(brandsData.brandList || brandsData.brands || [])];
935
-
936
- if (typeof isGlobal === 'boolean') list = list.filter(b => !!b.isGlobal === isGlobal);
937
- if (year) list = list.filter(b => b.year === Number(year));
938
- if (category) list = list.filter(b => (b.category && b.category.toLowerCase().includes(category.toLowerCase())) || (b.tier && b.tier.toLowerCase().includes(category.toLowerCase())));
939
-
940
- const genericArtistKeywords = ['ju jingyi', 'jujingyi', 'จวีจิ้งอี', 'kiku', 'เสี่ยวจวี', 'kikuuu', 'แบรนด์', 'พรีเซนเตอร์', 'brand', 'ambassador', 'endorsement', 'commercial'];
941
- const isGeneric = query && genericArtistKeywords.some(k => query.toLowerCase().trim() === k);
942
-
943
- if (query && !isGeneric) {
944
- const q = query.toLowerCase().trim();
945
- const numYear = Number(q);
946
- if (!isNaN(numYear) && numYear >= 2013 && numYear <= 2030) {
947
- list = list.filter(b => b.year === numYear);
948
- } else {
949
- list = list.filter(b =>
950
- (b.brandNameEn && b.brandNameEn.toLowerCase().includes(q)) ||
951
- (b.brandNameCn && b.brandNameCn.includes(query)) ||
952
- (b.title && b.title.toLowerCase().includes(q)) ||
953
- (b.category && b.category.toLowerCase().includes(q)) ||
954
- (b.highlight && b.highlight.toLowerCase().includes(q)) ||
955
- (b.tier && b.tier.toLowerCase().includes(q)) ||
956
- (b.name && b.name.toLowerCase().includes(q))
957
- );
958
- }
959
- }
960
-
961
- list.sort((a, b) => (b.year || 0) - (a.year || 0));
962
-
963
- return {
964
- status: "success",
965
- count: list.length,
966
- brands: list.slice(0, 8).map(b => ({
967
- brand: `${b.brandNameEn || b.brand || b.name} (${b.brandNameCn || ''})`,
968
- year: b.year,
969
- title: b.title || (b.isGlobal ? 'Global Brand Ambassador' : 'Brand Ambassador'),
970
- category: b.category,
971
- isGlobal: !!b.isGlobal,
972
- highlight: b.highlight || '',
973
- youtubeCommercialSearch: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${b.brandNameEn || b.brandNameCn} CF Commercial`)
974
- }))
975
- };
976
- },
977
-
978
- get_career_timeline: async ({ year, era } = {}) => {
979
- let eras = Array.isArray(timelineData) ? timelineData : (timelineData.eras || []);
980
- if (year) eras = eras.filter(e => e.year == year || (e.period && e.period.includes(String(year))));
981
- if (era) eras = eras.filter(e => e.title?.toLowerCase().includes(era.toLowerCase()) || e.era?.includes(era));
982
-
983
- return {
984
- status: "success",
985
- count: eras.length,
986
- milestones: eras.slice(0, 6).map(e => ({
987
- period: e.period || e.year,
988
- title: `${e.title} (${e.titleCn || ''})`,
989
- summary: e.summary?.slice(0, 150),
990
- youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${e.titleCn || e.title}`)
991
- }))
992
- };
993
- },
994
-
995
- get_awards: async ({ category, year } = {}) => {
996
- let list = Array.isArray(awardsData) ? awardsData : (awardsData.awards || []);
997
- if (year) list = list.filter(a => a.year === Number(year));
998
- if (category && category !== 'all') list = list.filter(a => a.category?.toLowerCase().includes(category.toLowerCase()));
999
-
1000
- return {
1001
- status: "success",
1002
- count: list.length,
1003
- awards: list.slice(0, 8).map(a => ({
1004
- year: a.year,
1005
- title: `${a.titleTh} (${a.titleCn})`,
1006
- ceremony: a.ceremony,
1007
- category: a.category,
1008
- youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${a.titleCn || a.ceremony}`)
1009
- }))
1010
- };
1011
- },
1012
-
1013
- get_social_feed: async ({ platform, limit = 4 } = {}) => {
1014
- let posts = socialUpdatesData.cards || [];
1015
- if (platform && platform !== 'all') posts = posts.filter(p => p.platform?.toLowerCase() === platform.toLowerCase());
1016
-
1017
- return {
1018
- status: "success",
1019
- count: Math.min(posts.length, Number(limit) || 4),
1020
- posts: posts.slice(0, Number(limit) || 4).map(p => ({
1021
- platform: p.platform,
1022
- date: p.date,
1023
- content: p.content?.slice(0, 120) + '...',
1024
- likes: p.likes
1025
- }))
1026
- };
1027
- },
1028
-
1029
- get_gallery_photos: async ({ category, limit = 4 } = {}) => {
1030
- let photos = Array.isArray(galleryData) ? galleryData : (galleryData.photos || []);
1031
- if (category && category !== 'all') photos = photos.filter(p => p.category?.toLowerCase().includes(category.toLowerCase()));
1032
-
1033
- return {
1034
- status: "success",
1035
- count: Math.min(photos.length, Number(limit) || 4),
1036
- photos: photos.slice(0, Number(limit) || 4).map(p => ({
1037
- title: p.titleTh || p.titleCn,
1038
- category: p.category,
1039
- year: p.era || p.year
1040
- }))
1041
- };
1042
- },
1043
-
1044
- global_search: async ({ query }) => {
1045
- if (!query) return { status: "error", message: "Query parameter is required." };
1046
- const q = query.toLowerCase();
1047
- const ignoredTerms = new Set(['the', 'from', 'what', 'are', 'drama', 'ost', 'song', 'songs', 'soundtrack', 'music']);
1048
- const terms = q.split(/[^\p{L}\p{N}]+/u).filter(term => term.length > 2 && !ignoredTerms.has(term));
1049
- const matchesQuery = (...values) => {
1050
- const haystack = values.filter(Boolean).join(' ').toLowerCase();
1051
- return haystack.includes(q) || (terms.length > 0 && terms.every(term => haystack.includes(term)));
1052
- };
1053
-
1054
- const matchedWorks = worksData.filter(w =>
1055
- matchesQuery(w.titleTh, w.titleEn, w.titleCn, w.role, w.roleCn)
1056
- ).slice(0, 4).map(w => ({
1057
- title: `${w.titleTh} (${w.year}) - Role: ${w.role}`,
1058
- trailerYouTubeSearch: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.titleCn || w.titleEn} Trailer`)
1059
- }));
1060
-
1061
- const disc = getCleanDiscography();
1062
- const matchedSongs = disc.filter(s =>
1063
- matchesQuery(s.titleTh, s.titleEn, s.titleCn, s.work, s.role)
1064
- ).slice(0, 4).map(s => ({
1065
- title: `${s.titleTh} (${s.year})`,
1066
- youtubeUrl: s.youtubeUrl || null,
1067
- youtubeSearchUrl: s.youtubeSearchUrl
1068
- }));
1069
-
1070
- const mvs = getAllMusicVideos().filter(m =>
1071
- matchesQuery(m.titleTh, m.titleCn, m.titlePinyin, m.dramaName, m.description)
1072
- ).slice(0, 4).map(m => ({
1073
- title: `${m.titleTh} (${m.titleCn}) [${m.category}]`,
1074
- youtubeUrl: m.youtubeId ? buildYouTubeWatchUrl(m.youtubeId) : null,
1075
- youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${m.titleCn || m.titleTh}`)
1076
- }));
1077
-
1078
- const brands = Array.isArray(brandsData) ? (brandsData.brandList || []) : (brandsData.brands || []);
1079
- const matchedBrands = brands.filter(b =>
1080
- matchesQuery(b.brandNameEn, b.brandNameCn, b.name, b.title)
1081
- ).slice(0, 4).map(b => `${b.brandNameEn || b.name} (${b.year}) - ${b.title}`);
1082
-
1083
- const count = matchedWorks.length + matchedSongs.length + mvs.length + matchedBrands.length;
1084
- return {
1085
- status: count > 0 ? "success" : "not_found",
1086
- message: count > 0 ? undefined : `No local catalog matches for "${query}". Use web_search for external results.`,
1087
- query,
1088
- count,
1089
- results: {
1090
- musicVideos: mvs,
1091
- works: matchedWorks,
1092
- songs: matchedSongs,
1093
- brands: matchedBrands,
1094
- youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${query}`)
1095
- }
1096
- };
1097
- },
1098
-
1099
- web_search: async ({ query } = {}) => {
1100
- return await searchWeb(query);
1101
- },
1102
-
1103
- fetch_webpage: async ({ url } = {}) => {
1104
- return await fetchWebpage(url);
1105
- },
1106
-
1107
- fetch_api: async ({ endpoint, query, baseUrl } = {}) => {
1108
- if (!endpoint) return { status: "error", message: "endpoint parameter is required (e.g. 'profile', 'works', 'search')" };
1109
- const base = (baseUrl || 'https://jujingyi-kikuuu.vercel.app/api/v1').replace(/\/$/, '');
1110
- const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
1111
- let url = `${base}${path}`;
1112
- if (query) url += `?q=${encodeURIComponent(query)}`;
1113
-
1114
- try {
1115
- const controller = new AbortController();
1116
- const timeoutId = setTimeout(() => controller.abort(), 10000);
1117
- const res = await fetch(url, {
1118
- headers: { 'Accept': 'application/json', 'User-Agent': 'KikuAgent/1.0' },
1119
- signal: controller.signal
1120
- });
1121
- clearTimeout(timeoutId);
1122
-
1123
- if (!res.ok) {
1124
- return { status: "error", message: `HTTP ${res.status} from ${url}` };
1125
- }
1126
-
1127
- const json = await res.json();
1128
-
1129
- const data = json.data || json;
1130
- return {
1131
- status: "success",
1132
- endpoint: path,
1133
- count: Array.isArray(data) ? data.length : undefined,
1134
- data
1135
- };
1136
- } catch (err) {
1137
- return {
1138
- status: "error",
1139
- endpoint: path,
1140
- message: err.name === 'AbortError' ? 'API request timed out' : err.message
1141
- };
1142
- }
1143
- },
1144
-
1145
- fetch_json: async ({ url, method, headers, body } = {}) => {
1146
- return await fetchJson(url, { method, headers, body });
1147
- },
1148
-
1149
- read_local_file: async ({ path: filePath, maxLines = 200 } = {}) => {
1150
- if (!filePath) return { status: 'error', message: 'path parameter is required.' };
1151
- try {
1152
- const projectRoot = process.cwd();
1153
- const resolved = resolve(projectRoot, filePath);
1154
- const relativePath = relative(projectRoot, resolved);
1155
-
1156
- if (relativePath.startsWith('..') || relativePath.includes('..')) {
1157
- return { status: 'error', message: 'Path must be within the project directory.' };
1158
- }
1159
-
1160
- const fileStat = await stat(resolved);
1161
- if (!fileStat.isFile()) {
1162
- return { status: 'error', message: `"${filePath}" is not a file.` };
1163
- }
1164
-
1165
- if (fileStat.size > 500000) {
1166
- return { status: 'error', message: `File too large (${Math.round(fileStat.size / 1024)}KB). Use maxLines to read a portion.` };
1167
- }
1168
-
1169
- const content = await readFile(resolved, 'utf-8');
1170
- const lines = content.split('\n');
1171
- const totalLines = lines.length;
1172
- const truncated = totalLines > maxLines;
1173
- const readContent = truncated ? lines.slice(0, maxLines).join('\n') : content;
1174
-
1175
- return {
1176
- status: 'success',
1177
- path: relativePath,
1178
- sizeBytes: fileStat.size,
1179
- totalLines,
1180
- linesRead: Math.min(totalLines, maxLines),
1181
- truncated,
1182
- content: truncated ? readContent + `\n\n... [Showing first ${maxLines} of ${totalLines} lines]` : readContent
1183
- };
1184
- } catch (err) {
1185
- if (err.code === 'ENOENT') {
1186
- return { status: 'error', message: `File not found: "${filePath}"` };
1187
- }
1188
- return { status: 'error', message: err.message };
1189
- }
1190
- },
1191
-
1192
- list_directory: async ({ path: dirPath = '.', recursive = false } = {}) => {
1193
- try {
1194
- const projectRoot = process.cwd();
1195
- const resolved = resolve(projectRoot, dirPath);
1196
- const relativePath = relative(projectRoot, resolved);
1197
-
1198
- if (relativePath.startsWith('..') || relativePath.includes('..')) {
1199
- return { status: 'error', message: 'Path must be within the project directory.' };
1200
- }
1201
-
1202
- async function listDir(dir, depth = 0) {
1203
- if (depth > (recursive ? 3 : 0)) return [];
1204
- const entries = await readdir(dir, { withFileTypes: true });
1205
- const items = [];
1206
-
1207
- for (const entry of entries) {
1208
- if (entry.name.startsWith('.') && entry.name !== '.env.example') continue;
1209
- if (entry.name === 'node_modules' || entry.name === '.git') continue;
1210
- if (entry.name === 'dist' || entry.name === 'build') continue;
1211
-
1212
- const entryPath = join(dir, entry.name);
1213
- const entryRelative = relative(projectRoot, entryPath);
1214
-
1215
- if (entry.isDirectory()) {
1216
- items.push({ name: entry.name, type: 'directory', path: entryRelative });
1217
- if (recursive && depth < 3) {
1218
- const children = await listDir(entryPath, depth + 1);
1219
- items.push(...children);
1220
- }
1221
- } else {
1222
- const fileStat = await stat(entryPath).catch(() => null);
1223
- items.push({
1224
- name: entry.name,
1225
- type: 'file',
1226
- path: entryRelative,
1227
- sizeBytes: fileStat?.size || 0
1228
- });
1229
- }
1230
- }
1231
-
1232
- items.sort((a, b) => {
1233
- if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
1234
- return a.name.localeCompare(b.name);
1235
- });
1236
-
1237
- return items;
1238
- }
1239
-
1240
- const items = await listDir(resolved);
1241
- return {
1242
- status: 'success',
1243
- path: relativePath || '.',
1244
- count: items.length,
1245
- items
1246
- };
1247
- } catch (err) {
1248
- if (err.code === 'ENOENT') {
1249
- return { status: 'error', message: `Directory not found: "${dirPath}"` };
1250
- }
1251
- return { status: 'error', message: err.message };
1252
- }
1253
- },
1254
-
1255
- screenshot_webpage: async ({ url, width, height } = {}) => {
1256
- return await screenshotWebpage(url, { width, height });
1257
- },
1258
-
1259
- web_extract: async ({ query, maxResults, allowedDomains } = {}) => {
1260
- return await webExtract(query, { maxResults, allowedDomains });
1261
- },
1262
-
1263
- get_system_stats: async () => {
1264
- const disc = getCleanDiscography();
1265
- const brands = brandsData?.brandList || brandsData?.brands || (Array.isArray(brandsData) ? brandsData : []);
1266
- const awards = Array.isArray(awardsData) ? awardsData : (awardsData?.awards || []);
1267
- const timeline = Array.isArray(timelineData) ? timelineData : (timelineData?.eras || []);
1268
- const mvs = getAllMusicVideos();
1269
-
1270
- return {
1271
- status: "success",
1272
- stats: {
1273
- artistName: profileData.nameTh,
1274
- artistNameCn: profileData.nameCn,
1275
- totalWorks: worksData.length,
1276
- totalUniqueSongs: disc.length,
1277
- totalMusicVideosAndStages: mvs.length,
1278
- brandEndorsements: brands.length,
1279
- totalAwards: awards.length,
1280
- timelineMilestones: timeline.length,
1281
- activeYears: "2013 - Present (2026)",
1282
- featured2026Works: ["Veil of Shadows (月鳞绮纪)", "The Eternal Fragrance (千香)", "Wanhua Sin (万华世界)"],
1283
- youtubeOfficialHub: buildYouTubeSearchUrl("Ju Jingyi 鞠婧祎")
1284
- }
1285
- };
1286
- },
1287
-
1288
- play_music: async (args) => {
1289
- try {
1290
- const query = args.query || '';
1291
-
1292
- const hasArtist = /jujingyi|จวีจิ้งอี|鞠婧祎/i.test(query);
1293
- const isUrl = /^https?:\/\//.test(query);
1294
- const searchQuery = (!hasArtist && !isUrl) ? `Ju Jingyi ${query}` : query;
1295
- const result = await audioPlayer.play(searchQuery);
1296
- return { status: 'success', message: result, playing: args.query };
1297
- } catch (err) {
1298
- return { status: 'error', message: err.message };
1299
- }
1300
- },
1301
-
1302
- stop_music: async () => {
1303
- const result = await audioPlayer.stop();
1304
- return { status: 'success', message: result };
1305
- },
1306
-
1307
- mute_music: async () => {
1308
- const result = await audioPlayer.mute();
1309
- return { status: 'success', message: result };
1310
- },
1311
-
1312
- unmute_music: async () => {
1313
- const result = await audioPlayer.unmute();
1314
- return { status: 'success', message: result };
1315
- },
1316
-
1317
- pause_music: async () => {
1318
- const result = await audioPlayer.pause();
1319
- return { status: 'success', message: result };
1320
- },
1321
-
1322
- resume_music: async () => {
1323
- const result = await audioPlayer.resume();
1324
- return { status: 'success', message: result };
1325
- },
1326
-
1327
- next_track: async () => {
1328
- const result = await audioPlayer.next();
1329
- return { status: 'success', message: result };
1330
- },
1331
-
1332
- prev_track: async () => {
1333
- const result = await audioPlayer.prev();
1334
- return { status: 'success', message: result };
1335
- },
1336
-
1337
- add_to_queue: async (args) => {
1338
- const result = await audioPlayer.addToQueue(args.query);
1339
- return { status: 'success', message: result };
1340
- },
1341
-
1342
- remove_from_queue: async (args) => {
1343
- const result = await audioPlayer.removeFromQueue(args.index);
1344
- return { status: 'success', message: result };
1345
- },
1346
-
1347
- clear_queue: async () => {
1348
- const result = await audioPlayer.clearQueue();
1349
- return { status: 'success', message: result };
1350
- },
1351
-
1352
- get_queue: async () => {
1353
- const queue = audioPlayer.getQueue();
1354
- return { status: 'success', queue };
1355
- },
1356
-
1357
- search_youtube: async (args) => {
1358
- try {
1359
- const results = await audioPlayer.searchYouTube(args.query);
1360
- return { status: 'success', query: args.query, results };
1361
- } catch (err) {
1362
- return { status: 'error', message: err.message };
1363
- }
1364
- },
1365
-
1366
- get_current_track: async () => {
1367
- const state = audioPlayer.getFullState();
1368
- return { status: 'success', ...state };
1369
- },
1370
-
1371
- get_music_status: async () => {
1372
- const state = audioPlayer.getFullState();
1373
- return { status: 'success', ...state };
1374
- }
1375
- };
1376
-
1377
- const toolSchemaByName = new Map(
1378
- toolsSchema.map(tool => [tool.function.name, tool.function.parameters || { type: 'object', properties: {} }])
1379
- );
1380
-
1381
- function coerceToolValue(value, schema = {}) {
1382
- if (value == null || !schema.type) return { ok: true, value };
1383
-
1384
- if (schema.type === 'string') {
1385
- if (typeof value === 'string') return { ok: true, value };
1386
- if (['number', 'boolean'].includes(typeof value)) return { ok: true, value: String(value), coerced: true };
1387
- return { ok: false };
1388
- }
1389
- if (schema.type === 'number' || schema.type === 'integer') {
1390
- const parsed = typeof value === 'number' ? value : Number(value);
1391
- if (!Number.isFinite(parsed)) return { ok: false };
1392
- return { ok: true, value: schema.type === 'integer' ? Math.trunc(parsed) : parsed, coerced: typeof value !== 'number' };
1393
- }
1394
- if (schema.type === 'boolean') {
1395
- if (typeof value === 'boolean') return { ok: true, value };
1396
- if (value === 'true' || value === '1' || value === 1) return { ok: true, value: true, coerced: true };
1397
- if (value === 'false' || value === '0' || value === 0) return { ok: true, value: false, coerced: true };
1398
- return { ok: false };
1399
- }
1400
- if (schema.type === 'array') {
1401
- if (!Array.isArray(value)) return { ok: false };
1402
- const converted = [];
1403
- for (const item of value) {
1404
- const checked = coerceToolValue(item, schema.items || {});
1405
- if (!checked.ok) return { ok: false };
1406
- converted.push(checked.value);
1407
- }
1408
- return { ok: true, value: converted };
1409
- }
1410
- if (schema.type === 'object') return { ok: !!value && typeof value === 'object' && !Array.isArray(value), value };
1411
- return { ok: true, value };
1412
- }
1413
-
1414
- export function prepareToolArguments(toolName, rawArgs = {}) {
1415
- const schema = toolSchemaByName.get(toolName);
1416
- if (!schema) {
1417
- return { safe: false, args: {}, warnings: [], error: `Unknown tool "${toolName}".` };
1418
- }
1419
-
1420
- let parsedArgs = rawArgs;
1421
- if (typeof rawArgs === 'string') {
1422
- const trimmed = rawArgs.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
1423
- if (!trimmed) parsedArgs = {};
1424
- else {
1425
- try { parsedArgs = JSON.parse(trimmed); }
1426
- catch (error) {
1427
- return { safe: false, args: {}, warnings: [], error: `Invalid JSON arguments: ${error.message}` };
1428
- }
1429
- }
1430
- }
1431
-
1432
- if (!parsedArgs || typeof parsedArgs !== 'object' || Array.isArray(parsedArgs)) {
1433
- return { safe: false, args: {}, warnings: [], error: 'Tool arguments must be a JSON object.' };
1434
- }
1435
-
1436
- const properties = schema.properties || {};
1437
- const required = new Set(schema.required || []);
1438
- const validated = {};
1439
- const warnings = [];
1440
-
1441
- for (const requiredName of required) {
1442
- if (parsedArgs[requiredName] == null || parsedArgs[requiredName] === '') {
1443
- return { safe: false, args: {}, warnings, error: `Missing required argument "${requiredName}".` };
1444
- }
1445
- }
1446
-
1447
- for (const [name, value] of Object.entries(parsedArgs)) {
1448
- const propertySchema = properties[name];
1449
- if (!propertySchema) {
1450
- warnings.push(`Unknown argument "${name}" was ignored`);
1451
- continue;
1452
- }
1453
- const checked = coerceToolValue(value, propertySchema);
1454
- if (!checked.ok) {
1455
- return { safe: false, args: {}, warnings, error: `Argument "${name}" must be ${propertySchema.type || 'valid'}.` };
1456
- }
1457
- if (propertySchema.enum && !propertySchema.enum.includes(checked.value)) {
1458
- return { safe: false, args: {}, warnings, error: `Argument "${name}" must be one of: ${propertySchema.enum.join(', ')}.` };
1459
- }
1460
- if (checked.coerced) warnings.push(`Argument "${name}" was converted to ${propertySchema.type}`);
1461
- validated[name] = checked.value;
1462
- }
1463
-
1464
- const sanitizedResult = sanitizeToolArgs(toolName, validated);
1465
- if (!sanitizedResult.safe) {
1466
- return { safe: false, args: {}, warnings: [...warnings, ...sanitizedResult.warnings], error: sanitizedResult.warnings.join('; ') };
1467
- }
1468
- return { safe: true, args: sanitizedResult.sanitized, warnings: [...warnings, ...sanitizedResult.warnings], error: null };
1469
- }
1470
-
1471
- export function normalizeToolResult(toolName, result) {
1472
- if (result == null) return { status: 'error', message: `Tool "${toolName}" returned no result.` };
1473
- if (typeof result !== 'object' || Array.isArray(result)) return { status: 'success', data: result };
1474
-
1475
- const normalized = { ...result };
1476
- if (!normalized.status) normalized.status = normalized.error ? 'error' : 'success';
1477
- if (normalized.error && !normalized.message) normalized.message = String(normalized.error);
1478
- if (normalized.status === 'success' && (normalized.count === 0 || normalized.returnedCount === 0)) {
1479
- normalized.status = 'not_found';
1480
- if (!normalized.message) normalized.message = 'No matching data found.';
1481
- }
1482
- return normalized;
1483
- }
1484
-
1485
- export async function executeTool(toolName, args = {}, options = {}) {
1486
- const handler = toolHandlers[toolName];
1487
- if (!handler) {
1488
- return {
1489
- status: "error",
1490
- message: `Unknown tool "${toolName}". Available tools: ${Object.keys(toolHandlers).join(', ')}`
1491
- };
1492
- }
1493
-
1494
- try {
1495
- const prepared = prepareToolArguments(toolName, args);
1496
- if (!prepared.safe) {
1497
- return {
1498
- status: 'error',
1499
- message: `Input rejected: ${prepared.error}`,
1500
- validationWarnings: prepared.warnings,
1501
- };
1502
- }
1503
-
1504
- const configuredTimeout = Number(options.timeoutMs ?? process.env.KIKU_TOOL_TIMEOUT_MS ?? 45_000);
1505
- const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : 45_000;
1506
- let timeoutId;
1507
- const timeoutPromise = new Promise((_, reject) => {
1508
- timeoutId = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
1509
- });
1510
- let result;
1511
- try {
1512
- result = await Promise.race([Promise.resolve(handler(prepared.args)), timeoutPromise]);
1513
- } finally {
1514
- clearTimeout(timeoutId);
1515
- }
1516
-
1517
- const normalized = normalizeToolResult(toolName, result);
1518
- if (prepared.warnings.length > 0) {
1519
- normalized.validationWarnings = prepared.warnings;
1520
- }
1521
- return normalized;
1522
- } catch (err) {
1523
- return {
1524
- status: "error",
1525
- message: `Failed executing tool "${toolName}": ${err.message}`
1526
- };
1527
- }
1528
- }
1
+ import { profileData } from '../../src/data/profileData.js';
2
+ import { worksData } from '../../src/data/worksData.js';
3
+ import { discographyData } from '../../src/data/discographyData.js';
4
+ import { musicVideoData } from '../../src/data/musicVideoData.js';
5
+ import { brandsData } from '../../src/data/brandsData.js';
6
+ import { socialUpdatesData } from '../../src/data/socialUpdatesData.js';
7
+ import { galleryData } from '../../src/data/galleryData.js';
8
+ import { awardsData } from '../../src/data/awardsData.js';
9
+ import { timelineData } from '../../src/data/timelineData.js';
10
+ import { searchWeb, fetchWebpage, fetchJson, screenshotWebpage, webExtract } from './webSearch.js';
11
+ import { readFile, readdir, stat } from 'node:fs/promises';
12
+ import { join, relative, extname, resolve } from 'node:path';
13
+ import { audioPlayer } from './audioPlayer.js';
14
+ import { sanitizeToolArgs } from './sanitizer.js';
15
+ import { getGpuMetrics } from './gpuMetrics.js';
16
+
17
+ export function buildYouTubeSearchUrl(query) {
18
+ return `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`;
19
+ }
20
+
21
+ export function buildYouTubeWatchUrl(videoId) {
22
+ return videoId ? `https://www.youtube.com/watch?v=${videoId}` : null;
23
+ }
24
+
25
+ export function buildBilibiliSearchUrl(query) {
26
+ return `https://search.bilibili.com/all?keyword=${encodeURIComponent(query)}`;
27
+ }
28
+
29
+ export function buildQqMusicSearchUrl(query) {
30
+ return `https://y.qq.com/n/ryqq/search?w=${encodeURIComponent(query)}`;
31
+ }
32
+
33
+ export function buildYoukuSearchUrl(query) {
34
+ return `https://so.youku.com/search_video/q_${encodeURIComponent(query)}`;
35
+ }
36
+
37
+ function getCleanDiscography() {
38
+ const map = new Map();
39
+ const allTracks = [
40
+ ...(discographyData.latestReleases || []),
41
+ ...(discographyData.ostList || []),
42
+ ...(discographyData.singles || [])
43
+ ];
44
+
45
+ for (const track of allTracks) {
46
+ const key = track.titleCn || track.titleTh;
47
+ if (!map.has(key)) {
48
+ const ytId = track.youtubeId || null;
49
+ const searchTerms = `Ju Jingyi 鞠婧祎 ${track.titleCn || track.titleTh || track.titleEn || ''}`;
50
+ map.set(key, {
51
+ id: track.id,
52
+ titleTh: track.titleTh,
53
+ titleCn: track.titleCn,
54
+ titleEn: track.titleEn || '',
55
+ year: track.year,
56
+ work: track.work || "Solo Single",
57
+ duration: track.duration || track.trackDuration || '',
58
+ theme: track.theme || "Pop / Ballad",
59
+ youtubeId: ytId,
60
+ youtubeUrl: track.youtubeUrl || buildYouTubeWatchUrl(ytId),
61
+ youtubeSearchUrl: buildYouTubeSearchUrl(searchTerms),
62
+ qqMusicSearchUrl: buildQqMusicSearchUrl(`鞠婧祎 ${track.titleCn || track.titleTh}`),
63
+ bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${track.titleCn || track.titleTh}`)
64
+ });
65
+ }
66
+ }
67
+
68
+ return Array.from(map.values()).sort((a, b) => (b.year || 0) - (a.year || 0));
69
+ }
70
+
71
+ function getAllMusicVideos() {
72
+ if (Array.isArray(musicVideoData)) {
73
+ return musicVideoData;
74
+ }
75
+ return [
76
+ ...(musicVideoData.officialMvs || []).map(m => ({ ...m, category: 'Official MV' })),
77
+ ...(musicVideoData.stagePerformances || []).map(m => ({ ...m, category: 'Stage Performance' }))
78
+ ];
79
+ }
80
+
81
+ export const toolsSchema = [
82
+ {
83
+ type: "function",
84
+ function: {
85
+ name: "get_profile_and_fandom",
86
+ description: "Get Ju Jingyi's biographical profile, Chinese/English/Thai names, birthday, birthplace, Miju (蜜橘) fan community, official slogan, and socials.",
87
+ parameters: {
88
+ type: "object",
89
+ properties: {},
90
+ required: []
91
+ }
92
+ }
93
+ },
94
+ {
95
+ type: "function",
96
+ function: {
97
+ name: "get_quotes",
98
+ description: "Retrieve inspiring quotes and famous statements made by Ju Jingyi (with Thai translation and Chinese text).",
99
+ parameters: {
100
+ type: "object",
101
+ properties: {
102
+ tag: { type: "string", description: "Optional tag (mindset, growth, acting, fandom)" }
103
+ },
104
+ required: []
105
+ }
106
+ }
107
+ },
108
+ {
109
+ type: "function",
110
+ function: {
111
+ name: "search_works",
112
+ description: "Search Ju Jingyi's filmography (TV dramas, web series, movies, and 2026/2025 upcoming projects) with official streaming links and YouTube trailer search links.",
113
+ parameters: {
114
+ type: "object",
115
+ properties: {
116
+ query: { type: "string", description: "Search keyword for drama title or character name." },
117
+ year: { type: "number", description: "Specific release year (e.g. 2026, 2025, 2024, 2023). Leave empty to get newest." },
118
+ category: { type: "string", description: "Category filter (e.g. 'series', 'movie', 'upcoming')." },
119
+ featuredOnly: { type: "boolean", description: "Filter only featured major hit dramas." }
120
+ },
121
+ required: []
122
+ }
123
+ }
124
+ },
125
+ {
126
+ type: "function",
127
+ function: {
128
+ name: "get_work_detail",
129
+ description: "Get full details of a specific drama or film by ID or title (synopsis, role, OST with YouTube links, episode count, platform, rating).",
130
+ parameters: {
131
+ type: "object",
132
+ properties: {
133
+ workIdOrTitle: { type: "string", description: "ID or title of drama (e.g. 'veil-of-shadows', 'in-blossom', 'legend-of-white-snake')" }
134
+ },
135
+ required: ["workIdOrTitle"]
136
+ }
137
+ }
138
+ },
139
+ {
140
+ type: "function",
141
+ function: {
142
+ name: "get_character_roles",
143
+ description: "List and search famous fictional character roles portrayed by Ju Jingyi in dramas and movies (e.g. Yang Caiwei, Lu Wuyi, Bai Suzhen, Han Yunxi).",
144
+ parameters: {
145
+ type: "object",
146
+ properties: {
147
+ query: { type: "string", description: "Optional role name or drama title keyword" }
148
+ },
149
+ required: []
150
+ }
151
+ }
152
+ },
153
+ {
154
+ type: "function",
155
+ function: {
156
+ name: "search_discography",
157
+ description: "Search Ju Jingyi's songs, singles, and OST drama soundtracks. Returns verified direct YouTube watch links (https://www.youtube.com/watch?v=...), real YouTube search queries (https://www.youtube.com/results?search_query=...), and music streaming links.",
158
+ parameters: {
159
+ type: "object",
160
+ properties: {
161
+ query: { type: "string", description: "Song title keyword (Thai, Chinese, or English)" },
162
+ year: { type: "number", description: "Release year" },
163
+ type: { type: "string", enum: ["all", "ost", "single"] }
164
+ },
165
+ required: []
166
+ }
167
+ }
168
+ },
169
+ {
170
+ type: "function",
171
+ function: {
172
+ name: "get_music_videos",
173
+ description: "Retrieve music videos (MV), stage performances, and dance practices with verified direct YouTube links and YouTube search query URLs.",
174
+ parameters: {
175
+ type: "object",
176
+ properties: {
177
+ category: { type: "string", description: "Category (Official MV, Stage Performance, OST, Single)" },
178
+ query: { type: "string", description: "Keyword matching title, song, or event" }
179
+ },
180
+ required: []
181
+ }
182
+ }
183
+ },
184
+ {
185
+ type: "function",
186
+ function: {
187
+ name: "get_media_links",
188
+ description: "Get direct YouTube video watch links (watch?v=...), official YouTube search queries (results?search_query=...), Bilibili links, and streaming URLs for any Ju Jingyi song, MV, live stage, or drama trailer.",
189
+ parameters: {
190
+ type: "object",
191
+ properties: {
192
+ query: { type: "string", description: "Song, drama, stage, or MV name (e.g. 'FIND THE WAY', 'Rain Love', 'Hong Zhao Yuan', 'In Blossom', 'Veil of Shadows', 'Luo Hua Cheng Ni')" },
193
+ category: { type: "string", enum: ["all", "mv", "stage", "ost", "single", "trailer"], description: "Optional media type filter" }
194
+ },
195
+ required: ["query"]
196
+ }
197
+ }
198
+ },
199
+ {
200
+ type: "function",
201
+ function: {
202
+ name: "get_brand_endorsements",
203
+ description: "Retrieve brand endorsements, commercial ambassadorships, luxury fashion, and beauty partnerships of Ju Jingyi.",
204
+ parameters: {
205
+ type: "object",
206
+ properties: {
207
+ isGlobal: { type: "boolean", description: "Filter for Global Brand Ambassador roles" },
208
+ year: { type: "number", description: "Year of endorsement (e.g. 2026, 2025, 2024). Leave empty for latest." },
209
+ category: { type: "string", description: "Category filter (Luxury, Beauty, FMCG, Tech)" },
210
+ query: { type: "string", description: "Brand name keyword or search term" }
211
+ },
212
+ required: []
213
+ }
214
+ }
215
+ },
216
+ {
217
+ type: "function",
218
+ function: {
219
+ name: "get_career_timeline",
220
+ description: "Explore Ju Jingyi's career milestones and chronological eras (SNH48 Era, Solo Studio, Breakthrough Dramas, Present).",
221
+ parameters: {
222
+ type: "object",
223
+ properties: {
224
+ year: { type: "number", description: "Year filter (2013-2026)" },
225
+ era: { type: "string", description: "Era keyword" }
226
+ },
227
+ required: []
228
+ }
229
+ }
230
+ },
231
+ {
232
+ type: "function",
233
+ function: {
234
+ name: "get_awards",
235
+ description: "Query awards and honors received by Ju Jingyi across music, television, and cultural influence.",
236
+ parameters: {
237
+ type: "object",
238
+ properties: {
239
+ category: { type: "string", description: "Category (Acting, Music, Honor)" },
240
+ year: { type: "number", description: "Year filter" }
241
+ },
242
+ required: []
243
+ }
244
+ }
245
+ },
246
+ {
247
+ type: "function",
248
+ function: {
249
+ name: "get_social_feed",
250
+ description: "Get recent social media updates from Weibo and Douyin.",
251
+ parameters: {
252
+ type: "object",
253
+ properties: {
254
+ platform: { type: "string", enum: ["all", "weibo", "douyin"] },
255
+ limit: { type: "number", description: "Number of posts (default 4)" }
256
+ },
257
+ required: []
258
+ }
259
+ }
260
+ },
261
+ {
262
+ type: "function",
263
+ function: {
264
+ name: "get_gallery_photos",
265
+ description: "Search visual photo gallery of Ju Jingyi (red carpet, photoshoot, drama stills).",
266
+ parameters: {
267
+ type: "object",
268
+ properties: {
269
+ category: { type: "string", description: "Category (fashion-magazine, red-carpet, drama-stills)" },
270
+ limit: { type: "number", description: "Number of photos (default 4)" }
271
+ },
272
+ required: []
273
+ }
274
+ }
275
+ },
276
+ {
277
+ type: "function",
278
+ function: {
279
+ name: "global_search",
280
+ description: "Universal search across ALL Ju Jingyi entities (works, songs, roles, brands, awards, videos) with YouTube and media links.",
281
+ parameters: {
282
+ type: "object",
283
+ properties: {
284
+ query: { type: "string", description: "Search term to match across all collections" }
285
+ },
286
+ required: ["query"]
287
+ }
288
+ }
289
+ },
290
+ {
291
+ type: "function",
292
+ function: {
293
+ name: "web_search",
294
+ description: "Search the LIVE internet for real-time news, recent updates, social media trends, Weibo topics, or external info about Ju Jingyi and entertainment topics.",
295
+ parameters: {
296
+ type: "object",
297
+ properties: {
298
+ query: { type: "string", description: "Search query keyword" }
299
+ },
300
+ required: ["query"]
301
+ }
302
+ }
303
+ },
304
+ {
305
+ type: "function",
306
+ function: {
307
+ name: "fetch_webpage",
308
+ description: "Fetch and read the text content of a specific public web page URL. Returns extracted text — read the content field carefully and extract specific facts (names, dates, titles, descriptions) for your answer. NOTE: This cannot read SPA/React sites — use fetch_api for jujingyi-kikuuu.vercel.app.",
309
+ parameters: {
310
+ type: "object",
311
+ properties: {
312
+ url: { type: "string", description: "The full http/https URL of the web page to read" }
313
+ },
314
+ required: ["url"]
315
+ }
316
+ }
317
+ },
318
+ {
319
+ type: "function",
320
+ function: {
321
+ name: "fetch_api",
322
+ description: "Query the Ju Jingyi Official REST API (jujingyi-kikuuu.vercel.app/api/v1) for structured JSON data. Use this instead of fetch_webpage when the user shares a link to the platform site, or when you need live/verified data from the official API.",
323
+ parameters: {
324
+ type: "object",
325
+ properties: {
326
+ endpoint: { type: "string", description: "API endpoint path (e.g. 'profile', 'works', 'music', 'commercial', 'milestones', 'honors', 'social', 'search', 'stats', 'quotes', 'fandom', 'character-roles', 'gallery')" },
327
+ query: { type: "string", description: "Optional search/filter query parameter" },
328
+ baseUrl: { type: "string", description: "Optional override base URL (defaults to https://jujingyi-kikuuu.vercel.app/api/v1)" }
329
+ },
330
+ required: ["endpoint"]
331
+ }
332
+ }
333
+ },
334
+ {
335
+ type: "function",
336
+ function: {
337
+ name: "fetch_json",
338
+ description: "Fetch data from any REST API or JSON endpoint. Returns parsed JSON data with status code, headers, and content. Use for public APIs, weather, translation, definitions, or any JSON-based service.",
339
+ parameters: {
340
+ type: "object",
341
+ properties: {
342
+ url: { type: "string", description: "The full HTTP/HTTPS URL of the JSON API endpoint" },
343
+ method: { type: "string", enum: ["GET", "POST", "PUT", "PATCH", "DELETE"], description: "HTTP method (default GET)" },
344
+ headers: { type: "object", description: "Optional custom headers as key-value pairs" },
345
+ body: { description: "Optional request body (for POST/PUT/PATCH). Can be a JSON object or string." }
346
+ },
347
+ required: ["url"]
348
+ }
349
+ }
350
+ },
351
+ {
352
+ type: "function",
353
+ function: {
354
+ name: "read_local_file",
355
+ description: "Read the contents of a local file in the project directory. Use for reading source code, config files, data files, markdown docs, etc. Returns the text content with file metadata.",
356
+ parameters: {
357
+ type: "object",
358
+ properties: {
359
+ path: { type: "string", description: "Relative file path from project root (e.g. 'package.json', 'src/App.jsx', 'README.md')" },
360
+ maxLines: { type: "number", description: "Maximum number of lines to read (default 200). Use for large files." }
361
+ },
362
+ required: ["path"]
363
+ }
364
+ }
365
+ },
366
+ {
367
+ type: "function",
368
+ function: {
369
+ name: "list_directory",
370
+ description: "List files and subdirectories in a project directory. Use to explore project structure, find files, or understand codebase layout.",
371
+ parameters: {
372
+ type: "object",
373
+ properties: {
374
+ path: { type: "string", description: "Relative directory path from project root (e.g. 'src', 'server/agent', '.')" },
375
+ recursive: { type: "boolean", description: "If true, list recursively (max 3 levels deep). Default false." }
376
+ },
377
+ required: []
378
+ }
379
+ }
380
+ },
381
+ {
382
+ type: "function",
383
+ function: {
384
+ name: "screenshot_webpage",
385
+ description: "Take a screenshot of a webpage using Chrome headless. Returns base64 PNG image data. Requires Google Chrome installed. Use for visual verification of pages, layouts, or UI elements.",
386
+ parameters: {
387
+ type: "object",
388
+ properties: {
389
+ url: { type: "string", description: "The full HTTP/HTTPS URL to screenshot" },
390
+ width: { type: "number", description: "Viewport width in pixels (default 1280)" },
391
+ height: { type: "number", description: "Viewport height in pixels (default 800)" }
392
+ },
393
+ required: ["url"]
394
+ }
395
+ }
396
+ },
397
+ {
398
+ type: "function",
399
+ function: {
400
+ name: "web_extract",
401
+ description: "Auto-crawl: search the web AND fetch+extract content from top results in one call. Returns structured page data (meta, headings, paragraphs, links, images) from each result. Use when you need comprehensive info from multiple sources on a topic.",
402
+ parameters: {
403
+ type: "object",
404
+ properties: {
405
+ query: { type: "string", description: "Search query to find and extract content about" },
406
+ maxResults: { type: "number", description: "Max pages to fetch (default 3, max 5). More = slower but more comprehensive." },
407
+ allowedDomains: { type: "array", items: { type: "string" }, description: "Only fetch from these domains (e.g. ['wikipedia.org', 'youtube.com']). Empty = all domains." }
408
+ },
409
+ required: ["query"]
410
+ }
411
+ }
412
+ },
413
+ {
414
+ type: "function",
415
+ function: {
416
+ name: "get_system_stats",
417
+ description: "Get high-level summary metrics of the entire Ju Jingyi database.",
418
+ parameters: {
419
+ type: "object",
420
+ properties: {},
421
+ required: []
422
+ }
423
+ }
424
+ },
425
+ {
426
+ type: "function",
427
+ function: {
428
+ name: "play_music",
429
+ description: "Play a specific song, OST, or music video audio directly in the user's terminal.",
430
+ parameters: {
431
+ type: "object",
432
+ properties: {
433
+ query: {
434
+ type: "string",
435
+ description: "The name of the song and artist (e.g., 'Ju Jingyi FIND THE WAY', 'In Blossom OST Ju Jingyi')"
436
+ }
437
+ },
438
+ required: ["query"]
439
+ }
440
+ }
441
+ },
442
+ {
443
+ type: "function",
444
+ function: {
445
+ name: "stop_music",
446
+ description: "Stop the currently playing music.",
447
+ parameters: { type: "object", properties: {} }
448
+ }
449
+ },
450
+ {
451
+ type: "function",
452
+ function: {
453
+ name: "mute_music",
454
+ description: "Mute the currently playing music.",
455
+ parameters: { type: "object", properties: {} }
456
+ }
457
+ },
458
+ {
459
+ type: "function",
460
+ function: {
461
+ name: "unmute_music",
462
+ description: "Unmute the currently playing music.",
463
+ parameters: { type: "object", properties: {} }
464
+ }
465
+ },
466
+ {
467
+ type: "function",
468
+ function: {
469
+ name: "pause_music",
470
+ description: "Pause the currently playing music.",
471
+ parameters: { type: "object", properties: {} }
472
+ }
473
+ },
474
+ {
475
+ type: "function",
476
+ function: {
477
+ name: "resume_music",
478
+ description: "Resume paused music.",
479
+ parameters: { type: "object", properties: {} }
480
+ }
481
+ },
482
+ {
483
+ type: "function",
484
+ function: {
485
+ name: "next_track",
486
+ description: "Skip to the next track in the queue.",
487
+ parameters: { type: "object", properties: {} }
488
+ }
489
+ },
490
+ {
491
+ type: "function",
492
+ function: {
493
+ name: "prev_track",
494
+ description: "Go back to the previous track in the queue.",
495
+ parameters: { type: "object", properties: {} }
496
+ }
497
+ },
498
+ {
499
+ type: "function",
500
+ function: {
501
+ name: "add_to_queue",
502
+ description: "Add a song to the playback queue.",
503
+ parameters: {
504
+ type: "object",
505
+ properties: {
506
+ query: {
507
+ type: "string",
508
+ description: "The name of the song and artist to queue."
509
+ }
510
+ },
511
+ required: ["query"]
512
+ }
513
+ }
514
+ },
515
+ {
516
+ type: "function",
517
+ function: {
518
+ name: "remove_from_queue",
519
+ description: "Remove a song from the queue by index.",
520
+ parameters: {
521
+ type: "object",
522
+ properties: {
523
+ index: {
524
+ type: "number",
525
+ description: "0-based index of the song in the queue to remove."
526
+ }
527
+ },
528
+ required: ["index"]
529
+ }
530
+ }
531
+ },
532
+ {
533
+ type: "function",
534
+ function: {
535
+ name: "clear_queue",
536
+ description: "Clear the entire playback queue and stop music.",
537
+ parameters: { type: "object", properties: {} }
538
+ }
539
+ },
540
+ {
541
+ type: "function",
542
+ function: {
543
+ name: "get_queue",
544
+ description: "Get the current playback queue list.",
545
+ parameters: { type: "object", properties: {} }
546
+ }
547
+ },
548
+ {
549
+ type: "function",
550
+ function: {
551
+ name: "search_youtube",
552
+ description: "Search YouTube for music videos or audio and return top results.",
553
+ parameters: {
554
+ type: "object",
555
+ properties: {
556
+ query: {
557
+ type: "string",
558
+ description: "Search query for YouTube."
559
+ }
560
+ },
561
+ required: ["query"]
562
+ }
563
+ }
564
+ },
565
+ {
566
+ type: "function",
567
+ function: {
568
+ name: "get_current_track",
569
+ description: "Get information about the currently playing or paused track.",
570
+ parameters: { type: "object", properties: {} }
571
+ }
572
+ },
573
+ {
574
+ type: "function",
575
+ function: {
576
+ name: "get_music_status",
577
+ description: "Get full music player status including playing state, current track, and queue info.",
578
+ parameters: { type: "object", properties: {} }
579
+ }
580
+ },
581
+ {
582
+ type: "function",
583
+ function: {
584
+ name: "run_subagent",
585
+ description: "Spawn an isolated research subagent with dedicated tool calling capabilities to investigate a specific Ju Jingyi research task or subtopic and return an evidence report.",
586
+ parameters: {
587
+ type: "object",
588
+ properties: {
589
+ task: { type: "string", description: "The specific research task or question to delegate to the subagent." }
590
+ },
591
+ required: ["task"]
592
+ }
593
+ }
594
+ },
595
+ {
596
+ type: "function",
597
+ function: {
598
+ name: "run_deep_research",
599
+ description: "Run multi-worker research orchestrator (3 parallel subagents: local catalog, live web sources, and independent verifier) to comprehensively research a complex question and synthesize a cited answer.",
600
+ parameters: {
601
+ type: "object",
602
+ properties: {
603
+ question: { type: "string", description: "The complex question requiring multi-angle research." }
604
+ },
605
+ required: ["question"]
606
+ }
607
+ }
608
+ },
609
+ {
610
+ type: "function",
611
+ function: {
612
+ name: "verify_claim",
613
+ description: "Verify a claim, rumor, cast leak, or unconfirmed news about Ju Jingyi across official catalog and web corroboration, returning a strict verdict (VERIFIED, UNVERIFIED, CONFLICTING, or FALSE) with sources.",
614
+ parameters: {
615
+ type: "object",
616
+ properties: {
617
+ claim: { type: "string", description: "The statement, rumor, or claim to verify." }
618
+ },
619
+ required: ["claim"]
620
+ }
621
+ }
622
+ },
623
+ {
624
+ type: "function",
625
+ function: {
626
+ name: "manage_skill",
627
+ description: "Learn or manage persistent response skills and persona preferences for Kiku AI (e.g. learning 'ตอบภาษาไทยแบบสั้นกระชับ', listing skills, toggling on/off, removing or clearing skills).",
628
+ parameters: {
629
+ type: "object",
630
+ properties: {
631
+ action: {
632
+ type: "string",
633
+ enum: ["learn", "list", "remove", "clear", "enable", "disable"],
634
+ description: "The skill action to perform."
635
+ },
636
+ instruction: {
637
+ type: "string",
638
+ description: "The response instruction to learn (required for 'learn')."
639
+ },
640
+ skillId: {
641
+ type: "string",
642
+ description: "The ID of the skill to remove (required for 'remove')."
643
+ }
644
+ },
645
+ required: ["action"]
646
+ }
647
+ }
648
+ }
649
+ ];
650
+
651
+ export const toolHandlers = {
652
+ get_profile_and_fandom: async () => {
653
+ const d = profileData.details || {};
654
+ return {
655
+ status: "success",
656
+ profile: {
657
+ nameTh: profileData.nameTh,
658
+ nameCn: profileData.nameCn,
659
+ nameEn: profileData.nameEn,
660
+ nickname: profileData.nickname,
661
+ birthDate: d.birthDate,
662
+ birthPlace: d.birthPlace,
663
+ height: d.height,
664
+ bloodType: d.bloodType,
665
+ zodiac: d.zodiac,
666
+ education: d.education,
667
+ debutDate: d.debutDate,
668
+ graduationDate: d.graduationDate,
669
+ currentStatus: d.currentStatus,
670
+ hobbies: d.hobbies,
671
+ favoriteFoods: d.favoriteFoods,
672
+ talentsSummary: d.talentsSummary,
673
+ fandomName: d.fandomName || "มี่จวี (Miju / 蜜橘)",
674
+ fandomColor: d.fandomColorName || "ฟ้าคริสตัล & ชมพูซากุระ",
675
+ slogan: profileData.motto || "保持热爱,奔赴山海",
676
+ youtubeSearchUrl: buildYouTubeSearchUrl("Ju Jingyi 鞠婧祎")
677
+ }
678
+ };
679
+ },
680
+
681
+ get_quotes: async ({ tag } = {}) => {
682
+ let quotes = profileData.quotes || [];
683
+ if (tag) {
684
+ const qLower = tag.toLowerCase();
685
+ quotes = quotes.filter(q =>
686
+ (q.tag && q.tag.toLowerCase().includes(qLower)) ||
687
+ (q.textTh && q.textTh.toLowerCase().includes(qLower)) ||
688
+ (q.textCn && q.textCn.includes(tag))
689
+ );
690
+ }
691
+ return {
692
+ status: "success",
693
+ count: quotes.length,
694
+ quotes: quotes.slice(0, 5).map(q => ({
695
+ textTh: q.textTh,
696
+ textCn: q.textCn,
697
+ tag: q.tag,
698
+ context: q.context
699
+ }))
700
+ };
701
+ },
702
+
703
+ search_works: async ({ query, year, category, featuredOnly } = {}) => {
704
+ let results = [...worksData];
705
+
706
+ if (year) results = results.filter(w => w.year === Number(year));
707
+ if (category && category !== 'all') results = results.filter(w => w.category === category);
708
+
709
+ const genericArtistKeywords = ['ju jingyi', 'jujingyi', 'จวีจิ้งอี', 'kiku', 'เสี่ยวจวี', 'kikuuu', 'ศิลปิน', 'ดารา', 'งานล่าสุด', 'ผลงานล่าสุด', 'ล่าสุด', 'latest', 'newest', 'recent', 'upcoming', 'ละคร'];
710
+ const isGenericArtistQuery = query && genericArtistKeywords.some(k => query.toLowerCase().trim() === k);
711
+
712
+ if (query && !isGenericArtistQuery) {
713
+ const q = query.toLowerCase().trim();
714
+ results = results.filter(w =>
715
+ (w.titleTh && w.titleTh.toLowerCase().includes(q)) ||
716
+ (w.titleEn && w.titleEn.toLowerCase().includes(q)) ||
717
+ (w.titleCn && w.titleCn.includes(query)) ||
718
+ (w.role && w.role.toLowerCase().includes(q)) ||
719
+ (w.roleCn && w.roleCn.includes(query))
720
+ );
721
+ }
722
+
723
+ if (featuredOnly && results.some(w => !!w.featured)) {
724
+ results = results.filter(w => !!w.featured);
725
+ }
726
+
727
+ results.sort((a, b) => (b.year || 0) - (a.year || 0));
728
+
729
+ return {
730
+ status: "success",
731
+ count: results.length,
732
+ works: results.slice(0, 6).map(w => {
733
+ const titleSearch = `Ju Jingyi 鞠婧祎 ${w.titleCn || w.titleEn || w.titleTh}`;
734
+ const themeSongYtId = w.themeSong?.youtubeId || null;
735
+ return {
736
+ id: w.id,
737
+ titleTh: w.titleTh,
738
+ titleCn: w.titleCn,
739
+ titleEn: w.titleEn,
740
+ year: w.year,
741
+ category: w.category,
742
+ role: w.role,
743
+ roleCn: w.roleCn,
744
+ episodes: w.episodes,
745
+ rating: w.rating,
746
+ platform: w.streamPlatform,
747
+ streamUrl: w.streamUrl || null,
748
+ youtubeTrailerSearch: buildYouTubeSearchUrl(`${titleSearch} Trailer Official`),
749
+ youkuSearchUrl: buildYoukuSearchUrl(w.titleCn || w.titleEn),
750
+ themeSong: w.themeSong ? {
751
+ title: `${w.themeSong.titleTh || ''} (${w.themeSong.titleCn || ''})`,
752
+ youtubeUrl: themeSongYtId ? buildYouTubeWatchUrl(themeSongYtId) : null,
753
+ youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.themeSong.titleCn || w.themeSong.titleTh} OST`)
754
+ } : null
755
+ };
756
+ })
757
+ };
758
+ },
759
+
760
+ get_work_detail: async ({ workIdOrTitle }) => {
761
+ if (!workIdOrTitle) return { status: "error", message: "workIdOrTitle parameter is required." };
762
+ const q = workIdOrTitle.toLowerCase().trim();
763
+ const work = worksData.find(w =>
764
+ w.id.toLowerCase() === q ||
765
+ w.titleTh.toLowerCase().includes(q) ||
766
+ (w.titleEn && w.titleEn.toLowerCase().includes(q)) ||
767
+ (w.titleCn && w.titleCn.includes(workIdOrTitle))
768
+ );
769
+
770
+ if (!work) {
771
+ return { status: "not_found", message: `No work found for "${workIdOrTitle}".` };
772
+ }
773
+
774
+ const titleSearch = `Ju Jingyi 鞠婧祎 ${work.titleCn || work.titleEn || work.titleTh}`;
775
+ const themeSongYtId = work.themeSong?.youtubeId || null;
776
+
777
+ return {
778
+ status: "success",
779
+ work: {
780
+ id: work.id,
781
+ titleTh: work.titleTh,
782
+ titleCn: work.titleCn,
783
+ titleEn: work.titleEn,
784
+ year: work.year,
785
+ category: work.category,
786
+ role: work.role,
787
+ episodes: work.episodes,
788
+ rating: work.rating,
789
+ platform: work.streamPlatform,
790
+ streamUrl: work.streamUrl || null,
791
+ youtubeTrailerSearch: buildYouTubeSearchUrl(`${titleSearch} Trailer Official`),
792
+ youkuSearchUrl: buildYoukuSearchUrl(work.titleCn || work.titleEn),
793
+ bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${work.titleCn}`),
794
+ synopsis: work.synopsis?.slice(0, 250) + (work.synopsis?.length > 250 ? '...' : ''),
795
+ highlights: (work.highlights || []).slice(0, 3),
796
+ themeSong: work.themeSong ? {
797
+ title: `${work.themeSong.titleTh || ''} (${work.themeSong.titleCn || ''})`,
798
+ youtubeUrl: themeSongYtId ? buildYouTubeWatchUrl(themeSongYtId) : null,
799
+ youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${work.themeSong.titleCn || work.themeSong.titleTh} OST`)
800
+ } : null
801
+ }
802
+ };
803
+ },
804
+
805
+ get_character_roles: async ({ query } = {}) => {
806
+ const roles = worksData.map(w => ({
807
+ workTitle: `${w.titleTh} (${w.titleCn})`,
808
+ year: w.year,
809
+ role: w.role,
810
+ roleCn: w.roleCn,
811
+ youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.roleCn || w.role} ${w.titleCn}`)
812
+ }));
813
+
814
+ let filtered = roles;
815
+ const genericArtistKeywords = ['ju jingyi', 'jujingyi', 'จวีจิ้งอี', 'kiku', 'เสี่ยวจวี', 'kikuuu'];
816
+ const isGeneric = query && genericArtistKeywords.some(k => query.toLowerCase().trim() === k);
817
+
818
+ if (query && !isGeneric) {
819
+ const q = query.toLowerCase().trim();
820
+ filtered = roles.filter(r =>
821
+ (r.role && r.role.toLowerCase().includes(q)) ||
822
+ (r.roleCn && r.roleCn.includes(query)) ||
823
+ (r.workTitle && r.workTitle.toLowerCase().includes(q))
824
+ );
825
+ }
826
+
827
+ filtered.sort((a, b) => (b.year || 0) - (a.year || 0));
828
+
829
+ return {
830
+ status: "success",
831
+ count: filtered.length,
832
+ roles: filtered.slice(0, 8)
833
+ };
834
+ },
835
+
836
+ search_discography: async ({ query, year, type } = {}) => {
837
+ let list = getCleanDiscography();
838
+
839
+ if (year) list = list.filter(s => s.year === Number(year));
840
+ if (type === 'ost') list = list.filter(s => s.work && s.work !== "Solo Single");
841
+ if (type === 'single') list = list.filter(s => !s.work || s.work === "Solo Single");
842
+
843
+ const genericArtistKeywords = ['ju jingyi', 'jujingyi', 'จวีจิ้งอี', 'kiku', 'เสี่ยวจวี', 'kikuuu', 'เพลง', 'เพลงทั้งหมด', 'เพลงใหม่', 'music', 'song', 'songs'];
844
+ const isGeneric = query && genericArtistKeywords.some(k => query.toLowerCase().trim() === k);
845
+
846
+ if (query && !isGeneric) {
847
+ const q = query.toLowerCase().trim();
848
+ list = list.filter(s =>
849
+ (s.titleTh && s.titleTh.toLowerCase().includes(q)) ||
850
+ (s.titleCn && s.titleCn.includes(query)) ||
851
+ (s.titleEn && s.titleEn.toLowerCase().includes(q)) ||
852
+ (s.work && s.work.toLowerCase().includes(q))
853
+ );
854
+ }
855
+
856
+ return {
857
+ status: list.length > 0 ? "success" : "not_found",
858
+ message: list.length > 0 ? undefined : `No matching discography entries for "${query || 'this filter'}".`,
859
+ count: list.length,
860
+ songs: list.slice(0, 8).map(s => ({
861
+ id: s.id,
862
+ title: `${s.titleTh} (${s.titleCn})`,
863
+ titleEn: s.titleEn,
864
+ year: s.year,
865
+ type: s.work,
866
+ duration: s.duration,
867
+ youtubeUrl: s.youtubeUrl || (s.youtubeId ? buildYouTubeWatchUrl(s.youtubeId) : null),
868
+ youtubeSearchUrl: s.youtubeSearchUrl || buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${s.titleCn || s.titleTh}`),
869
+ qqMusicSearchUrl: s.qqMusicSearchUrl || buildQqMusicSearchUrl(`鞠婧祎 ${s.titleCn || s.titleTh}`),
870
+ bilibiliSearchUrl: s.bilibiliSearchUrl || buildBilibiliSearchUrl(`鞠婧祎 ${s.titleCn || s.titleTh}`)
871
+ }))
872
+ };
873
+ },
874
+
875
+ get_music_videos: async ({ category, query } = {}) => {
876
+ const allMVs = getAllMusicVideos();
877
+
878
+ let results = allMVs;
879
+ if (category) {
880
+ const catLower = category.toLowerCase();
881
+ results = results.filter(m => {
882
+ const mCat = (m.category || '').toLowerCase();
883
+ if (catLower.includes('mv') || catLower.includes('official')) return mCat.includes('mv') || mCat === 'single' || mCat === 'ost';
884
+ if (catLower.includes('stage') || catLower.includes('performance')) return mCat.includes('stage');
885
+ if (catLower.includes('ost')) return mCat === 'ost';
886
+ if (catLower.includes('single')) return mCat === 'single';
887
+ return mCat.includes(catLower);
888
+ });
889
+ }
890
+
891
+ const genericArtistKeywords = ['ju jingyi', 'jujingyi', 'จวีจิ้งอี', 'kiku', 'เสี่ยวจวี', 'kikuuu', 'mv', 'music video', 'วิดีโอ', 'คลิป'];
892
+ const isGeneric = query && genericArtistKeywords.some(k => query.toLowerCase().trim() === k);
893
+
894
+ if (query && !isGeneric) {
895
+ const q = query.toLowerCase().trim();
896
+ results = results.filter(m =>
897
+ (m.titleTh && m.titleTh.toLowerCase().includes(q)) ||
898
+ (m.titleCn && m.titleCn.includes(query)) ||
899
+ (m.titlePinyin && m.titlePinyin.toLowerCase().includes(q)) ||
900
+ (m.dramaName && m.dramaName.toLowerCase().includes(q)) ||
901
+ (m.description && m.description.toLowerCase().includes(q))
902
+ );
903
+ }
904
+
905
+ return {
906
+ status: "success",
907
+ count: results.length,
908
+ videos: results.slice(0, 8).map(m => {
909
+ const ytId = m.youtubeId || null;
910
+ const searchTitle = `Ju Jingyi 鞠婧祎 ${m.titleCn || m.titleTh} ${m.category === 'Stage-Performance' ? 'Stage Live' : 'Official MV'}`;
911
+ return {
912
+ id: m.id,
913
+ title: `${m.titleTh} (${m.titleCn})`,
914
+ category: m.category,
915
+ year: m.releaseYear || m.year,
916
+ drama: m.dramaName || null,
917
+ duration: m.duration || null,
918
+ description: m.description || null,
919
+ isFeatured: !!m.isFeatured,
920
+ youtubeUrl: ytId ? buildYouTubeWatchUrl(ytId) : null,
921
+ youtubeSearchUrl: buildYouTubeSearchUrl(searchTitle),
922
+ bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${m.titleCn || m.titleTh}`)
923
+ };
924
+ })
925
+ };
926
+ },
927
+
928
+ get_media_links: async ({ query, category } = {}) => {
929
+ if (!query) return { status: "error", message: "query parameter is required." };
930
+ const q = query.toLowerCase().trim();
931
+
932
+ const mvs = getAllMusicVideos();
933
+ const disc = getCleanDiscography();
934
+ const works = worksData;
935
+
936
+ const matchedVideos = mvs.filter(m =>
937
+ (m.titleTh && m.titleTh.toLowerCase().includes(q)) ||
938
+ (m.titleCn && m.titleCn.includes(query)) ||
939
+ (m.titlePinyin && m.titlePinyin.toLowerCase().includes(q)) ||
940
+ (m.dramaName && m.dramaName.toLowerCase().includes(q))
941
+ ).map(m => ({
942
+ type: m.category || 'MV',
943
+ title: `${m.titleTh} (${m.titleCn})`,
944
+ year: m.releaseYear || m.year,
945
+ duration: m.duration,
946
+ youtubeUrl: m.youtubeId ? buildYouTubeWatchUrl(m.youtubeId) : null,
947
+ youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${m.titleCn || m.titleTh} ${m.category === 'Stage-Performance' ? 'Stage' : 'Official MV'}`),
948
+ bilibiliSearchUrl: buildBilibiliSearchUrl(`鞠婧祎 ${m.titleCn || m.titleTh}`)
949
+ }));
950
+
951
+ const matchedSongs = disc.filter(s =>
952
+ (s.titleTh && s.titleTh.toLowerCase().includes(q)) ||
953
+ (s.titleCn && s.titleCn.includes(query)) ||
954
+ (s.titleEn && s.titleEn.toLowerCase().includes(q)) ||
955
+ (s.work && s.work.toLowerCase().includes(q))
956
+ ).map(s => ({
957
+ type: s.work || 'Song',
958
+ title: `${s.titleTh} (${s.titleCn})`,
959
+ year: s.year,
960
+ duration: s.duration,
961
+ youtubeUrl: s.youtubeUrl || (s.youtubeId ? buildYouTubeWatchUrl(s.youtubeId) : null),
962
+ youtubeSearchUrl: s.youtubeSearchUrl || buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${s.titleCn || s.titleTh}`),
963
+ qqMusicSearchUrl: s.qqMusicSearchUrl || buildQqMusicSearchUrl(`鞠婧祎 ${s.titleCn || s.titleTh}`),
964
+ bilibiliSearchUrl: s.bilibiliSearchUrl || buildBilibiliSearchUrl(`鞠婧祎 ${s.titleCn || s.titleTh}`)
965
+ }));
966
+
967
+ const matchedWorks = works.filter(w =>
968
+ (w.titleTh && w.titleTh.toLowerCase().includes(q)) ||
969
+ (w.titleCn && w.titleCn.includes(query)) ||
970
+ (w.titleEn && w.titleEn.toLowerCase().includes(q))
971
+ ).map(w => ({
972
+ type: 'Drama / Series',
973
+ title: `${w.titleTh} (${w.titleCn})`,
974
+ year: w.year,
975
+ platform: w.streamPlatform,
976
+ streamUrl: w.streamUrl || null,
977
+ youtubeTrailerSearch: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.titleCn || w.titleEn} Trailer Official`),
978
+ youkuSearchUrl: buildYoukuSearchUrl(w.titleCn || w.titleEn),
979
+ themeSong: w.themeSong ? {
980
+ title: `${w.themeSong.titleTh || ''} (${w.themeSong.titleCn || ''})`,
981
+ youtubeUrl: w.themeSong.youtubeId ? buildYouTubeWatchUrl(w.themeSong.youtubeId) : null,
982
+ youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.themeSong.titleCn || w.themeSong.titleTh} OST`)
983
+ } : null
984
+ }));
985
+
986
+ const generalYouTubeSearch = buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${query}`);
987
+ const generalBilibiliSearch = buildBilibiliSearchUrl(`鞠婧祎 ${query}`);
988
+
989
+ return {
990
+ status: "success",
991
+ query,
992
+ fallbackYouTubeSearch: generalYouTubeSearch,
993
+ fallbackBilibiliSearch: generalBilibiliSearch,
994
+ results: {
995
+ musicVideos: matchedVideos.slice(0, 5),
996
+ songs: matchedSongs.slice(0, 5),
997
+ works: matchedWorks.slice(0, 3)
998
+ }
999
+ };
1000
+ },
1001
+
1002
+ get_brand_endorsements: async ({ isGlobal, category, year, query } = {}) => {
1003
+ let list = Array.isArray(brandsData) ? [...brandsData] : [...(brandsData.brandList || brandsData.brands || [])];
1004
+
1005
+ if (typeof isGlobal === 'boolean') list = list.filter(b => !!b.isGlobal === isGlobal);
1006
+ if (year) list = list.filter(b => b.year === Number(year));
1007
+ if (category) list = list.filter(b => (b.category && b.category.toLowerCase().includes(category.toLowerCase())) || (b.tier && b.tier.toLowerCase().includes(category.toLowerCase())));
1008
+
1009
+ const genericArtistKeywords = ['ju jingyi', 'jujingyi', 'จวีจิ้งอี', 'kiku', 'เสี่ยวจวี', 'kikuuu', 'แบรนด์', 'พรีเซนเตอร์', 'brand', 'ambassador', 'endorsement', 'commercial'];
1010
+ const isGeneric = query && genericArtistKeywords.some(k => query.toLowerCase().trim() === k);
1011
+
1012
+ if (query && !isGeneric) {
1013
+ const q = query.toLowerCase().trim();
1014
+ const numYear = Number(q);
1015
+ if (!isNaN(numYear) && numYear >= 2013 && numYear <= 2030) {
1016
+ list = list.filter(b => b.year === numYear);
1017
+ } else {
1018
+ list = list.filter(b =>
1019
+ (b.brandNameEn && b.brandNameEn.toLowerCase().includes(q)) ||
1020
+ (b.brandNameCn && b.brandNameCn.includes(query)) ||
1021
+ (b.title && b.title.toLowerCase().includes(q)) ||
1022
+ (b.category && b.category.toLowerCase().includes(q)) ||
1023
+ (b.highlight && b.highlight.toLowerCase().includes(q)) ||
1024
+ (b.tier && b.tier.toLowerCase().includes(q)) ||
1025
+ (b.name && b.name.toLowerCase().includes(q))
1026
+ );
1027
+ }
1028
+ }
1029
+
1030
+ list.sort((a, b) => (b.year || 0) - (a.year || 0));
1031
+
1032
+ return {
1033
+ status: "success",
1034
+ count: list.length,
1035
+ brands: list.slice(0, 8).map(b => ({
1036
+ brand: `${b.brandNameEn || b.brand || b.name} (${b.brandNameCn || ''})`,
1037
+ year: b.year,
1038
+ title: b.title || (b.isGlobal ? 'Global Brand Ambassador' : 'Brand Ambassador'),
1039
+ category: b.category,
1040
+ isGlobal: !!b.isGlobal,
1041
+ highlight: b.highlight || '',
1042
+ youtubeCommercialSearch: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${b.brandNameEn || b.brandNameCn} CF Commercial`)
1043
+ }))
1044
+ };
1045
+ },
1046
+
1047
+ get_career_timeline: async ({ year, era } = {}) => {
1048
+ let eras = Array.isArray(timelineData) ? timelineData : (timelineData.eras || []);
1049
+ if (year) eras = eras.filter(e => e.year == year || (e.period && e.period.includes(String(year))));
1050
+ if (era) eras = eras.filter(e => e.title?.toLowerCase().includes(era.toLowerCase()) || e.era?.includes(era));
1051
+
1052
+ return {
1053
+ status: "success",
1054
+ count: eras.length,
1055
+ milestones: eras.slice(0, 6).map(e => ({
1056
+ period: e.period || e.year,
1057
+ title: `${e.title} (${e.titleCn || ''})`,
1058
+ summary: e.summary?.slice(0, 150),
1059
+ youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${e.titleCn || e.title}`)
1060
+ }))
1061
+ };
1062
+ },
1063
+
1064
+ get_awards: async ({ category, year } = {}) => {
1065
+ let list = Array.isArray(awardsData) ? awardsData : (awardsData.awards || []);
1066
+ if (year) list = list.filter(a => a.year === Number(year));
1067
+ if (category && category !== 'all') list = list.filter(a => a.category?.toLowerCase().includes(category.toLowerCase()));
1068
+
1069
+ return {
1070
+ status: "success",
1071
+ count: list.length,
1072
+ awards: list.slice(0, 8).map(a => ({
1073
+ year: a.year,
1074
+ title: `${a.titleTh} (${a.titleCn})`,
1075
+ ceremony: a.ceremony,
1076
+ category: a.category,
1077
+ youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${a.titleCn || a.ceremony}`)
1078
+ }))
1079
+ };
1080
+ },
1081
+
1082
+ get_social_feed: async ({ platform, limit = 4 } = {}) => {
1083
+ let posts = socialUpdatesData.cards || [];
1084
+ if (platform && platform !== 'all') posts = posts.filter(p => p.platform?.toLowerCase() === platform.toLowerCase());
1085
+
1086
+ return {
1087
+ status: "success",
1088
+ count: Math.min(posts.length, Number(limit) || 4),
1089
+ posts: posts.slice(0, Number(limit) || 4).map(p => ({
1090
+ platform: p.platform,
1091
+ date: p.date,
1092
+ content: p.content?.slice(0, 120) + '...',
1093
+ likes: p.likes
1094
+ }))
1095
+ };
1096
+ },
1097
+
1098
+ get_gallery_photos: async ({ category, limit = 4 } = {}) => {
1099
+ let photos = Array.isArray(galleryData) ? galleryData : (galleryData.photos || []);
1100
+ if (category && category !== 'all') photos = photos.filter(p => p.category?.toLowerCase().includes(category.toLowerCase()));
1101
+
1102
+ return {
1103
+ status: "success",
1104
+ count: Math.min(photos.length, Number(limit) || 4),
1105
+ photos: photos.slice(0, Number(limit) || 4).map(p => ({
1106
+ title: p.titleTh || p.titleCn,
1107
+ category: p.category,
1108
+ year: p.era || p.year
1109
+ }))
1110
+ };
1111
+ },
1112
+
1113
+ global_search: async ({ query }) => {
1114
+ if (!query) return { status: "error", message: "Query parameter is required." };
1115
+ const q = query.toLowerCase();
1116
+ const ignoredTerms = new Set(['the', 'from', 'what', 'are', 'drama', 'ost', 'song', 'songs', 'soundtrack', 'music']);
1117
+ const terms = q.split(/[^\p{L}\p{N}]+/u).filter(term => term.length > 2 && !ignoredTerms.has(term));
1118
+ const matchesQuery = (...values) => {
1119
+ const haystack = values.filter(Boolean).join(' ').toLowerCase();
1120
+ return haystack.includes(q) || (terms.length > 0 && terms.every(term => haystack.includes(term)));
1121
+ };
1122
+
1123
+ const matchedWorks = worksData.filter(w =>
1124
+ matchesQuery(w.titleTh, w.titleEn, w.titleCn, w.role, w.roleCn)
1125
+ ).slice(0, 4).map(w => ({
1126
+ title: `${w.titleTh} (${w.year}) - Role: ${w.role}`,
1127
+ trailerYouTubeSearch: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${w.titleCn || w.titleEn} Trailer`)
1128
+ }));
1129
+
1130
+ const disc = getCleanDiscography();
1131
+ const matchedSongs = disc.filter(s =>
1132
+ matchesQuery(s.titleTh, s.titleEn, s.titleCn, s.work, s.role)
1133
+ ).slice(0, 4).map(s => ({
1134
+ title: `${s.titleTh} (${s.year})`,
1135
+ youtubeUrl: s.youtubeUrl || null,
1136
+ youtubeSearchUrl: s.youtubeSearchUrl
1137
+ }));
1138
+
1139
+ const mvs = getAllMusicVideos().filter(m =>
1140
+ matchesQuery(m.titleTh, m.titleCn, m.titlePinyin, m.dramaName, m.description)
1141
+ ).slice(0, 4).map(m => ({
1142
+ title: `${m.titleTh} (${m.titleCn}) [${m.category}]`,
1143
+ youtubeUrl: m.youtubeId ? buildYouTubeWatchUrl(m.youtubeId) : null,
1144
+ youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${m.titleCn || m.titleTh}`)
1145
+ }));
1146
+
1147
+ const brands = Array.isArray(brandsData) ? (brandsData.brandList || []) : (brandsData.brands || []);
1148
+ const matchedBrands = brands.filter(b =>
1149
+ matchesQuery(b.brandNameEn, b.brandNameCn, b.name, b.title)
1150
+ ).slice(0, 4).map(b => `${b.brandNameEn || b.name} (${b.year}) - ${b.title}`);
1151
+
1152
+ const count = matchedWorks.length + matchedSongs.length + mvs.length + matchedBrands.length;
1153
+ return {
1154
+ status: count > 0 ? "success" : "not_found",
1155
+ message: count > 0 ? undefined : `No local catalog matches for "${query}". Use web_search for external results.`,
1156
+ query,
1157
+ count,
1158
+ results: {
1159
+ musicVideos: mvs,
1160
+ works: matchedWorks,
1161
+ songs: matchedSongs,
1162
+ brands: matchedBrands,
1163
+ youtubeSearchUrl: buildYouTubeSearchUrl(`Ju Jingyi 鞠婧祎 ${query}`)
1164
+ }
1165
+ };
1166
+ },
1167
+
1168
+ web_search: async ({ query } = {}) => {
1169
+ return await searchWeb(query);
1170
+ },
1171
+
1172
+ fetch_webpage: async ({ url } = {}) => {
1173
+ return await fetchWebpage(url);
1174
+ },
1175
+
1176
+ fetch_api: async ({ endpoint, query, baseUrl } = {}) => {
1177
+ if (!endpoint) return { status: "error", message: "endpoint parameter is required (e.g. 'profile', 'works', 'search')" };
1178
+ const base = (baseUrl || 'https://jujingyi-kikuuu.vercel.app/api/v1').replace(/\/$/, '');
1179
+ const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
1180
+ let url = `${base}${path}`;
1181
+ if (query) url += `?q=${encodeURIComponent(query)}`;
1182
+
1183
+ try {
1184
+ const controller = new AbortController();
1185
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
1186
+ const res = await fetch(url, {
1187
+ headers: { 'Accept': 'application/json', 'User-Agent': 'KikuAgent/1.0' },
1188
+ signal: controller.signal
1189
+ });
1190
+ clearTimeout(timeoutId);
1191
+
1192
+ if (!res.ok) {
1193
+ return { status: "error", message: `HTTP ${res.status} from ${url}` };
1194
+ }
1195
+
1196
+ const json = await res.json();
1197
+
1198
+ const data = json.data || json;
1199
+ return {
1200
+ status: "success",
1201
+ endpoint: path,
1202
+ count: Array.isArray(data) ? data.length : undefined,
1203
+ data
1204
+ };
1205
+ } catch (err) {
1206
+ return {
1207
+ status: "error",
1208
+ endpoint: path,
1209
+ message: err.name === 'AbortError' ? 'API request timed out' : err.message
1210
+ };
1211
+ }
1212
+ },
1213
+
1214
+ fetch_json: async ({ url, method, headers, body } = {}) => {
1215
+ return await fetchJson(url, { method, headers, body });
1216
+ },
1217
+
1218
+ read_local_file: async ({ path: filePath, maxLines = 200 } = {}) => {
1219
+ if (!filePath) return { status: 'error', message: 'path parameter is required.' };
1220
+ try {
1221
+ const projectRoot = process.cwd();
1222
+ const resolved = resolve(projectRoot, filePath);
1223
+ const relativePath = relative(projectRoot, resolved);
1224
+
1225
+ if (relativePath.startsWith('..') || relativePath.includes('..')) {
1226
+ return { status: 'error', message: 'Path must be within the project directory.' };
1227
+ }
1228
+
1229
+ const fileStat = await stat(resolved);
1230
+ if (!fileStat.isFile()) {
1231
+ return { status: 'error', message: `"${filePath}" is not a file.` };
1232
+ }
1233
+
1234
+ if (fileStat.size > 500000) {
1235
+ return { status: 'error', message: `File too large (${Math.round(fileStat.size / 1024)}KB). Use maxLines to read a portion.` };
1236
+ }
1237
+
1238
+ const content = await readFile(resolved, 'utf-8');
1239
+ const lines = content.split('\n');
1240
+ const totalLines = lines.length;
1241
+ const truncated = totalLines > maxLines;
1242
+ const readContent = truncated ? lines.slice(0, maxLines).join('\n') : content;
1243
+
1244
+ return {
1245
+ status: 'success',
1246
+ path: relativePath,
1247
+ sizeBytes: fileStat.size,
1248
+ totalLines,
1249
+ linesRead: Math.min(totalLines, maxLines),
1250
+ truncated,
1251
+ content: truncated ? readContent + `\n\n... [Showing first ${maxLines} of ${totalLines} lines]` : readContent
1252
+ };
1253
+ } catch (err) {
1254
+ if (err.code === 'ENOENT') {
1255
+ return { status: 'error', message: `File not found: "${filePath}"` };
1256
+ }
1257
+ return { status: 'error', message: err.message };
1258
+ }
1259
+ },
1260
+
1261
+ list_directory: async ({ path: dirPath = '.', recursive = false } = {}) => {
1262
+ try {
1263
+ const projectRoot = process.cwd();
1264
+ const resolved = resolve(projectRoot, dirPath);
1265
+ const relativePath = relative(projectRoot, resolved);
1266
+
1267
+ if (relativePath.startsWith('..') || relativePath.includes('..')) {
1268
+ return { status: 'error', message: 'Path must be within the project directory.' };
1269
+ }
1270
+
1271
+ async function listDir(dir, depth = 0) {
1272
+ if (depth > (recursive ? 3 : 0)) return [];
1273
+ const entries = await readdir(dir, { withFileTypes: true });
1274
+ const items = [];
1275
+
1276
+ for (const entry of entries) {
1277
+ if (entry.name.startsWith('.') && entry.name !== '.env.example') continue;
1278
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
1279
+ if (entry.name === 'dist' || entry.name === 'build') continue;
1280
+
1281
+ const entryPath = join(dir, entry.name);
1282
+ const entryRelative = relative(projectRoot, entryPath);
1283
+
1284
+ if (entry.isDirectory()) {
1285
+ items.push({ name: entry.name, type: 'directory', path: entryRelative });
1286
+ if (recursive && depth < 3) {
1287
+ const children = await listDir(entryPath, depth + 1);
1288
+ items.push(...children);
1289
+ }
1290
+ } else {
1291
+ const fileStat = await stat(entryPath).catch(() => null);
1292
+ items.push({
1293
+ name: entry.name,
1294
+ type: 'file',
1295
+ path: entryRelative,
1296
+ sizeBytes: fileStat?.size || 0
1297
+ });
1298
+ }
1299
+ }
1300
+
1301
+ items.sort((a, b) => {
1302
+ if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
1303
+ return a.name.localeCompare(b.name);
1304
+ });
1305
+
1306
+ return items;
1307
+ }
1308
+
1309
+ const items = await listDir(resolved);
1310
+ return {
1311
+ status: 'success',
1312
+ path: relativePath || '.',
1313
+ count: items.length,
1314
+ items
1315
+ };
1316
+ } catch (err) {
1317
+ if (err.code === 'ENOENT') {
1318
+ return { status: 'error', message: `Directory not found: "${dirPath}"` };
1319
+ }
1320
+ return { status: 'error', message: err.message };
1321
+ }
1322
+ },
1323
+
1324
+ screenshot_webpage: async ({ url, width, height } = {}) => {
1325
+ return await screenshotWebpage(url, { width, height });
1326
+ },
1327
+
1328
+ web_extract: async ({ query, maxResults, allowedDomains } = {}) => {
1329
+ return await webExtract(query, { maxResults, allowedDomains });
1330
+ },
1331
+
1332
+ get_system_stats: async () => {
1333
+ const disc = getCleanDiscography();
1334
+ const brands = brandsData?.brandList || brandsData?.brands || (Array.isArray(brandsData) ? brandsData : []);
1335
+ const awards = Array.isArray(awardsData) ? awardsData : (awardsData?.awards || []);
1336
+ const timeline = Array.isArray(timelineData) ? timelineData : (timelineData?.eras || []);
1337
+ const mvs = getAllMusicVideos();
1338
+ const gpu = await getGpuMetrics().catch(() => ({ available: false }));
1339
+
1340
+ return {
1341
+ status: "success",
1342
+ stats: {
1343
+ artistName: profileData.nameTh,
1344
+ artistNameCn: profileData.nameCn,
1345
+ totalWorks: worksData.length,
1346
+ totalUniqueSongs: disc.length,
1347
+ totalMusicVideosAndStages: mvs.length,
1348
+ brandEndorsements: brands.length,
1349
+ totalAwards: awards.length,
1350
+ timelineMilestones: timeline.length,
1351
+ activeYears: "2013 - Present (2026)",
1352
+ featured2026Works: ["Veil of Shadows (月鳞绮纪)", "The Eternal Fragrance (千香)", "Wanhua Sin (万华世界)"],
1353
+ youtubeOfficialHub: buildYouTubeSearchUrl("Ju Jingyi 鞠婧祎"),
1354
+ gpu: gpu.available ? gpu.primary : { available: false }
1355
+ }
1356
+ };
1357
+ },
1358
+
1359
+ play_music: async (args) => {
1360
+ try {
1361
+ const query = args.query || '';
1362
+
1363
+ const hasArtist = /jujingyi|จวีจิ้งอี|鞠婧祎/i.test(query);
1364
+ const isUrl = /^https?:\/\//.test(query);
1365
+ const searchQuery = (!hasArtist && !isUrl) ? `Ju Jingyi ${query}` : query;
1366
+ const result = await audioPlayer.play(searchQuery);
1367
+ return { status: 'success', message: result, playing: args.query };
1368
+ } catch (err) {
1369
+ return { status: 'error', message: err.message };
1370
+ }
1371
+ },
1372
+
1373
+ stop_music: async () => {
1374
+ const result = await audioPlayer.stop();
1375
+ return { status: 'success', message: result };
1376
+ },
1377
+
1378
+ mute_music: async () => {
1379
+ const result = await audioPlayer.mute();
1380
+ return { status: 'success', message: result };
1381
+ },
1382
+
1383
+ unmute_music: async () => {
1384
+ const result = await audioPlayer.unmute();
1385
+ return { status: 'success', message: result };
1386
+ },
1387
+
1388
+ pause_music: async () => {
1389
+ const result = await audioPlayer.pause();
1390
+ return { status: 'success', message: result };
1391
+ },
1392
+
1393
+ resume_music: async () => {
1394
+ const result = await audioPlayer.resume();
1395
+ return { status: 'success', message: result };
1396
+ },
1397
+
1398
+ next_track: async () => {
1399
+ const result = await audioPlayer.next();
1400
+ return { status: 'success', message: result };
1401
+ },
1402
+
1403
+ prev_track: async () => {
1404
+ const result = await audioPlayer.prev();
1405
+ return { status: 'success', message: result };
1406
+ },
1407
+
1408
+ add_to_queue: async (args) => {
1409
+ const result = await audioPlayer.addToQueue(args.query);
1410
+ return { status: 'success', message: result };
1411
+ },
1412
+
1413
+ remove_from_queue: async (args) => {
1414
+ const result = await audioPlayer.removeFromQueue(args.index);
1415
+ return { status: 'success', message: result };
1416
+ },
1417
+
1418
+ clear_queue: async () => {
1419
+ const result = await audioPlayer.clearQueue();
1420
+ return { status: 'success', message: result };
1421
+ },
1422
+
1423
+ get_queue: async () => {
1424
+ const queue = audioPlayer.getQueue();
1425
+ return { status: 'success', queue };
1426
+ },
1427
+
1428
+ search_youtube: async (args) => {
1429
+ try {
1430
+ const results = await audioPlayer.searchYouTube(args.query);
1431
+ return { status: 'success', query: args.query, results };
1432
+ } catch (err) {
1433
+ return { status: 'error', message: err.message };
1434
+ }
1435
+ },
1436
+
1437
+ get_current_track: async () => {
1438
+ const state = audioPlayer.getFullState();
1439
+ return { status: 'success', ...state };
1440
+ },
1441
+
1442
+ get_music_status: async () => {
1443
+ const state = audioPlayer.getFullState();
1444
+ return { status: 'success', ...state };
1445
+ },
1446
+
1447
+ run_subagent: async ({ task }, options = {}) => {
1448
+ if (options?.agent?.runSubagent) {
1449
+ const result = await options.agent.runSubagent(task, { signal: options.signal });
1450
+ return { status: "success", task, answer: result.answer, toolCalls: result.toolCalls, iterations: result.iterations };
1451
+ }
1452
+ const { LMStudioClient } = await import('./llmClient.js');
1453
+ const { ResearchSubagent } = await import('./subagent.js');
1454
+ const client = options?.client || new LMStudioClient();
1455
+ const subagent = new ResearchSubagent({ client });
1456
+ const result = await subagent.run(task, { signal: options?.signal });
1457
+ return { status: "success", task, answer: result.answer, toolCalls: result.toolCalls, iterations: result.iterations };
1458
+ },
1459
+
1460
+ run_deep_research: async ({ question }, options = {}) => {
1461
+ if (options?.agent?.runResearch) {
1462
+ const result = await options.agent.runResearch(question, { signal: options.signal });
1463
+ return { status: "success", question, answer: result.answer, sources: result.sources, reports: result.reports };
1464
+ }
1465
+ const { LMStudioClient } = await import('./llmClient.js');
1466
+ const { ResearchOrchestrator } = await import('./research.js');
1467
+ const client = options?.client || new LMStudioClient();
1468
+ const orchestrator = new ResearchOrchestrator({ client });
1469
+ const result = await orchestrator.run(question, { signal: options?.signal });
1470
+ return { status: "success", question, answer: result.answer, sources: result.sources, reports: result.reports };
1471
+ },
1472
+
1473
+ verify_claim: async ({ claim }, options = {}) => {
1474
+ if (options?.agent?.runResearch) {
1475
+ const result = await options.agent.runResearch(claim, { mode: 'verify', signal: options.signal });
1476
+ return { status: "success", claim, answer: result.answer, sources: result.sources, reports: result.reports };
1477
+ }
1478
+ const { LMStudioClient } = await import('./llmClient.js');
1479
+ const { ResearchOrchestrator } = await import('./research.js');
1480
+ const client = options?.client || new LMStudioClient();
1481
+ const orchestrator = new ResearchOrchestrator({ client });
1482
+ const result = await orchestrator.run(claim, { mode: 'verify', signal: options?.signal });
1483
+ return { status: "success", claim, answer: result.answer, sources: result.sources, reports: result.reports };
1484
+ },
1485
+
1486
+ manage_skill: async ({ action, instruction, skillId }, options = {}) => {
1487
+ let memory = options?.agent?.memory || options?.memory;
1488
+ if (!memory) {
1489
+ const { AgentMemory } = await import('./memory.js');
1490
+ memory = new AgentMemory();
1491
+ await memory.load();
1492
+ }
1493
+ switch (action) {
1494
+ case 'learn': {
1495
+ if (!instruction) return { status: 'error', message: 'Instruction is required for action "learn".' };
1496
+ const skill = memory.addUserSkill(instruction);
1497
+ return { status: 'success', message: `Learned response skill "${skill.id}".`, skill };
1498
+ }
1499
+ case 'remove': {
1500
+ if (!skillId) return { status: 'error', message: 'skillId is required for action "remove".' };
1501
+ const removed = memory.removeUserSkill(skillId);
1502
+ return removed ? { status: 'success', message: `Skill ${skillId} removed.` } : { status: 'error', message: `Skill ${skillId} not found.` };
1503
+ }
1504
+ case 'clear': {
1505
+ memory.clearUserSkills();
1506
+ return { status: 'success', message: 'All learned response skills cleared.' };
1507
+ }
1508
+ case 'enable': {
1509
+ memory.setPreference('skillsEnabled', true);
1510
+ return { status: 'success', message: 'Learned response skills enabled.' };
1511
+ }
1512
+ case 'disable': {
1513
+ memory.setPreference('skillsEnabled', false);
1514
+ return { status: 'success', message: 'Learned response skills disabled.' };
1515
+ }
1516
+ case 'list':
1517
+ default: {
1518
+ return {
1519
+ status: 'success',
1520
+ enabled: memory.preferences.skillsEnabled !== false,
1521
+ skills: memory.preferences.userSkills || []
1522
+ };
1523
+ }
1524
+ }
1525
+ }
1526
+ };
1527
+
1528
+ const toolSchemaByName = new Map(
1529
+ toolsSchema.map(tool => [tool.function.name, tool.function.parameters || { type: 'object', properties: {} }])
1530
+ );
1531
+
1532
+ function coerceToolValue(value, schema = {}) {
1533
+ if (value == null || !schema.type) return { ok: true, value };
1534
+
1535
+ if (schema.type === 'string') {
1536
+ if (typeof value === 'string') return { ok: true, value };
1537
+ if (['number', 'boolean'].includes(typeof value)) return { ok: true, value: String(value), coerced: true };
1538
+ return { ok: false };
1539
+ }
1540
+ if (schema.type === 'number' || schema.type === 'integer') {
1541
+ const parsed = typeof value === 'number' ? value : Number(value);
1542
+ if (!Number.isFinite(parsed)) return { ok: false };
1543
+ return { ok: true, value: schema.type === 'integer' ? Math.trunc(parsed) : parsed, coerced: typeof value !== 'number' };
1544
+ }
1545
+ if (schema.type === 'boolean') {
1546
+ if (typeof value === 'boolean') return { ok: true, value };
1547
+ if (value === 'true' || value === '1' || value === 1) return { ok: true, value: true, coerced: true };
1548
+ if (value === 'false' || value === '0' || value === 0) return { ok: true, value: false, coerced: true };
1549
+ return { ok: false };
1550
+ }
1551
+ if (schema.type === 'array') {
1552
+ if (!Array.isArray(value)) return { ok: false };
1553
+ const converted = [];
1554
+ for (const item of value) {
1555
+ const checked = coerceToolValue(item, schema.items || {});
1556
+ if (!checked.ok) return { ok: false };
1557
+ converted.push(checked.value);
1558
+ }
1559
+ return { ok: true, value: converted };
1560
+ }
1561
+ if (schema.type === 'object') return { ok: !!value && typeof value === 'object' && !Array.isArray(value), value };
1562
+ return { ok: true, value };
1563
+ }
1564
+
1565
+ export function prepareToolArguments(toolName, rawArgs = {}) {
1566
+ const schema = toolSchemaByName.get(toolName);
1567
+ if (!schema) {
1568
+ return { safe: false, args: {}, warnings: [], error: `Unknown tool "${toolName}".` };
1569
+ }
1570
+
1571
+ let parsedArgs = rawArgs;
1572
+ if (typeof rawArgs === 'string') {
1573
+ const trimmed = rawArgs.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
1574
+ if (!trimmed) parsedArgs = {};
1575
+ else {
1576
+ try { parsedArgs = JSON.parse(trimmed); }
1577
+ catch (error) {
1578
+ return { safe: false, args: {}, warnings: [], error: `Invalid JSON arguments: ${error.message}` };
1579
+ }
1580
+ }
1581
+ }
1582
+
1583
+ if (!parsedArgs || typeof parsedArgs !== 'object' || Array.isArray(parsedArgs)) {
1584
+ return { safe: false, args: {}, warnings: [], error: 'Tool arguments must be a JSON object.' };
1585
+ }
1586
+
1587
+ const properties = schema.properties || {};
1588
+ const required = new Set(schema.required || []);
1589
+ const validated = {};
1590
+ const warnings = [];
1591
+
1592
+ for (const requiredName of required) {
1593
+ if (parsedArgs[requiredName] == null || parsedArgs[requiredName] === '') {
1594
+ return { safe: false, args: {}, warnings, error: `Missing required argument "${requiredName}".` };
1595
+ }
1596
+ }
1597
+
1598
+ for (const [name, value] of Object.entries(parsedArgs)) {
1599
+ const propertySchema = properties[name];
1600
+ if (!propertySchema) {
1601
+ warnings.push(`Unknown argument "${name}" was ignored`);
1602
+ continue;
1603
+ }
1604
+ const checked = coerceToolValue(value, propertySchema);
1605
+ if (!checked.ok) {
1606
+ return { safe: false, args: {}, warnings, error: `Argument "${name}" must be ${propertySchema.type || 'valid'}.` };
1607
+ }
1608
+ if (propertySchema.enum && !propertySchema.enum.includes(checked.value)) {
1609
+ return { safe: false, args: {}, warnings, error: `Argument "${name}" must be one of: ${propertySchema.enum.join(', ')}.` };
1610
+ }
1611
+ if (checked.coerced) warnings.push(`Argument "${name}" was converted to ${propertySchema.type}`);
1612
+ validated[name] = checked.value;
1613
+ }
1614
+
1615
+ const sanitizedResult = sanitizeToolArgs(toolName, validated);
1616
+ if (!sanitizedResult.safe) {
1617
+ return { safe: false, args: {}, warnings: [...warnings, ...sanitizedResult.warnings], error: sanitizedResult.warnings.join('; ') };
1618
+ }
1619
+ return { safe: true, args: sanitizedResult.sanitized, warnings: [...warnings, ...sanitizedResult.warnings], error: null };
1620
+ }
1621
+
1622
+ export function normalizeToolResult(toolName, result) {
1623
+ if (result == null) return { status: 'error', message: `Tool "${toolName}" returned no result.` };
1624
+ if (typeof result !== 'object' || Array.isArray(result)) return { status: 'success', data: result };
1625
+
1626
+ const normalized = { ...result };
1627
+ if (!normalized.status) normalized.status = normalized.error ? 'error' : 'success';
1628
+ if (normalized.error && !normalized.message) normalized.message = String(normalized.error);
1629
+ if (normalized.status === 'success' && (normalized.count === 0 || normalized.returnedCount === 0)) {
1630
+ normalized.status = 'not_found';
1631
+ if (!normalized.message) normalized.message = 'No matching data found.';
1632
+ }
1633
+ return normalized;
1634
+ }
1635
+
1636
+ export async function executeTool(toolName, args = {}, options = {}) {
1637
+ const handler = toolHandlers[toolName];
1638
+ if (!handler) {
1639
+ return {
1640
+ status: "error",
1641
+ message: `Unknown tool "${toolName}". Available tools: ${Object.keys(toolHandlers).join(', ')}`
1642
+ };
1643
+ }
1644
+
1645
+ try {
1646
+ const prepared = prepareToolArguments(toolName, args);
1647
+ if (!prepared.safe) {
1648
+ return {
1649
+ status: 'error',
1650
+ message: `Input rejected: ${prepared.error}`,
1651
+ validationWarnings: prepared.warnings,
1652
+ };
1653
+ }
1654
+
1655
+ const configuredTimeout = Number(options.timeoutMs ?? process.env.KIKU_TOOL_TIMEOUT_MS ?? 45_000);
1656
+ const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : 45_000;
1657
+ let timeoutId;
1658
+ const timeoutPromise = new Promise((_, reject) => {
1659
+ timeoutId = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
1660
+ });
1661
+ let result;
1662
+ try {
1663
+ result = await Promise.race([Promise.resolve(handler(prepared.args, options)), timeoutPromise]);
1664
+ } finally {
1665
+ clearTimeout(timeoutId);
1666
+ }
1667
+
1668
+ const normalized = normalizeToolResult(toolName, result);
1669
+ if (prepared.warnings.length > 0) {
1670
+ normalized.validationWarnings = prepared.warnings;
1671
+ }
1672
+ return normalized;
1673
+ } catch (err) {
1674
+ return {
1675
+ status: "error",
1676
+ message: `Failed executing tool "${toolName}": ${err.message}`
1677
+ };
1678
+ }
1679
+ }