@aborruso/ckan-mcp-server 0.4.6 → 0.4.8
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.
- package/EXAMPLES.md +10 -0
- package/LOG.md +57 -0
- package/PRD.md +339 -252
- package/README.md +39 -36
- package/dist/index.js +160 -21
- package/dist/worker.js +306 -74
- package/openspec/changes/update-search-parser-config/proposal.md +13 -0
- package/openspec/changes/update-search-parser-config/specs/ckan-insights/spec.md +11 -0
- package/openspec/changes/update-search-parser-config/specs/ckan-search/spec.md +11 -0
- package/openspec/changes/update-search-parser-config/tasks.md +6 -0
- package/openspec/project.md +9 -7
- package/openspec/specs/ckan-insights/spec.md +8 -1
- package/package.json +1 -1
- package/web-gui/PRD.md +158 -0
- package/web-gui/public/index.html +883 -0
- /package/openspec/changes/{add-ckan-find-relevant-datasets → archive/2026-01-10-add-ckan-find-relevant-datasets}/proposal.md +0 -0
- /package/openspec/changes/{add-ckan-find-relevant-datasets → archive/2026-01-10-add-ckan-find-relevant-datasets}/specs/ckan-insights/spec.md +0 -0
- /package/openspec/changes/{add-ckan-find-relevant-datasets → archive/2026-01-10-add-ckan-find-relevant-datasets}/tasks.md +0 -0
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="it">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>CKAN Open Data Explorer</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
|
10
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
11
|
+
<style>
|
|
12
|
+
:root {
|
|
13
|
+
--primary: #06b6d4;
|
|
14
|
+
--primary-dark: #0891b2;
|
|
15
|
+
--bg-main: #0f1419;
|
|
16
|
+
--bg-secondary: #1a1f28;
|
|
17
|
+
--bg-tertiary: #252d38;
|
|
18
|
+
--text-primary: #f8fafc;
|
|
19
|
+
--text-secondary: #cbd5e1;
|
|
20
|
+
--text-muted: #64748b;
|
|
21
|
+
--border: #334155;
|
|
22
|
+
--accent-gradient: linear-gradient(135deg, #06b6d4 0%, #3b82f6 100%);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
* {
|
|
26
|
+
margin: 0;
|
|
27
|
+
padding: 0;
|
|
28
|
+
box-sizing: border-box;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
body {
|
|
32
|
+
font-family: 'IBM Plex Sans', sans-serif;
|
|
33
|
+
background: var(--bg-main);
|
|
34
|
+
color: var(--text-primary);
|
|
35
|
+
overflow-x: hidden;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Background grid pattern */
|
|
39
|
+
body::before {
|
|
40
|
+
content: '';
|
|
41
|
+
position: fixed;
|
|
42
|
+
inset: 0;
|
|
43
|
+
background-image:
|
|
44
|
+
linear-gradient(to right, rgba(255,255,255,0.02) 1px, transparent 1px),
|
|
45
|
+
linear-gradient(to bottom, rgba(255,255,255,0.02) 1px, transparent 1px);
|
|
46
|
+
background-size: 32px 32px;
|
|
47
|
+
pointer-events: none;
|
|
48
|
+
z-index: 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.serif {
|
|
52
|
+
font-family: 'DM Serif Display', serif;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* Smooth animations */
|
|
56
|
+
@keyframes slideInUp {
|
|
57
|
+
from {
|
|
58
|
+
opacity: 0;
|
|
59
|
+
transform: translateY(20px);
|
|
60
|
+
}
|
|
61
|
+
to {
|
|
62
|
+
opacity: 1;
|
|
63
|
+
transform: translateY(0);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@keyframes pulse {
|
|
68
|
+
0%, 100% {
|
|
69
|
+
opacity: 1;
|
|
70
|
+
}
|
|
71
|
+
50% {
|
|
72
|
+
opacity: 0.5;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@keyframes shimmer {
|
|
77
|
+
0% {
|
|
78
|
+
background-position: -1000px 0;
|
|
79
|
+
}
|
|
80
|
+
100% {
|
|
81
|
+
background-position: 1000px 0;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.animate-slide-in {
|
|
86
|
+
animation: slideInUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.animate-pulse-slow {
|
|
90
|
+
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* Custom scrollbar */
|
|
94
|
+
::-webkit-scrollbar {
|
|
95
|
+
width: 8px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
::-webkit-scrollbar-track {
|
|
99
|
+
background: var(--bg-secondary);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
::-webkit-scrollbar-thumb {
|
|
103
|
+
background: var(--border);
|
|
104
|
+
border-radius: 4px;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
::-webkit-scrollbar-thumb:hover {
|
|
108
|
+
background: var(--text-muted);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Glass morphism effect */
|
|
112
|
+
.glass {
|
|
113
|
+
background: rgba(26, 31, 40, 0.6);
|
|
114
|
+
backdrop-filter: blur(12px);
|
|
115
|
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* Gradient text */
|
|
119
|
+
.gradient-text {
|
|
120
|
+
background: var(--accent-gradient);
|
|
121
|
+
-webkit-background-clip: text;
|
|
122
|
+
-webkit-text-fill-color: transparent;
|
|
123
|
+
background-clip: text;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* Message animations */
|
|
127
|
+
.message-user {
|
|
128
|
+
animation: slideInUp 0.3s ease-out;
|
|
129
|
+
margin-left: auto;
|
|
130
|
+
max-width: 80%;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.message-assistant {
|
|
134
|
+
animation: slideInUp 0.3s ease-out 0.1s both;
|
|
135
|
+
max-width: 100%;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* Dataset card hover effect */
|
|
139
|
+
.dataset-card {
|
|
140
|
+
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
141
|
+
border: 1px solid var(--border);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.dataset-card:hover {
|
|
145
|
+
transform: translateY(-2px);
|
|
146
|
+
border-color: var(--primary);
|
|
147
|
+
box-shadow: 0 8px 24px rgba(6, 182, 212, 0.15);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* Button styles */
|
|
151
|
+
.btn-primary {
|
|
152
|
+
background: var(--accent-gradient);
|
|
153
|
+
transition: all 0.2s ease;
|
|
154
|
+
position: relative;
|
|
155
|
+
overflow: hidden;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.btn-primary::before {
|
|
159
|
+
content: '';
|
|
160
|
+
position: absolute;
|
|
161
|
+
inset: 0;
|
|
162
|
+
background: linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0) 100%);
|
|
163
|
+
opacity: 0;
|
|
164
|
+
transition: opacity 0.2s ease;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.btn-primary:hover::before {
|
|
168
|
+
opacity: 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.btn-primary:hover {
|
|
172
|
+
transform: translateY(-1px);
|
|
173
|
+
box-shadow: 0 8px 16px rgba(6, 182, 212, 0.3);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.btn-secondary {
|
|
177
|
+
background: var(--bg-tertiary);
|
|
178
|
+
border: 1px solid var(--border);
|
|
179
|
+
transition: all 0.2s ease;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.btn-secondary:hover {
|
|
183
|
+
background: var(--bg-secondary);
|
|
184
|
+
border-color: var(--text-muted);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* Input focus states */
|
|
188
|
+
input:focus, textarea:focus {
|
|
189
|
+
outline: none;
|
|
190
|
+
border-color: var(--primary) !important;
|
|
191
|
+
box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* Status indicator pulse */
|
|
195
|
+
.status-online {
|
|
196
|
+
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
197
|
+
box-shadow: 0 0 0 0 rgba(6, 182, 212, 0.4);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* Collapsible settings */
|
|
201
|
+
.settings-collapsed {
|
|
202
|
+
max-height: 0;
|
|
203
|
+
overflow: hidden;
|
|
204
|
+
transition: max-height 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.settings-expanded {
|
|
208
|
+
max-height: 1000px;
|
|
209
|
+
transition: max-height 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* Loading shimmer */
|
|
213
|
+
.shimmer {
|
|
214
|
+
background: linear-gradient(
|
|
215
|
+
90deg,
|
|
216
|
+
var(--bg-tertiary) 0%,
|
|
217
|
+
rgba(255,255,255,0.05) 50%,
|
|
218
|
+
var(--bg-tertiary) 100%
|
|
219
|
+
);
|
|
220
|
+
background-size: 1000px 100%;
|
|
221
|
+
animation: shimmer 2s infinite linear;
|
|
222
|
+
}
|
|
223
|
+
</style>
|
|
224
|
+
</head>
|
|
225
|
+
<body class="min-h-screen">
|
|
226
|
+
<main class="relative z-10 mx-auto flex w-full max-w-5xl flex-col gap-6 px-4 py-8 md:py-12">
|
|
227
|
+
<!-- Header -->
|
|
228
|
+
<header class="glass rounded-2xl p-6 md:p-8 animate-slide-in">
|
|
229
|
+
<div class="flex items-start justify-between gap-4 mb-6">
|
|
230
|
+
<div class="flex-1">
|
|
231
|
+
<h1 class="serif text-4xl md:text-5xl mb-2 gradient-text">
|
|
232
|
+
CKAN Explorer
|
|
233
|
+
</h1>
|
|
234
|
+
<p class="text-base" style="color: var(--text-secondary);">
|
|
235
|
+
Conversational interface for open data research
|
|
236
|
+
</p>
|
|
237
|
+
</div>
|
|
238
|
+
<div class="flex items-center gap-2 px-3 py-2 rounded-full" style="background: var(--bg-tertiary); border: 1px solid var(--border);">
|
|
239
|
+
<span id="status-dot" class="h-2.5 w-2.5 rounded-full" style="background: var(--text-muted);"></span>
|
|
240
|
+
<span id="status-text" class="text-sm" style="color: var(--text-secondary);">Checking...</span>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<!-- Settings toggle -->
|
|
245
|
+
<button
|
|
246
|
+
id="settings-toggle"
|
|
247
|
+
class="w-full flex items-center justify-between px-4 py-3 rounded-xl mb-4 btn-secondary text-sm font-medium"
|
|
248
|
+
style="color: var(--text-secondary);"
|
|
249
|
+
>
|
|
250
|
+
<span>⚙️ Configuration</span>
|
|
251
|
+
<svg id="settings-icon" class="w-5 h-5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
252
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
253
|
+
</svg>
|
|
254
|
+
</button>
|
|
255
|
+
|
|
256
|
+
<!-- Settings panel -->
|
|
257
|
+
<div id="settings-panel" class="settings-collapsed">
|
|
258
|
+
<div class="grid gap-4 md:grid-cols-3 mb-4">
|
|
259
|
+
<label class="flex flex-col gap-2">
|
|
260
|
+
<span class="text-xs font-medium uppercase tracking-wider" style="color: var(--text-muted);">MCP Endpoint</span>
|
|
261
|
+
<input
|
|
262
|
+
id="mcp-endpoint"
|
|
263
|
+
class="rounded-lg px-3 py-2.5 text-sm transition-all"
|
|
264
|
+
style="background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text-primary);"
|
|
265
|
+
placeholder="https://ckan-mcp-server.andy-pr.workers.dev/mcp"
|
|
266
|
+
/>
|
|
267
|
+
</label>
|
|
268
|
+
<label class="flex flex-col gap-2">
|
|
269
|
+
<span class="text-xs font-medium uppercase tracking-wider" style="color: var(--text-muted);">CKAN Server</span>
|
|
270
|
+
<input
|
|
271
|
+
id="ckan-server"
|
|
272
|
+
class="rounded-lg px-3 py-2.5 text-sm transition-all"
|
|
273
|
+
style="background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text-primary);"
|
|
274
|
+
placeholder="https://www.dati.gov.it/opendata"
|
|
275
|
+
/>
|
|
276
|
+
</label>
|
|
277
|
+
<label class="flex flex-col gap-2">
|
|
278
|
+
<span class="text-xs font-medium uppercase tracking-wider" style="color: var(--text-muted);">Gemini API Key</span>
|
|
279
|
+
<input
|
|
280
|
+
id="gemini-key"
|
|
281
|
+
type="password"
|
|
282
|
+
class="rounded-lg px-3 py-2.5 text-sm transition-all"
|
|
283
|
+
style="background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text-primary);"
|
|
284
|
+
placeholder="AIza..."
|
|
285
|
+
/>
|
|
286
|
+
</label>
|
|
287
|
+
</div>
|
|
288
|
+
<div class="flex flex-wrap items-center justify-between gap-4">
|
|
289
|
+
<button
|
|
290
|
+
id="save-settings"
|
|
291
|
+
class="btn-primary px-6 py-2.5 rounded-lg font-medium text-sm text-white"
|
|
292
|
+
>
|
|
293
|
+
Apply Settings
|
|
294
|
+
</button>
|
|
295
|
+
<p class="text-xs" style="color: var(--text-muted);">
|
|
296
|
+
Powered by Gemini 2.5 Flash • Natural language queries
|
|
297
|
+
</p>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
</header>
|
|
301
|
+
|
|
302
|
+
<!-- Messages area -->
|
|
303
|
+
<section class="glass rounded-2xl p-6 min-h-[400px] flex flex-col animate-slide-in" style="animation-delay: 0.1s;">
|
|
304
|
+
<div id="tool-status" class="hidden mb-4 px-4 py-2 rounded-lg shimmer text-sm font-medium" style="color: var(--primary);">
|
|
305
|
+
⚡ Processing...
|
|
306
|
+
</div>
|
|
307
|
+
<div id="messages" class="flex flex-col gap-4 flex-1">
|
|
308
|
+
<div class="message-assistant rounded-xl px-4 py-3 text-sm" style="background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border);">
|
|
309
|
+
👋 Welcome! Ask me anything about open datasets (e.g., "renewable energy datasets")
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
</section>
|
|
313
|
+
|
|
314
|
+
<!-- Input area -->
|
|
315
|
+
<section class="glass rounded-2xl p-4 md:p-6 animate-slide-in" style="animation-delay: 0.2s;">
|
|
316
|
+
<div class="flex flex-col md:flex-row gap-3">
|
|
317
|
+
<textarea
|
|
318
|
+
id="message-input"
|
|
319
|
+
rows="2"
|
|
320
|
+
class="flex-1 rounded-xl px-4 py-3 text-sm resize-none transition-all"
|
|
321
|
+
style="background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text-primary);"
|
|
322
|
+
placeholder="Ask about datasets..."
|
|
323
|
+
></textarea>
|
|
324
|
+
<div class="flex gap-2">
|
|
325
|
+
<button
|
|
326
|
+
id="reset-btn"
|
|
327
|
+
class="btn-secondary px-5 py-3 rounded-xl text-sm font-medium whitespace-nowrap"
|
|
328
|
+
style="color: var(--text-secondary);"
|
|
329
|
+
title="Clear conversation"
|
|
330
|
+
>
|
|
331
|
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
332
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
333
|
+
</svg>
|
|
334
|
+
</button>
|
|
335
|
+
<button
|
|
336
|
+
id="send-btn"
|
|
337
|
+
class="btn-primary px-6 py-3 rounded-xl text-sm font-semibold text-white whitespace-nowrap flex items-center gap-2"
|
|
338
|
+
>
|
|
339
|
+
<span>Search</span>
|
|
340
|
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
341
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
|
342
|
+
</svg>
|
|
343
|
+
</button>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</section>
|
|
347
|
+
</main>
|
|
348
|
+
|
|
349
|
+
<script>
|
|
350
|
+
const DEFAULT_ENDPOINT = "https://ckan-mcp-server.andy-pr.workers.dev/mcp";
|
|
351
|
+
const DEFAULT_CKAN = "https://www.dati.gov.it/opendata";
|
|
352
|
+
const CORS_PROXY = "https://corsproxy.io/?";
|
|
353
|
+
|
|
354
|
+
const elements = {
|
|
355
|
+
endpoint: document.getElementById("mcp-endpoint"),
|
|
356
|
+
ckan: document.getElementById("ckan-server"),
|
|
357
|
+
geminiKey: document.getElementById("gemini-key"),
|
|
358
|
+
save: document.getElementById("save-settings"),
|
|
359
|
+
statusDot: document.getElementById("status-dot"),
|
|
360
|
+
statusText: document.getElementById("status-text"),
|
|
361
|
+
messages: document.getElementById("messages"),
|
|
362
|
+
input: document.getElementById("message-input"),
|
|
363
|
+
send: document.getElementById("send-btn"),
|
|
364
|
+
reset: document.getElementById("reset-btn"),
|
|
365
|
+
toolStatus: document.getElementById("tool-status"),
|
|
366
|
+
settingsToggle: document.getElementById("settings-toggle"),
|
|
367
|
+
settingsPanel: document.getElementById("settings-panel"),
|
|
368
|
+
settingsIcon: document.getElementById("settings-icon")
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const state = {
|
|
372
|
+
endpoint: localStorage.getItem("mcpEndpoint") || DEFAULT_ENDPOINT,
|
|
373
|
+
ckanServer: localStorage.getItem("ckanServer") || DEFAULT_CKAN,
|
|
374
|
+
geminiKey: localStorage.getItem("geminiApiKey") || "",
|
|
375
|
+
busy: false,
|
|
376
|
+
conversationHistory: [],
|
|
377
|
+
settingsExpanded: false,
|
|
378
|
+
availableTools: []
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
elements.endpoint.value = state.endpoint;
|
|
382
|
+
elements.ckan.value = state.ckanServer;
|
|
383
|
+
elements.geminiKey.value = state.geminiKey;
|
|
384
|
+
|
|
385
|
+
const setStatus = (status, text) => {
|
|
386
|
+
elements.statusText.textContent = text;
|
|
387
|
+
elements.statusDot.className = "h-2.5 w-2.5 rounded-full";
|
|
388
|
+
if (status === "ok") {
|
|
389
|
+
elements.statusDot.style.background = "#06b6d4";
|
|
390
|
+
elements.statusDot.classList.add("status-online");
|
|
391
|
+
} else if (status === "error") {
|
|
392
|
+
elements.statusDot.style.background = "#ef4444";
|
|
393
|
+
elements.statusDot.classList.remove("status-online");
|
|
394
|
+
} else {
|
|
395
|
+
elements.statusDot.style.background = "#64748b";
|
|
396
|
+
elements.statusDot.classList.remove("status-online");
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const setToolStatus = (message) => {
|
|
401
|
+
if (message) {
|
|
402
|
+
elements.toolStatus.textContent = message;
|
|
403
|
+
elements.toolStatus.classList.remove("hidden");
|
|
404
|
+
} else {
|
|
405
|
+
elements.toolStatus.classList.add("hidden");
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const addMessage = (role, content) => {
|
|
410
|
+
const wrapper = document.createElement("div");
|
|
411
|
+
|
|
412
|
+
if (role === "user") {
|
|
413
|
+
wrapper.className = "message-user rounded-xl px-4 py-3 text-sm";
|
|
414
|
+
wrapper.style.cssText = "background: linear-gradient(135deg, #06b6d4 0%, #3b82f6 100%); color: white; font-weight: 500;";
|
|
415
|
+
} else {
|
|
416
|
+
wrapper.className = "message-assistant rounded-xl px-4 py-3 text-sm";
|
|
417
|
+
wrapper.style.cssText = "background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border);";
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (typeof content === "string") {
|
|
421
|
+
wrapper.textContent = content;
|
|
422
|
+
} else {
|
|
423
|
+
wrapper.appendChild(content);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
elements.messages.appendChild(wrapper);
|
|
427
|
+
setTimeout(() => {
|
|
428
|
+
wrapper.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
429
|
+
}, 100);
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const createDatasetCard = (dataset) => {
|
|
433
|
+
const card = document.createElement("div");
|
|
434
|
+
card.className = "dataset-card rounded-xl p-5 cursor-pointer";
|
|
435
|
+
card.style.cssText = "background: var(--bg-secondary);";
|
|
436
|
+
|
|
437
|
+
const title = document.createElement("h3");
|
|
438
|
+
title.className = "text-base font-semibold mb-2";
|
|
439
|
+
title.style.color = "var(--text-primary)";
|
|
440
|
+
title.textContent = dataset.title || dataset.name || "Dataset";
|
|
441
|
+
|
|
442
|
+
const desc = document.createElement("p");
|
|
443
|
+
desc.className = "text-sm mb-3 line-clamp-2";
|
|
444
|
+
desc.style.color = "var(--text-secondary)";
|
|
445
|
+
desc.textContent = dataset.notes || "No description available.";
|
|
446
|
+
|
|
447
|
+
const meta = document.createElement("div");
|
|
448
|
+
meta.className = "flex items-center gap-4 text-xs";
|
|
449
|
+
meta.style.color = "var(--text-muted)";
|
|
450
|
+
|
|
451
|
+
const orgName = dataset.organization?.title || dataset.organization?.name || "Unknown";
|
|
452
|
+
const resources = Array.isArray(dataset.resources) ? dataset.resources.length : 0;
|
|
453
|
+
|
|
454
|
+
meta.innerHTML = `
|
|
455
|
+
<span class="flex items-center gap-1">
|
|
456
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
457
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
|
|
458
|
+
</svg>
|
|
459
|
+
${orgName}
|
|
460
|
+
</span>
|
|
461
|
+
<span class="flex items-center gap-1">
|
|
462
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
463
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
|
464
|
+
</svg>
|
|
465
|
+
${resources} resource${resources !== 1 ? 's' : ''}
|
|
466
|
+
</span>
|
|
467
|
+
`;
|
|
468
|
+
|
|
469
|
+
card.append(title, desc, meta);
|
|
470
|
+
return card;
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const createOrganizationCard = (org) => {
|
|
474
|
+
const card = document.createElement("div");
|
|
475
|
+
card.className = "dataset-card rounded-xl p-5 cursor-pointer";
|
|
476
|
+
card.style.cssText = "background: var(--bg-secondary);";
|
|
477
|
+
|
|
478
|
+
const title = document.createElement("h3");
|
|
479
|
+
title.className = "text-base font-semibold mb-2";
|
|
480
|
+
title.style.color = "var(--text-primary)";
|
|
481
|
+
title.textContent = org.title || org.display_name || org.name || "Organization";
|
|
482
|
+
|
|
483
|
+
const desc = document.createElement("p");
|
|
484
|
+
desc.className = "text-sm mb-3 line-clamp-2";
|
|
485
|
+
desc.style.color = "var(--text-secondary)";
|
|
486
|
+
desc.textContent = org.description || "No description available.";
|
|
487
|
+
|
|
488
|
+
const meta = document.createElement("div");
|
|
489
|
+
meta.className = "flex items-center gap-4 text-xs";
|
|
490
|
+
meta.style.color = "var(--text-muted)";
|
|
491
|
+
|
|
492
|
+
const packageCount = org.package_count || org.packages?.length || 0;
|
|
493
|
+
|
|
494
|
+
meta.innerHTML = `
|
|
495
|
+
<span class="flex items-center gap-1">
|
|
496
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
497
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
|
|
498
|
+
</svg>
|
|
499
|
+
${packageCount.toLocaleString()} dataset${packageCount !== 1 ? 's' : ''}
|
|
500
|
+
</span>
|
|
501
|
+
`;
|
|
502
|
+
|
|
503
|
+
card.append(title, desc, meta);
|
|
504
|
+
return card;
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const extractStructured = (result) => {
|
|
508
|
+
if (!result) {
|
|
509
|
+
console.error("extractStructured: result is null/undefined");
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (result.isError) {
|
|
514
|
+
console.error("MCP returned error:", result);
|
|
515
|
+
const errorText = result.content?.[0]?.text || "Unknown MCP error";
|
|
516
|
+
console.error("Error text:", errorText);
|
|
517
|
+
throw new Error(errorText);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (result.structuredContent) return result.structuredContent;
|
|
521
|
+
|
|
522
|
+
const text = result.content?.[0]?.text;
|
|
523
|
+
if (!text) {
|
|
524
|
+
console.error("extractStructured: no text in result.content[0]");
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
const parsed = JSON.parse(text);
|
|
530
|
+
console.log("Successfully parsed JSON from MCP response");
|
|
531
|
+
return parsed;
|
|
532
|
+
} catch (e) {
|
|
533
|
+
console.error("Failed to parse JSON:", e, "Text was:", text);
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const requestMcp = async (method, params, useProxy = false) => {
|
|
539
|
+
const endpoint = useProxy ? `${CORS_PROXY}${encodeURIComponent(state.endpoint)}` : state.endpoint;
|
|
540
|
+
const payload = {
|
|
541
|
+
jsonrpc: "2.0",
|
|
542
|
+
id: crypto.randomUUID(),
|
|
543
|
+
method,
|
|
544
|
+
params
|
|
545
|
+
};
|
|
546
|
+
const response = await fetch(endpoint, {
|
|
547
|
+
method: "POST",
|
|
548
|
+
headers: {
|
|
549
|
+
"Content-Type": "application/json",
|
|
550
|
+
Accept: "application/json, text/event-stream"
|
|
551
|
+
},
|
|
552
|
+
body: JSON.stringify(payload)
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
if (!response.ok) {
|
|
556
|
+
throw new Error(`HTTP ${response.status}`);
|
|
557
|
+
}
|
|
558
|
+
const data = await response.json();
|
|
559
|
+
if (data.error) {
|
|
560
|
+
throw new Error(data.error.message || "MCP Error");
|
|
561
|
+
}
|
|
562
|
+
return data.result;
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const callMcp = async (method, params) => {
|
|
566
|
+
try {
|
|
567
|
+
return await requestMcp(method, params, false);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
return await requestMcp(method, params, true);
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const checkMcp = async () => {
|
|
574
|
+
setStatus("checking", "Checking...");
|
|
575
|
+
try {
|
|
576
|
+
const result = await callMcp("tools/list", {});
|
|
577
|
+
state.availableTools = result.tools || [];
|
|
578
|
+
console.log("Available MCP tools:", state.availableTools.map(t => t.name));
|
|
579
|
+
setStatus("ok", `Online (${state.availableTools.length} tools)`);
|
|
580
|
+
} catch (error) {
|
|
581
|
+
console.error("MCP check error:", error);
|
|
582
|
+
setStatus("error", "Offline");
|
|
583
|
+
state.availableTools = [];
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const stopwords = new Set([
|
|
588
|
+
"a", "al", "allo", "alla", "alle", "ai", "agli", "da", "dal", "dallo",
|
|
589
|
+
"dalla", "delle", "dei", "del", "della", "di", "e", "ed", "il", "lo",
|
|
590
|
+
"la", "le", "i", "gli", "in", "su", "per", "con", "che", "quali",
|
|
591
|
+
"quale", "quanti", "quanto", "tema", "dataset"
|
|
592
|
+
]);
|
|
593
|
+
|
|
594
|
+
const normalizeQuery = (query) => {
|
|
595
|
+
if (/["*:]|\bAND\b|\bOR\b|\bNOT\b/i.test(query)) {
|
|
596
|
+
return query;
|
|
597
|
+
}
|
|
598
|
+
const tokens = query
|
|
599
|
+
.toLowerCase()
|
|
600
|
+
.match(/[\p{L}\p{N}]+/gu)
|
|
601
|
+
?.filter((token) => token.length > 1 && !stopwords.has(token));
|
|
602
|
+
if (!tokens || tokens.length === 0) return "*:*";
|
|
603
|
+
return tokens.join(" ");
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const callGemini = async (userQuery, history = []) => {
|
|
607
|
+
if (!state.geminiKey) {
|
|
608
|
+
throw new Error("Missing Gemini API key");
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Build tool list for prompt
|
|
612
|
+
const toolList = state.availableTools.map(t =>
|
|
613
|
+
`- ${t.name}: ${t.description?.substring(0, 100) || 'No description'}`
|
|
614
|
+
).join('\n');
|
|
615
|
+
|
|
616
|
+
const systemPrompt = `You are an assistant for CKAN open data queries.
|
|
617
|
+
Available MCP tools:
|
|
618
|
+
${toolList}
|
|
619
|
+
|
|
620
|
+
CRITICAL RULES FOR TOOL SELECTION:
|
|
621
|
+
1. If user asks about ORGANIZATIONS (like "which organizations", "organizations with most datasets", "list organizations"):
|
|
622
|
+
→ Use ckan_organization_list with ONLY these parameters:
|
|
623
|
+
{
|
|
624
|
+
"server_url": "CKAN_SERVER",
|
|
625
|
+
"all_fields": true,
|
|
626
|
+
"limit": 100,
|
|
627
|
+
"response_format": "json"
|
|
628
|
+
}
|
|
629
|
+
Do NOT use sort parameter - results will be sorted client-side by package_count
|
|
630
|
+
|
|
631
|
+
2. If user asks about DATASETS (like "find datasets about X", "datasets on topic Y"):
|
|
632
|
+
→ Use ckan_package_search or ckan_find_relevant_datasets
|
|
633
|
+
|
|
634
|
+
3. If user asks about TAGS:
|
|
635
|
+
→ Use ckan_tag_list
|
|
636
|
+
|
|
637
|
+
4. NEVER use ckan_package_search with faceting to get organizations - use ckan_organization_list instead!
|
|
638
|
+
|
|
639
|
+
Respond ONLY with JSON in this exact format:
|
|
640
|
+
{
|
|
641
|
+
"tool": "tool_name",
|
|
642
|
+
"arguments": {
|
|
643
|
+
"server_url": "CKAN_SERVER",
|
|
644
|
+
...other params
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
Always include server_url and response_format="json".
|
|
649
|
+
Use conversation context to refine queries.`;
|
|
650
|
+
|
|
651
|
+
const contents = [
|
|
652
|
+
...history,
|
|
653
|
+
{
|
|
654
|
+
role: "user",
|
|
655
|
+
parts: [{ text: `${systemPrompt}\n\nCKAN Server: ${state.ckanServer}\nUser request: ${userQuery}` }]
|
|
656
|
+
}
|
|
657
|
+
];
|
|
658
|
+
|
|
659
|
+
const response = await fetch(
|
|
660
|
+
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${state.geminiKey}`,
|
|
661
|
+
{
|
|
662
|
+
method: "POST",
|
|
663
|
+
headers: {
|
|
664
|
+
"Content-Type": "application/json",
|
|
665
|
+
Accept: "application/json"
|
|
666
|
+
},
|
|
667
|
+
body: JSON.stringify({
|
|
668
|
+
contents: contents,
|
|
669
|
+
generationConfig: { temperature: 0.2, maxOutputTokens: 500 }
|
|
670
|
+
})
|
|
671
|
+
}
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
if (!response.ok) {
|
|
675
|
+
throw new Error(`Gemini HTTP ${response.status}`);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const data = await response.json();
|
|
679
|
+
const text = data.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
|
680
|
+
console.log("Gemini raw response:", text);
|
|
681
|
+
const jsonStart = text.indexOf("{");
|
|
682
|
+
const jsonEnd = text.lastIndexOf("}");
|
|
683
|
+
if (jsonStart === -1 || jsonEnd === -1) {
|
|
684
|
+
console.error("No JSON found in Gemini response");
|
|
685
|
+
throw new Error("Invalid Gemini response");
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const jsonText = text.slice(jsonStart, jsonEnd + 1);
|
|
689
|
+
console.log("Extracted JSON:", jsonText);
|
|
690
|
+
const parsed = JSON.parse(jsonText);
|
|
691
|
+
console.log("Parsed Gemini response:", parsed);
|
|
692
|
+
|
|
693
|
+
if (!parsed.tool || !parsed.arguments) {
|
|
694
|
+
console.error("Missing tool or arguments in response");
|
|
695
|
+
throw new Error("Gemini did not return tool and arguments");
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return parsed;
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const runSearch = async (query) => {
|
|
702
|
+
// Try Gemini to select tool and arguments
|
|
703
|
+
let toolCall;
|
|
704
|
+
try {
|
|
705
|
+
toolCall = await callGemini(query, state.conversationHistory);
|
|
706
|
+
console.log("Gemini selected tool:", toolCall);
|
|
707
|
+
} catch (error) {
|
|
708
|
+
console.error("Gemini error:", error);
|
|
709
|
+
// Fallback: use ckan_package_search with normalized query
|
|
710
|
+
const normalizedQuery = normalizeQuery(query);
|
|
711
|
+
toolCall = {
|
|
712
|
+
tool: "ckan_package_search",
|
|
713
|
+
arguments: {
|
|
714
|
+
server_url: state.ckanServer,
|
|
715
|
+
q: normalizedQuery,
|
|
716
|
+
rows: 5,
|
|
717
|
+
response_format: "json"
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
console.log("Using fallback tool:", toolCall);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Ensure server_url is set
|
|
724
|
+
if (!toolCall.arguments.server_url) {
|
|
725
|
+
toolCall.arguments.server_url = state.ckanServer;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Ensure response_format is json for programmatic parsing
|
|
729
|
+
if (!toolCall.arguments.response_format) {
|
|
730
|
+
toolCall.arguments.response_format = "json";
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
console.log("Final tool call:", toolCall);
|
|
734
|
+
setToolStatus(`⚡ Using ${toolCall.tool}...`);
|
|
735
|
+
const result = await callMcp("tools/call", {
|
|
736
|
+
name: toolCall.tool,
|
|
737
|
+
arguments: toolCall.arguments
|
|
738
|
+
});
|
|
739
|
+
console.log("MCP result:", result);
|
|
740
|
+
setToolStatus("");
|
|
741
|
+
|
|
742
|
+
const data = extractStructured(result);
|
|
743
|
+
console.log("Extracted data:", data);
|
|
744
|
+
return {
|
|
745
|
+
tool: toolCall.tool,
|
|
746
|
+
data: data
|
|
747
|
+
};
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
const handleSend = async () => {
|
|
751
|
+
const query = elements.input.value.trim();
|
|
752
|
+
if (!query || state.busy) return;
|
|
753
|
+
state.busy = true;
|
|
754
|
+
elements.send.disabled = true;
|
|
755
|
+
elements.send.style.opacity = "0.5";
|
|
756
|
+
|
|
757
|
+
addMessage("user", query);
|
|
758
|
+
elements.input.value = "";
|
|
759
|
+
|
|
760
|
+
// Add user message to conversation history
|
|
761
|
+
state.conversationHistory.push({
|
|
762
|
+
role: "user",
|
|
763
|
+
parts: [{ text: query }]
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
try {
|
|
767
|
+
const response = await runSearch(query);
|
|
768
|
+
const { tool, data } = response;
|
|
769
|
+
|
|
770
|
+
if (!data) {
|
|
771
|
+
addMessage("assistant", "No results found.");
|
|
772
|
+
state.conversationHistory.push({
|
|
773
|
+
role: "model",
|
|
774
|
+
parts: [{ text: "No results found." }]
|
|
775
|
+
});
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const container = document.createElement("div");
|
|
780
|
+
container.className = "flex flex-col gap-3";
|
|
781
|
+
|
|
782
|
+
// Handle different tool types
|
|
783
|
+
if (tool.includes("organization")) {
|
|
784
|
+
// Organization tools
|
|
785
|
+
let items = data.results || data || [];
|
|
786
|
+
|
|
787
|
+
// Sort by package_count descending (client-side)
|
|
788
|
+
items = items.sort((a, b) => {
|
|
789
|
+
const countA = a.package_count || 0;
|
|
790
|
+
const countB = b.package_count || 0;
|
|
791
|
+
return countB - countA;
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
const summary = document.createElement("p");
|
|
795
|
+
summary.className = "text-sm font-medium mb-1";
|
|
796
|
+
summary.style.color = "var(--primary)";
|
|
797
|
+
summary.textContent = `🏢 Found ${data.count ?? items.length} organization${(data.count ?? items.length) !== 1 ? 's' : ''}`;
|
|
798
|
+
container.appendChild(summary);
|
|
799
|
+
|
|
800
|
+
items.slice(0, 10).forEach((org) => container.appendChild(createOrganizationCard(org)));
|
|
801
|
+
addMessage("assistant", container);
|
|
802
|
+
|
|
803
|
+
state.conversationHistory.push({
|
|
804
|
+
role: "model",
|
|
805
|
+
parts: [{ text: `Found ${data.count ?? items.length} organizations for "${query}"` }]
|
|
806
|
+
});
|
|
807
|
+
} else {
|
|
808
|
+
// Dataset tools (package_search, find_relevant_datasets, etc.)
|
|
809
|
+
const items = data.results || data || [];
|
|
810
|
+
const summary = document.createElement("p");
|
|
811
|
+
summary.className = "text-sm font-medium mb-1";
|
|
812
|
+
summary.style.color = "var(--primary)";
|
|
813
|
+
summary.textContent = `📊 Found ${data.count ?? items.length} dataset${(data.count ?? items.length) !== 1 ? 's' : ''}`;
|
|
814
|
+
container.appendChild(summary);
|
|
815
|
+
|
|
816
|
+
items.slice(0, 10).forEach((dataset) => container.appendChild(createDatasetCard(dataset)));
|
|
817
|
+
addMessage("assistant", container);
|
|
818
|
+
|
|
819
|
+
state.conversationHistory.push({
|
|
820
|
+
role: "model",
|
|
821
|
+
parts: [{ text: `Found ${data.count ?? items.length} datasets for "${query}"` }]
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
} catch (error) {
|
|
825
|
+
addMessage("assistant", `❌ Error: ${error.message || "Could not contact MCP"}`);
|
|
826
|
+
state.conversationHistory.push({
|
|
827
|
+
role: "model",
|
|
828
|
+
parts: [{ text: `Error: ${error.message || "Could not contact MCP"}` }]
|
|
829
|
+
});
|
|
830
|
+
} finally {
|
|
831
|
+
state.busy = false;
|
|
832
|
+
elements.send.disabled = false;
|
|
833
|
+
elements.send.style.opacity = "1";
|
|
834
|
+
setToolStatus("");
|
|
835
|
+
|
|
836
|
+
// Limit history to last 10 messages (5 exchanges)
|
|
837
|
+
if (state.conversationHistory.length > 10) {
|
|
838
|
+
state.conversationHistory = state.conversationHistory.slice(-10);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
// Settings toggle
|
|
844
|
+
elements.settingsToggle.addEventListener("click", () => {
|
|
845
|
+
state.settingsExpanded = !state.settingsExpanded;
|
|
846
|
+
if (state.settingsExpanded) {
|
|
847
|
+
elements.settingsPanel.classList.remove("settings-collapsed");
|
|
848
|
+
elements.settingsPanel.classList.add("settings-expanded");
|
|
849
|
+
elements.settingsIcon.style.transform = "rotate(180deg)";
|
|
850
|
+
} else {
|
|
851
|
+
elements.settingsPanel.classList.remove("settings-expanded");
|
|
852
|
+
elements.settingsPanel.classList.add("settings-collapsed");
|
|
853
|
+
elements.settingsIcon.style.transform = "rotate(0deg)";
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
elements.save.addEventListener("click", async () => {
|
|
858
|
+
state.endpoint = elements.endpoint.value.trim() || DEFAULT_ENDPOINT;
|
|
859
|
+
state.ckanServer = elements.ckan.value.trim() || DEFAULT_CKAN;
|
|
860
|
+
state.geminiKey = elements.geminiKey.value.trim();
|
|
861
|
+
localStorage.setItem("mcpEndpoint", state.endpoint);
|
|
862
|
+
localStorage.setItem("ckanServer", state.ckanServer);
|
|
863
|
+
localStorage.setItem("geminiApiKey", state.geminiKey);
|
|
864
|
+
await checkMcp();
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
elements.send.addEventListener("click", handleSend);
|
|
868
|
+
elements.input.addEventListener("keydown", (event) => {
|
|
869
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
870
|
+
event.preventDefault();
|
|
871
|
+
handleSend();
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
elements.reset.addEventListener("click", () => {
|
|
876
|
+
state.conversationHistory = [];
|
|
877
|
+
elements.messages.innerHTML = '<div class="message-assistant rounded-xl px-4 py-3 text-sm" style="background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border);">🔄 Conversation reset. Ask me anything!</div>';
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
checkMcp();
|
|
881
|
+
</script>
|
|
882
|
+
</body>
|
|
883
|
+
</html>
|