spagestic commited on
Commit
9befa59
·
1 Parent(s): 89cf883

Added header controls and client-side chat histor. Assistant messages now render as markdown

Browse files
Files changed (3) hide show
  1. assets/app.js +216 -2
  2. assets/index.html +32 -3
  3. assets/server.css +202 -3
assets/app.js CHANGED
@@ -1,7 +1,16 @@
1
  import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
 
 
2
 
3
  const client = await Client.connect(window.location.origin);
4
 
 
 
 
 
 
 
 
5
  const REQUIRED_FIELDS = [
6
  "current-country",
7
  "residence-status",
@@ -22,8 +31,9 @@ function unwrapResult(result) {
22
  }
23
 
24
  const state = {
 
25
  history: [],
26
- globeState: { version: 0, markers: [], highlights: [], fly_to: null },
27
  choices: null,
28
  busy: false,
29
  view: "form",
@@ -42,8 +52,17 @@ const els = {
42
  personaList: document.getElementById("persona-list"),
43
  authLogin: document.getElementById("auth-login"),
44
  authLogout: document.getElementById("auth-logout"),
 
 
 
 
 
45
  };
46
 
 
 
 
 
47
  function authRedirectTarget() {
48
  return encodeURIComponent(window.location.pathname + window.location.search);
49
  }
@@ -104,6 +123,164 @@ function showChatView() {
104
  setStatus("");
105
  }
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  function setBusy(busy) {
108
  state.busy = busy;
109
  els.chatSend.disabled = busy;
@@ -205,6 +382,31 @@ function clearFieldValidation() {
205
  }
206
  }
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  function renderMessages() {
209
  els.chatMessages.innerHTML = "";
210
  for (const message of state.history) {
@@ -222,7 +424,7 @@ function renderMessages() {
222
  node.appendChild(title);
223
  }
224
  const body = document.createElement("div");
225
- body.textContent = message.content || "";
226
  node.appendChild(body);
227
  els.chatMessages.appendChild(node);
228
  }
@@ -284,6 +486,7 @@ async function runChat(message) {
284
  if (payload.globe_state) {
285
  applyGlobeState(payload.globe_state);
286
  }
 
287
  }
288
 
289
  async function sendChatMessage(message) {
@@ -349,6 +552,17 @@ for (const id of REQUIRED_FIELDS) {
349
 
350
  els.intakeForm.addEventListener("submit", submitForm);
351
  els.chatSend.addEventListener("click", sendChat);
 
 
 
 
 
 
 
 
 
 
 
352
  els.chatInput.addEventListener("keydown", (event) => {
353
  if (event.key === "Enter" && !event.shiftKey) {
354
  event.preventDefault();
 
1
  import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
2
+ import { marked } from "https://cdn.jsdelivr.net/npm/marked@15.0.7/lib/marked.esm.js";
3
+ import DOMPurify from "https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.es.min.js";
4
 
5
  const client = await Client.connect(window.location.origin);
6
 
7
+ marked.setOptions({
8
+ breaks: true,
9
+ gfm: true,
10
+ });
11
+
12
+ const CHAT_SESSIONS_KEY = "borderless-chat-sessions";
13
+
14
  const REQUIRED_FIELDS = [
15
  "current-country",
16
  "residence-status",
 
31
  }
32
 
33
  const state = {
34
+ sessionId: crypto.randomUUID(),
35
  history: [],
36
+ globeState: emptyGlobeState(),
37
  choices: null,
38
  busy: false,
39
  view: "form",
 
52
  personaList: document.getElementById("persona-list"),
53
  authLogin: document.getElementById("auth-login"),
54
  authLogout: document.getElementById("auth-logout"),
55
+ newChat: document.getElementById("new-chat"),
56
+ historyOpen: document.getElementById("history-open"),
57
+ historyDialog: document.getElementById("history-dialog"),
58
+ historyClose: document.getElementById("history-close"),
59
+ historyList: document.getElementById("history-list"),
60
  };
61
 
62
+ function emptyGlobeState() {
63
+ return { version: 0, markers: [], highlights: [], fly_to: null };
64
+ }
65
+
66
  function authRedirectTarget() {
67
  return encodeURIComponent(window.location.pathname + window.location.search);
68
  }
 
123
  setStatus("");
124
  }
125
 
126
+ function showFormView() {
127
+ state.view = "form";
128
+ els.chatView.classList.remove("is-active");
129
+ els.formView.classList.add("is-active");
130
+ setStatus("");
131
+ }
132
+
133
+ function resetForm() {
134
+ for (const id of REQUIRED_FIELDS) {
135
+ const element = document.getElementById(id);
136
+ if (element.tagName === "TEXTAREA") {
137
+ element.value = "";
138
+ } else {
139
+ element.value = "";
140
+ }
141
+ setFieldInvalid(id, false);
142
+ }
143
+ }
144
+
145
+ function sessionTitle(history) {
146
+ const firstUserMessage = history.find(
147
+ (message) => message.role === "user" && message.content,
148
+ );
149
+ if (!firstUserMessage) {
150
+ return "Untitled chat";
151
+ }
152
+ const text = String(firstUserMessage.content).trim().replace(/\s+/g, " ");
153
+ return text.length > 72 ? `${text.slice(0, 69)}...` : text;
154
+ }
155
+
156
+ function formatSessionDate(timestamp) {
157
+ return new Date(timestamp).toLocaleString(undefined, {
158
+ month: "short",
159
+ day: "numeric",
160
+ hour: "numeric",
161
+ minute: "2-digit",
162
+ });
163
+ }
164
+
165
+ function loadSessions() {
166
+ try {
167
+ const sessions = JSON.parse(localStorage.getItem(CHAT_SESSIONS_KEY) || "[]");
168
+ return Array.isArray(sessions) ? sessions : [];
169
+ } catch {
170
+ return [];
171
+ }
172
+ }
173
+
174
+ function saveSessions(sessions) {
175
+ localStorage.setItem(CHAT_SESSIONS_KEY, JSON.stringify(sessions));
176
+ }
177
+
178
+ function persistActiveSession() {
179
+ if (!state.history.length) {
180
+ return;
181
+ }
182
+
183
+ const sessions = loadSessions();
184
+ const now = Date.now();
185
+ const payload = {
186
+ id: state.sessionId,
187
+ title: sessionTitle(state.history),
188
+ updatedAt: now,
189
+ history: state.history,
190
+ globeState: state.globeState,
191
+ };
192
+
193
+ const index = sessions.findIndex((session) => session.id === state.sessionId);
194
+ if (index >= 0) {
195
+ sessions[index] = { ...sessions[index], ...payload };
196
+ } else {
197
+ sessions.unshift({ ...payload, createdAt: now });
198
+ }
199
+
200
+ sessions.sort((left, right) => right.updatedAt - left.updatedAt);
201
+ saveSessions(sessions);
202
+ }
203
+
204
+ function openHistoryDialog() {
205
+ renderHistoryList();
206
+ els.historyDialog.hidden = false;
207
+ }
208
+
209
+ function closeHistoryDialog() {
210
+ els.historyDialog.hidden = true;
211
+ }
212
+
213
+ function renderHistoryList() {
214
+ const sessions = loadSessions();
215
+ els.historyList.innerHTML = "";
216
+
217
+ if (!sessions.length) {
218
+ const empty = document.createElement("p");
219
+ empty.className = "history-list-empty";
220
+ empty.textContent = "No saved chats yet.";
221
+ els.historyList.appendChild(empty);
222
+ return;
223
+ }
224
+
225
+ for (const session of sessions) {
226
+ const button = document.createElement("button");
227
+ button.type = "button";
228
+ button.className = "history-item";
229
+ if (session.id === state.sessionId) {
230
+ button.classList.add("is-active");
231
+ }
232
+
233
+ const title = document.createElement("span");
234
+ title.className = "history-item-title";
235
+ title.textContent = session.title || "Untitled chat";
236
+
237
+ const meta = document.createElement("span");
238
+ meta.className = "history-item-meta";
239
+ meta.textContent = formatSessionDate(session.updatedAt || session.createdAt);
240
+
241
+ button.appendChild(title);
242
+ button.appendChild(meta);
243
+ button.addEventListener("click", () => loadSession(session.id));
244
+ els.historyList.appendChild(button);
245
+ }
246
+ }
247
+
248
+ function loadSession(sessionId) {
249
+ const session = loadSessions().find((entry) => entry.id === sessionId);
250
+ if (!session) {
251
+ return;
252
+ }
253
+
254
+ if (state.history.length && state.sessionId !== sessionId) {
255
+ persistActiveSession();
256
+ }
257
+
258
+ state.sessionId = session.id;
259
+ state.history = session.history || [];
260
+ state.globeState = session.globeState || emptyGlobeState();
261
+ els.chatInput.value = "";
262
+ renderMessages();
263
+ applyGlobeState(state.globeState);
264
+ showChatView();
265
+ closeHistoryDialog();
266
+ }
267
+
268
+ function startNewChat() {
269
+ if (state.history.length) {
270
+ persistActiveSession();
271
+ }
272
+
273
+ state.sessionId = crypto.randomUUID();
274
+ state.history = [];
275
+ state.globeState = emptyGlobeState();
276
+ resetForm();
277
+ els.chatInput.value = "";
278
+ renderMessages();
279
+ applyGlobeState(state.globeState);
280
+ showFormView();
281
+ closeHistoryDialog();
282
+ }
283
+
284
  function setBusy(busy) {
285
  state.busy = busy;
286
  els.chatSend.disabled = busy;
 
382
  }
383
  }
384
 
385
+ function shouldRenderMarkdown(message, isTool) {
386
+ return message.role === "assistant" && !isTool;
387
+ }
388
+
389
+ function renderMessageBody(body, message, isTool) {
390
+ const content = message.content || "";
391
+ body.className = "chat-message-body";
392
+
393
+ if (!shouldRenderMarkdown(message, isTool)) {
394
+ body.textContent = content;
395
+ return;
396
+ }
397
+
398
+ body.classList.add("markdown-body");
399
+ const html = marked.parse(content);
400
+ body.innerHTML = DOMPurify.sanitize(html, {
401
+ USE_PROFILES: { html: true },
402
+ });
403
+
404
+ for (const link of body.querySelectorAll("a")) {
405
+ link.target = "_blank";
406
+ link.rel = "noopener noreferrer";
407
+ }
408
+ }
409
+
410
  function renderMessages() {
411
  els.chatMessages.innerHTML = "";
412
  for (const message of state.history) {
 
424
  node.appendChild(title);
425
  }
426
  const body = document.createElement("div");
427
+ renderMessageBody(body, message, isTool);
428
  node.appendChild(body);
429
  els.chatMessages.appendChild(node);
430
  }
 
486
  if (payload.globe_state) {
487
  applyGlobeState(payload.globe_state);
488
  }
489
+ persistActiveSession();
490
  }
491
 
492
  async function sendChatMessage(message) {
 
552
 
553
  els.intakeForm.addEventListener("submit", submitForm);
554
  els.chatSend.addEventListener("click", sendChat);
555
+ els.newChat.addEventListener("click", startNewChat);
556
+ els.historyOpen.addEventListener("click", openHistoryDialog);
557
+ els.historyClose.addEventListener("click", closeHistoryDialog);
558
+ els.historyDialog
559
+ .querySelector("[data-history-close]")
560
+ .addEventListener("click", closeHistoryDialog);
561
+ document.addEventListener("keydown", (event) => {
562
+ if (event.key === "Escape" && !els.historyDialog.hidden) {
563
+ closeHistoryDialog();
564
+ }
565
+ });
566
  els.chatInput.addEventListener("keydown", (event) => {
567
  if (event.key === "Enter" && !event.shiftKey) {
568
  event.preventDefault();
assets/index.html CHANGED
@@ -20,11 +20,17 @@
20
  Describe your background once. The agent researches official sources, and suggests countries to migrate to.
21
  </p>
22
  </div>
23
- <div class="auth-actions">
24
- <a id="auth-login" class="auth-button" href="/login/huggingface" hidden>
 
 
 
 
 
 
25
  Log in with Hugging Face
26
  </a>
27
- <a id="auth-logout" class="auth-button" href="/logout" hidden>
28
  Log out
29
  </a>
30
  </div>
@@ -125,6 +131,29 @@
125
  </main>
126
  </div>
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  <script src="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
129
  <script src="/assets/globe.js"></script>
130
  <script type="module" src="/assets/app.js"></script>
 
20
  Describe your background once. The agent researches official sources, and suggests countries to migrate to.
21
  </p>
22
  </div>
23
+ <div class="header-actions">
24
+ <button type="button" id="history-open" class="header-button">
25
+ History
26
+ </button>
27
+ <button type="button" id="new-chat" class="header-button">
28
+ New chat
29
+ </button>
30
+ <a id="auth-login" class="header-button" href="/login/huggingface" hidden>
31
  Log in with Hugging Face
32
  </a>
33
+ <a id="auth-logout" class="header-button" href="/logout" hidden>
34
  Log out
35
  </a>
36
  </div>
 
131
  </main>
132
  </div>
133
 
134
+ <div id="history-dialog" class="history-dialog" hidden>
135
+ <div class="history-dialog-backdrop" data-history-close></div>
136
+ <div
137
+ class="history-dialog-panel"
138
+ role="dialog"
139
+ aria-modal="true"
140
+ aria-labelledby="history-dialog-title"
141
+ >
142
+ <div class="history-dialog-header">
143
+ <h3 id="history-dialog-title">Chat history</h3>
144
+ <button
145
+ type="button"
146
+ id="history-close"
147
+ class="history-dialog-close"
148
+ aria-label="Close"
149
+ >
150
+ Close
151
+ </button>
152
+ </div>
153
+ <div id="history-list" class="history-list"></div>
154
+ </div>
155
+ </div>
156
+
157
  <script src="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
158
  <script src="/assets/globe.js"></script>
159
  <script type="module" src="/assets/app.js"></script>
assets/server.css CHANGED
@@ -40,17 +40,20 @@ a {
40
  font-size: 1.1rem;
41
  }
42
 
43
- .auth-actions {
44
  display: flex;
45
  gap: 8px;
46
  align-items: center;
 
 
47
  }
48
 
49
- .auth-actions [hidden] {
50
  display: none !important;
51
  }
52
 
53
- .auth-actions a,
 
54
  button {
55
  border-radius: 8px;
56
  border: 1px solid rgba(148, 163, 184, 0.25);
@@ -226,9 +229,16 @@ button:disabled {
226
  border-radius: 10px;
227
  padding: 10px 12px;
228
  line-height: 1.45;
 
 
 
229
  white-space: pre-wrap;
230
  }
231
 
 
 
 
 
232
  .chat-message.user {
233
  background: rgba(59, 130, 246, 0.15);
234
  align-self: flex-end;
@@ -256,6 +266,105 @@ button:disabled {
256
  margin-bottom: 4px;
257
  }
258
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  .chat-input-row {
260
  display: flex;
261
  gap: 8px;
@@ -287,6 +396,96 @@ button:disabled {
287
  display: block;
288
  }
289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  @media (max-width: 900px) {
291
  .app-shell {
292
  height: auto;
 
40
  font-size: 1.1rem;
41
  }
42
 
43
+ .header-actions {
44
  display: flex;
45
  gap: 8px;
46
  align-items: center;
47
+ flex-wrap: wrap;
48
+ justify-content: flex-end;
49
  }
50
 
51
+ .header-actions [hidden] {
52
  display: none !important;
53
  }
54
 
55
+ .header-actions a,
56
+ .header-actions button,
57
  button {
58
  border-radius: 8px;
59
  border: 1px solid rgba(148, 163, 184, 0.25);
 
229
  border-radius: 10px;
230
  padding: 10px 12px;
231
  line-height: 1.45;
232
+ }
233
+
234
+ .chat-message-body {
235
  white-space: pre-wrap;
236
  }
237
 
238
+ .chat-message.assistant .chat-message-body.markdown-body {
239
+ white-space: normal;
240
+ }
241
+
242
  .chat-message.user {
243
  background: rgba(59, 130, 246, 0.15);
244
  align-self: flex-end;
 
266
  margin-bottom: 4px;
267
  }
268
 
269
+ .markdown-body > :first-child {
270
+ margin-top: 0;
271
+ }
272
+
273
+ .markdown-body > :last-child {
274
+ margin-bottom: 0;
275
+ }
276
+
277
+ .markdown-body p,
278
+ .markdown-body ul,
279
+ .markdown-body ol,
280
+ .markdown-body pre,
281
+ .markdown-body blockquote,
282
+ .markdown-body table {
283
+ margin: 0.65em 0;
284
+ }
285
+
286
+ .markdown-body h1,
287
+ .markdown-body h2,
288
+ .markdown-body h3,
289
+ .markdown-body h4 {
290
+ margin: 0.9em 0 0.45em;
291
+ line-height: 1.3;
292
+ color: #f9fafb;
293
+ }
294
+
295
+ .markdown-body h1 {
296
+ font-size: 1.2rem;
297
+ }
298
+
299
+ .markdown-body h2 {
300
+ font-size: 1.1rem;
301
+ }
302
+
303
+ .markdown-body h3,
304
+ .markdown-body h4 {
305
+ font-size: 1rem;
306
+ }
307
+
308
+ .markdown-body ul,
309
+ .markdown-body ol {
310
+ padding-left: 1.25rem;
311
+ }
312
+
313
+ .markdown-body li + li {
314
+ margin-top: 0.25em;
315
+ }
316
+
317
+ .markdown-body a {
318
+ color: #fbbf24;
319
+ text-decoration: underline;
320
+ }
321
+
322
+ .markdown-body code {
323
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
324
+ font-size: 0.9em;
325
+ background: rgba(15, 23, 42, 0.8);
326
+ border: 1px solid rgba(148, 163, 184, 0.2);
327
+ border-radius: 4px;
328
+ padding: 0.1em 0.35em;
329
+ }
330
+
331
+ .markdown-body pre {
332
+ overflow-x: auto;
333
+ background: rgba(15, 23, 42, 0.9);
334
+ border: 1px solid rgba(148, 163, 184, 0.2);
335
+ border-radius: 8px;
336
+ padding: 10px 12px;
337
+ }
338
+
339
+ .markdown-body pre code {
340
+ background: none;
341
+ border: 0;
342
+ padding: 0;
343
+ }
344
+
345
+ .markdown-body blockquote {
346
+ border-left: 3px solid rgba(245, 158, 11, 0.45);
347
+ padding-left: 12px;
348
+ color: #d1d5db;
349
+ }
350
+
351
+ .markdown-body table {
352
+ width: 100%;
353
+ border-collapse: collapse;
354
+ font-size: 0.92rem;
355
+ }
356
+
357
+ .markdown-body th,
358
+ .markdown-body td {
359
+ border: 1px solid rgba(148, 163, 184, 0.22);
360
+ padding: 6px 8px;
361
+ text-align: left;
362
+ }
363
+
364
+ .markdown-body th {
365
+ background: rgba(15, 23, 42, 0.7);
366
+ }
367
+
368
  .chat-input-row {
369
  display: flex;
370
  gap: 8px;
 
396
  display: block;
397
  }
398
 
399
+ .history-dialog {
400
+ position: fixed;
401
+ inset: 0;
402
+ z-index: 1000;
403
+ display: flex;
404
+ align-items: center;
405
+ justify-content: center;
406
+ padding: 20px;
407
+ }
408
+
409
+ .history-dialog[hidden] {
410
+ display: none !important;
411
+ }
412
+
413
+ .history-dialog-backdrop {
414
+ position: absolute;
415
+ inset: 0;
416
+ background: rgba(2, 6, 23, 0.72);
417
+ }
418
+
419
+ .history-dialog-panel {
420
+ position: relative;
421
+ width: min(480px, 100%);
422
+ max-height: min(70vh, 560px);
423
+ display: flex;
424
+ flex-direction: column;
425
+ gap: 12px;
426
+ border-radius: 12px;
427
+ border: 1px solid rgba(148, 163, 184, 0.22);
428
+ background: #111827;
429
+ box-shadow: 0 24px 48px rgba(0, 0, 0, 0.45);
430
+ padding: 16px;
431
+ }
432
+
433
+ .history-dialog-header {
434
+ display: flex;
435
+ align-items: center;
436
+ justify-content: space-between;
437
+ gap: 12px;
438
+ }
439
+
440
+ .history-dialog-header h3 {
441
+ margin: 0;
442
+ font-size: 1rem;
443
+ }
444
+
445
+ .history-dialog-close {
446
+ font-size: 0.85rem;
447
+ }
448
+
449
+ .history-list {
450
+ display: flex;
451
+ flex-direction: column;
452
+ gap: 8px;
453
+ overflow-y: auto;
454
+ min-height: 0;
455
+ }
456
+
457
+ .history-list-empty {
458
+ margin: 0;
459
+ padding: 12px 0;
460
+ color: #9ca3af;
461
+ font-size: 0.9rem;
462
+ }
463
+
464
+ .history-item {
465
+ display: flex;
466
+ flex-direction: column;
467
+ align-items: flex-start;
468
+ gap: 4px;
469
+ width: 100%;
470
+ text-align: left;
471
+ padding: 10px 12px;
472
+ }
473
+
474
+ .history-item.is-active {
475
+ border-color: #f59e0b;
476
+ background: rgba(245, 158, 11, 0.08);
477
+ }
478
+
479
+ .history-item-title {
480
+ font-weight: 600;
481
+ color: #f9fafb;
482
+ }
483
+
484
+ .history-item-meta {
485
+ font-size: 0.8rem;
486
+ color: #9ca3af;
487
+ }
488
+
489
  @media (max-width: 900px) {
490
  .app-shell {
491
  height: auto;