kiku-agent 1.3.2 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +69 -12
  2. package/package.json +2 -1
  3. package/server/agent/agentCore.js +319 -46
  4. package/server/agent/appPaths.js +31 -0
  5. package/server/agent/audioPlayer.js +339 -12
  6. package/server/agent/autoContext.js +193 -0
  7. package/server/agent/catalogEntityResolver.js +170 -28
  8. package/server/agent/clarifyingQuestions.js +137 -35
  9. package/server/agent/cli.js +177 -48
  10. package/server/agent/cloudAdapters.js +116 -0
  11. package/server/agent/commandRegistry.js +409 -80
  12. package/server/agent/commands.js +1056 -125
  13. package/server/agent/config.js +1088 -690
  14. package/server/agent/contextCompactor.js +213 -21
  15. package/server/agent/cpuMetrics.js +199 -0
  16. package/server/agent/engineService.js +591 -0
  17. package/server/agent/eval/modules/evaluation/runner.js +4 -14
  18. package/server/agent/eval/modules/evaluation/scoring.js +15 -3
  19. package/server/agent/eval/modules/server/evalApi.js +5 -5
  20. package/server/agent/eval/modules/ui/evalUi.js +7 -4
  21. package/server/agent/eventBus.js +1 -0
  22. package/server/agent/ink/main.jsx +196 -191
  23. package/server/agent/jobManager.js +11 -1
  24. package/server/agent/lanDiscovery.js +721 -23
  25. package/server/agent/llmClient.js +317 -71
  26. package/server/agent/lmStudioWakeup.js +517 -65
  27. package/server/agent/locationService.js +797 -0
  28. package/server/agent/mcpQuizData.js +509 -0
  29. package/server/agent/memory/contextAllocator.js +19 -7
  30. package/server/agent/memory.js +3 -1
  31. package/server/agent/modelCompatibility.js +352 -0
  32. package/server/agent/modelDefaults.js +1 -0
  33. package/server/agent/modelManager.js +46 -2
  34. package/server/agent/newsWatch.js +9 -3
  35. package/server/agent/processManager.js +229 -0
  36. package/server/agent/prompts.js +174 -8
  37. package/server/agent/routingInterceptor.js +9 -1
  38. package/server/agent/sanitizer.js +41 -6
  39. package/server/agent/searchQueryPlanner.js +202 -0
  40. package/server/agent/session.js +59 -4
  41. package/server/agent/tests/agentFollowUps.test.js +128 -2
  42. package/server/agent/tests/agentMusicControl.test.js +245 -0
  43. package/server/agent/tests/apiRouter.test.js +1 -1
  44. package/server/agent/tests/autoContext.test.js +176 -0
  45. package/server/agent/tests/bilibiliEmbed.test.js +45 -0
  46. package/server/agent/tests/chatHardening.test.js +121 -0
  47. package/server/agent/tests/chineseWebSearchEscalation.test.js +62 -0
  48. package/server/agent/tests/clarifyingQuestions.test.js +33 -0
  49. package/server/agent/tests/cliAutomation.test.js +38 -1
  50. package/server/agent/tests/cloudProviders.test.js +91 -3
  51. package/server/agent/tests/commands.test.js +92 -3
  52. package/server/agent/tests/contextIntelligence.test.js +50 -3
  53. package/server/agent/tests/cpuMetrics.test.js +101 -0
  54. package/server/agent/tests/decisionAndSearchEnhancements.test.js +32 -0
  55. package/server/agent/tests/edgeCasesScenarios.test.js +82 -0
  56. package/server/agent/tests/engineService.test.js +436 -0
  57. package/server/agent/tests/engineUnload.test.js +94 -0
  58. package/server/agent/tests/envCommand.test.js +69 -0
  59. package/server/agent/tests/evalMetrics.test.js +8 -0
  60. package/server/agent/tests/fetchApi.test.js +99 -0
  61. package/server/agent/tests/hybridWebSearch.test.js +2 -0
  62. package/server/agent/tests/lanDiscovery.test.js +5 -0
  63. package/server/agent/tests/lanInterfaces.test.js +510 -0
  64. package/server/agent/tests/lmStudioWakeup.test.js +278 -0
  65. package/server/agent/tests/locationTool.test.js +278 -0
  66. package/server/agent/tests/memoryRoute.test.js +173 -0
  67. package/server/agent/tests/memoryUiNormalization.test.js +75 -0
  68. package/server/agent/tests/modelCompatibility.test.js +148 -0
  69. package/server/agent/tests/modelManager.test.js +32 -0
  70. package/server/agent/tests/musicControlUiBridge.test.js +113 -0
  71. package/server/agent/tests/nextGenArchitecture.test.js +4 -2
  72. package/server/agent/tests/openDomainPrompt.test.js +197 -0
  73. package/server/agent/tests/openDomainSearch.test.js +121 -0
  74. package/server/agent/tests/processManager.test.js +144 -0
  75. package/server/agent/tests/providerPersistence.test.js +10 -5
  76. package/server/agent/tests/quickActionsAndModals.test.js +180 -0
  77. package/server/agent/tests/searchQueryPlanner.test.js +108 -0
  78. package/server/agent/tests/securityAuditRemediation.test.js +16 -9
  79. package/server/agent/tests/securityAuditRemediations2.test.js +154 -0
  80. package/server/agent/tests/securityHardening.test.js +4 -4
  81. package/server/agent/tests/sessionAndCopy.test.js +39 -4
  82. package/server/agent/tests/sidebarControls.test.js +34 -0
  83. package/server/agent/tests/statefulMemory.test.js +8 -1
  84. package/server/agent/tests/textProtocolLoop.test.js +71 -0
  85. package/server/agent/tests/textToolParsing.test.js +48 -0
  86. package/server/agent/tests/toolRoutingRegressions.test.js +41 -0
  87. package/server/agent/tests/unifiedEndpoints.test.js +13 -6
  88. package/server/agent/tests/v110Features.test.js +1 -1
  89. package/server/agent/tests/vramEstimator.test.js +75 -0
  90. package/server/agent/tests/webChatDocs.test.js +199 -0
  91. package/server/agent/tests/webChatInferenceControl.test.js +298 -0
  92. package/server/agent/tests/webChatInspection.test.js +256 -5
  93. package/server/agent/tests/webChatMcp.test.js +94 -0
  94. package/server/agent/tests/webExtract.test.js +23 -0
  95. package/server/agent/tests/webSources.test.js +46 -12
  96. package/server/agent/textToolProtocol.js +173 -46
  97. package/server/agent/toolRouter.js +318 -44
  98. package/server/agent/tools.js +877 -96
  99. package/server/agent/ui.js +39 -34
  100. package/server/agent/vramService.js +35 -54
  101. package/server/agent/webSearch.js +441 -162
  102. package/server/agent/websearch/orchestrator.js +15 -5
  103. package/server/agent/websearch/resilience/cache.js +9 -2
  104. package/server/agent/websearch/security/ssrf.js +41 -10
  105. package/server/agent/youtubeAuth.js +5 -20
  106. package/server/apiRouter.js +5558 -4422
  107. package/server/mcp/data/discography.js +2 -2
  108. package/server/mcp/examples/antigravity.example.json +17 -0
  109. package/server/mcp/handler.js +8 -4
  110. package/server/mcp/security.js +220 -21
  111. package/server/mcp/server.js +2 -1
  112. package/server/mcp/tests/connection.test.js +14 -8
  113. package/server/mcp/tests/mcpKeyAuth.test.js +156 -0
  114. package/server/mcp/tools.js +7 -1
  115. package/server/supabase_migration_prefix.sql +102 -0
  116. package/server/supabase_schema.sql +18 -18
  117. package/server/web-chat/core/connection.js +179 -218
  118. package/server/web-chat/core/conversationContext.js +9 -2
  119. package/server/web-chat/core/directCatalogEngine.js +52 -28
  120. package/server/web-chat/core/dramaKnowledge.js +33 -22
  121. package/server/web-chat/core/followUpEngine.js +735 -81
  122. package/server/web-chat/core/futureEventGuard.js +5 -1
  123. package/server/web-chat/core/musicControlAction.js +70 -0
  124. package/server/web-chat/core/security.js +32 -21
  125. package/server/web-chat/core/toolInspector.js +6 -6
  126. package/server/web-chat/core/urlGuard.js +45 -0
  127. package/server/web-chat/router.js +204 -157
  128. package/server/web-chat/routes/chatRoute.js +885 -488
  129. package/server/web-chat/routes/engineRoute.js +270 -0
  130. package/server/web-chat/routes/exportRoute.js +1 -1
  131. package/server/web-chat/routes/lanDiscoveryRoute.js +17 -2
  132. package/server/web-chat/routes/lmStudioLoadRoute.js +172 -0
  133. package/server/web-chat/routes/mcpRoute.js +386 -0
  134. package/server/web-chat/routes/memoryRoute.js +253 -0
  135. package/server/web-chat/routes/providerRoute.js +49 -16
  136. package/server/web-chat/routes/staticRoute.js +306 -17
  137. package/server/web-chat/routes/statusRoute.js +194 -73
  138. package/server/web-chat/routes/suggestRoute.js +11 -3
  139. package/server/web-chat/routes/verifyRoute.js +26 -4
  140. package/server/web-chat/server.js +7 -6
  141. package/server/web-chat/ui/docsBuilder.js +3558 -0
  142. package/server/web-chat/ui/htmlBuilder.js +12 -0
  143. package/server/web-chat/ui/scripts/client.js +3401 -1302
  144. package/server/web-chat/ui/scripts/webchatI18n.js +155 -153
  145. package/server/web-chat/ui/settingsBuilder.js +7870 -0
  146. package/server/web-chat/ui/styles/lan-card.css +165 -0
  147. package/server/web-chat/ui/styles/main.css +4289 -447
  148. package/server/web-chat/ui/sw.js +64 -0
  149. package/server/web-chat/ui/templates/body.html +407 -281
  150. package/src/data/awardsData.js +146 -146
  151. package/src/data/musicVideoData.js +6 -4
  152. package/src/lib/vram/estimator.js +195 -19
@@ -0,0 +1,797 @@
1
+ const OSM_CACHE = new Map();
2
+ const CACHE_TTL_MS = 600_000;
3
+ const EARTH_RADIUS_METERS = 6371000;
4
+ const USER_AGENT_HEADER = 'KikuAgent-LocationService/1.4 (https://github.com/Themehackers/jujingyi_kikuuu)';
5
+
6
+ export function calculateHaversineDistance(lat1, lon1, lat2, lon2) {
7
+ const p1 = Number(lat1) * Math.PI / 180;
8
+ const p2 = Number(lat2) * Math.PI / 180;
9
+ const deltaP = (Number(lat2) - Number(lat1)) * Math.PI / 180;
10
+ const deltaL = (Number(lon2) - Number(lon1)) * Math.PI / 180;
11
+
12
+ const a = Math.sin(deltaP / 2) * Math.sin(deltaP / 2) +
13
+ Math.cos(p1) * Math.cos(p2) *
14
+ Math.sin(deltaL / 2) * Math.sin(deltaL / 2);
15
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
16
+
17
+ return EARTH_RADIUS_METERS * c;
18
+ }
19
+
20
+ export function estimateWalkingTime(meters) {
21
+ return Math.max(1, Math.ceil(meters / 80));
22
+ }
23
+
24
+ export function estimateDrivingTime(meters) {
25
+ return Math.max(1, Math.ceil(meters / 667));
26
+ }
27
+
28
+ export function formatDistance(meters) {
29
+ if (meters < 1000) {
30
+ return `${Math.round(meters)} m`;
31
+ }
32
+ return `${(meters / 1000).toFixed(1)} km`;
33
+ }
34
+
35
+ export const LANDMARK_REGISTRY = Object.freeze([
36
+ {
37
+ id: 'democracy_monument',
38
+ nameTh: 'อนุสาวรีย์ประชาธิปไตย',
39
+ nameEn: 'Democracy Monument',
40
+ nameZh: '民主纪念碑',
41
+ latitude: 13.7567,
42
+ longitude: 100.5018,
43
+ category: 'monument',
44
+ city: 'Bangkok',
45
+ country: 'Thailand'
46
+ },
47
+ {
48
+ id: 'siam_paragon',
49
+ nameTh: 'สยามพารากอน',
50
+ nameEn: 'Siam Paragon',
51
+ nameZh: '暹罗百丽宫',
52
+ latitude: 13.7460,
53
+ longitude: 100.5348,
54
+ category: 'shopping',
55
+ city: 'Bangkok',
56
+ country: 'Thailand'
57
+ },
58
+ {
59
+ id: 'central_world',
60
+ nameTh: 'เซ็นทรัลเวิลด์',
61
+ nameEn: 'CentralWorld',
62
+ nameZh: '尚泰中央世界',
63
+ latitude: 13.7466,
64
+ longitude: 100.5393,
65
+ category: 'shopping',
66
+ city: 'Bangkok',
67
+ country: 'Thailand'
68
+ },
69
+ {
70
+ id: 'grand_palace',
71
+ nameTh: 'พระบรมมหาราชวัง / วัดพระแก้ว',
72
+ nameEn: 'Grand Palace / Temple of the Emerald Buddha',
73
+ nameZh: '大皇宫 / 玉佛寺',
74
+ latitude: 13.7500,
75
+ longitude: 100.4914,
76
+ category: 'temple',
77
+ city: 'Bangkok',
78
+ country: 'Thailand'
79
+ },
80
+ {
81
+ id: 'iconsiam',
82
+ nameTh: 'ไอคอนสยาม',
83
+ nameEn: 'ICONSIAM',
84
+ nameZh: '暹罗天地',
85
+ latitude: 13.7267,
86
+ longitude: 100.5105,
87
+ category: 'shopping',
88
+ city: 'Bangkok',
89
+ country: 'Thailand'
90
+ },
91
+ {
92
+ id: 'suvarnabhumi',
93
+ nameTh: 'ท่าอากาศยานสุวรรณภูมิ',
94
+ nameEn: 'Suvarnabhumi Airport',
95
+ nameZh: '素万那普国际机场',
96
+ latitude: 13.6900,
97
+ longitude: 100.7501,
98
+ category: 'airport',
99
+ city: 'Samut Prakan',
100
+ country: 'Thailand'
101
+ },
102
+ {
103
+ id: 'don_mueang',
104
+ nameTh: 'ท่าอากาศยานดอนเมือง',
105
+ nameEn: 'Don Mueang International Airport',
106
+ nameZh: '廊曼国际机场',
107
+ latitude: 13.9126,
108
+ longitude: 100.6067,
109
+ category: 'airport',
110
+ city: 'Bangkok',
111
+ country: 'Thailand'
112
+ },
113
+ {
114
+ id: 'chiang_mai_gate',
115
+ nameTh: 'ประตูท่าแพ เชียงใหม่',
116
+ nameEn: 'Tha Phae Gate Chiang Mai',
117
+ nameZh: '清迈塔佩门',
118
+ latitude: 18.7877,
119
+ longitude: 98.9931,
120
+ category: 'historic',
121
+ city: 'Chiang Mai',
122
+ country: 'Thailand'
123
+ },
124
+ {
125
+ id: 'phuket_old_town',
126
+ nameTh: 'ย่านเมืองเก่าภูเก็ต',
127
+ nameEn: 'Phuket Old Town',
128
+ nameZh: '普吉老街',
129
+ latitude: 7.8840,
130
+ longitude: 98.3887,
131
+ category: 'historic',
132
+ city: 'Phuket',
133
+ country: 'Thailand'
134
+ },
135
+ {
136
+ id: 'pattaya_beach',
137
+ nameTh: 'หาดพัทยา',
138
+ nameEn: 'Pattaya Beach',
139
+ nameZh: '芭提雅海滩',
140
+ latitude: 12.9354,
141
+ longitude: 100.8809,
142
+ category: 'beach',
143
+ city: 'Pattaya',
144
+ country: 'Thailand'
145
+ },
146
+ {
147
+ id: 'khon_kaen_city',
148
+ nameTh: 'บึงแก่นนคร ขอนแก่น',
149
+ nameEn: 'Kaen Nakhon Lake Khon Kaen',
150
+ nameZh: '孔敬孔敬湖',
151
+ latitude: 16.4172,
152
+ longitude: 102.8358,
153
+ category: 'lake',
154
+ city: 'Khon Kaen',
155
+ country: 'Thailand'
156
+ },
157
+ {
158
+ id: 'hengdian_studios',
159
+ nameTh: 'โรงถ่ายภาพยนตร์เหิงเตี้ยน',
160
+ nameEn: 'Hengdian World Studios',
161
+ nameZh: '横店影视城',
162
+ latitude: 29.1454,
163
+ longitude: 120.3104,
164
+ category: 'studio',
165
+ city: 'Jinhua',
166
+ province: 'Zhejiang',
167
+ country: 'China'
168
+ },
169
+ {
170
+ id: 'shanghai_mercedes',
171
+ nameTh: 'เมอร์เซเดส-เบนซ์ อารีน่า เซี่ยงไฮ้',
172
+ nameEn: 'Mercedes-Benz Arena Shanghai',
173
+ nameZh: '梅赛德斯-奔驰文化中心',
174
+ latitude: 31.1895,
175
+ longitude: 121.4938,
176
+ category: 'arena',
177
+ city: 'Shanghai',
178
+ country: 'China'
179
+ },
180
+ {
181
+ id: 'shanghai_grand_theatre',
182
+ nameTh: 'เซี่ยงไฮ้ แกรนด์ เธียเตอร์',
183
+ nameEn: 'Shanghai Grand Theatre',
184
+ nameZh: '上海大剧院',
185
+ latitude: 31.2312,
186
+ longitude: 121.4704,
187
+ category: 'theatre',
188
+ city: 'Shanghai',
189
+ country: 'China'
190
+ },
191
+ {
192
+ id: 'shanghai_hongqiao',
193
+ nameTh: 'ท่าอากาศยานนานาชาติเซี่ยงไฮ้ หงเฉียว',
194
+ nameEn: 'Shanghai Hongqiao International Airport',
195
+ nameZh: '上海虹桥国际机场',
196
+ latitude: 31.1979,
197
+ longitude: 121.3363,
198
+ category: 'airport',
199
+ city: 'Shanghai',
200
+ country: 'China'
201
+ },
202
+ {
203
+ id: 'beijing_forbidden_city',
204
+ nameTh: 'พระราชวังต้องห้าม ปักกิ่ง',
205
+ nameEn: 'Forbidden City Beijing',
206
+ nameZh: '故宫博物院',
207
+ latitude: 39.9163,
208
+ longitude: 116.3972,
209
+ category: 'historic',
210
+ city: 'Beijing',
211
+ country: 'China'
212
+ },
213
+ {
214
+ id: 'beijing_capital_airport',
215
+ nameTh: 'ท่าอากาศยานนานาชาติปักกิ่ง',
216
+ nameEn: 'Beijing Capital International Airport',
217
+ nameZh: '北京首都国际机场',
218
+ latitude: 40.0799,
219
+ longitude: 116.6031,
220
+ category: 'airport',
221
+ city: 'Beijing',
222
+ country: 'China'
223
+ },
224
+ {
225
+ id: 'chengdu_chunxi',
226
+ nameTh: 'ถนนคนเดินชุนซีลู่ เฉิงตู',
227
+ nameEn: 'Chunxi Road Chengdu',
228
+ nameZh: '成都春熙路',
229
+ latitude: 30.6558,
230
+ longitude: 104.0818,
231
+ category: 'shopping',
232
+ city: 'Chengdu',
233
+ country: 'China'
234
+ },
235
+ {
236
+ id: 'suining_city',
237
+ nameTh: 'ซุ่ยหนิง เสฉวน (บ้านเกิดจวีจิ้งอี)',
238
+ nameEn: 'Suining Sichuan (Ju Jingyi Birthplace)',
239
+ nameZh: '四川遂宁 (鞠婧祎出生地)',
240
+ latitude: 30.5328,
241
+ longitude: 105.5929,
242
+ category: 'city',
243
+ city: 'Suining',
244
+ country: 'China'
245
+ }
246
+ ]);
247
+
248
+ export const CATEGORY_PROMINENCE_WEIGHTS = Object.freeze({
249
+ studio: 1.35,
250
+ transport: 1.25,
251
+ theatre: 1.20,
252
+ arena: 1.20,
253
+ monument: 1.15,
254
+ temple: 1.15,
255
+ shopping: 1.05,
256
+ park: 1.0,
257
+ city: 1.10,
258
+ other: 0.95
259
+ });
260
+
261
+ export function scoreLandmarkCandidate(landmark, lat, lon, context = {}) {
262
+ const dist = calculateHaversineDistance(lat, lon, landmark.latitude, landmark.longitude);
263
+ const distanceScore = 100 / (1 + (dist / 1500));
264
+ const categoryWeight = CATEGORY_PROMINENCE_WEIGHTS[landmark.category] || 1.0;
265
+ const prominenceScore = 80 * categoryWeight;
266
+
267
+ let contextScore = 0;
268
+ const queryText = String(context.query || '').toLowerCase().trim();
269
+ if (queryText) {
270
+ const targetTh = String(landmark.nameTh || '').toLowerCase();
271
+ const targetEn = String(landmark.nameEn || '').toLowerCase();
272
+ const targetZh = String(landmark.nameZh || '').toLowerCase();
273
+ if (targetTh.includes(queryText) || targetEn.includes(queryText) || targetZh.includes(queryText)) {
274
+ contextScore += 50;
275
+ }
276
+ }
277
+
278
+ let geographicMatchScore = 0;
279
+ if (context.city && landmark.city && String(context.city).toLowerCase() === String(landmark.city).toLowerCase()) {
280
+ geographicMatchScore += 25;
281
+ }
282
+ if (context.country && landmark.country && String(context.country).toLowerCase() === String(landmark.country).toLowerCase()) {
283
+ geographicMatchScore += 15;
284
+ }
285
+
286
+ const finalScore = Number((distanceScore * 0.55 + prominenceScore * 0.20 + contextScore * 0.15 + geographicMatchScore * 0.10).toFixed(2));
287
+
288
+ return {
289
+ landmark,
290
+ distanceMeters: Math.round(dist),
291
+ distanceScore: Number(distanceScore.toFixed(2)),
292
+ prominenceScore: Number(prominenceScore.toFixed(2)),
293
+ contextScore,
294
+ geographicMatchScore,
295
+ finalScore
296
+ };
297
+ }
298
+
299
+ export function rankLandmarksByScore(lat, lon, context = {}) {
300
+ if (!Number.isFinite(Number(lat)) || !Number.isFinite(Number(lon))) {
301
+ return [];
302
+ }
303
+ const scored = LANDMARK_REGISTRY.map(lm => scoreLandmarkCandidate(lm, lat, lon, context));
304
+ scored.sort((a, b) => b.finalScore - a.finalScore || a.distanceMeters - b.distanceMeters);
305
+ return scored;
306
+ }
307
+
308
+ export function findNearestLandmark(lat, lon, context = {}) {
309
+ const ranked = rankLandmarksByScore(lat, lon, context);
310
+ if (ranked.length === 0) return null;
311
+
312
+ const top = ranked[0];
313
+ const minDistance = top.distanceMeters;
314
+ const walkingMinutes = estimateWalkingTime(minDistance);
315
+ const drivingMinutes = estimateDrivingTime(minDistance);
316
+
317
+ return {
318
+ landmark: top.landmark,
319
+ score: top.finalScore,
320
+ distanceMeters: minDistance,
321
+ distanceKm: Number((minDistance / 1000).toFixed(2)),
322
+ formattedDistance: formatDistance(minDistance),
323
+ walkingMinutes,
324
+ drivingMinutes,
325
+ rankedCandidates: ranked.slice(0, 3).map(r => ({
326
+ id: r.landmark.id,
327
+ name: r.landmark.nameTh,
328
+ distance: formatDistance(r.distanceMeters),
329
+ score: r.finalScore
330
+ })),
331
+ summaryTh: `ใกล้${top.landmark.nameTh} (${formatDistance(minDistance)})`,
332
+ summaryEn: `Near ${top.landmark.nameEn} (${formatDistance(minDistance)})`,
333
+ summaryZh: `靠近${top.landmark.nameZh} (${formatDistance(minDistance)})`
334
+ };
335
+ }
336
+
337
+ export function buildOsmMapTag(lat, lon, name = 'Location') {
338
+ const safeLat = Number(lat).toFixed(4);
339
+ const safeLon = Number(lon).toFixed(4);
340
+ const safeName = String(name || 'Location').replace(/[\[\],]/g, ' ').trim();
341
+ return `[MAP_OSM:${safeLat},${safeLon},${safeName}]`;
342
+ }
343
+
344
+ export function buildOsmUrl(lat, lon, zoom = 16) {
345
+ const safeLat = Number(lat).toFixed(4);
346
+ const safeLon = Number(lon).toFixed(4);
347
+ return `https://www.openstreetmap.org/?mlat=${safeLat}&mlon=${safeLon}#map=${zoom}/${safeLat}/${safeLon}`;
348
+ }
349
+
350
+ export async function reverseGeocodeOsm(lat, lon, options = {}) {
351
+ const numLat = Number(lat);
352
+ const numLon = Number(lon);
353
+ if (!Number.isFinite(numLat) || !Number.isFinite(numLon)) {
354
+ return null;
355
+ }
356
+
357
+ const cacheKey = `rev:${numLat.toFixed(4)},${numLon.toFixed(4)}`;
358
+ const now = Date.now();
359
+ const cached = OSM_CACHE.get(cacheKey);
360
+ if (cached && now - cached.timestamp < CACHE_TTL_MS) {
361
+ return cached.data;
362
+ }
363
+
364
+ const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : 3500;
365
+ const queryUrl = new URL('https://nominatim.openstreetmap.org/reverse');
366
+ queryUrl.searchParams.set('lat', String(numLat));
367
+ queryUrl.searchParams.set('lon', String(numLon));
368
+ queryUrl.searchParams.set('format', 'json');
369
+ queryUrl.searchParams.set('addressdetails', '1');
370
+
371
+ try {
372
+ const res = await fetch(queryUrl.toString(), {
373
+ headers: {
374
+ 'User-Agent': USER_AGENT_HEADER,
375
+ 'Accept-Language': 'th-TH,th;q=0.9,zh-CN;q=0.8,en;q=0.7'
376
+ },
377
+ signal: AbortSignal.timeout(timeoutMs)
378
+ });
379
+
380
+ if (!res.ok) {
381
+ return null;
382
+ }
383
+
384
+ const data = await res.json().catch(() => null);
385
+ if (!data || !data.address) {
386
+ return null;
387
+ }
388
+
389
+ const addr = data.address || {};
390
+ const subdistrict = addr.quarter || addr.subdistrict || addr.neighbourhood || addr.village || '';
391
+ const district = addr.suburb || addr.district || addr.city_district || addr.county || '';
392
+ const city = addr.city || addr.town || addr.municipality || district;
393
+ const province = addr.state || addr.province || addr['ISO3166-2-lvl4'] || city;
394
+ const postcode = addr.postcode || '';
395
+ const country = addr.country || '';
396
+ const countryCode = (addr.country_code || '').toUpperCase();
397
+ const road = addr.road || '';
398
+ const displayName = data.display_name || '';
399
+
400
+ const resolved = {
401
+ displayName,
402
+ road,
403
+ subdistrict,
404
+ district,
405
+ city,
406
+ province,
407
+ postcode,
408
+ country,
409
+ countryCode,
410
+ latitude: numLat,
411
+ longitude: numLon,
412
+ mapUrl: buildOsmUrl(numLat, numLon),
413
+ mapTag: buildOsmMapTag(numLat, numLon, district || city || 'Location')
414
+ };
415
+
416
+ OSM_CACHE.set(cacheKey, { data: resolved, timestamp: now });
417
+ if (OSM_CACHE.size > 200) {
418
+ const firstKey = OSM_CACHE.keys().next().value;
419
+ OSM_CACHE.delete(firstKey);
420
+ }
421
+
422
+ return resolved;
423
+ } catch {
424
+ return null;
425
+ }
426
+ }
427
+
428
+ export function cleanLocationQuery(rawQuery) {
429
+ if (!rawQuery || typeof rawQuery !== 'string') return '';
430
+ return rawQuery
431
+ .replace(/(แผนที่ของ|แผนที่|ขอแผนที่|พิกัดของ|พิกัด|นำทางไป|นำทาง|ทางไป|อยู่ตรงไหน|อยู่ที่ไหน|ตรงไหน|แถว|พาไป|หา|ขอดู|เปิดแมพ|แมพ|\bmap of\b|\bmap\b|\bwhere is\b|\bhow to go to\b|\bnavigate to\b|\bdirections to\b|\bcoordinates of\b|\bcoords\b)/gi, '')
432
+ .trim();
433
+ }
434
+
435
+ export async function searchLocationOsm(query, options = {}) {
436
+ const cleaned = cleanLocationQuery(query);
437
+ const targetQuery = cleaned || query;
438
+ if (!targetQuery || !targetQuery.trim()) {
439
+ return [];
440
+ }
441
+
442
+ const cacheKey = `search:${targetQuery.toLowerCase().trim()}`;
443
+ const now = Date.now();
444
+ const cached = OSM_CACHE.get(cacheKey);
445
+ if (cached && now - cached.timestamp < CACHE_TTL_MS) {
446
+ return cached.data;
447
+ }
448
+
449
+ const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : 4000;
450
+ const limit = Math.min(Math.max(1, Number(options.limit || 5)), 10);
451
+ const cleanQ = targetQuery.toLowerCase().trim();
452
+
453
+ const matchedLandmarks = [];
454
+ for (const lm of LANDMARK_REGISTRY) {
455
+ const th = String(lm.nameTh || '').toLowerCase();
456
+ const en = String(lm.nameEn || '').toLowerCase();
457
+ const zh = String(lm.nameZh || '').toLowerCase();
458
+ if (th.includes(cleanQ) || en.includes(cleanQ) || zh.includes(cleanQ) || (cleanQ.length > 3 && (th.includes(cleanQ.slice(0, 4)) || en.includes(cleanQ.slice(0, 4))))) {
459
+ matchedLandmarks.push({
460
+ name: lm.nameTh,
461
+ displayName: `${lm.nameTh} (${lm.nameEn}), ${lm.city}, ${lm.country}`,
462
+ latitude: lm.latitude,
463
+ longitude: lm.longitude,
464
+ type: lm.category || 'landmark',
465
+ category: 'landmark',
466
+ importance: 0.98,
467
+ road: '',
468
+ subdistrict: '',
469
+ district: '',
470
+ city: lm.city,
471
+ province: lm.city,
472
+ country: lm.country,
473
+ postcode: '',
474
+ mapUrl: buildOsmUrl(lm.latitude, lm.longitude),
475
+ mapTag: buildOsmMapTag(lm.latitude, lm.longitude, lm.nameTh)
476
+ });
477
+ }
478
+ }
479
+
480
+ const queryUrl = new URL('https://nominatim.openstreetmap.org/search');
481
+ queryUrl.searchParams.set('q', targetQuery.trim());
482
+ queryUrl.searchParams.set('format', 'json');
483
+ queryUrl.searchParams.set('limit', String(limit));
484
+ queryUrl.searchParams.set('addressdetails', '1');
485
+
486
+ try {
487
+ const res = await fetch(queryUrl.toString(), {
488
+ headers: {
489
+ 'User-Agent': USER_AGENT_HEADER,
490
+ 'Accept-Language': 'th-TH,th;q=0.9,zh-CN;q=0.8,en;q=0.7'
491
+ },
492
+ signal: AbortSignal.timeout(timeoutMs)
493
+ });
494
+
495
+ let osmResults = [];
496
+ if (res.ok) {
497
+ const data = await res.json().catch(() => null);
498
+ if (Array.isArray(data)) {
499
+ osmResults = data.map((item) => {
500
+ const lat = parseFloat(item.lat);
501
+ const lon = parseFloat(item.lon);
502
+ const addr = item.address || {};
503
+ const primaryName = (item.display_name || '').split(',')[0].trim();
504
+
505
+ return {
506
+ name: primaryName,
507
+ displayName: item.display_name || '',
508
+ latitude: lat,
509
+ longitude: lon,
510
+ type: item.type || '',
511
+ category: item.class || '',
512
+ importance: Number(item.importance || 0),
513
+ road: addr.road || '',
514
+ subdistrict: addr.quarter || addr.subdistrict || addr.neighbourhood || '',
515
+ district: addr.suburb || addr.district || addr.county || '',
516
+ city: addr.city || addr.town || '',
517
+ province: addr.state || addr.province || '',
518
+ country: addr.country || '',
519
+ postcode: addr.postcode || '',
520
+ mapUrl: buildOsmUrl(lat, lon),
521
+ mapTag: buildOsmMapTag(lat, lon, primaryName)
522
+ };
523
+ });
524
+ }
525
+ }
526
+
527
+ const combined = [...matchedLandmarks];
528
+ for (const osm of osmResults) {
529
+ const isDuplicate = combined.some(existing =>
530
+ calculateHaversineDistance(existing.latitude, existing.longitude, osm.latitude, osm.longitude) < 200
531
+ );
532
+ if (!isDuplicate) {
533
+ combined.push(osm);
534
+ }
535
+ }
536
+
537
+ const rankedResults = rankLocationSearchResults(combined, targetQuery);
538
+
539
+ OSM_CACHE.set(cacheKey, { data: rankedResults, timestamp: now });
540
+ if (OSM_CACHE.size > 200) {
541
+ const firstKey = OSM_CACHE.keys().next().value;
542
+ OSM_CACHE.delete(firstKey);
543
+ }
544
+
545
+ return rankedResults;
546
+ } catch {
547
+ if (matchedLandmarks.length > 0) {
548
+ return rankLocationSearchResults(matchedLandmarks, targetQuery);
549
+ }
550
+ return [];
551
+ }
552
+ }
553
+
554
+ export function scoreLocationSearchResult(item, query = '') {
555
+ let score = 0;
556
+ const cleanQ = String(query).toLowerCase().trim();
557
+ const displayName = String(item.displayName || '').toLowerCase();
558
+ const primaryName = String(item.name || '').toLowerCase();
559
+
560
+ if (cleanQ) {
561
+ if (primaryName === cleanQ) {
562
+ score += 50;
563
+ } else if (primaryName.startsWith(cleanQ) || primaryName.includes(cleanQ)) {
564
+ score += 35;
565
+ } else if (displayName.includes(cleanQ)) {
566
+ score += 20;
567
+ }
568
+ }
569
+
570
+ const importance = Number(item.importance || 0);
571
+ score += Math.min(30, Math.max(0, importance * 35));
572
+
573
+ if (item.city) score += 8;
574
+ if (item.district) score += 6;
575
+ if (item.province) score += 6;
576
+ if (item.postcode) score += 4;
577
+
578
+ const type = String(item.type || '').toLowerCase();
579
+ const category = String(item.category || '').toLowerCase();
580
+ if (['city', 'administrative', 'town', 'municipality'].includes(type)) score += 15;
581
+ else if (['tourism', 'historic', 'amenity', 'landmark'].includes(category)) score += 12;
582
+ else if (['suburb', 'quarter', 'neighbourhood'].includes(type)) score += 8;
583
+ else if (['highway', 'road'].includes(category)) score += 4;
584
+
585
+ return Number(score.toFixed(2));
586
+ }
587
+
588
+ export function rankLocationSearchResults(results, query = '') {
589
+ if (!Array.isArray(results) || results.length === 0) return [];
590
+ const scored = results.map(item => ({
591
+ ...item,
592
+ relevanceScore: scoreLocationSearchResult(item, query)
593
+ }));
594
+ scored.sort((a, b) => b.relevanceScore - a.relevanceScore);
595
+ return scored;
596
+ }
597
+
598
+ export function scoreLocationConfidence(locationData = {}) {
599
+ let score = 0;
600
+ const source = locationData.source || '';
601
+ if (source === 'explicit_coordinates') score = 100;
602
+ else if (source === 'client_location') score = 95;
603
+ else if (source === 'osm_query_search') score = 88;
604
+ else if (source === 'ip_lookup') score = 75;
605
+ else if (source === 'system_timezone_estimate') score = 55;
606
+ else if (source === 'client_denied') score = 10;
607
+ else score = 40;
608
+
609
+ if (locationData.accuracy && typeof locationData.accuracy === 'number') {
610
+ if (locationData.accuracy <= 50) score = Math.min(100, score + 5);
611
+ else if (locationData.accuracy > 5000) score = Math.max(20, score - 15);
612
+ }
613
+
614
+ return {
615
+ score,
616
+ confidenceTier: score >= 90 ? 'high' : (score >= 65 ? 'medium' : 'low'),
617
+ isEstimated: score < 90
618
+ };
619
+ }
620
+
621
+ const WEATHER_CACHE = new Map();
622
+ const WEATHER_CACHE_TTL_MS = 300_000;
623
+ const IP_LOC_CACHE = new Map();
624
+ const IP_LOC_CACHE_TTL_MS = 1_800_000;
625
+
626
+ export const WMO_WEATHER_TABLE = Object.freeze({
627
+ 0: { th: 'ท้องฟ้าแจ่มใส', en: 'Clear sky' },
628
+ 1: { th: 'ท้องฟ้าโปร่งเกือบหมด', en: 'Mainly clear' },
629
+ 2: { th: 'มีเมฆบางส่วน', en: 'Partly cloudy' },
630
+ 3: { th: 'มีเมฆมาก', en: 'Overcast' },
631
+ 45: { th: 'มีหมอก', en: 'Fog' },
632
+ 48: { th: 'มีหมอกน้ำค้างแข็ง', en: 'Depositing rime fog' },
633
+ 51: { th: 'ฝนละอองเบาบาง', en: 'Light drizzle' },
634
+ 53: { th: 'ฝนละอองปานกลาง', en: 'Moderate drizzle' },
635
+ 55: { th: 'ฝนละอองหนาแน่น', en: 'Dense drizzle' },
636
+ 56: { th: 'ฝนละอองเยือกแข็งเบาบาง', en: 'Light freezing drizzle' },
637
+ 57: { th: 'ฝนละอองเยือกแข็งหนาแน่น', en: 'Dense freezing drizzle' },
638
+ 61: { th: 'ฝนตกเล็กน้อย', en: 'Slight rain' },
639
+ 63: { th: 'ฝนตกปานกลาง', en: 'Moderate rain' },
640
+ 65: { th: 'ฝนตกหนัก', en: 'Heavy rain' },
641
+ 66: { th: 'ฝนเยือกแข็งเบาบาง', en: 'Light freezing rain' },
642
+ 67: { th: 'ฝนเยือกแข็งหนัก', en: 'Heavy freezing rain' },
643
+ 71: { th: 'หิมะตกเล็กน้อย', en: 'Slight snow fall' },
644
+ 73: { th: 'หิมะตกปานกลาง', en: 'Moderate snow fall' },
645
+ 75: { th: 'หิมะตกหนัก', en: 'Heavy snow fall' },
646
+ 77: { th: 'ลูกเห็บเม็ดเล็ก', en: 'Snow grains' },
647
+ 80: { th: 'ฝนซู่เล็กน้อย', en: 'Slight rain showers' },
648
+ 81: { th: 'ฝนซู่ปานกลาง', en: 'Moderate rain showers' },
649
+ 82: { th: 'ฝนซู่รุนแรง', en: 'Violent rain showers' },
650
+ 85: { th: 'หิมะโปรยเบาบาง', en: 'Slight snow showers' },
651
+ 86: { th: 'หิมะโปรยหนัก', en: 'Heavy snow showers' },
652
+ 95: { th: 'พายุฝนฟ้าคะนอง', en: 'Thunderstorm' },
653
+ 96: { th: 'พายุฝนฟ้าคะนองพร้อมลูกเห็บเล็กน้อย', en: 'Thunderstorm with slight hail' },
654
+ 99: { th: 'พายุฝนฟ้าคะนองพร้อมลูกเห็บหนัก', en: 'Thunderstorm with heavy hail' }
655
+ });
656
+
657
+ export async function detectIpLocation(options = {}) {
658
+ const cached = IP_LOC_CACHE.get('client_ip_location');
659
+ if (cached && (Date.now() - cached.timestamp) < IP_LOC_CACHE_TTL_MS) {
660
+ return cached.data;
661
+ }
662
+
663
+ const timeoutMs = options.timeoutMs || 2500;
664
+ let detected = null;
665
+
666
+ try {
667
+ const res = await fetch('http://ip-api.com/json/?fields=status,country,regionName,city,lat,lon,timezone', {
668
+ headers: { 'User-Agent': USER_AGENT_HEADER },
669
+ signal: AbortSignal.timeout(timeoutMs)
670
+ });
671
+ if (res.ok) {
672
+ const data = await res.json().catch(() => null);
673
+ if (data && data.status === 'success' && typeof data.lat === 'number' && typeof data.lon === 'number') {
674
+ detected = {
675
+ latitude: data.lat,
676
+ longitude: data.lon,
677
+ city: data.city || 'Bangkok',
678
+ region: data.regionName || data.city || 'Bangkok',
679
+ country: data.country || 'Thailand',
680
+ timezone: data.timezone || 'Asia/Bangkok',
681
+ source: 'ip_lookup',
682
+ isEstimated: true
683
+ };
684
+ }
685
+ }
686
+ } catch {}
687
+
688
+ if (!detected) {
689
+ let defaultTz = 'Asia/Bangkok';
690
+ try {
691
+ defaultTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Bangkok';
692
+ } catch {}
693
+
694
+ const tzProfiles = {
695
+ 'Asia/Bangkok': { city: 'Bangkok', region: 'Bangkok', country: 'Thailand', lat: 13.7563, lon: 100.5018 },
696
+ 'Asia/Shanghai': { city: 'Shanghai', region: 'Shanghai', country: 'China', lat: 31.2304, lon: 121.4737 },
697
+ 'Asia/Tokyo': { city: 'Tokyo', region: 'Kanto', country: 'Japan', lat: 35.6762, lon: 139.6503 },
698
+ 'Asia/Seoul': { city: 'Seoul', region: 'Seoul', country: 'South Korea', lat: 37.5665, lon: 126.9780 },
699
+ 'Asia/Singapore': { city: 'Singapore', region: 'Singapore', country: 'Singapore', lat: 1.3521, lon: 103.8198 },
700
+ 'Europe/London': { city: 'London', region: 'England', country: 'United Kingdom', lat: 51.5074, lon: -0.1278 },
701
+ 'America/New_York': { city: 'New York', region: 'New York', country: 'United States', lat: 40.7128, lon: -74.0060 },
702
+ 'America/Los_Angeles': { city: 'Los Angeles', region: 'California', country: 'United States', lat: 34.0522, lon: -118.2437 }
703
+ };
704
+
705
+ const profile = tzProfiles[defaultTz] || {
706
+ city: 'Bangkok',
707
+ region: 'Bangkok',
708
+ country: 'Thailand',
709
+ lat: 13.7563,
710
+ lon: 100.5018
711
+ };
712
+
713
+ detected = {
714
+ latitude: profile.lat,
715
+ longitude: profile.lon,
716
+ city: profile.city,
717
+ region: profile.region,
718
+ country: profile.country,
719
+ timezone: defaultTz,
720
+ source: 'system_timezone_estimate',
721
+ isEstimated: true
722
+ };
723
+ }
724
+
725
+ IP_LOC_CACHE.set('client_ip_location', { data: detected, timestamp: Date.now() });
726
+ return detected;
727
+ }
728
+
729
+ export async function fetchRealtimeWeather(lat, lon, options = {}) {
730
+ const latitude = Number(Number(lat).toFixed(4));
731
+ const longitude = Number(Number(lon).toFixed(4));
732
+ const cacheKey = `${latitude},${longitude}`;
733
+ const now = Date.now();
734
+
735
+ const cached = WEATHER_CACHE.get(cacheKey);
736
+ if (cached && (now - cached.timestamp) < WEATHER_CACHE_TTL_MS) {
737
+ return cached.data;
738
+ }
739
+
740
+ const timeoutMs = options.timeoutMs || 4000;
741
+ const url = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m&timezone=auto`;
742
+
743
+ try {
744
+ const res = await fetch(url, {
745
+ headers: { 'User-Agent': USER_AGENT_HEADER },
746
+ signal: AbortSignal.timeout(timeoutMs)
747
+ });
748
+
749
+ if (!res.ok) {
750
+ return null;
751
+ }
752
+
753
+ const data = await res.json().catch(() => null);
754
+ if (!data || !data.current) {
755
+ return null;
756
+ }
757
+
758
+ const cur = data.current;
759
+ const code = Number(cur.weather_code ?? 0);
760
+ const condition = WMO_WEATHER_TABLE[code] || { th: 'สภาพอากาศปกติ', en: 'Fair' };
761
+ const temp = Number(cur.temperature_2m ?? 0);
762
+ const apparent = Number(cur.apparent_temperature ?? temp);
763
+ const humidity = Number(cur.relative_humidity_2m ?? 0);
764
+ const precip = Number(cur.precipitation ?? 0);
765
+ const wind = Number(cur.wind_speed_10m ?? 0);
766
+
767
+ const summaryTh = `สภาพอากาศ: ${condition.th}, อุณหภูมิ ${temp}°C (รู้สึกเหมือน ${apparent}°C), ความชื้นสัมพัทธ์ ${humidity}%, ลม ${wind} กม./ชม.${precip > 0 ? `, ปริมาณฝน ${precip} มม.` : ''}`;
768
+ const summaryEn = `Weather: ${condition.en}, Temperature ${temp}°C (Feels like ${apparent}°C), Humidity ${humidity}%, Wind ${wind} km/h${precip > 0 ? `, Precipitation ${precip} mm` : ''}`;
769
+
770
+ const result = {
771
+ status: 'success',
772
+ latitude,
773
+ longitude,
774
+ timezone: data.timezone || 'Asia/Bangkok',
775
+ temperature: temp,
776
+ apparentTemperature: apparent,
777
+ humidity,
778
+ precipitation: precip,
779
+ windSpeed: wind,
780
+ weatherCode: code,
781
+ conditionTh: condition.th,
782
+ conditionEn: condition.en,
783
+ summaryTh,
784
+ summaryEn
785
+ };
786
+
787
+ WEATHER_CACHE.set(cacheKey, { data: result, timestamp: now });
788
+ if (WEATHER_CACHE.size > 200) {
789
+ const firstKey = WEATHER_CACHE.keys().next().value;
790
+ WEATHER_CACHE.delete(firstKey);
791
+ }
792
+
793
+ return result;
794
+ } catch {
795
+ return null;
796
+ }
797
+ }