nsarrazin HF Staff commited on
Commit
a1a6daf
·
unverified ·
1 Parent(s): 973387a

chores(svelte): migration to svelte 5 (#1685)

Browse files

* chores(svelte): migration to svelte 5

* chores(svelte5): remove preventDefault & stopPropagation legacy handlers

* chores(deps): version bumps

* fix: closing modal works properly

* fix: messagte loading reactivity

* fix: loading indicator

* fix: conversation list reactivity

* fix: bug on tool logo rendering

* fix: infinite loading circular dep

* feat: hide new chat button on new chats

* fix: move apply directive to main.css

* fix: remove legacy syntax in `TokensCounter.svelte`

* fix: replace legacy `run` with Svelte 5 `$effect` in NavMenu

* fix: replace legacy `run` with Svelte 5 `$effect` in ScrollToPreviousBtn

* fix: migrate `settings/[modelId]` page to Svelte 5 syntax

* fix: replace legacy `run` with Svelte 5 `$effect` in ToolUpdate component

* fix: migrate ToolEdit component to Svelte 5 syntax

* fix: simplify login required handling in ChatInput

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .prettierrc +0 -1
  2. package-lock.json +0 -0
  3. package.json +12 -11
  4. src/app.html +1 -1
  5. src/lib/components/AnnouncementBanner.svelte +8 -3
  6. src/lib/components/AssistantSettings.svelte +43 -35
  7. src/lib/components/AssistantToolPicker.svelte +22 -8
  8. src/lib/components/CodeBlock.svelte +7 -3
  9. src/lib/components/ContinueBtn.svelte +7 -2
  10. src/lib/components/CopyToClipBoardBtn.svelte +15 -7
  11. src/lib/components/DisclaimerModal.svelte +3 -1
  12. src/lib/components/ExpandNavigation.svelte +10 -5
  13. src/lib/components/HoverTooltip.svelte +9 -4
  14. src/lib/components/InfiniteScroll.svelte +8 -4
  15. src/lib/components/LoginModal.svelte +1 -1
  16. src/lib/components/MobileNav.svelte +30 -17
  17. src/lib/components/Modal.svelte +17 -12
  18. src/lib/components/ModelCardMetadata.svelte +8 -5
  19. src/lib/components/NavConversationItem.svelte +18 -8
  20. src/lib/components/NavMenu.svelte +29 -22
  21. src/lib/components/OpenWebSearchResults.svelte +15 -9
  22. src/lib/components/Pagination.svelte +10 -7
  23. src/lib/components/PaginationArrow.svelte +7 -3
  24. src/lib/components/Portal.svelte +8 -3
  25. src/lib/components/RetryBtn.svelte +7 -2
  26. src/lib/components/ScrollToBottomBtn.svelte +23 -18
  27. src/lib/components/ScrollToPreviousBtn.svelte +26 -19
  28. src/lib/components/StopGeneratingBtn.svelte +7 -2
  29. src/lib/components/Switch.svelte +7 -3
  30. src/lib/components/SystemPromptModal.svelte +10 -6
  31. src/lib/components/Toast.svelte +5 -1
  32. src/lib/components/TokensCounter.svelte +34 -34
  33. src/lib/components/ToolBadge.svelte +8 -2
  34. src/lib/components/ToolLogo.svelte +43 -33
  35. src/lib/components/ToolsMenu.svelte +26 -13
  36. src/lib/components/Tooltip.svelte +12 -4
  37. src/lib/components/UploadBtn.svelte +8 -4
  38. src/lib/components/WebSearchToggle.svelte +2 -2
  39. src/lib/components/chat/Alternatives.svelte +10 -6
  40. src/lib/components/chat/AssistantIntroduction.svelte +29 -24
  41. src/lib/components/chat/ChatInput.svelte +82 -53
  42. src/lib/components/chat/ChatIntroduction.svelte +10 -4
  43. src/lib/components/chat/ChatMessage.svelte +94 -103
  44. src/lib/components/chat/ChatWindow.svelte +105 -65
  45. src/lib/components/chat/FileDropzone.svelte +23 -9
  46. src/lib/components/chat/MarkdownRenderer.svelte +7 -3
  47. src/lib/components/chat/ModelSwitch.svelte +10 -6
  48. src/lib/components/chat/OpenReasoningResults.svelte +7 -3
  49. src/lib/components/chat/ToolUpdate.svelte +57 -49
  50. src/lib/components/chat/UploadedFile.svelte +36 -20
.prettierrc CHANGED
@@ -3,6 +3,5 @@
3
  "trailingComma": "es5",
4
  "printWidth": 100,
5
  "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
6
- "pluginSearchDirs": ["."],
7
  "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
8
  }
 
3
  "trailingComma": "es5",
4
  "printWidth": 100,
5
  "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
 
6
  "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
7
  }
package-lock.json CHANGED
The diff for this file is too large to render. See raw diff
 
package.json CHANGED
@@ -9,8 +9,8 @@
9
  "preview": "vite preview",
10
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
11
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
12
- "lint": "prettier --plugin-search-dir . --check . && eslint .",
13
- "format": "prettier --plugin-search-dir . --write .",
14
  "test": "vitest",
15
  "updateLocalEnv": "node --loader ts-node/esm scripts/updateLocalEnv.ts",
16
  "populate": "vite-node --options.transformMode.ssr='/.*/' scripts/populate.ts",
@@ -21,7 +21,8 @@
21
  "@iconify-json/carbon": "^1.1.16",
22
  "@iconify-json/eos-icons": "^1.1.6",
23
  "@sveltejs/adapter-node": "^5.2.0",
24
- "@sveltejs/kit": "^2.8.3",
 
25
  "@tailwindcss/typography": "^0.5.9",
26
  "@types/dompurify": "^3.0.5",
27
  "@types/express": "^4.17.21",
@@ -40,22 +41,22 @@
40
  "dompurify": "^3.1.6",
41
  "eslint": "^8.28.0",
42
  "eslint-config-prettier": "^8.5.0",
43
- "eslint-plugin-svelte": "^2.30.0",
44
  "isomorphic-dompurify": "^2.13.0",
45
  "js-yaml": "^4.1.0",
46
  "minimist": "^1.2.8",
47
  "mongodb-memory-server": "^10.1.2",
48
- "prettier": "^2.8.0",
49
- "prettier-plugin-svelte": "^2.10.1",
50
- "prettier-plugin-tailwindcss": "^0.2.7",
51
  "prom-client": "^15.1.2",
52
- "svelte": "^4.2.19",
53
- "svelte-check": "^3.8.5",
54
  "ts-node": "^10.9.1",
55
  "tslib": "^2.4.1",
56
- "typescript": "^5.0.0",
57
  "unplugin-icons": "^0.16.1",
58
- "vite": "^5.4.14",
59
  "vite-node": "^1.3.1",
60
  "vitest": "^2.1.9"
61
  },
 
9
  "preview": "vite preview",
10
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
11
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
12
+ "lint": "prettier --check . && eslint .",
13
+ "format": "prettier --write .",
14
  "test": "vitest",
15
  "updateLocalEnv": "node --loader ts-node/esm scripts/updateLocalEnv.ts",
16
  "populate": "vite-node --options.transformMode.ssr='/.*/' scripts/populate.ts",
 
21
  "@iconify-json/carbon": "^1.1.16",
22
  "@iconify-json/eos-icons": "^1.1.6",
23
  "@sveltejs/adapter-node": "^5.2.0",
24
+ "@sveltejs/kit": "^2.17.1",
25
+ "@sveltejs/vite-plugin-svelte": "^5.0.3",
26
  "@tailwindcss/typography": "^0.5.9",
27
  "@types/dompurify": "^3.0.5",
28
  "@types/express": "^4.17.21",
 
41
  "dompurify": "^3.1.6",
42
  "eslint": "^8.28.0",
43
  "eslint-config-prettier": "^8.5.0",
44
+ "eslint-plugin-svelte": "^2.45.1",
45
  "isomorphic-dompurify": "^2.13.0",
46
  "js-yaml": "^4.1.0",
47
  "minimist": "^1.2.8",
48
  "mongodb-memory-server": "^10.1.2",
49
+ "prettier": "^3.1.0",
50
+ "prettier-plugin-svelte": "^3.2.6",
51
+ "prettier-plugin-tailwindcss": "^0.6.11",
52
  "prom-client": "^15.1.2",
53
+ "svelte": "^5.0.0",
54
+ "svelte-check": "^4.0.0",
55
  "ts-node": "^10.9.1",
56
  "tslib": "^2.4.1",
57
+ "typescript": "^5.5.0",
58
  "unplugin-icons": "^0.16.1",
59
+ "vite": "^6.1.0",
60
  "vite-node": "^1.3.1",
61
  "vitest": "^2.1.9"
62
  },
src/app.html CHANGED
@@ -1,4 +1,4 @@
1
- <!DOCTYPE html>
2
  <html lang="en" class="h-full">
3
  <head>
4
  <meta charset="utf-8" />
 
1
+ <!doctype html>
2
  <html lang="en" class="h-full">
3
  <head>
4
  <meta charset="utf-8" />
src/lib/components/AnnouncementBanner.svelte CHANGED
@@ -1,6 +1,11 @@
1
  <script lang="ts">
2
- export let title = "";
3
- export let classNames = "";
 
 
 
 
 
4
  </script>
5
 
6
  <div class="flex items-center rounded-xl bg-gray-100 p-1 text-sm dark:bg-gray-800 {classNames}">
@@ -10,6 +15,6 @@
10
  >
11
  {title}
12
  <div class="ml-auto shrink-0">
13
- <slot />
14
  </div>
15
  </div>
 
1
  <script lang="ts">
2
+ interface Props {
3
+ title?: string;
4
+ classNames?: string;
5
+ children?: import("svelte").Snippet;
6
+ }
7
+
8
+ let { title = "", classNames = "", children }: Props = $props();
9
  </script>
10
 
11
  <div class="flex items-center rounded-xl bg-gray-100 p-1 text-sm dark:bg-gray-800 {classNames}">
 
15
  >
16
  {title}
17
  <div class="ml-auto shrink-0">
18
+ {@render children?.()}
19
  </div>
20
  </div>
src/lib/components/AssistantSettings.svelte CHANGED
@@ -31,18 +31,22 @@
31
 
32
  type AssistantFront = Omit<Assistant, "_id" | "createdById"> & { _id: string };
33
 
34
- export let form: ActionData;
35
- export let assistant: AssistantFront | undefined = undefined;
36
- export let models: Model[] = [];
 
 
 
 
37
 
38
- let files: FileList | null = null;
39
  const settings = useSettingsStore();
40
- let modelId = "";
41
- let systemPrompt = assistant?.preprompt ?? "";
42
- let dynamicPrompt = assistant?.dynamicPrompt ?? false;
43
- let showModelSettings = Object.values(assistant?.generateSettings ?? {}).some((v) => !!v);
44
 
45
- let compress: typeof readAndCompressImage | null = null;
46
 
47
  onMount(async () => {
48
  const module = await import("browser-image-resizer");
@@ -51,10 +55,10 @@
51
  modelId = findCurrentModel(models, assistant ? assistant.modelId : $settings.activeModel).id;
52
  });
53
 
54
- let inputMessage1 = assistant?.exampleInputs[0] ?? "";
55
- let inputMessage2 = assistant?.exampleInputs[1] ?? "";
56
- let inputMessage3 = assistant?.exampleInputs[2] ?? "";
57
- let inputMessage4 = assistant?.exampleInputs[3] ?? "";
58
 
59
  function resetErrors() {
60
  if (form) {
@@ -83,23 +87,25 @@
83
  return returnForm?.errors.find((error) => error.field === field)?.message ?? "";
84
  }
85
 
86
- let deleteExistingAvatar = false;
87
 
88
- let loading = false;
89
 
90
- let ragMode: false | "links" | "domains" | "all" = assistant?.rag?.allowAllDomains
91
- ? "all"
92
- : assistant?.rag?.allowedLinks?.length ?? 0 > 0
93
- ? "links"
94
- : (assistant?.rag?.allowedDomains?.length ?? 0) > 0
95
- ? "domains"
96
- : false;
 
 
97
 
98
- let tools = assistant?.tools ?? [];
99
  const regex = /{{\s?(get|post|url|today)(=.*?)?\s?}}/g;
100
 
101
- $: templateVariables = [...systemPrompt.matchAll(regex)];
102
- $: selectedModel = models.find((m) => m.id === modelId);
103
  </script>
104
 
105
  <form
@@ -184,7 +190,7 @@
184
  name="avatar"
185
  id="avatar"
186
  class="hidden"
187
- on:change={onFilesChange}
188
  />
189
 
190
  {#if (files && files[0]) || (assistant?.avatar && !deleteExistingAvatar)}
@@ -213,7 +219,9 @@
213
  <div class="mx-auto w-max pt-1">
214
  <button
215
  type="button"
216
- on:click|stopPropagation|preventDefault={() => {
 
 
217
  files = null;
218
  deleteExistingAvatar = true;
219
  }}
@@ -253,7 +261,7 @@
253
  class="h-15 w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2"
254
  placeholder="It knows everything about python"
255
  value={assistant?.description ?? ""}
256
- />
257
  <p class="text-xs text-red-500">{getError("description", form)}</p>
258
  </label>
259
 
@@ -268,14 +276,14 @@
268
  {#each models.filter((model) => !model.unlisted) as model}
269
  <option value={model.id}>{model.displayName}</option>
270
  {/each}
271
- <p class="text-xs text-red-500">{getError("modelId", form)}</p>
272
  </select>
 
273
  <button
274
  type="button"
275
  class="flex aspect-square items-center gap-2 whitespace-nowrap rounded-lg border px-3 {showModelSettings
276
  ? 'border-blue-500/20 bg-blue-50 text-blue-600'
277
  : ''}"
278
- on:click={() => (showModelSettings = !showModelSettings)}
279
  ><CarbonSettingsAdjust class="text-xs" /></button
280
  >
281
  </div>
@@ -443,7 +451,7 @@
443
  <label class="mt-1">
444
  <input
445
  checked={!ragMode}
446
- on:change={() => (ragMode = false)}
447
  type="radio"
448
  name="ragMode"
449
  value={false}
@@ -460,7 +468,7 @@
460
  <label class="mt-1">
461
  <input
462
  checked={ragMode === "all"}
463
- on:change={() => (ragMode = "all")}
464
  type="radio"
465
  name="ragMode"
466
  value={"all"}
@@ -476,7 +484,7 @@
476
  <label class="mt-1">
477
  <input
478
  checked={ragMode === "domains"}
479
- on:change={() => (ragMode = "domains")}
480
  type="radio"
481
  name="ragMode"
482
  value={false}
@@ -502,7 +510,7 @@
502
  <label class="mt-1">
503
  <input
504
  checked={ragMode === "links"}
505
- on:change={() => (ragMode = "links")}
506
  type="radio"
507
  name="ragMode"
508
  value={false}
@@ -576,7 +584,7 @@
576
  class="min-h-[8lh] flex-1 rounded-lg border-2 border-gray-200 bg-gray-100 p-2 text-sm"
577
  placeholder="You'll act as..."
578
  bind:value={systemPrompt}
579
- />
580
  {#if modelId}
581
  {@const model = models.find((_model) => _model.id === modelId)}
582
  {#if model?.tokenizer && systemPrompt}
 
31
 
32
  type AssistantFront = Omit<Assistant, "_id" | "createdById"> & { _id: string };
33
 
34
+ interface Props {
35
+ form: ActionData;
36
+ assistant?: AssistantFront | undefined;
37
+ models?: Model[];
38
+ }
39
+
40
+ let { form = $bindable(), assistant = undefined, models = [] }: Props = $props();
41
 
42
+ let files: FileList | null = $state(null);
43
  const settings = useSettingsStore();
44
+ let modelId = $state("");
45
+ let systemPrompt = $state(assistant?.preprompt ?? "");
46
+ let dynamicPrompt = $state(assistant?.dynamicPrompt ?? false);
47
+ let showModelSettings = $state(Object.values(assistant?.generateSettings ?? {}).some((v) => !!v));
48
 
49
+ let compress: typeof readAndCompressImage | null = $state(null);
50
 
51
  onMount(async () => {
52
  const module = await import("browser-image-resizer");
 
55
  modelId = findCurrentModel(models, assistant ? assistant.modelId : $settings.activeModel).id;
56
  });
57
 
58
+ let inputMessage1 = $state(assistant?.exampleInputs[0] ?? "");
59
+ let inputMessage2 = $state(assistant?.exampleInputs[1] ?? "");
60
+ let inputMessage3 = $state(assistant?.exampleInputs[2] ?? "");
61
+ let inputMessage4 = $state(assistant?.exampleInputs[3] ?? "");
62
 
63
  function resetErrors() {
64
  if (form) {
 
87
  return returnForm?.errors.find((error) => error.field === field)?.message ?? "";
88
  }
89
 
90
+ let deleteExistingAvatar = $state(false);
91
 
92
+ let loading = $state(false);
93
 
94
+ let ragMode: false | "links" | "domains" | "all" = $state(
95
+ assistant?.rag?.allowAllDomains
96
+ ? "all"
97
+ : (assistant?.rag?.allowedLinks?.length ?? 0 > 0)
98
+ ? "links"
99
+ : (assistant?.rag?.allowedDomains?.length ?? 0) > 0
100
+ ? "domains"
101
+ : false
102
+ );
103
 
104
+ let tools = $state(assistant?.tools ?? []);
105
  const regex = /{{\s?(get|post|url|today)(=.*?)?\s?}}/g;
106
 
107
+ let templateVariables = $derived([...systemPrompt.matchAll(regex)]);
108
+ let selectedModel = $derived(models.find((m) => m.id === modelId));
109
  </script>
110
 
111
  <form
 
190
  name="avatar"
191
  id="avatar"
192
  class="hidden"
193
+ onchange={onFilesChange}
194
  />
195
 
196
  {#if (files && files[0]) || (assistant?.avatar && !deleteExistingAvatar)}
 
219
  <div class="mx-auto w-max pt-1">
220
  <button
221
  type="button"
222
+ onclick={(e) => {
223
+ e.preventDefault();
224
+ e.stopPropagation();
225
  files = null;
226
  deleteExistingAvatar = true;
227
  }}
 
261
  class="h-15 w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2"
262
  placeholder="It knows everything about python"
263
  value={assistant?.description ?? ""}
264
+ ></textarea>
265
  <p class="text-xs text-red-500">{getError("description", form)}</p>
266
  </label>
267
 
 
276
  {#each models.filter((model) => !model.unlisted) as model}
277
  <option value={model.id}>{model.displayName}</option>
278
  {/each}
 
279
  </select>
280
+ <p class="text-xs text-red-500">{getError("modelId", form)}</p>
281
  <button
282
  type="button"
283
  class="flex aspect-square items-center gap-2 whitespace-nowrap rounded-lg border px-3 {showModelSettings
284
  ? 'border-blue-500/20 bg-blue-50 text-blue-600'
285
  : ''}"
286
+ onclick={() => (showModelSettings = !showModelSettings)}
287
  ><CarbonSettingsAdjust class="text-xs" /></button
288
  >
289
  </div>
 
451
  <label class="mt-1">
452
  <input
453
  checked={!ragMode}
454
+ onchange={() => (ragMode = false)}
455
  type="radio"
456
  name="ragMode"
457
  value={false}
 
468
  <label class="mt-1">
469
  <input
470
  checked={ragMode === "all"}
471
+ onchange={() => (ragMode = "all")}
472
  type="radio"
473
  name="ragMode"
474
  value={"all"}
 
484
  <label class="mt-1">
485
  <input
486
  checked={ragMode === "domains"}
487
+ onchange={() => (ragMode = "domains")}
488
  type="radio"
489
  name="ragMode"
490
  value={false}
 
510
  <label class="mt-1">
511
  <input
512
  checked={ragMode === "links"}
513
+ onchange={() => (ragMode = "links")}
514
  type="radio"
515
  name="ragMode"
516
  value={false}
 
584
  class="min-h-[8lh] flex-1 rounded-lg border-2 border-gray-200 bg-gray-100 p-2 text-sm"
585
  placeholder="You'll act as..."
586
  bind:value={systemPrompt}
587
+ ></textarea>
588
  {#if modelId}
589
  {@const model = models.find((_model) => _model.id === modelId)}
590
  {#if model?.tokenizer && systemPrompt}
src/lib/components/AssistantToolPicker.svelte CHANGED
@@ -15,9 +15,13 @@
15
  icon: ToolLogoIcon;
16
  }
17
 
18
- export let toolIds: string[] = [];
 
 
 
 
19
 
20
- let selectedValues: ToolSuggestion[] = [];
21
 
22
  onMount(async () => {
23
  selectedValues = await Promise.all(
@@ -27,10 +31,10 @@
27
  await fetchSuggestions("");
28
  });
29
 
30
- let inputValue = "";
31
  let maxValues = 3;
32
 
33
- let suggestions: ToolSuggestion[] = [];
34
 
35
  async function fetchSuggestions(query: string) {
36
  suggestions = (await fetch(`${base}/api/tools/search?q=${query}`).then((res) =>
@@ -61,7 +65,9 @@
61
  <div
62
  class="flex items-center justify-center space-x-2 rounded border border-gray-300 bg-gray-200 px-2 py-1"
63
  >
64
- <ToolLogo color={value.color} icon={value.icon} size="sm" />
 
 
65
  <div class="flex flex-col items-center justify-center py-1">
66
  <a
67
  href={`${base}/tools/${value._id}`}
@@ -81,7 +87,11 @@
81
  {/if}
82
  </div>
83
  <button
84
- on:click|stopPropagation|preventDefault={() => removeValue(value._id)}
 
 
 
 
85
  class="text-lg text-gray-600"
86
  >
87
  <CarbonClose />
@@ -96,7 +106,7 @@
96
  <input
97
  type="text"
98
  bind:value={inputValue}
99
- on:input={(ev) => {
100
  inputValue = ev.currentTarget.value;
101
  debouncedFetch(inputValue);
102
  }}
@@ -117,7 +127,11 @@
117
  {:else}
118
  {#each suggestions as suggestion}
119
  <button
120
- on:click|stopPropagation|preventDefault={() => addValue(suggestion)}
 
 
 
 
121
  class="w-full cursor-pointer px-3 py-2 text-left hover:bg-blue-500 hover:text-white"
122
  >
123
  {suggestion.displayName}
 
15
  icon: ToolLogoIcon;
16
  }
17
 
18
+ interface Props {
19
+ toolIds?: string[];
20
+ }
21
+
22
+ let { toolIds = $bindable([]) }: Props = $props();
23
 
24
+ let selectedValues: ToolSuggestion[] = $state([]);
25
 
26
  onMount(async () => {
27
  selectedValues = await Promise.all(
 
31
  await fetchSuggestions("");
32
  });
33
 
34
+ let inputValue = $state("");
35
  let maxValues = 3;
36
 
37
+ let suggestions: ToolSuggestion[] = $state([]);
38
 
39
  async function fetchSuggestions(query: string) {
40
  suggestions = (await fetch(`${base}/api/tools/search?q=${query}`).then((res) =>
 
65
  <div
66
  class="flex items-center justify-center space-x-2 rounded border border-gray-300 bg-gray-200 px-2 py-1"
67
  >
68
+ {#key value.color + value.icon}
69
+ <ToolLogo color={value.color} icon={value.icon} size="sm" />
70
+ {/key}
71
  <div class="flex flex-col items-center justify-center py-1">
72
  <a
73
  href={`${base}/tools/${value._id}`}
 
87
  {/if}
88
  </div>
89
  <button
90
+ onclick={(e) => {
91
+ e.preventDefault();
92
+ e.stopPropagation();
93
+ removeValue(value._id);
94
+ }}
95
  class="text-lg text-gray-600"
96
  >
97
  <CarbonClose />
 
106
  <input
107
  type="text"
108
  bind:value={inputValue}
109
+ oninput={(ev) => {
110
  inputValue = ev.currentTarget.value;
111
  debouncedFetch(inputValue);
112
  }}
 
127
  {:else}
128
  {#each suggestions as suggestion}
129
  <button
130
+ onclick={(e) => {
131
+ e.preventDefault();
132
+ e.stopPropagation();
133
+ addValue(suggestion);
134
+ }}
135
  class="w-full cursor-pointer px-3 py-2 text-left hover:bg-blue-500 hover:text-white"
136
  >
137
  {suggestion.displayName}
src/lib/components/CodeBlock.svelte CHANGED
@@ -3,10 +3,14 @@
3
  import DOMPurify from "isomorphic-dompurify";
4
  import hljs from "highlight.js";
5
 
6
- export let code = "";
7
- export let lang = "";
 
 
8
 
9
- $: highlightedCode = hljs.highlightAuto(code, hljs.getLanguage(lang)?.aliases).value;
 
 
10
  </script>
11
 
12
  <div class="group relative my-4 rounded-lg">
 
3
  import DOMPurify from "isomorphic-dompurify";
4
  import hljs from "highlight.js";
5
 
6
+ interface Props {
7
+ code?: string;
8
+ lang?: string;
9
+ }
10
 
11
+ let { code = "", lang = "" }: Props = $props();
12
+
13
+ let highlightedCode = $derived(hljs.highlightAuto(code, hljs.getLanguage(lang)?.aliases).value);
14
  </script>
15
 
16
  <div class="group relative my-4 rounded-lg">
src/lib/components/ContinueBtn.svelte CHANGED
@@ -1,12 +1,17 @@
1
  <script lang="ts">
2
  import CarbonContinue from "~icons/carbon/continue";
3
 
4
- export let classNames = "";
 
 
 
 
 
5
  </script>
6
 
7
  <button
8
  type="button"
9
- on:click
10
  class="btn flex h-8 rounded-lg border bg-white px-3 py-1 text-gray-500 shadow-sm transition-all hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 {classNames}"
11
  >
12
  <CarbonContinue class="mr-2 text-xs " /> Continue
 
1
  <script lang="ts">
2
  import CarbonContinue from "~icons/carbon/continue";
3
 
4
+ interface Props {
5
+ classNames?: string;
6
+ onClick?: () => void;
7
+ }
8
+
9
+ let { classNames = "", onClick }: Props = $props();
10
  </script>
11
 
12
  <button
13
  type="button"
14
+ onclick={onClick}
15
  class="btn flex h-8 rounded-lg border bg-white px-3 py-1 text-gray-500 shadow-sm transition-all hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 {classNames}"
16
  >
17
  <CarbonContinue class="mr-2 text-xs " /> Continue
src/lib/components/CopyToClipBoardBtn.svelte CHANGED
@@ -4,10 +4,16 @@
4
  import IconCopy from "./icons/IconCopy.svelte";
5
  import Tooltip from "./Tooltip.svelte";
6
 
7
- export let classNames = "";
8
- export let value: string;
 
 
 
 
9
 
10
- let isSuccess = false;
 
 
11
  let timeout: ReturnType<typeof setTimeout>;
12
 
13
  const unsecuredCopy = (text: string) => {
@@ -58,13 +64,15 @@
58
  class={classNames}
59
  title={"Copy to clipboard"}
60
  type="button"
61
- on:click
62
- on:click={handleClick}
 
 
63
  >
64
  <div class="relative">
65
- <slot>
66
  <IconCopy classNames="h-[1.14em] w-[1.14em]" />
67
- </slot>
68
 
69
  <Tooltip classNames={isSuccess ? "opacity-100" : "opacity-0"} />
70
  </div>
 
4
  import IconCopy from "./icons/IconCopy.svelte";
5
  import Tooltip from "./Tooltip.svelte";
6
 
7
+ interface Props {
8
+ classNames?: string;
9
+ value: string;
10
+ children?: import("svelte").Snippet;
11
+ onClick?: () => void;
12
+ }
13
 
14
+ let { classNames = "", value, children, onClick }: Props = $props();
15
+
16
+ let isSuccess = $state(false);
17
  let timeout: ReturnType<typeof setTimeout>;
18
 
19
  const unsecuredCopy = (text: string) => {
 
64
  class={classNames}
65
  title={"Copy to clipboard"}
66
  type="button"
67
+ onclick={() => {
68
+ onClick?.();
69
+ handleClick();
70
+ }}
71
  >
72
  <div class="relative">
73
+ {#if children}{@render children()}{:else}
74
  <IconCopy classNames="h-[1.14em] w-[1.14em]" />
75
+ {/if}
76
 
77
  <Tooltip classNames={isSuccess ? "opacity-100" : "opacity-0"} />
78
  </div>
src/lib/components/DisclaimerModal.svelte CHANGED
@@ -34,7 +34,9 @@
34
  class:bg-white={$page.data.loginEnabled}
35
  class:text-gray-800={$page.data.loginEnabled}
36
  class:hover:bg-slate-100={$page.data.loginEnabled}
37
- on:click|preventDefault|stopPropagation={() => {
 
 
38
  if (!cookiesAreEnabled()) {
39
  window.open(window.location.href, "_blank");
40
  }
 
34
  class:bg-white={$page.data.loginEnabled}
35
  class:text-gray-800={$page.data.loginEnabled}
36
  class:hover:bg-slate-100={$page.data.loginEnabled}
37
+ onclick={(e) => {
38
+ e.preventDefault();
39
+ e.stopPropagation();
40
  if (!cookiesAreEnabled()) {
41
  window.open(window.location.href, "_blank");
42
  }
src/lib/components/ExpandNavigation.svelte CHANGED
@@ -1,16 +1,21 @@
1
  <script lang="ts">
2
- export let isCollapsed: boolean;
3
- export let classNames: string;
 
 
 
 
 
4
  </script>
5
 
6
  <button
7
- on:click
8
  class="{classNames} group flex h-16 w-6 flex-col items-center justify-center -space-y-1 outline-none *:h-3 *:w-1 *:rounded-full *:hover:bg-gray-300 dark:*:hover:bg-gray-600 max-md:hidden {!isCollapsed
9
  ? '*:bg-gray-200/70 dark:*:bg-gray-800'
10
  : '*:bg-gray-200 dark:*:bg-gray-700'}"
11
  name="sidebar-toggle"
12
  aria-label="Toggle sidebar navigation"
13
  >
14
- <div class={!isCollapsed ? "group-hover:rotate-[20deg]" : "group-hover:-rotate-[20deg]"} />
15
- <div class={!isCollapsed ? "group-hover:-rotate-[20deg]" : "group-hover:rotate-[20deg]"} />
16
  </button>
 
1
  <script lang="ts">
2
+ interface Props {
3
+ isCollapsed: boolean;
4
+ onClick: () => void;
5
+ classNames: string;
6
+ }
7
+
8
+ let { isCollapsed, classNames, onClick }: Props = $props();
9
  </script>
10
 
11
  <button
12
+ onclick={onClick}
13
  class="{classNames} group flex h-16 w-6 flex-col items-center justify-center -space-y-1 outline-none *:h-3 *:w-1 *:rounded-full *:hover:bg-gray-300 dark:*:hover:bg-gray-600 max-md:hidden {!isCollapsed
14
  ? '*:bg-gray-200/70 dark:*:bg-gray-800'
15
  : '*:bg-gray-200 dark:*:bg-gray-700'}"
16
  name="sidebar-toggle"
17
  aria-label="Toggle sidebar navigation"
18
  >
19
+ <div class={!isCollapsed ? "group-hover:rotate-[20deg]" : "group-hover:-rotate-[20deg]"}></div>
20
+ <div class={!isCollapsed ? "group-hover:-rotate-[20deg]" : "group-hover:rotate-[20deg]"}></div>
21
  </button>
src/lib/components/HoverTooltip.svelte CHANGED
@@ -1,7 +1,12 @@
1
  <script lang="ts">
2
- export let label = "";
3
- export let position: "top" | "bottom" | "left" | "right" = "bottom";
4
- export let TooltipClassNames = "";
 
 
 
 
 
5
 
6
  const positionClasses = {
7
  top: "bottom-full mb-2",
@@ -12,7 +17,7 @@
12
  </script>
13
 
14
  <div class="group/tooltip inline-block md:relative">
15
- <slot />
16
 
17
  <div
18
  class="
 
1
  <script lang="ts">
2
+ interface Props {
3
+ label?: string;
4
+ position?: "top" | "bottom" | "left" | "right";
5
+ TooltipClassNames?: string;
6
+ children?: import("svelte").Snippet;
7
+ }
8
+
9
+ let { label = "", position = "bottom", TooltipClassNames = "", children }: Props = $props();
10
 
11
  const positionClasses = {
12
  top: "bottom-full mb-2",
 
17
  </script>
18
 
19
  <div class="group/tooltip inline-block md:relative">
20
+ {@render children?.()}
21
 
22
  <div
23
  class="
src/lib/components/InfiniteScroll.svelte CHANGED
@@ -2,11 +2,15 @@
2
  import { onMount, createEventDispatcher } from "svelte";
3
 
4
  const dispatch = createEventDispatcher();
5
- let loader: HTMLDivElement;
6
  let observer: IntersectionObserver;
7
  let intervalId: ReturnType<typeof setInterval> | undefined;
8
 
9
  onMount(() => {
 
 
 
 
10
  observer = new IntersectionObserver((entries) => {
11
  entries.forEach((entry) => {
12
  if (entry.isIntersecting) {
@@ -40,7 +44,7 @@
40
  </script>
41
 
42
  <div bind:this={loader} class="flex animate-pulse flex-col gap-4">
43
- <div class="ml-2 h-5 w-4/5 gap-5 rounded bg-gray-200 dark:bg-gray-700" />
44
- <div class="ml-2 h-5 w-4/5 gap-5 rounded bg-gray-200 dark:bg-gray-700" />
45
- <div class="ml-2 h-5 w-4/5 gap-5 rounded bg-gray-200 dark:bg-gray-700" />
46
  </div>
 
2
  import { onMount, createEventDispatcher } from "svelte";
3
 
4
  const dispatch = createEventDispatcher();
5
+ let loader: HTMLDivElement | undefined = $state();
6
  let observer: IntersectionObserver;
7
  let intervalId: ReturnType<typeof setInterval> | undefined;
8
 
9
  onMount(() => {
10
+ if (!loader) {
11
+ return;
12
+ }
13
+
14
  observer = new IntersectionObserver((entries) => {
15
  entries.forEach((entry) => {
16
  if (entry.isIntersecting) {
 
44
  </script>
45
 
46
  <div bind:this={loader} class="flex animate-pulse flex-col gap-4">
47
+ <div class="ml-2 h-5 w-4/5 gap-5 rounded bg-gray-200 dark:bg-gray-700"></div>
48
+ <div class="ml-2 h-5 w-4/5 gap-5 rounded bg-gray-200 dark:bg-gray-700"></div>
49
+ <div class="ml-2 h-5 w-4/5 gap-5 rounded bg-gray-200 dark:bg-gray-700"></div>
50
  </div>
src/lib/components/LoginModal.svelte CHANGED
@@ -47,7 +47,7 @@
47
  {:else}
48
  <button
49
  class="flex w-full items-center justify-center whitespace-nowrap rounded-full border-2 border-black bg-black px-5 py-2 text-lg font-semibold text-gray-100 transition-colors hover:bg-gray-900"
50
- on:click={(e) => {
51
  if (!cookiesAreEnabled()) {
52
  e.preventDefault();
53
  window.open(window.location.href, "_blank");
 
47
  {:else}
48
  <button
49
  class="flex w-full items-center justify-center whitespace-nowrap rounded-full border-2 border-black bg-black px-5 py-2 text-lg font-semibold text-gray-100 transition-colors hover:bg-gray-900"
50
+ onclick={(e) => {
51
  if (!cookiesAreEnabled()) {
52
  e.preventDefault();
53
  window.open(window.location.href, "_blank");
src/lib/components/MobileNav.svelte CHANGED
@@ -1,4 +1,6 @@
1
  <script lang="ts">
 
 
2
  import { navigating } from "$app/stores";
3
  import { createEventDispatcher } from "svelte";
4
  import { browser } from "$app/environment";
@@ -9,25 +11,36 @@
9
  import CarbonTextAlignJustify from "~icons/carbon/text-align-justify";
10
  import IconNew from "$lib/components/icons/IconNew.svelte";
11
 
12
- export let isOpen = false;
13
- export let title: string | undefined;
 
 
 
 
 
14
 
15
- $: title = title ?? "New Chat";
 
 
16
 
17
- let closeEl: HTMLButtonElement;
18
- let openEl: HTMLButtonElement;
19
 
20
  const dispatch = createEventDispatcher();
21
 
22
- $: if ($navigating) {
23
- dispatch("toggle", false);
24
- }
 
 
25
 
26
- $: if (isOpen && closeEl) {
27
- closeEl.focus();
28
- } else if (!isOpen && browser && document.activeElement === closeEl) {
29
- openEl.focus();
30
- }
 
 
31
  </script>
32
 
33
  <nav
@@ -36,12 +49,12 @@
36
  <button
37
  type="button"
38
  class="-ml-3 flex size-12 shrink-0 items-center justify-center text-lg"
39
- on:click={() => dispatch("toggle", true)}
40
  aria-label="Open menu"
41
  bind:this={openEl}><CarbonTextAlignJustify /></button
42
  >
43
  {#await title}
44
- <div class="flex h-full items-center justify-center" />
45
  {:then title}
46
  <span class="truncate px-4">{title ?? ""}</span>
47
  {/await}
@@ -60,10 +73,10 @@
60
  <button
61
  type="button"
62
  class="-mr-3 ml-auto flex size-12 items-center justify-center text-lg"
63
- on:click={() => dispatch("toggle", false)}
64
  aria-label="Close menu"
65
  bind:this={closeEl}><CarbonClose /></button
66
  >
67
  </div>
68
- <slot />
69
  </nav>
 
1
  <script lang="ts">
2
+ import { run } from "svelte/legacy";
3
+
4
  import { navigating } from "$app/stores";
5
  import { createEventDispatcher } from "svelte";
6
  import { browser } from "$app/environment";
 
11
  import CarbonTextAlignJustify from "~icons/carbon/text-align-justify";
12
  import IconNew from "$lib/components/icons/IconNew.svelte";
13
 
14
+ interface Props {
15
+ isOpen?: boolean;
16
+ title: string | undefined;
17
+ children?: import("svelte").Snippet;
18
+ }
19
+
20
+ let { isOpen = false, title = $bindable(), children }: Props = $props();
21
 
22
+ run(() => {
23
+ title = title ?? "New Chat";
24
+ });
25
 
26
+ let closeEl: HTMLButtonElement | undefined = $state();
27
+ let openEl: HTMLButtonElement | undefined = $state();
28
 
29
  const dispatch = createEventDispatcher();
30
 
31
+ run(() => {
32
+ if ($navigating) {
33
+ dispatch("toggle", false);
34
+ }
35
+ });
36
 
37
+ run(() => {
38
+ if (isOpen && closeEl) {
39
+ closeEl.focus();
40
+ } else if (!isOpen && browser && document.activeElement === closeEl) {
41
+ openEl?.focus();
42
+ }
43
+ });
44
  </script>
45
 
46
  <nav
 
49
  <button
50
  type="button"
51
  class="-ml-3 flex size-12 shrink-0 items-center justify-center text-lg"
52
+ onclick={() => dispatch("toggle", true)}
53
  aria-label="Open menu"
54
  bind:this={openEl}><CarbonTextAlignJustify /></button
55
  >
56
  {#await title}
57
+ <div class="flex h-full items-center justify-center"></div>
58
  {:then title}
59
  <span class="truncate px-4">{title ?? ""}</span>
60
  {/await}
 
73
  <button
74
  type="button"
75
  class="-mr-3 ml-auto flex size-12 items-center justify-center text-lg"
76
+ onclick={() => dispatch("toggle", false)}
77
  aria-label="Close menu"
78
  bind:this={closeEl}><CarbonClose /></button
79
  >
80
  </div>
81
+ {@render children?.()}
82
  </nav>
src/lib/components/Modal.svelte CHANGED
@@ -5,10 +5,15 @@
5
  import Portal from "./Portal.svelte";
6
  import { browser } from "$app/environment";
7
 
8
- export let width = "max-w-sm";
 
 
 
 
 
9
 
10
- let backdropEl: HTMLDivElement;
11
- let modalEl: HTMLDivElement;
12
 
13
  const dispatch = createEventDispatcher<{ close: void }>();
14
 
@@ -31,25 +36,25 @@
31
 
32
  onMount(() => {
33
  document.getElementById("app")?.setAttribute("inert", "true");
34
- modalEl.focus();
35
  });
36
 
37
  onDestroy(() => {
38
  if (!browser) return;
39
- // remove inert attribute if this is the last modal
40
- if (document.querySelectorAll('[role="dialog"]:not(#app *)').length === 1) {
41
- document.getElementById("app")?.removeAttribute("inert");
42
- }
43
  });
44
  </script>
45
 
46
  <Portal>
47
- <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
48
  <div
49
  role="presentation"
50
  tabindex="-1"
51
  bind:this={backdropEl}
52
- on:click|stopPropagation={handleBackdropClick}
 
 
 
53
  transition:fade|local={{ easing: cubicOut, duration: 300 }}
54
  class="fixed inset-0 z-40 flex items-center justify-center bg-black/80 p-8 backdrop-blur-sm dark:bg-black/50"
55
  >
@@ -57,10 +62,10 @@
57
  role="dialog"
58
  tabindex="-1"
59
  bind:this={modalEl}
60
- on:keydown={handleKeydown}
61
  class="max-h-[90dvh] overflow-y-auto overflow-x-hidden rounded-2xl bg-white shadow-2xl outline-none sm:-mt-10 {width}"
62
  >
63
- <slot />
64
  </div>
65
  </div>
66
  </Portal>
 
5
  import Portal from "./Portal.svelte";
6
  import { browser } from "$app/environment";
7
 
8
+ interface Props {
9
+ width?: string;
10
+ children?: import("svelte").Snippet;
11
+ }
12
+
13
+ let { width = "max-w-sm", children }: Props = $props();
14
 
15
+ let backdropEl: HTMLDivElement | undefined = $state();
16
+ let modalEl: HTMLDivElement | undefined = $state();
17
 
18
  const dispatch = createEventDispatcher<{ close: void }>();
19
 
 
36
 
37
  onMount(() => {
38
  document.getElementById("app")?.setAttribute("inert", "true");
39
+ modalEl?.focus();
40
  });
41
 
42
  onDestroy(() => {
43
  if (!browser) return;
44
+ document.getElementById("app")?.removeAttribute("inert");
 
 
 
45
  });
46
  </script>
47
 
48
  <Portal>
49
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
50
  <div
51
  role="presentation"
52
  tabindex="-1"
53
  bind:this={backdropEl}
54
+ onclick={(e) => {
55
+ e.stopPropagation();
56
+ handleBackdropClick(e);
57
+ }}
58
  transition:fade|local={{ easing: cubicOut, duration: 300 }}
59
  class="fixed inset-0 z-40 flex items-center justify-center bg-black/80 p-8 backdrop-blur-sm dark:bg-black/50"
60
  >
 
62
  role="dialog"
63
  tabindex="-1"
64
  bind:this={modalEl}
65
+ onkeydown={handleKeydown}
66
  class="max-h-[90dvh] overflow-y-auto overflow-x-hidden rounded-2xl bg-white shadow-2xl outline-none sm:-mt-10 {width}"
67
  >
68
+ {@render children?.()}
69
  </div>
70
  </div>
71
  </Portal>
src/lib/components/ModelCardMetadata.svelte CHANGED
@@ -5,12 +5,15 @@
5
  import CarbonCode from "~icons/carbon/code";
6
  import type { Model } from "$lib/types/Model";
7
 
8
- export let model: Pick<
9
- Model,
10
- "name" | "datasetName" | "websiteUrl" | "modelUrl" | "datasetUrl" | "hasInferenceAPI"
11
- >;
 
 
 
12
 
13
- export let variant: "light" | "dark" = "light";
14
  </script>
15
 
16
  <div
 
5
  import CarbonCode from "~icons/carbon/code";
6
  import type { Model } from "$lib/types/Model";
7
 
8
+ interface Props {
9
+ model: Pick<
10
+ Model,
11
+ "name" | "datasetName" | "websiteUrl" | "modelUrl" | "datasetUrl" | "hasInferenceAPI"
12
+ >;
13
+ variant?: "light" | "dark";
14
+ }
15
 
16
+ let { model, variant = "light" }: Props = $props();
17
  </script>
18
 
19
  <div
src/lib/components/NavConversationItem.svelte CHANGED
@@ -9,9 +9,13 @@
9
  import CarbonEdit from "~icons/carbon/edit";
10
  import type { ConvSidebar } from "$lib/types/ConvSidebar";
11
 
12
- export let conv: ConvSidebar;
 
 
13
 
14
- let confirmDelete = false;
 
 
15
 
16
  const dispatch = createEventDispatcher<{
17
  deleteConversation: string;
@@ -21,7 +25,7 @@
21
 
22
  <a
23
  data-sveltekit-noscroll
24
- on:mouseleave={() => {
25
  confirmDelete = false;
26
  }}
27
  href="{base}/conversation/{conv.id}"
@@ -44,7 +48,7 @@
44
  {:else if conv.assistantId}
45
  <div
46
  class="mr-1.5 flex size-4 flex-none items-center justify-center rounded-full bg-gray-300 text-xs font-bold uppercase text-gray-500"
47
- />
48
  {conv.title.replace(/\p{Emoji}/gu, "")}
49
  {:else}
50
  {conv.title}
@@ -56,7 +60,10 @@
56
  type="button"
57
  class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
58
  title="Cancel delete action"
59
- on:click|preventDefault={() => (confirmDelete = false)}
 
 
 
60
  >
61
  <CarbonClose class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" />
62
  </button>
@@ -64,7 +71,8 @@
64
  type="button"
65
  class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
66
  title="Confirm delete action"
67
- on:click|preventDefault={() => {
 
68
  confirmDelete = false;
69
  dispatch("deleteConversation", conv.id);
70
  }}
@@ -76,7 +84,8 @@
76
  type="button"
77
  class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
78
  title="Edit conversation title"
79
- on:click|preventDefault={() => {
 
80
  const newTitle = prompt("Edit this conversation title:", conv.title);
81
  if (!newTitle) return;
82
  dispatch("editConversationTitle", { id: conv.id, title: newTitle });
@@ -89,7 +98,8 @@
89
  type="button"
90
  class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
91
  title="Delete conversation"
92
- on:click|preventDefault={(event) => {
 
93
  if (event.shiftKey) {
94
  dispatch("deleteConversation", conv.id);
95
  } else {
 
9
  import CarbonEdit from "~icons/carbon/edit";
10
  import type { ConvSidebar } from "$lib/types/ConvSidebar";
11
 
12
+ interface Props {
13
+ conv: ConvSidebar;
14
+ }
15
 
16
+ let { conv }: Props = $props();
17
+
18
+ let confirmDelete = $state(false);
19
 
20
  const dispatch = createEventDispatcher<{
21
  deleteConversation: string;
 
25
 
26
  <a
27
  data-sveltekit-noscroll
28
+ onmouseleave={() => {
29
  confirmDelete = false;
30
  }}
31
  href="{base}/conversation/{conv.id}"
 
48
  {:else if conv.assistantId}
49
  <div
50
  class="mr-1.5 flex size-4 flex-none items-center justify-center rounded-full bg-gray-300 text-xs font-bold uppercase text-gray-500"
51
+ ></div>
52
  {conv.title.replace(/\p{Emoji}/gu, "")}
53
  {:else}
54
  {conv.title}
 
60
  type="button"
61
  class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
62
  title="Cancel delete action"
63
+ onclick={(e) => {
64
+ e.preventDefault();
65
+ confirmDelete = false;
66
+ }}
67
  >
68
  <CarbonClose class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" />
69
  </button>
 
71
  type="button"
72
  class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
73
  title="Confirm delete action"
74
+ onclick={(e) => {
75
+ e.preventDefault();
76
  confirmDelete = false;
77
  dispatch("deleteConversation", conv.id);
78
  }}
 
84
  type="button"
85
  class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
86
  title="Edit conversation title"
87
+ onclick={(e) => {
88
+ e.preventDefault();
89
  const newTitle = prompt("Edit this conversation title:", conv.title);
90
  if (!newTitle) return;
91
  dispatch("editConversationTitle", { id: conv.id, title: newTitle });
 
98
  type="button"
99
  class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
100
  title="Delete conversation"
101
+ onclick={(event) => {
102
+ event.preventDefault();
103
  if (event.shiftKey) {
104
  dispatch("deleteConversation", conv.id);
105
  } else {
src/lib/components/NavMenu.svelte CHANGED
@@ -14,13 +14,16 @@
14
  import type { Conversation } from "$lib/types/Conversation";
15
  import { CONV_NUM_PER_PAGE } from "$lib/constants/pagination";
16
 
17
- export let conversations: ConvSidebar[];
18
- export let canLogin: boolean;
19
- export let user: LayoutData["user"];
 
 
 
20
 
21
- export let p = 0;
22
 
23
- let hasMore = true;
24
 
25
  function handleNewChatClick() {
26
  isAborted.set(true);
@@ -32,7 +35,7 @@
32
  new Date().setMonth(new Date().getMonth() - 1),
33
  ];
34
 
35
- $: groupedConversations = {
36
  today: conversations.filter(({ updatedAt }) => updatedAt.getTime() > dateRanges[0]),
37
  week: conversations.filter(
38
  ({ updatedAt }) => updatedAt.getTime() > dateRanges[1] && updatedAt.getTime() < dateRanges[0]
@@ -41,7 +44,7 @@
41
  ({ updatedAt }) => updatedAt.getTime() > dateRanges[2] && updatedAt.getTime() < dateRanges[1]
42
  ),
43
  older: conversations.filter(({ updatedAt }) => updatedAt.getTime() < dateRanges[2]),
44
- };
45
 
46
  const titles: { [key: string]: string } = {
47
  today: "Today",
@@ -73,11 +76,13 @@
73
  conversations = [...conversations, ...newConvs];
74
  }
75
 
76
- $: if (conversations.length <= CONV_NUM_PER_PAGE) {
77
- // reset p to 0 if there's only one page of content
78
- // that would be caused by a data loading invalidation
79
- p = 0;
80
- }
 
 
81
  </script>
82
 
83
  <div class="sticky top-0 flex flex-none items-center justify-between px-1.5 py-3.5 max-sm:pt-0">
@@ -88,13 +93,15 @@
88
  <Logo classNames="mr-1" />
89
  {envPublic.PUBLIC_APP_NAME}
90
  </a>
91
- <a
92
- href={`${base}/`}
93
- on:click={handleNewChatClick}
94
- class="flex rounded-lg border bg-white px-2 py-0.5 text-center shadow-sm hover:shadow-none dark:border-gray-600 dark:bg-gray-700 sm:text-smd"
95
- >
96
- New Chat
97
- </a>
 
 
98
  </div>
99
  <div
100
  class="scrollbar-custom flex flex-col gap-1 overflow-y-auto rounded-r-xl from-gray-50 px-3 pb-3 pt-2 text-[.9rem] dark:from-gray-800/30 max-sm:bg-gradient-to-t md:bg-gradient-to-l"
@@ -103,9 +110,9 @@
103
  {#if $page.data.nConversations > 0}
104
  <div class="overflow-y-hidden">
105
  <div class="flex animate-pulse flex-col gap-4">
106
- <div class="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
107
  {#each Array(100) as _}
108
- <div class="ml-2 h-5 w-4/5 gap-5 rounded bg-gray-200 dark:bg-gray-700" />
109
  {/each}
110
  </div>
111
  </div>
@@ -162,7 +169,7 @@
162
  </form>
163
  {/if}
164
  <button
165
- on:click={switchTheme}
166
  type="button"
167
  class="flex h-9 flex-none items-center gap-1.5 rounded-lg pl-2.5 pr-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
168
  >
 
14
  import type { Conversation } from "$lib/types/Conversation";
15
  import { CONV_NUM_PER_PAGE } from "$lib/constants/pagination";
16
 
17
+ interface Props {
18
+ conversations: ConvSidebar[];
19
+ canLogin: boolean;
20
+ user: LayoutData["user"];
21
+ p?: number;
22
+ }
23
 
24
+ let { conversations = $bindable(), canLogin, user, p = $bindable(0) }: Props = $props();
25
 
26
+ let hasMore = $state(true);
27
 
28
  function handleNewChatClick() {
29
  isAborted.set(true);
 
35
  new Date().setMonth(new Date().getMonth() - 1),
36
  ];
37
 
38
+ let groupedConversations = $derived({
39
  today: conversations.filter(({ updatedAt }) => updatedAt.getTime() > dateRanges[0]),
40
  week: conversations.filter(
41
  ({ updatedAt }) => updatedAt.getTime() > dateRanges[1] && updatedAt.getTime() < dateRanges[0]
 
44
  ({ updatedAt }) => updatedAt.getTime() > dateRanges[2] && updatedAt.getTime() < dateRanges[1]
45
  ),
46
  older: conversations.filter(({ updatedAt }) => updatedAt.getTime() < dateRanges[2]),
47
+ });
48
 
49
  const titles: { [key: string]: string } = {
50
  today: "Today",
 
76
  conversations = [...conversations, ...newConvs];
77
  }
78
 
79
+ $effect(() => {
80
+ if (conversations.length <= CONV_NUM_PER_PAGE) {
81
+ // reset p to 0 if there's only one page of content
82
+ // that would be caused by a data loading invalidation
83
+ p = 0;
84
+ }
85
+ });
86
  </script>
87
 
88
  <div class="sticky top-0 flex flex-none items-center justify-between px-1.5 py-3.5 max-sm:pt-0">
 
93
  <Logo classNames="mr-1" />
94
  {envPublic.PUBLIC_APP_NAME}
95
  </a>
96
+ {#if $page.url.pathname !== base + "/"}
97
+ <a
98
+ href={`${base}/`}
99
+ onclick={handleNewChatClick}
100
+ class="flex rounded-lg border bg-white px-2 py-0.5 text-center shadow-sm hover:shadow-none dark:border-gray-600 dark:bg-gray-700 sm:text-smd"
101
+ >
102
+ New Chat
103
+ </a>
104
+ {/if}
105
  </div>
106
  <div
107
  class="scrollbar-custom flex flex-col gap-1 overflow-y-auto rounded-r-xl from-gray-50 px-3 pb-3 pt-2 text-[.9rem] dark:from-gray-800/30 max-sm:bg-gradient-to-t md:bg-gradient-to-l"
 
110
  {#if $page.data.nConversations > 0}
111
  <div class="overflow-y-hidden">
112
  <div class="flex animate-pulse flex-col gap-4">
113
+ <div class="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700"></div>
114
  {#each Array(100) as _}
115
+ <div class="ml-2 h-5 w-4/5 gap-5 rounded bg-gray-200 dark:bg-gray-700"></div>
116
  {/each}
117
  </div>
118
  </div>
 
169
  </form>
170
  {/if}
171
  <button
172
+ onclick={switchTheme}
173
  type="button"
174
  class="flex h-9 flex-none items-center gap-1.5 rounded-lg pl-2.5 pr-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
175
  >
src/lib/components/OpenWebSearchResults.svelte CHANGED
@@ -10,16 +10,22 @@
10
  import IconInternet from "./icons/IconInternet.svelte";
11
  import CarbonCaretDown from "~icons/carbon/caret-down";
12
 
13
- export let webSearchMessages: MessageWebSearchUpdate[] = [];
 
 
 
 
14
 
15
- $: sources = webSearchMessages.find(isMessageWebSearchSourcesUpdate)?.sources;
16
- $: lastMessage = webSearchMessages
17
- .filter((update) => update.subtype !== MessageWebSearchUpdateType.Sources)
18
- .at(-1) as MessageWebSearchUpdate;
19
- $: errored = webSearchMessages.some(
20
- (update) => update.subtype === MessageWebSearchUpdateType.Error
21
  );
22
- $: loading = !sources && !errored;
 
 
 
23
  </script>
24
 
25
  <details
@@ -80,7 +86,7 @@
80
  class="-ml-1.5 h-3 w-3 flex-none rounded-full bg-gray-200 dark:bg-gray-600 {loading
81
  ? 'group-last:animate-pulse group-last:bg-gray-300 group-last:dark:bg-gray-500'
82
  : ''}"
83
- />
84
  <h3 class="text-md -mt-1.5 pl-2.5 text-gray-800 dark:text-gray-100">
85
  {message.message}
86
  </h3>
 
10
  import IconInternet from "./icons/IconInternet.svelte";
11
  import CarbonCaretDown from "~icons/carbon/caret-down";
12
 
13
+ interface Props {
14
+ webSearchMessages?: MessageWebSearchUpdate[];
15
+ }
16
+
17
+ let { webSearchMessages = [] }: Props = $props();
18
 
19
+ let sources = $derived(webSearchMessages.find(isMessageWebSearchSourcesUpdate)?.sources);
20
+ let lastMessage = $derived(
21
+ webSearchMessages
22
+ .filter((update) => update.subtype !== MessageWebSearchUpdateType.Sources)
23
+ .at(-1) as MessageWebSearchUpdate
 
24
  );
25
+ let errored = $derived(
26
+ webSearchMessages.some((update) => update.subtype === MessageWebSearchUpdateType.Error)
27
+ );
28
+ let loading = $derived(!sources && !errored);
29
  </script>
30
 
31
  <details
 
86
  class="-ml-1.5 h-3 w-3 flex-none rounded-full bg-gray-200 dark:bg-gray-600 {loading
87
  ? 'group-last:animate-pulse group-last:bg-gray-300 group-last:dark:bg-gray-500'
88
  : ''}"
89
+ ></div>
90
  <h3 class="text-md -mt-1.5 pl-2.5 text-gray-800 dark:text-gray-100">
91
  {message.message}
92
  </h3>
src/lib/components/Pagination.svelte CHANGED
@@ -3,15 +3,15 @@
3
  import { getHref } from "$lib/utils/getHref";
4
  import PaginationArrow from "./PaginationArrow.svelte";
5
 
6
- export let classNames = "";
7
- export let numItemsPerPage: number;
8
- export let numTotalItems: number;
 
 
9
 
10
- const ELLIPSIS_IDX = -1 as const;
11
 
12
- $: numTotalPages = Math.ceil(numTotalItems / numItemsPerPage);
13
- $: pageIndex = parseInt($page.url.searchParams.get("p") ?? "0");
14
- $: pageIndexes = getPageIndexes(pageIndex, numTotalPages);
15
 
16
  function getPageIndexes(pageIdx: number, nTotalPages: number) {
17
  let pageIdxs: number[] = [];
@@ -52,6 +52,9 @@
52
  }
53
  return pageIdxs;
54
  }
 
 
 
55
  </script>
56
 
57
  {#if numTotalPages > 1}
 
3
  import { getHref } from "$lib/utils/getHref";
4
  import PaginationArrow from "./PaginationArrow.svelte";
5
 
6
+ interface Props {
7
+ classNames?: string;
8
+ numItemsPerPage: number;
9
+ numTotalItems: number;
10
+ }
11
 
12
+ let { classNames = "", numItemsPerPage, numTotalItems }: Props = $props();
13
 
14
+ const ELLIPSIS_IDX = -1 as const;
 
 
15
 
16
  function getPageIndexes(pageIdx: number, nTotalPages: number) {
17
  let pageIdxs: number[] = [];
 
52
  }
53
  return pageIdxs;
54
  }
55
+ let numTotalPages = $derived(Math.ceil(numTotalItems / numItemsPerPage));
56
+ let pageIndex = $derived(parseInt($page.url.searchParams.get("p") ?? "0"));
57
+ let pageIndexes = $derived(getPageIndexes(pageIndex, numTotalPages));
58
  </script>
59
 
60
  {#if numTotalPages > 1}
src/lib/components/PaginationArrow.svelte CHANGED
@@ -2,9 +2,13 @@
2
  import CarbonCaretLeft from "~icons/carbon/caret-left";
3
  import CarbonCaretRight from "~icons/carbon/caret-right";
4
 
5
- export let href: string;
6
- export let direction: "next" | "previous";
7
- export let isDisabled = false;
 
 
 
 
8
  </script>
9
 
10
  <a
 
2
  import CarbonCaretLeft from "~icons/carbon/caret-left";
3
  import CarbonCaretRight from "~icons/carbon/caret-right";
4
 
5
+ interface Props {
6
+ href: string;
7
+ direction: "next" | "previous";
8
+ isDisabled?: boolean;
9
+ }
10
+
11
+ let { href, direction, isDisabled = false }: Props = $props();
12
  </script>
13
 
14
  <a
src/lib/components/Portal.svelte CHANGED
@@ -1,10 +1,15 @@
1
  <script lang="ts">
2
  import { onMount, onDestroy } from "svelte";
 
 
 
3
 
4
- let el: HTMLElement;
 
 
5
 
6
  onMount(() => {
7
- el.ownerDocument.body.appendChild(el);
8
  });
9
 
10
  onDestroy(() => {
@@ -15,5 +20,5 @@
15
  </script>
16
 
17
  <div bind:this={el} class="contents" hidden>
18
- <slot />
19
  </div>
 
1
  <script lang="ts">
2
  import { onMount, onDestroy } from "svelte";
3
+ interface Props {
4
+ children?: import("svelte").Snippet;
5
+ }
6
 
7
+ let { children }: Props = $props();
8
+
9
+ let el: HTMLElement | undefined = $state();
10
 
11
  onMount(() => {
12
+ el?.ownerDocument.body.appendChild(el);
13
  });
14
 
15
  onDestroy(() => {
 
20
  </script>
21
 
22
  <div bind:this={el} class="contents" hidden>
23
+ {@render children?.()}
24
  </div>
src/lib/components/RetryBtn.svelte CHANGED
@@ -1,12 +1,17 @@
1
  <script lang="ts">
2
  import CarbonRotate360 from "~icons/carbon/rotate-360";
3
 
4
- export let classNames = "";
 
 
 
 
 
5
  </script>
6
 
7
  <button
8
  type="button"
9
- on:click
10
  class="btn flex h-8 rounded-lg border bg-white px-3 py-1 text-gray-500 shadow-sm transition-all hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 {classNames}"
11
  >
12
  <CarbonRotate360 class="mr-2 text-xs " /> Retry
 
1
  <script lang="ts">
2
  import CarbonRotate360 from "~icons/carbon/rotate-360";
3
 
4
+ interface Props {
5
+ classNames?: string;
6
+ onClick?: () => void;
7
+ }
8
+
9
+ let { classNames = "", onClick }: Props = $props();
10
  </script>
11
 
12
  <button
13
  type="button"
14
+ onclick={onClick}
15
  class="btn flex h-8 rounded-lg border bg-white px-3 py-1 text-gray-500 shadow-sm transition-all hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 {classNames}"
16
  >
17
  <CarbonRotate360 class="mr-2 text-xs " /> Retry
src/lib/components/ScrollToBottomBtn.svelte CHANGED
@@ -1,27 +1,19 @@
1
  <script lang="ts">
 
 
2
  import { fade } from "svelte/transition";
3
  import { onDestroy } from "svelte";
4
  import IconChevron from "./icons/IconChevron.svelte";
5
 
6
- export let scrollNode: HTMLElement;
7
- export { className as class };
8
-
9
- let visible = false;
10
- let className = "";
11
- let observer: ResizeObserver | null = null;
12
-
13
- $: if (scrollNode) {
14
- destroy();
15
-
16
- if (window.ResizeObserver) {
17
- observer = new ResizeObserver(() => {
18
- updateVisibility();
19
- });
20
- observer.observe(scrollNode);
21
- }
22
- scrollNode.addEventListener("scroll", updateVisibility);
23
  }
24
 
 
 
 
25
  function updateVisibility() {
26
  if (!scrollNode) return;
27
  visible =
@@ -34,12 +26,25 @@
34
  }
35
 
36
  onDestroy(destroy);
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  </script>
38
 
39
  {#if visible}
40
  <button
41
  transition:fade={{ duration: 150 }}
42
- on:click={() => scrollNode.scrollTo({ top: scrollNode.scrollHeight, behavior: "smooth" })}
43
  class="btn absolute flex h-[41px] w-[41px] rounded-full border bg-white shadow-md transition-all hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:shadow-gray-950 dark:hover:bg-gray-600 {className}"
44
  ><IconChevron classNames="mt-[2px]" /></button
45
  >
 
1
  <script lang="ts">
2
+ import { run } from "svelte/legacy";
3
+
4
  import { fade } from "svelte/transition";
5
  import { onDestroy } from "svelte";
6
  import IconChevron from "./icons/IconChevron.svelte";
7
 
8
+ let visible = $state(false);
9
+ interface Props {
10
+ scrollNode: HTMLElement;
11
+ class?: string;
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  }
13
 
14
+ let { scrollNode, class: className = "" }: Props = $props();
15
+ let observer: ResizeObserver | null = $state(null);
16
+
17
  function updateVisibility() {
18
  if (!scrollNode) return;
19
  visible =
 
26
  }
27
 
28
  onDestroy(destroy);
29
+ run(() => {
30
+ if (scrollNode) {
31
+ destroy();
32
+
33
+ if (window.ResizeObserver) {
34
+ observer = new ResizeObserver(() => {
35
+ updateVisibility();
36
+ });
37
+ observer.observe(scrollNode);
38
+ }
39
+ scrollNode.addEventListener("scroll", updateVisibility);
40
+ }
41
+ });
42
  </script>
43
 
44
  {#if visible}
45
  <button
46
  transition:fade={{ duration: 150 }}
47
+ onclick={() => scrollNode.scrollTo({ top: scrollNode.scrollHeight, behavior: "smooth" })}
48
  class="btn absolute flex h-[41px] w-[41px] rounded-full border bg-white shadow-md transition-all hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:shadow-gray-950 dark:hover:bg-gray-600 {className}"
49
  ><IconChevron classNames="mt-[2px]" /></button
50
  >
src/lib/components/ScrollToPreviousBtn.svelte CHANGED
@@ -1,27 +1,17 @@
1
  <script lang="ts">
2
  import { fade } from "svelte/transition";
3
- import { onDestroy } from "svelte";
4
  import IconChevron from "./icons/IconChevron.svelte";
5
 
6
- export let scrollNode: HTMLElement;
7
- export { className as class };
8
-
9
- let visible = false;
10
- let className = "";
11
- let observer: ResizeObserver | null = null;
12
-
13
- $: if (scrollNode) {
14
- destroy();
15
-
16
- if (window.ResizeObserver) {
17
- observer = new ResizeObserver(() => {
18
- updateVisibility();
19
- });
20
- observer.observe(scrollNode);
21
- }
22
- scrollNode.addEventListener("scroll", updateVisibility);
23
  }
24
 
 
 
 
25
  function updateVisibility() {
26
  if (!scrollNode) return;
27
  visible =
@@ -57,12 +47,29 @@
57
  }
58
 
59
  onDestroy(destroy);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  </script>
61
 
62
  {#if visible}
63
  <button
64
  transition:fade={{ duration: 150 }}
65
- on:click={scrollToPrevious}
66
  class="btn absolute flex h-[41px] w-[41px] rounded-full border bg-white shadow-md transition-all hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:shadow-gray-950 dark:hover:bg-gray-600 {className}"
67
  >
68
  <IconChevron classNames="rotate-180 mt-[2px]" />
 
1
  <script lang="ts">
2
  import { fade } from "svelte/transition";
3
+ import { onDestroy, untrack } from "svelte";
4
  import IconChevron from "./icons/IconChevron.svelte";
5
 
6
+ let visible = $state(false);
7
+ interface Props {
8
+ scrollNode: HTMLElement;
9
+ class?: string;
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  }
11
 
12
+ let { scrollNode, class: className = "" }: Props = $props();
13
+ let observer: ResizeObserver | null = $state(null);
14
+
15
  function updateVisibility() {
16
  if (!scrollNode) return;
17
  visible =
 
47
  }
48
 
49
  onDestroy(destroy);
50
+
51
+ $effect(() => {
52
+ scrollNode &&
53
+ untrack(() => {
54
+ if (scrollNode) {
55
+ destroy();
56
+
57
+ if (window.ResizeObserver) {
58
+ observer = new ResizeObserver(() => {
59
+ updateVisibility();
60
+ });
61
+ observer.observe(scrollNode);
62
+ }
63
+ scrollNode.addEventListener("scroll", updateVisibility);
64
+ }
65
+ });
66
+ });
67
  </script>
68
 
69
  {#if visible}
70
  <button
71
  transition:fade={{ duration: 150 }}
72
+ onclick={scrollToPrevious}
73
  class="btn absolute flex h-[41px] w-[41px] rounded-full border bg-white shadow-md transition-all hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:shadow-gray-950 dark:hover:bg-gray-600 {className}"
74
  >
75
  <IconChevron classNames="rotate-180 mt-[2px]" />
src/lib/components/StopGeneratingBtn.svelte CHANGED
@@ -1,12 +1,17 @@
1
  <script lang="ts">
2
  import CarbonStopFilledAlt from "~icons/carbon/stop-filled-alt";
3
 
4
- export let classNames = "";
 
 
 
 
 
5
  </script>
6
 
7
  <button
8
  type="button"
9
- on:click
10
  class="btn flex h-8 rounded-lg border bg-white px-3 py-1 shadow-sm transition-all hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:hover:bg-gray-600 {classNames}"
11
  >
12
  <CarbonStopFilledAlt class="-ml-1 mr-1 h-[1.25rem] w-[1.1875rem] text-gray-300" /> Stop generating
 
1
  <script lang="ts">
2
  import CarbonStopFilledAlt from "~icons/carbon/stop-filled-alt";
3
 
4
+ interface Props {
5
+ classNames?: string;
6
+ onClick?: () => void;
7
+ }
8
+
9
+ let { classNames = "", onClick }: Props = $props();
10
  </script>
11
 
12
  <button
13
  type="button"
14
+ onclick={onClick}
15
  class="btn flex h-8 rounded-lg border bg-white px-3 py-1 shadow-sm transition-all hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:hover:bg-gray-600 {classNames}"
16
  >
17
  <CarbonStopFilledAlt class="-ml-1 mr-1 h-[1.25rem] w-[1.1875rem] text-gray-300" /> Stop generating
src/lib/components/Switch.svelte CHANGED
@@ -1,6 +1,10 @@
1
  <script lang="ts">
2
- export let checked: boolean;
3
- export let name: string;
 
 
 
 
4
  </script>
5
 
6
  <input bind:checked type="checkbox" {name} class="peer pointer-events-none absolute opacity-0" />
@@ -12,5 +16,5 @@
12
  tabindex="0"
13
  class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-gray-300 p-1 shadow-inner ring-gray-400 transition-all peer-checked:bg-blue-600 peer-focus-visible:ring peer-focus-visible:ring-offset-1 hover:bg-gray-400 dark:bg-gray-600 peer-checked:[&>div]:translate-x-3.5"
14
  >
15
- <div class="h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-all" />
16
  </div>
 
1
  <script lang="ts">
2
+ interface Props {
3
+ checked: boolean;
4
+ name: string;
5
+ }
6
+
7
+ let { checked = $bindable(), name }: Props = $props();
8
  </script>
9
 
10
  <input bind:checked type="checkbox" {name} class="peer pointer-events-none absolute opacity-0" />
 
16
  tabindex="0"
17
  class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-gray-300 p-1 shadow-inner ring-gray-400 transition-all peer-checked:bg-blue-600 peer-focus-visible:ring peer-focus-visible:ring-offset-1 hover:bg-gray-400 dark:bg-gray-600 peer-checked:[&>div]:translate-x-3.5"
18
  >
19
+ <div class="h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-all"></div>
20
  </div>
src/lib/components/SystemPromptModal.svelte CHANGED
@@ -3,16 +3,20 @@
3
  import CarbonClose from "~icons/carbon/close";
4
  import CarbonBlockchain from "~icons/carbon/blockchain";
5
 
6
- export let preprompt: string;
 
 
7
 
8
- let isOpen = false;
 
 
9
  </script>
10
 
11
  <button
12
  type="button"
13
  class="mx-auto flex items-center gap-1.5 rounded-full border border-gray-100 bg-gray-50 px-3 py-1 text-xs text-gray-500 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
14
- on:click={() => (isOpen = !isOpen)}
15
- on:keydown={(e) => e.key === "Enter" && (isOpen = !isOpen)}
16
  >
17
  <CarbonBlockchain class="text-xxs" /> Using Custom System Prompt
18
  </button>
@@ -22,7 +26,7 @@
22
  <div class="flex w-full flex-col gap-5 p-6">
23
  <div class="flex items-start justify-between text-xl font-semibold text-gray-800">
24
  <h2>System Prompt</h2>
25
- <button type="button" class="group" on:click={() => (isOpen = false)}>
26
  <CarbonClose class="mt-auto text-gray-900 group-hover:text-gray-500" />
27
  </button>
28
  </div>
@@ -30,7 +34,7 @@
30
  disabled
31
  value={preprompt}
32
  class="min-h-[420px] w-full resize-none rounded-lg border bg-gray-50 p-2.5 text-gray-600 max-sm:text-sm"
33
- />
34
  </div>
35
  </Modal>
36
  {/if}
 
3
  import CarbonClose from "~icons/carbon/close";
4
  import CarbonBlockchain from "~icons/carbon/blockchain";
5
 
6
+ interface Props {
7
+ preprompt: string;
8
+ }
9
 
10
+ let { preprompt }: Props = $props();
11
+
12
+ let isOpen = $state(false);
13
  </script>
14
 
15
  <button
16
  type="button"
17
  class="mx-auto flex items-center gap-1.5 rounded-full border border-gray-100 bg-gray-50 px-3 py-1 text-xs text-gray-500 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
18
+ onclick={() => (isOpen = !isOpen)}
19
+ onkeydown={(e) => e.key === "Enter" && (isOpen = !isOpen)}
20
  >
21
  <CarbonBlockchain class="text-xxs" /> Using Custom System Prompt
22
  </button>
 
26
  <div class="flex w-full flex-col gap-5 p-6">
27
  <div class="flex items-start justify-between text-xl font-semibold text-gray-800">
28
  <h2>System Prompt</h2>
29
+ <button type="button" class="group" onclick={() => (isOpen = false)}>
30
  <CarbonClose class="mt-auto text-gray-900 group-hover:text-gray-500" />
31
  </button>
32
  </div>
 
34
  disabled
35
  value={preprompt}
36
  class="min-h-[420px] w-full resize-none rounded-lg border bg-gray-50 p-2.5 text-gray-600 max-sm:text-sm"
37
+ ></textarea>
38
  </div>
39
  </Modal>
40
  {/if}
src/lib/components/Toast.svelte CHANGED
@@ -3,7 +3,11 @@
3
 
4
  import IconDazzled from "$lib/components/icons/IconDazzled.svelte";
5
 
6
- export let message = "";
 
 
 
 
7
  </script>
8
 
9
  <div
 
3
 
4
  import IconDazzled from "$lib/components/icons/IconDazzled.svelte";
5
 
6
+ interface Props {
7
+ message?: string;
8
+ }
9
+
10
+ let { message = "" }: Props = $props();
11
  </script>
12
 
13
  <div
src/lib/components/TokensCounter.svelte CHANGED
@@ -2,43 +2,43 @@
2
  import type { Model } from "$lib/types/Model";
3
  import { getTokenizer } from "$lib/utils/getTokenizer";
4
  import type { PreTrainedTokenizer } from "@huggingface/transformers";
 
5
 
6
- export let classNames = "";
7
- export let prompt = "";
8
- export let modelTokenizer: Exclude<Model["tokenizer"], undefined>;
9
- export let truncate: number | undefined = undefined;
 
 
10
 
11
- let tokenizer: PreTrainedTokenizer | undefined = undefined;
12
 
13
- async function tokenizeText(_prompt: string) {
14
- if (!tokenizer) {
15
- return;
16
- }
17
- const { input_ids } = await tokenizer(_prompt);
18
- return input_ids.size;
19
- }
 
 
 
 
 
 
20
 
21
- $: (async () => {
22
- tokenizer = await getTokenizer(modelTokenizer);
23
- })();
24
  </script>
25
 
26
- {#if tokenizer}
27
- {#await tokenizeText(prompt) then nTokens}
28
- {@const exceedLimit = nTokens > (truncate || Infinity)}
29
- <div class={classNames}>
30
- <p
31
- class="peer text-sm {exceedLimit
32
- ? 'text-red-500 opacity-100'
33
- : 'opacity-60 hover:opacity-90'}"
34
- >
35
- {nTokens}{truncate ? `/${truncate}` : ""}
36
- </p>
37
- <div
38
- class="invisible absolute -top-6 right-0 whitespace-nowrap rounded bg-black px-1 text-sm text-white peer-hover:visible"
39
- >
40
- Tokens usage
41
- </div>
42
- </div>
43
- {/await}
44
- {/if}
 
2
  import type { Model } from "$lib/types/Model";
3
  import { getTokenizer } from "$lib/utils/getTokenizer";
4
  import type { PreTrainedTokenizer } from "@huggingface/transformers";
5
+ import { untrack } from "svelte";
6
 
7
+ interface Props {
8
+ classNames?: string;
9
+ prompt?: string;
10
+ modelTokenizer: Exclude<Model["tokenizer"], undefined>;
11
+ truncate?: number | undefined;
12
+ }
13
 
14
+ let { classNames = "", prompt = "", modelTokenizer, truncate = undefined }: Props = $props();
15
 
16
+ let tokenizer: Promise<PreTrainedTokenizer> = $derived(getTokenizer(modelTokenizer));
17
+
18
+ let nTokens = $state(0);
19
+
20
+ $effect(() => {
21
+ prompt &&
22
+ untrack(() => {
23
+ tokenizer.then((tokenizer) => {
24
+ const { input_ids } = tokenizer(prompt);
25
+ nTokens = input_ids.size;
26
+ });
27
+ });
28
+ });
29
 
30
+ let exceedLimit = $derived(nTokens > (truncate || Infinity));
 
 
31
  </script>
32
 
33
+ <div class={classNames}>
34
+ <p
35
+ class="peer text-sm {exceedLimit ? 'text-red-500 opacity-100' : 'opacity-60 hover:opacity-90'}"
36
+ >
37
+ {nTokens}{truncate ? `/${truncate}` : ""}
38
+ </p>
39
+ <div
40
+ class="invisible absolute -top-6 right-0 whitespace-nowrap rounded bg-black px-1 text-sm text-white peer-hover:visible"
41
+ >
42
+ Tokens usage
43
+ </div>
44
+ </div>
 
 
 
 
 
 
 
src/lib/components/ToolBadge.svelte CHANGED
@@ -3,7 +3,11 @@
3
  import { base } from "$app/paths";
4
  import { browser } from "$app/environment";
5
 
6
- export let toolId: string;
 
 
 
 
7
  </script>
8
 
9
  <div
@@ -11,7 +15,9 @@
11
  >
12
  {#if browser}
13
  {#await fetch(`${base}/api/tools/${toolId}`).then((res) => res.json()) then value}
14
- <ToolLogo color={value.color} icon={value.icon} size="sm" />
 
 
15
  <div class="flex flex-col items-center justify-center py-1">
16
  <a
17
  href={`${base}/tools/${value._id}`}
 
3
  import { base } from "$app/paths";
4
  import { browser } from "$app/environment";
5
 
6
+ interface Props {
7
+ toolId: string;
8
+ }
9
+
10
+ let { toolId }: Props = $props();
11
  </script>
12
 
13
  <div
 
15
  >
16
  {#if browser}
17
  {#await fetch(`${base}/api/tools/${toolId}`).then((res) => res.json()) then value}
18
+ {#key value.color + value.icon}
19
+ <ToolLogo color={value.color} icon={value.icon} size="sm" />
20
+ {/key}
21
  <div class="flex flex-col items-center justify-center py-1">
22
  <a
23
  href={`${base}/tools/${value._id}`}
src/lib/components/ToolLogo.svelte CHANGED
@@ -11,28 +11,34 @@
11
  import CarbonSpeaker from "~icons/carbon/volume-up";
12
  import CarbonVideo from "~icons/carbon/video";
13
 
14
- export let color: string;
15
- export let icon: string;
16
- export let size: "xs" | "sm" | "md" | "lg" = "md";
 
 
 
 
17
 
18
- $: gradientColor = (() => {
19
- switch (color) {
20
- case "purple":
21
- return "#653789";
22
- case "blue":
23
- return "#375889";
24
- case "green":
25
- return "#37894E";
26
- case "yellow":
27
- return "#897C37";
28
- case "red":
29
- return "#893737";
30
- default:
31
- return "#FFF";
32
- }
33
- })();
 
 
34
 
35
- let iconEl = CarbonWikis;
36
 
37
  switch (icon) {
38
  case "wikis":
@@ -70,18 +76,22 @@
70
  break;
71
  }
72
 
73
- $: sizeClass = (() => {
74
- switch (size) {
75
- case "xs":
76
- return "size-4";
77
- case "sm":
78
- return "size-8";
79
- case "md":
80
- return "size-14";
81
- case "lg":
82
- return "size-24";
83
- }
84
- })();
 
 
 
 
85
  </script>
86
 
87
  <div class="flex {sizeClass} relative items-center justify-center">
@@ -100,5 +110,5 @@
100
  </defs>
101
  <rect width="100%" height="100%" fill="url(#gradient-{gradientColor})" mask="url(#mask)" />
102
  </svg>
103
- <svelte:component this={iconEl} class="relative {sizeClass} scale-50 text-clip text-gray-200" />
104
  </div>
 
11
  import CarbonSpeaker from "~icons/carbon/volume-up";
12
  import CarbonVideo from "~icons/carbon/video";
13
 
14
+ interface Props {
15
+ color: string;
16
+ icon: string;
17
+ size?: "xs" | "sm" | "md" | "lg";
18
+ }
19
+
20
+ let { color, icon, size = "md" }: Props = $props();
21
 
22
+ let gradientColor = $derived(
23
+ (() => {
24
+ switch (color) {
25
+ case "purple":
26
+ return "#653789";
27
+ case "blue":
28
+ return "#375889";
29
+ case "green":
30
+ return "#37894E";
31
+ case "yellow":
32
+ return "#897C37";
33
+ case "red":
34
+ return "#893737";
35
+ default:
36
+ return "#FFF";
37
+ }
38
+ })()
39
+ );
40
 
41
+ let iconEl = $state(CarbonWikis);
42
 
43
  switch (icon) {
44
  case "wikis":
 
76
  break;
77
  }
78
 
79
+ let sizeClass = $derived(
80
+ (() => {
81
+ switch (size) {
82
+ case "xs":
83
+ return "size-4";
84
+ case "sm":
85
+ return "size-8";
86
+ case "md":
87
+ return "size-14";
88
+ case "lg":
89
+ return "size-24";
90
+ }
91
+ })()
92
+ );
93
+
94
+ const SvelteComponent = $derived(iconEl);
95
  </script>
96
 
97
  <div class="flex {sizeClass} relative items-center justify-center">
 
110
  </defs>
111
  <rect width="100%" height="100%" fill="url(#gradient-{gradientColor})" mask="url(#mask)" />
112
  </svg>
113
+ <SvelteComponent class="relative {sizeClass} scale-50 text-clip text-gray-200" />
114
  </div>
src/lib/components/ToolsMenu.svelte CHANGED
@@ -9,17 +9,23 @@
9
  import CarbonInformation from "~icons/carbon/information";
10
  import CarbonGlobe from "~icons/carbon/earth-filled";
11
 
12
- export let loading = false;
 
 
 
 
13
  const settings = useSettingsStore();
14
 
15
- let detailsEl: HTMLDetailsElement;
16
 
17
  // active tools are all the checked tools, either from settings or on by default
18
- $: activeToolCount = $page.data.tools.filter(
19
- (tool: ToolFront) =>
20
- // community tools are always on by default
21
- tool.type === "community" || $settings?.tools?.includes(tool._id)
22
- ).length;
 
 
23
 
24
  async function setAllTools(value: boolean) {
25
  const configToolsIds = $page.data.tools
@@ -37,16 +43,16 @@
37
  }
38
  }
39
 
40
- $: allToolsEnabled = activeToolCount === $page.data.tools.length;
41
 
42
- $: tools = $page.data.tools;
43
  </script>
44
 
45
  <details
46
  class="group relative bottom-0 h-full min-h-8"
47
  bind:this={detailsEl}
48
  use:clickOutside={() => {
49
- if (detailsEl.hasAttribute("open")) {
50
  detailsEl.removeAttribute("open");
51
  }
52
  }}
@@ -75,7 +81,10 @@
75
  {/if}
76
  <button
77
  class="ml-auto text-xs underline"
78
- on:click|stopPropagation={() => setAllTools(!allToolsEnabled)}
 
 
 
79
  >
80
  {#if allToolsEnabled}
81
  Disable all
@@ -104,7 +113,9 @@
104
  id={tool._id}
105
  checked={true}
106
  class="rounded-xs font-semibold accent-purple-500 hover:accent-purple-600"
107
- on:click|stopPropagation|preventDefault={async () => {
 
 
108
  await settings.instantSet({
109
  tools: $settings?.tools?.filter((t) => t !== tool._id) ?? [],
110
  });
@@ -116,7 +127,9 @@
116
  id={tool._id}
117
  checked={isChecked}
118
  disabled={loading}
119
- on:click|stopPropagation={async () => {
 
 
120
  if (isChecked) {
121
  await settings.instantSet({
122
  tools: ($settings?.tools ?? []).filter((t) => t !== tool._id),
 
9
  import CarbonInformation from "~icons/carbon/information";
10
  import CarbonGlobe from "~icons/carbon/earth-filled";
11
 
12
+ interface Props {
13
+ loading?: boolean;
14
+ }
15
+
16
+ let { loading = false }: Props = $props();
17
  const settings = useSettingsStore();
18
 
19
+ let detailsEl: HTMLDetailsElement | undefined = $state();
20
 
21
  // active tools are all the checked tools, either from settings or on by default
22
+ let activeToolCount = $derived(
23
+ $page.data.tools.filter(
24
+ (tool: ToolFront) =>
25
+ // community tools are always on by default
26
+ tool.type === "community" || $settings?.tools?.includes(tool._id)
27
+ ).length
28
+ );
29
 
30
  async function setAllTools(value: boolean) {
31
  const configToolsIds = $page.data.tools
 
43
  }
44
  }
45
 
46
+ let allToolsEnabled = $derived(activeToolCount === $page.data.tools.length);
47
 
48
+ let tools = $derived($page.data.tools);
49
  </script>
50
 
51
  <details
52
  class="group relative bottom-0 h-full min-h-8"
53
  bind:this={detailsEl}
54
  use:clickOutside={() => {
55
+ if (detailsEl?.hasAttribute("open")) {
56
  detailsEl.removeAttribute("open");
57
  }
58
  }}
 
81
  {/if}
82
  <button
83
  class="ml-auto text-xs underline"
84
+ onclick={(e) => {
85
+ e.stopPropagation();
86
+ setAllTools(!allToolsEnabled);
87
+ }}
88
  >
89
  {#if allToolsEnabled}
90
  Disable all
 
113
  id={tool._id}
114
  checked={true}
115
  class="rounded-xs font-semibold accent-purple-500 hover:accent-purple-600"
116
+ onclick={async (e) => {
117
+ e.preventDefault();
118
+ e.stopPropagation();
119
  await settings.instantSet({
120
  tools: $settings?.tools?.filter((t) => t !== tool._id) ?? [],
121
  });
 
127
  id={tool._id}
128
  checked={isChecked}
129
  disabled={loading}
130
+ onclick={async (e) => {
131
+ e.preventDefault();
132
+ e.stopPropagation();
133
  if (isChecked) {
134
  await settings.instantSet({
135
  tools: ($settings?.tools ?? []).filter((t) => t !== tool._id),
src/lib/components/Tooltip.svelte CHANGED
@@ -1,7 +1,15 @@
1
  <script lang="ts">
2
- export let classNames = "";
3
- export let label = "Copied";
4
- export let position = "left-1/2 top-full transform -translate-x-1/2 translate-y-2";
 
 
 
 
 
 
 
 
5
  </script>
6
 
7
  <div
@@ -17,6 +25,6 @@
17
  border-left-color: transparent;
18
  border-right-color: transparent;
19
  "
20
- />
21
  {label}
22
  </div>
 
1
  <script lang="ts">
2
+ interface Props {
3
+ classNames?: string;
4
+ label?: string;
5
+ position?: string;
6
+ }
7
+
8
+ let {
9
+ classNames = "",
10
+ label = "Copied",
11
+ position = "left-1/2 top-full transform -translate-x-1/2 translate-y-2",
12
+ }: Props = $props();
13
  </script>
14
 
15
  <div
 
25
  border-left-color: transparent;
26
  border-right-color: transparent;
27
  "
28
+ ></div>
29
  {label}
30
  </div>
src/lib/components/UploadBtn.svelte CHANGED
@@ -1,9 +1,13 @@
1
  <script lang="ts">
2
  import CarbonUpload from "~icons/carbon/upload";
3
 
4
- export let classNames = "";
5
- export let files: File[];
6
- export let mimeTypes: string[];
 
 
 
 
7
 
8
  /**
9
  * Due to a bug with Svelte, we cannot use bind:files with multiple
@@ -23,7 +27,7 @@
23
  class="absolute w-full cursor-pointer opacity-0"
24
  aria-label="Upload file"
25
  type="file"
26
- on:change={onFileChange}
27
  accept={mimeTypes.join(",")}
28
  />
29
  <CarbonUpload class="mr-2 text-xxs" /> Upload file
 
1
  <script lang="ts">
2
  import CarbonUpload from "~icons/carbon/upload";
3
 
4
+ interface Props {
5
+ classNames?: string;
6
+ files: File[];
7
+ mimeTypes: string[];
8
+ }
9
+
10
+ let { classNames = "", files = $bindable(), mimeTypes }: Props = $props();
11
 
12
  /**
13
  * Due to a bug with Svelte, we cannot use bind:files with multiple
 
27
  class="absolute w-full cursor-pointer opacity-0"
28
  aria-label="Upload file"
29
  type="file"
30
+ onchange={onFileChange}
31
  accept={mimeTypes.join(",")}
32
  />
33
  <CarbonUpload class="mr-2 text-xxs" /> Upload file
src/lib/components/WebSearchToggle.svelte CHANGED
@@ -8,8 +8,8 @@
8
 
9
  <div
10
  class="flex h-8 cursor-pointer select-none items-center gap-2 rounded-lg border bg-white p-1.5 shadow-sm hover:shadow-none dark:border-gray-800 dark:bg-gray-900"
11
- on:click={toggle}
12
- on:keydown={toggle}
13
  aria-checked={$webSearchParameters.useSearch}
14
  aria-label="Web Search Toggle"
15
  role="switch"
 
8
 
9
  <div
10
  class="flex h-8 cursor-pointer select-none items-center gap-2 rounded-lg border bg-white p-1.5 shadow-sm hover:shadow-none dark:border-gray-800 dark:bg-gray-900"
11
+ onclick={toggle}
12
+ onkeydown={toggle}
13
  aria-checked={$webSearchParameters.useSearch}
14
  aria-label="Web Search Toggle"
15
  role="switch"
src/lib/components/chat/Alternatives.svelte CHANGED
@@ -7,11 +7,15 @@
7
  import { enhance } from "$app/forms";
8
  import { createEventDispatcher } from "svelte";
9
 
10
- export let message: Message;
11
- export let alternatives: Message["id"][] = [];
12
- export let loading = false;
 
 
13
 
14
- $: currentIdx = alternatives.findIndex((id) => id === message.id);
 
 
15
 
16
  const dispatch = createEventDispatcher<{
17
  showAlternateMsg: { id: Message["id"] };
@@ -23,7 +27,7 @@
23
  >
24
  <button
25
  class="inline text-lg font-thin text-gray-400 hover:text-gray-800 disabled:pointer-events-none disabled:opacity-25 dark:text-gray-500 dark:hover:text-gray-200"
26
- on:click={() => dispatch("showAlternateMsg", { id: alternatives[Math.max(0, currentIdx - 1)] })}
27
  disabled={currentIdx === 0 || loading}
28
  >
29
  <CarbonChevronLeft class="text-sm" />
@@ -33,7 +37,7 @@
33
  </span>
34
  <button
35
  class="inline text-lg font-thin text-gray-400 hover:text-gray-800 disabled:pointer-events-none disabled:opacity-25 dark:text-gray-500 dark:hover:text-gray-200"
36
- on:click={() =>
37
  dispatch("showAlternateMsg", {
38
  id: alternatives[Math.min(alternatives.length - 1, currentIdx + 1)],
39
  })}
 
7
  import { enhance } from "$app/forms";
8
  import { createEventDispatcher } from "svelte";
9
 
10
+ interface Props {
11
+ message: Message;
12
+ alternatives?: Message["id"][];
13
+ loading?: boolean;
14
+ }
15
 
16
+ let { message, alternatives = [], loading = false }: Props = $props();
17
+
18
+ let currentIdx = $derived(alternatives.findIndex((id) => id === message.id));
19
 
20
  const dispatch = createEventDispatcher<{
21
  showAlternateMsg: { id: Message["id"] };
 
27
  >
28
  <button
29
  class="inline text-lg font-thin text-gray-400 hover:text-gray-800 disabled:pointer-events-none disabled:opacity-25 dark:text-gray-500 dark:hover:text-gray-200"
30
+ onclick={() => dispatch("showAlternateMsg", { id: alternatives[Math.max(0, currentIdx - 1)] })}
31
  disabled={currentIdx === 0 || loading}
32
  >
33
  <CarbonChevronLeft class="text-sm" />
 
37
  </span>
38
  <button
39
  class="inline text-lg font-thin text-gray-400 hover:text-gray-800 disabled:pointer-events-none disabled:opacity-25 dark:text-gray-500 dark:hover:text-gray-200"
40
+ onclick={() =>
41
  dispatch("showAlternateMsg", {
42
  id: alternatives[Math.min(alternatives.length - 1, currentIdx + 1)],
43
  })}
src/lib/components/chat/AssistantIntroduction.svelte CHANGED
@@ -18,36 +18,41 @@
18
  import { env as envPublic } from "$env/dynamic/public";
19
  import { page } from "$app/stores";
20
 
21
- export let models: Model[];
22
- export let assistant: Pick<
23
- Assistant,
24
- | "avatar"
25
- | "name"
26
- | "rag"
27
- | "dynamicPrompt"
28
- | "modelId"
29
- | "createdByName"
30
- | "exampleInputs"
31
- | "_id"
32
- | "description"
33
- | "userCount"
34
- | "tools"
35
- >;
 
 
 
 
36
 
37
  const dispatch = createEventDispatcher<{ message: string }>();
38
 
39
- $: hasRag =
40
  assistant?.rag?.allowAllDomains ||
41
- (assistant?.rag?.allowedDomains?.length ?? 0) > 0 ||
42
- (assistant?.rag?.allowedLinks?.length ?? 0) > 0 ||
43
- assistant?.dynamicPrompt;
 
44
 
45
  const prefix =
46
  envPublic.PUBLIC_SHARE_PREFIX || `${envPublic.PUBLIC_ORIGIN || $page.url.origin}${base}`;
47
 
48
- $: shareUrl = `${prefix}/assistant/${assistant?._id}`;
49
 
50
- let isCopied = false;
51
 
52
  const settings = useSettingsStore();
53
  </script>
@@ -128,7 +133,7 @@
128
  <div class="flex flex-row items-center gap-1">
129
  <button
130
  class="flex h-7 items-center gap-1.5 rounded-full border bg-white px-2.5 py-1 text-gray-800 shadow-sm hover:shadow-inner dark:border-gray-700 dark:bg-gray-700 dark:text-gray-300/90 dark:hover:bg-gray-800 max-sm:px-1.5 md:text-sm"
131
- on:click={() => {
132
  if (!isCopied) {
133
  share(shareUrl, assistant.name);
134
  isCopied = true;
@@ -154,7 +159,7 @@
154
  </div>
155
  </div>
156
  <button
157
- on:click={() => {
158
  settings.instantSet({
159
  activeModel: models[0].name,
160
  });
@@ -177,7 +182,7 @@
177
  <button
178
  type="button"
179
  class="truncate whitespace-nowrap rounded-xl border bg-gray-50 px-3 py-2 text-left text-smd text-gray-600 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
180
- on:click={() => dispatch("message", example)}
181
  >
182
  {example}
183
  </button>
 
18
  import { env as envPublic } from "$env/dynamic/public";
19
  import { page } from "$app/stores";
20
 
21
+ interface Props {
22
+ models: Model[];
23
+ assistant: Pick<
24
+ Assistant,
25
+ | "avatar"
26
+ | "name"
27
+ | "rag"
28
+ | "dynamicPrompt"
29
+ | "modelId"
30
+ | "createdByName"
31
+ | "exampleInputs"
32
+ | "_id"
33
+ | "description"
34
+ | "userCount"
35
+ | "tools"
36
+ >;
37
+ }
38
+
39
+ let { models, assistant }: Props = $props();
40
 
41
  const dispatch = createEventDispatcher<{ message: string }>();
42
 
43
+ let hasRag = $derived(
44
  assistant?.rag?.allowAllDomains ||
45
+ (assistant?.rag?.allowedDomains?.length ?? 0) > 0 ||
46
+ (assistant?.rag?.allowedLinks?.length ?? 0) > 0 ||
47
+ assistant?.dynamicPrompt
48
+ );
49
 
50
  const prefix =
51
  envPublic.PUBLIC_SHARE_PREFIX || `${envPublic.PUBLIC_ORIGIN || $page.url.origin}${base}`;
52
 
53
+ let shareUrl = $derived(`${prefix}/assistant/${assistant?._id}`);
54
 
55
+ let isCopied = $state(false);
56
 
57
  const settings = useSettingsStore();
58
  </script>
 
133
  <div class="flex flex-row items-center gap-1">
134
  <button
135
  class="flex h-7 items-center gap-1.5 rounded-full border bg-white px-2.5 py-1 text-gray-800 shadow-sm hover:shadow-inner dark:border-gray-700 dark:bg-gray-700 dark:text-gray-300/90 dark:hover:bg-gray-800 max-sm:px-1.5 md:text-sm"
136
+ onclick={() => {
137
  if (!isCopied) {
138
  share(shareUrl, assistant.name);
139
  isCopied = true;
 
159
  </div>
160
  </div>
161
  <button
162
+ onclick={() => {
163
  settings.instantSet({
164
  activeModel: models[0].name,
165
  });
 
182
  <button
183
  type="button"
184
  class="truncate whitespace-nowrap rounded-xl border bg-gray-50 px-3 py-2 text-left text-smd text-gray-600 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
185
+ onclick={() => dispatch("message", example)}
186
  >
187
  {example}
188
  </button>
src/lib/components/chat/ChatInput.svelte CHANGED
@@ -23,18 +23,35 @@
23
  import IconAdd from "~icons/carbon/add";
24
  import { captureScreen } from "$lib/utils/screenshot";
25
  import IconScreenshot from "../icons/IconScreenshot.svelte";
 
26
 
27
- export let files: File[] = [];
28
- export let mimeTypes: string[] = [];
29
-
30
- export let value = "";
31
- export let placeholder = "";
32
- export let loading = false;
33
- export let disabled = false;
34
- export let assistant: Assistant | undefined = undefined;
 
 
 
 
 
35
 
36
- export let modelHasTools = false;
37
- export let modelIsMultimodal = false;
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  const onFileChange = async (e: Event) => {
40
  if (!e.target) return;
@@ -48,20 +65,20 @@
48
  }
49
  };
50
 
51
- let textareaElement: HTMLTextAreaElement;
52
- let isCompositionOn = false;
53
 
54
  const dispatch = createEventDispatcher<{ submit: void }>();
55
 
56
  onMount(() => {
57
  if (!isVirtualKeyboard()) {
58
- textareaElement.focus();
59
  }
60
  function onFormSubmit() {
61
  adjustTextareaHeight();
62
  }
63
 
64
- const formEl = textareaElement.closest("form");
65
  formEl?.addEventListener("submit", onFormSubmit);
66
  return () => {
67
  formEl?.removeEventListener("submit", onFormSubmit);
@@ -84,6 +101,10 @@
84
  }
85
 
86
  function adjustTextareaHeight() {
 
 
 
 
87
  textareaElement.style.height = "auto";
88
  textareaElement.style.height = `${textareaElement.scrollHeight}px`;
89
 
@@ -109,24 +130,29 @@
109
 
110
  // tool section
111
 
112
- $: webSearchIsOn = modelHasTools
113
- ? ($settings.tools?.includes(webSearchToolId) ?? false) ||
114
- ($settings.tools?.includes(fetchUrlToolId) ?? false)
115
- : $webSearchParameters.useSearch;
116
- $: imageGenIsOn = $settings.tools?.includes(imageGenToolId) ?? false;
 
 
117
 
118
- $: documentParserIsOn =
119
- modelHasTools && files.length > 0 && files.some((file) => file.type.startsWith("application/"));
 
120
 
121
- $: extraTools = $page.data.tools
122
- .filter((t: ToolFront) => $settings.tools?.includes(t._id))
123
- .filter(
124
- (t: ToolFront) =>
125
- ![documentParserToolId, imageGenToolId, webSearchToolId, fetchUrlToolId].includes(t._id)
126
- ) satisfies ToolFront[];
 
 
127
  </script>
128
 
129
- <div class="flex min-h-full flex-1 flex-col" on:paste>
130
  <textarea
131
  rows="1"
132
  tabindex="0"
@@ -135,14 +161,19 @@
135
  class:text-gray-400={disabled}
136
  bind:value
137
  bind:this={textareaElement}
138
- on:keydown={handleKeydown}
139
- on:compositionstart={() => (isCompositionOn = true)}
140
- on:compositionend={() => (isCompositionOn = false)}
141
- on:input={adjustTextareaHeight}
142
- on:beforeinput
 
 
 
 
 
143
  {placeholder}
144
  {disabled}
145
- />
146
 
147
  {#if !assistant}
148
  <div
@@ -159,7 +190,8 @@
159
  class="base-tool"
160
  class:active-tool={webSearchIsOn}
161
  disabled={loading}
162
- on:click|preventDefault={async () => {
 
163
  if (modelHasTools) {
164
  if (webSearchIsOn) {
165
  await settings.instantSet({
@@ -195,7 +227,8 @@
195
  class="base-tool"
196
  class:active-tool={imageGenIsOn}
197
  disabled={loading}
198
- on:click|preventDefault={async () => {
 
199
  if (modelHasTools) {
200
  if (imageGenIsOn) {
201
  await settings.instantSet({
@@ -227,7 +260,7 @@
227
  return m.split("/")[1];
228
  })
229
  .join(", ")}
230
- <form class="flex items-center">
231
  <HoverTooltip
232
  label={mimeTypesString.includes("*")
233
  ? "Upload any file"
@@ -241,7 +274,7 @@
241
  class="absolute hidden size-0"
242
  aria-label="Upload file"
243
  type="file"
244
- on:change={onFileChange}
245
  accept={mimeTypes.join(",")}
246
  />
247
  <IconPaperclip classNames="text-xl" />
@@ -250,7 +283,7 @@
250
  {/if}
251
  </label>
252
  </HoverTooltip>
253
- </form>
254
  {#if mimeTypes.includes("image/*")}
255
  <HoverTooltip
256
  label="Capture screenshot"
@@ -259,7 +292,8 @@
259
  >
260
  <button
261
  class="base-tool"
262
- on:click|preventDefault={async () => {
 
263
  const screenshot = await captureScreen();
264
 
265
  // Convert base64 to blob
@@ -282,11 +316,14 @@
282
  <button
283
  class="active-tool base-tool"
284
  disabled={loading}
285
- on:click|preventDefault={async () => {
 
286
  goto(`${base}/tools/${tool._id}`);
287
  }}
288
  >
289
- <ToolLogo icon={tool.icon} color={tool.color} size="xs" />
 
 
290
  {tool.displayName}
291
  </button>
292
  {/each}
@@ -308,22 +345,14 @@
308
  {/if}
309
  </div>
310
  {/if}
311
- <slot />
312
  </div>
313
 
314
  <style lang="postcss">
315
- pre,
316
- textarea {
317
  font-family: inherit;
318
  box-sizing: border-box;
319
  line-height: 1.5;
320
  }
321
-
322
- .base-tool {
323
- @apply flex h-[1.6rem] items-center gap-[.2rem] whitespace-nowrap border border-transparent text-xs outline-none transition-all focus:outline-none active:outline-none dark:hover:text-gray-300 sm:hover:text-purple-600;
324
- }
325
-
326
- .active-tool {
327
- @apply rounded-full !border-purple-200 bg-purple-100 pl-1 pr-2 text-purple-600 hover:text-purple-600 dark:!border-purple-700 dark:bg-purple-600/40 dark:text-purple-200;
328
- }
329
  </style>
 
23
  import IconAdd from "~icons/carbon/add";
24
  import { captureScreen } from "$lib/utils/screenshot";
25
  import IconScreenshot from "../icons/IconScreenshot.svelte";
26
+ import { loginModalOpen } from "$lib/stores/loginModal";
27
 
28
+ interface Props {
29
+ files?: File[];
30
+ mimeTypes?: string[];
31
+ value?: string;
32
+ placeholder?: string;
33
+ loading?: boolean;
34
+ disabled?: boolean;
35
+ assistant?: Assistant | undefined;
36
+ modelHasTools?: boolean;
37
+ modelIsMultimodal?: boolean;
38
+ children?: import("svelte").Snippet;
39
+ onPaste?: (e: ClipboardEvent) => void;
40
+ }
41
 
42
+ let {
43
+ files = $bindable([]),
44
+ mimeTypes = [],
45
+ value = $bindable(""),
46
+ placeholder = "",
47
+ loading = false,
48
+ disabled = false,
49
+ assistant = undefined,
50
+ modelHasTools = false,
51
+ modelIsMultimodal = false,
52
+ children,
53
+ onPaste,
54
+ }: Props = $props();
55
 
56
  const onFileChange = async (e: Event) => {
57
  if (!e.target) return;
 
65
  }
66
  };
67
 
68
+ let textareaElement: HTMLTextAreaElement | undefined = $state();
69
+ let isCompositionOn = $state(false);
70
 
71
  const dispatch = createEventDispatcher<{ submit: void }>();
72
 
73
  onMount(() => {
74
  if (!isVirtualKeyboard()) {
75
+ textareaElement?.focus();
76
  }
77
  function onFormSubmit() {
78
  adjustTextareaHeight();
79
  }
80
 
81
+ const formEl = textareaElement?.closest("form");
82
  formEl?.addEventListener("submit", onFormSubmit);
83
  return () => {
84
  formEl?.removeEventListener("submit", onFormSubmit);
 
101
  }
102
 
103
  function adjustTextareaHeight() {
104
+ if (!textareaElement) {
105
+ return;
106
+ }
107
+
108
  textareaElement.style.height = "auto";
109
  textareaElement.style.height = `${textareaElement.scrollHeight}px`;
110
 
 
130
 
131
  // tool section
132
 
133
+ let webSearchIsOn = $derived(
134
+ modelHasTools
135
+ ? ($settings.tools?.includes(webSearchToolId) ?? false) ||
136
+ ($settings.tools?.includes(fetchUrlToolId) ?? false)
137
+ : $webSearchParameters.useSearch
138
+ );
139
+ let imageGenIsOn = $derived($settings.tools?.includes(imageGenToolId) ?? false);
140
 
141
+ let documentParserIsOn = $derived(
142
+ modelHasTools && files.length > 0 && files.some((file) => file.type.startsWith("application/"))
143
+ );
144
 
145
+ let extraTools = $derived(
146
+ $page.data.tools
147
+ .filter((t: ToolFront) => $settings.tools?.includes(t._id))
148
+ .filter(
149
+ (t: ToolFront) =>
150
+ ![documentParserToolId, imageGenToolId, webSearchToolId, fetchUrlToolId].includes(t._id)
151
+ ) satisfies ToolFront[]
152
+ );
153
  </script>
154
 
155
+ <div class="flex min-h-full flex-1 flex-col" onpaste={onPaste}>
156
  <textarea
157
  rows="1"
158
  tabindex="0"
 
161
  class:text-gray-400={disabled}
162
  bind:value
163
  bind:this={textareaElement}
164
+ onkeydown={handleKeydown}
165
+ oncompositionstart={() => (isCompositionOn = true)}
166
+ oncompositionend={() => (isCompositionOn = false)}
167
+ oninput={adjustTextareaHeight}
168
+ onbeforeinput={(ev) => {
169
+ if ($page.data.loginRequired) {
170
+ ev.preventDefault();
171
+ $loginModalOpen = true;
172
+ }
173
+ }}
174
  {placeholder}
175
  {disabled}
176
+ ></textarea>
177
 
178
  {#if !assistant}
179
  <div
 
190
  class="base-tool"
191
  class:active-tool={webSearchIsOn}
192
  disabled={loading}
193
+ onclick={async (e) => {
194
+ e.preventDefault();
195
  if (modelHasTools) {
196
  if (webSearchIsOn) {
197
  await settings.instantSet({
 
227
  class="base-tool"
228
  class:active-tool={imageGenIsOn}
229
  disabled={loading}
230
+ onclick={async (e) => {
231
+ e.preventDefault();
232
  if (modelHasTools) {
233
  if (imageGenIsOn) {
234
  await settings.instantSet({
 
260
  return m.split("/")[1];
261
  })
262
  .join(", ")}
263
+ <div class="flex items-center">
264
  <HoverTooltip
265
  label={mimeTypesString.includes("*")
266
  ? "Upload any file"
 
274
  class="absolute hidden size-0"
275
  aria-label="Upload file"
276
  type="file"
277
+ onchange={onFileChange}
278
  accept={mimeTypes.join(",")}
279
  />
280
  <IconPaperclip classNames="text-xl" />
 
283
  {/if}
284
  </label>
285
  </HoverTooltip>
286
+ </div>
287
  {#if mimeTypes.includes("image/*")}
288
  <HoverTooltip
289
  label="Capture screenshot"
 
292
  >
293
  <button
294
  class="base-tool"
295
+ onclick={async (e) => {
296
+ e.preventDefault();
297
  const screenshot = await captureScreen();
298
 
299
  // Convert base64 to blob
 
316
  <button
317
  class="active-tool base-tool"
318
  disabled={loading}
319
+ onclick={async (e) => {
320
+ e.preventDefault();
321
  goto(`${base}/tools/${tool._id}`);
322
  }}
323
  >
324
+ {#key tool.icon + tool.color}
325
+ <ToolLogo icon={tool.icon} color={tool.color} size="xs" />
326
+ {/key}
327
  {tool.displayName}
328
  </button>
329
  {/each}
 
345
  {/if}
346
  </div>
347
  {/if}
348
+ {@render children?.()}
349
  </div>
350
 
351
  <style lang="postcss">
352
+ :global(pre),
353
+ :global(textarea) {
354
  font-family: inherit;
355
  box-sizing: border-box;
356
  line-height: 1.5;
357
  }
 
 
 
 
 
 
 
 
358
  </style>
src/lib/components/chat/ChatIntroduction.svelte CHANGED
@@ -9,7 +9,11 @@
9
  import { base } from "$app/paths";
10
  import JSON5 from "json5";
11
 
12
- export let currentModel: Model;
 
 
 
 
13
 
14
  const announcementBanners = envPublic.PUBLIC_ANNOUNCEMENT_BANNERS
15
  ? JSON5.parse(envPublic.PUBLIC_ANNOUNCEMENT_BANNERS)
@@ -58,7 +62,9 @@
58
  alt=""
59
  />
60
  {:else}
61
- <div class="size-4 rounded border border-transparent bg-gray-300 dark:bg-gray-800" />
 
 
62
  {/if}
63
  {currentModel.displayName}
64
  </div>
@@ -81,12 +87,12 @@
81
  <button
82
  type="button"
83
  class="rounded-xl border bg-gray-50 p-3 text-gray-600 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 max-xl:text-sm xl:p-3.5"
84
- on:click={() => dispatch("message", example.prompt)}
85
  >
86
  {example.title}
87
  </button>
88
  {/each}
89
  </div>
90
  </div>{/if}
91
- <div class="h-40 sm:h-24" />
92
  </div>
 
9
  import { base } from "$app/paths";
10
  import JSON5 from "json5";
11
 
12
+ interface Props {
13
+ currentModel: Model;
14
+ }
15
+
16
+ let { currentModel }: Props = $props();
17
 
18
  const announcementBanners = envPublic.PUBLIC_ANNOUNCEMENT_BANNERS
19
  ? JSON5.parse(envPublic.PUBLIC_ANNOUNCEMENT_BANNERS)
 
62
  alt=""
63
  />
64
  {:else}
65
+ <div
66
+ class="size-4 rounded border border-transparent bg-gray-300 dark:bg-gray-800"
67
+ ></div>
68
  {/if}
69
  {currentModel.displayName}
70
  </div>
 
87
  <button
88
  type="button"
89
  class="rounded-xl border bg-gray-50 p-3 text-gray-600 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 max-xl:text-sm xl:p-3.5"
90
+ onclick={() => dispatch("message", example.prompt)}
91
  >
92
  {example.title}
93
  </button>
94
  {/each}
95
  </div>
96
  </div>{/if}
97
+ <div class="h-40 sm:h-24"></div>
98
  </div>
src/lib/components/chat/ChatMessage.svelte CHANGED
@@ -1,7 +1,8 @@
1
  <script lang="ts">
 
 
2
  import type { Message } from "$lib/types/Message";
3
- import { afterUpdate, createEventDispatcher, tick } from "svelte";
4
- import { deepestChild } from "$lib/utils/deepestChild";
5
  import { page } from "$app/stores";
6
 
7
  import CopyToClipBoardBtn from "../CopyToClipBoardBtn.svelte";
@@ -25,117 +26,106 @@
25
  } from "$lib/types/MessageUpdate";
26
  import { base } from "$app/paths";
27
  import ToolUpdate from "./ToolUpdate.svelte";
28
- import { useSettingsStore } from "$lib/stores/settings";
29
  import MarkdownRenderer from "./MarkdownRenderer.svelte";
30
  import OpenReasoningResults from "./OpenReasoningResults.svelte";
31
  import Alternatives from "./Alternatives.svelte";
32
  import Vote from "./Vote.svelte";
33
 
34
- export let message: Message;
35
- export let loading = false;
36
- export let isAuthor = true;
37
- export let readOnly = false;
38
- export let isTapped = false;
39
- export let alternatives: Message["id"][] = [];
40
- export let editMsdgId: Message["id"] | null = null;
41
- export let isLast = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  const dispatch = createEventDispatcher<{
44
  retry: { content?: string; id: Message["id"] };
45
  }>();
46
 
47
- let contentEl: HTMLElement;
48
- let loadingEl: IconLoading;
49
- let pendingTimeout: ReturnType<typeof setTimeout>;
50
- let isCopied = false;
51
-
52
- $: emptyLoad =
53
- !message.content && (webSearchIsDone || (searchUpdates && searchUpdates.length === 0));
54
-
55
- const settings = useSettingsStore();
56
-
57
- afterUpdate(() => {
58
- if ($settings.disableStream) {
59
- return;
60
- }
61
-
62
- loadingEl?.$destroy();
63
- clearTimeout(pendingTimeout);
64
-
65
- // Add loading animation to the last message if update takes more than 600ms
66
- if (isLast && loading && emptyLoad) {
67
- pendingTimeout = setTimeout(() => {
68
- if (contentEl) {
69
- loadingEl = new IconLoading({
70
- target: deepestChild(contentEl),
71
- props: { classNames: "loading inline ml-2 first:ml-0" },
72
- });
73
- }
74
- }, 600);
75
- }
76
- });
77
 
78
  function handleKeyDown(e: KeyboardEvent) {
79
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
80
- editFormEl.requestSubmit();
81
  }
82
  }
83
 
84
- $: searchUpdates = (message.updates?.filter(({ type }) => type === MessageUpdateType.WebSearch) ??
85
- []) as MessageWebSearchUpdate[];
86
 
87
- $: reasoningUpdates = (message.updates?.filter(
88
- ({ type }) => type === MessageUpdateType.Reasoning
89
- ) ?? []) as MessageReasoningUpdate[];
 
90
 
91
- $: messageFinalAnswer = message.updates?.find(
92
- ({ type }) => type === MessageUpdateType.FinalAnswer
93
- ) as MessageFinalAnswerUpdate;
 
94
 
 
 
 
 
 
95
  // filter all updates with type === "tool" then group them by uuid field
96
 
97
- $: toolUpdates = message.updates
98
- ?.filter(({ type }) => type === "tool")
99
- .reduce((acc, update) => {
100
- if (update.type !== "tool") {
101
- return acc;
102
- }
103
- acc[update.uuid] = acc[update.uuid] ?? [];
104
- acc[update.uuid].push(update);
105
- return acc;
106
- }, {} as Record<string, MessageToolUpdate[]>);
107
-
108
- $: urlNotTrailing = $page.url.pathname.replace(/\/$/, "");
109
- $: downloadLink = urlNotTrailing + `/message/${message.id}/prompt`;
110
-
111
- let webSearchIsDone = true;
112
-
113
- $: webSearchIsDone = searchUpdates.some(
114
- (update) => update.subtype === MessageWebSearchUpdateType.Finished
115
  );
116
-
117
- $: webSearchSources = searchUpdates?.find(
118
- (update): update is MessageWebSearchSourcesUpdate =>
119
- update.subtype === MessageWebSearchUpdateType.Sources
120
- )?.sources;
121
-
122
- $: if (isCopied) {
123
- setTimeout(() => {
124
- isCopied = false;
125
- }, 1000);
126
- }
127
-
128
- $: editMode = editMsdgId === message.id;
129
- let editContentEl: HTMLTextAreaElement;
130
- let editFormEl: HTMLFormElement;
131
-
132
- $: if (editMode) {
133
- tick();
134
- if (editContentEl) {
135
- editContentEl.value = message.content;
136
- editContentEl?.focus();
137
  }
138
- }
 
 
 
 
 
 
 
 
 
 
139
  </script>
140
 
141
  {#if message.from === "assistant"}
@@ -144,8 +134,8 @@
144
  data-message-id={message.id}
145
  data-message-role="assistant"
146
  role="presentation"
147
- on:click={() => (isTapped = !isTapped)}
148
- on:keydown={() => (isTapped = !isTapped)}
149
  >
150
  {#if $page.data?.assistant?.avatar}
151
  <img
@@ -199,7 +189,7 @@
199
  bind:this={contentEl}
200
  class:mt-2={reasoningUpdates.length > 0 || searchUpdates.length > 0}
201
  >
202
- {#if isLast && loading && $settings.disableStream}
203
  <IconLoading classNames="loading inline ml-2 first:ml-0" />
204
  {/if}
205
 
@@ -269,14 +259,14 @@
269
  class="btn rounded-sm p-1 text-sm text-gray-400 hover:text-gray-500 focus:ring-0 dark:text-gray-400 dark:hover:text-gray-300"
270
  title="Retry"
271
  type="button"
272
- on:click={() => {
273
  dispatch("retry", { id: message.id });
274
  }}
275
  >
276
  <CarbonRotate360 />
277
  </button>
278
  <CopyToClipBoardBtn
279
- on:click={() => {
280
  isCopied = true;
281
  }}
282
  classNames="btn rounded-sm p-1 text-sm text-gray-400 hover:text-gray-500 focus:ring-0 dark:text-gray-400 dark:hover:text-gray-300"
@@ -295,8 +285,8 @@
295
  data-message-id={message.id}
296
  data-message-type="user"
297
  role="presentation"
298
- on:click={() => (isTapped = !isTapped)}
299
- on:keydown={() => (isTapped = !isTapped)}
300
  >
301
  <div class="flex w-full flex-col gap-2">
302
  {#if message.files?.length}
@@ -318,8 +308,9 @@
318
  <form
319
  class="flex w-full flex-col"
320
  bind:this={editFormEl}
321
- on:submit|preventDefault={() => {
322
- dispatch("retry", { content: editContentEl.value, id: message.id });
 
323
  editMsdgId = null;
324
  }}
325
  >
@@ -328,9 +319,9 @@
328
  rows="5"
329
  bind:this={editContentEl}
330
  value={message.content.trim()}
331
- on:keydown={handleKeyDown}
332
  required
333
- />
334
  <div class="flex w-full flex-row flex-nowrap items-center justify-center gap-2 pt-2">
335
  <button
336
  type="submit"
@@ -346,7 +337,7 @@
346
  <button
347
  type="button"
348
  class="btn rounded-sm p-2 text-sm text-gray-400 hover:text-gray-500 focus:ring-0 dark:text-gray-400 dark:hover:text-gray-300"
349
- on:click={() => {
350
  editMsdgId = null;
351
  }}
352
  >
@@ -379,7 +370,7 @@
379
  class="cursor-pointer rounded-lg border border-gray-100 bg-gray-100 p-1 text-xs text-gray-400 group-hover:block hover:text-gray-500 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400 dark:hover:text-gray-300 md:hidden lg:-right-2"
380
  title="Branch"
381
  type="button"
382
- on:click={() => (editMsdgId = message.id)}
383
  >
384
  <CarbonPen />
385
  </button>
 
1
  <script lang="ts">
2
+ import { run } from "svelte/legacy";
3
+
4
  import type { Message } from "$lib/types/Message";
5
+ import { createEventDispatcher, tick } from "svelte";
 
6
  import { page } from "$app/stores";
7
 
8
  import CopyToClipBoardBtn from "../CopyToClipBoardBtn.svelte";
 
26
  } from "$lib/types/MessageUpdate";
27
  import { base } from "$app/paths";
28
  import ToolUpdate from "./ToolUpdate.svelte";
 
29
  import MarkdownRenderer from "./MarkdownRenderer.svelte";
30
  import OpenReasoningResults from "./OpenReasoningResults.svelte";
31
  import Alternatives from "./Alternatives.svelte";
32
  import Vote from "./Vote.svelte";
33
 
34
+ interface Props {
35
+ message: Message;
36
+ loading?: boolean;
37
+ isAuthor?: boolean;
38
+ readOnly?: boolean;
39
+ isTapped?: boolean;
40
+ alternatives?: Message["id"][];
41
+ editMsdgId?: Message["id"] | null;
42
+ isLast?: boolean;
43
+ }
44
+
45
+ let {
46
+ message,
47
+ loading = false,
48
+ isAuthor = true,
49
+ readOnly = false,
50
+ isTapped = $bindable(false),
51
+ alternatives = [],
52
+ editMsdgId = $bindable(null),
53
+ isLast = false,
54
+ }: Props = $props();
55
 
56
  const dispatch = createEventDispatcher<{
57
  retry: { content?: string; id: Message["id"] };
58
  }>();
59
 
60
+ let contentEl: HTMLElement | undefined = $state();
61
+ let isCopied = $state(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  function handleKeyDown(e: KeyboardEvent) {
64
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
65
+ editFormEl?.requestSubmit();
66
  }
67
  }
68
 
69
+ let editContentEl: HTMLTextAreaElement | undefined = $state();
70
+ let editFormEl: HTMLFormElement | undefined = $state();
71
 
72
+ let searchUpdates = $derived(
73
+ (message.updates?.filter(({ type }) => type === MessageUpdateType.WebSearch) ??
74
+ []) as MessageWebSearchUpdate[]
75
+ );
76
 
77
+ let reasoningUpdates = $derived(
78
+ (message.updates?.filter(({ type }) => type === MessageUpdateType.Reasoning) ??
79
+ []) as MessageReasoningUpdate[]
80
+ );
81
 
82
+ let messageFinalAnswer = $derived(
83
+ message.updates?.find(
84
+ ({ type }) => type === MessageUpdateType.FinalAnswer
85
+ ) as MessageFinalAnswerUpdate
86
+ );
87
  // filter all updates with type === "tool" then group them by uuid field
88
 
89
+ let toolUpdates = $derived(
90
+ message.updates
91
+ ?.filter(({ type }) => type === "tool")
92
+ .reduce(
93
+ (acc, update) => {
94
+ if (update.type !== "tool") {
95
+ return acc;
96
+ }
97
+ acc[update.uuid] = acc[update.uuid] ?? [];
98
+ acc[update.uuid].push(update);
99
+ return acc;
100
+ },
101
+ {} as Record<string, MessageToolUpdate[]>
102
+ )
 
 
 
 
103
  );
104
+ let urlNotTrailing = $derived($page.url.pathname.replace(/\/$/, ""));
105
+ let downloadLink = $derived(urlNotTrailing + `/message/${message.id}/prompt`);
106
+ let webSearchSources = $derived(
107
+ searchUpdates?.find(
108
+ (update): update is MessageWebSearchSourcesUpdate =>
109
+ update.subtype === MessageWebSearchUpdateType.Sources
110
+ )?.sources
111
+ );
112
+ run(() => {
113
+ if (isCopied) {
114
+ setTimeout(() => {
115
+ isCopied = false;
116
+ }, 1000);
 
 
 
 
 
 
 
 
117
  }
118
+ });
119
+ let editMode = $derived(editMsdgId === message.id);
120
+ run(() => {
121
+ if (editMode) {
122
+ tick();
123
+ if (editContentEl) {
124
+ editContentEl.value = message.content;
125
+ editContentEl?.focus();
126
+ }
127
+ }
128
+ });
129
  </script>
130
 
131
  {#if message.from === "assistant"}
 
134
  data-message-id={message.id}
135
  data-message-role="assistant"
136
  role="presentation"
137
+ onclick={() => (isTapped = !isTapped)}
138
+ onkeydown={() => (isTapped = !isTapped)}
139
  >
140
  {#if $page.data?.assistant?.avatar}
141
  <img
 
189
  bind:this={contentEl}
190
  class:mt-2={reasoningUpdates.length > 0 || searchUpdates.length > 0}
191
  >
192
+ {#if isLast && loading && message.content.length === 0}
193
  <IconLoading classNames="loading inline ml-2 first:ml-0" />
194
  {/if}
195
 
 
259
  class="btn rounded-sm p-1 text-sm text-gray-400 hover:text-gray-500 focus:ring-0 dark:text-gray-400 dark:hover:text-gray-300"
260
  title="Retry"
261
  type="button"
262
+ onclick={() => {
263
  dispatch("retry", { id: message.id });
264
  }}
265
  >
266
  <CarbonRotate360 />
267
  </button>
268
  <CopyToClipBoardBtn
269
+ onClick={() => {
270
  isCopied = true;
271
  }}
272
  classNames="btn rounded-sm p-1 text-sm text-gray-400 hover:text-gray-500 focus:ring-0 dark:text-gray-400 dark:hover:text-gray-300"
 
285
  data-message-id={message.id}
286
  data-message-type="user"
287
  role="presentation"
288
+ onclick={() => (isTapped = !isTapped)}
289
+ onkeydown={() => (isTapped = !isTapped)}
290
  >
291
  <div class="flex w-full flex-col gap-2">
292
  {#if message.files?.length}
 
308
  <form
309
  class="flex w-full flex-col"
310
  bind:this={editFormEl}
311
+ onsubmit={(e) => {
312
+ e.preventDefault();
313
+ dispatch("retry", { content: editContentEl?.value, id: message.id });
314
  editMsdgId = null;
315
  }}
316
  >
 
319
  rows="5"
320
  bind:this={editContentEl}
321
  value={message.content.trim()}
322
+ onkeydown={handleKeyDown}
323
  required
324
+ ></textarea>
325
  <div class="flex w-full flex-row flex-nowrap items-center justify-center gap-2 pt-2">
326
  <button
327
  type="submit"
 
337
  <button
338
  type="button"
339
  class="btn rounded-sm p-2 text-sm text-gray-400 hover:text-gray-500 focus:ring-0 dark:text-gray-400 dark:hover:text-gray-300"
340
+ onclick={() => {
341
  editMsdgId = null;
342
  }}
343
  >
 
370
  class="cursor-pointer rounded-lg border border-gray-100 bg-gray-100 p-1 text-xs text-gray-400 group-hover:block hover:text-gray-500 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400 dark:hover:text-gray-300 md:hidden lg:-right-2"
371
  title="Branch"
372
  type="button"
373
+ onclick={() => (editMsdgId = message.id)}
374
  >
375
  <CarbonPen />
376
  </button>
src/lib/components/chat/ChatWindow.svelte CHANGED
@@ -1,4 +1,7 @@
1
  <script lang="ts">
 
 
 
2
  import type { Message, MessageFile } from "$lib/types/Message";
3
  import { createEventDispatcher, onDestroy, tick } from "svelte";
4
 
@@ -35,26 +38,43 @@
35
  import type { ToolFront } from "$lib/types/Tool";
36
  import { loginModalOpen } from "$lib/stores/loginModal";
37
 
38
- export let messages: Message[] = [];
39
- export let messagesAlternatives: Message["id"][][] = [];
40
- export let loading = false;
41
- export let pending = false;
42
-
43
- export let shared = false;
44
- export let currentModel: Model;
45
- export let models: Model[];
46
- export let assistant: Assistant | undefined = undefined;
47
- export let preprompt: string | undefined = undefined;
48
- export let files: File[] = [];
49
-
50
- $: isReadOnly = !models.some((model) => model.id === currentModel.id);
51
 
52
- let message: string;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  let timeout: ReturnType<typeof setTimeout>;
54
- let isSharedRecently = false;
55
- let editMsdgId: Message["id"] | null = null;
56
- $: pastedLongContent = false;
57
- $: $page.params.id && (isSharedRecently = false);
 
 
 
58
 
59
  const dispatch = createEventDispatcher<{
60
  message: string;
@@ -72,7 +92,7 @@
72
 
73
  let lastTarget: EventTarget | null = null;
74
 
75
- let onDrag = false;
76
 
77
  const onDragEnter = (e: DragEvent) => {
78
  lastTarget = e.target;
@@ -122,15 +142,23 @@
122
  }
123
  };
124
 
125
- $: lastMessage = browser && (messages.at(-1) as Message);
126
- $: lastIsError =
127
  lastMessage &&
128
- !loading &&
129
- (lastMessage.from === "user" ||
130
- lastMessage.updates?.findIndex((u) => u.type === "status" && u.status === "error") !== -1);
 
131
 
132
- $: sources = files?.map<Promise<MessageFile>>((file) =>
133
- file2base64(file).then((value) => ({ type: "base64", value, mime: file.type, name: file.name }))
 
 
 
 
 
 
 
134
  );
135
 
136
  function onShare() {
@@ -154,47 +182,62 @@
154
  }
155
  });
156
 
157
- let chatContainer: HTMLElement;
158
 
159
  async function scrollToBottom() {
160
  await tick();
 
161
  chatContainer.scrollTop = chatContainer.scrollHeight;
162
  }
163
 
164
  // If last message is from user, scroll to bottom
165
- $: if (lastMessage && lastMessage.from === "user") {
166
- scrollToBottom();
167
- }
 
 
168
 
169
  const settings = useSettingsStore();
170
 
171
- $: mimeTypesFromActiveTools = $page.data.tools
172
- .filter((tool: ToolFront) => {
173
- if (assistant) {
174
- return assistant.tools?.includes(tool._id);
175
- }
176
- if (currentModel.tools) {
177
- return $settings?.tools?.includes(tool._id) ?? tool.isOnByDefault;
178
- }
179
- return false;
180
- })
181
- .flatMap((tool: ToolFront) => tool.mimeTypes ?? []);
182
-
183
- $: activeMimeTypes = Array.from(
184
- new Set([
185
- ...mimeTypesFromActiveTools, // fetch mime types from active tools either from tool settings or active assistant
186
- ...(currentModel.tools && !assistant ? ["application/pdf"] : []), // if its a tool model, we can always enable document parser so we always accept pdfs
187
- ...(currentModel.multimodal ? currentModel.multimodalAcceptedMimetypes ?? ["image/*"] : []), // if its a multimodal model, we always accept images
188
- ])
 
 
 
 
 
 
189
  );
190
- $: isFileUploadEnabled = activeMimeTypes.length > 0;
191
  </script>
192
 
193
  <svelte:window
194
- on:dragenter={onDragEnter}
195
- on:dragleave={onDragLeave}
196
- on:dragover|preventDefault
197
- on:drop|preventDefault={() => (onDrag = false)}
 
 
 
 
 
 
198
  />
199
 
200
  <div class="relative min-h-0 min-w-0">
@@ -326,11 +369,11 @@
326
  <div class="w-full">
327
  <div class="flex w-full *:mb-3">
328
  {#if loading}
329
- <StopGeneratingBtn classNames="ml-auto" on:click={() => dispatch("stop")} />
330
  {:else if lastIsError}
331
  <RetryBtn
332
  classNames="ml-auto"
333
- on:click={() => {
334
  if (lastMessage && lastMessage.ancestors) {
335
  dispatch("retry", {
336
  id: lastMessage.id,
@@ -341,7 +384,7 @@
341
  {:else if messages && lastMessage && lastMessage.interrupted && !isReadOnly}
342
  <div class="ml-auto gap-2">
343
  <ContinueBtn
344
- on:click={() => {
345
  if (lastMessage && lastMessage.ancestors) {
346
  dispatch("continue", {
347
  id: lastMessage?.id,
@@ -355,7 +398,10 @@
355
  <form
356
  tabindex="-1"
357
  aria-label={isFileUploadEnabled ? "file dropzone" : undefined}
358
- on:submit|preventDefault={handleSubmit}
 
 
 
359
  class="relative flex w-full max-w-4xl flex-1 items-center rounded-xl border bg-gray-100 dark:border-gray-600 dark:bg-gray-700
360
  {isReadOnly ? 'opacity-30' : ''}"
361
  >
@@ -377,13 +423,7 @@
377
  bind:files
378
  mimeTypes={activeMimeTypes}
379
  on:submit={handleSubmit}
380
- on:beforeinput={(ev) => {
381
- if ($page.data.loginRequired) {
382
- ev.preventDefault();
383
- $loginModalOpen = true;
384
- }
385
- }}
386
- on:paste={onPaste}
387
  disabled={isReadOnly || lastIsError}
388
  modelHasTools={currentModel.tools}
389
  modelIsMultimodal={currentModel.multimodal}
@@ -463,7 +503,7 @@
463
  class="flex flex-none items-center hover:text-gray-400 max-sm:rounded-lg max-sm:bg-gray-50 max-sm:px-2.5 dark:max-sm:bg-gray-800"
464
  type="button"
465
  class:hover:underline={!isSharedRecently}
466
- on:click={onShare}
467
  disabled={isSharedRecently}
468
  >
469
  {#if isSharedRecently}
 
1
  <script lang="ts">
2
+ import { run, createBubbler } from "svelte/legacy";
3
+
4
+ const bubble = createBubbler();
5
  import type { Message, MessageFile } from "$lib/types/Message";
6
  import { createEventDispatcher, onDestroy, tick } from "svelte";
7
 
 
38
  import type { ToolFront } from "$lib/types/Tool";
39
  import { loginModalOpen } from "$lib/stores/loginModal";
40
 
41
+ interface Props {
42
+ messages?: Message[];
43
+ messagesAlternatives?: Message["id"][][];
44
+ loading?: boolean;
45
+ pending?: boolean;
46
+ shared?: boolean;
47
+ currentModel: Model;
48
+ models: Model[];
49
+ assistant?: Assistant | undefined;
50
+ preprompt?: string | undefined;
51
+ files?: File[];
52
+ }
 
53
 
54
+ let {
55
+ messages = [],
56
+ messagesAlternatives = [],
57
+ loading = false,
58
+ pending = false,
59
+ shared = false,
60
+ currentModel,
61
+ models,
62
+ assistant = undefined,
63
+ preprompt = undefined,
64
+ files = $bindable([]),
65
+ }: Props = $props();
66
+
67
+ let isReadOnly = $derived(!models.some((model) => model.id === currentModel.id));
68
+
69
+ let message: string = $state("");
70
  let timeout: ReturnType<typeof setTimeout>;
71
+ let isSharedRecently = $state(false);
72
+ let editMsdgId: Message["id"] | null = $state(null);
73
+ let pastedLongContent = $state(false);
74
+
75
+ run(() => {
76
+ $page.params.id && (isSharedRecently = false);
77
+ });
78
 
79
  const dispatch = createEventDispatcher<{
80
  message: string;
 
92
 
93
  let lastTarget: EventTarget | null = null;
94
 
95
+ let onDrag = $state(false);
96
 
97
  const onDragEnter = (e: DragEvent) => {
98
  lastTarget = e.target;
 
142
  }
143
  };
144
 
145
+ let lastMessage = $derived(browser && (messages.at(-1) as Message));
146
+ let lastIsError = $derived(
147
  lastMessage &&
148
+ !loading &&
149
+ (lastMessage.from === "user" ||
150
+ lastMessage.updates?.findIndex((u) => u.type === "status" && u.status === "error") !== -1)
151
+ );
152
 
153
+ let sources = $derived(
154
+ files?.map<Promise<MessageFile>>((file) =>
155
+ file2base64(file).then((value) => ({
156
+ type: "base64",
157
+ value,
158
+ mime: file.type,
159
+ name: file.name,
160
+ }))
161
+ )
162
  );
163
 
164
  function onShare() {
 
182
  }
183
  });
184
 
185
+ let chatContainer: HTMLElement | undefined = $state();
186
 
187
  async function scrollToBottom() {
188
  await tick();
189
+ if (!chatContainer) return;
190
  chatContainer.scrollTop = chatContainer.scrollHeight;
191
  }
192
 
193
  // If last message is from user, scroll to bottom
194
+ run(() => {
195
+ if (lastMessage && lastMessage.from === "user") {
196
+ scrollToBottom();
197
+ }
198
+ });
199
 
200
  const settings = useSettingsStore();
201
 
202
+ let mimeTypesFromActiveTools = $derived(
203
+ $page.data.tools
204
+ .filter((tool: ToolFront) => {
205
+ if (assistant) {
206
+ return assistant.tools?.includes(tool._id);
207
+ }
208
+ if (currentModel.tools) {
209
+ return $settings?.tools?.includes(tool._id) ?? tool.isOnByDefault;
210
+ }
211
+ return false;
212
+ })
213
+ .flatMap((tool: ToolFront) => tool.mimeTypes ?? [])
214
+ );
215
+
216
+ let activeMimeTypes = $derived(
217
+ Array.from(
218
+ new Set([
219
+ ...mimeTypesFromActiveTools, // fetch mime types from active tools either from tool settings or active assistant
220
+ ...(currentModel.tools && !assistant ? ["application/pdf"] : []), // if its a tool model, we can always enable document parser so we always accept pdfs
221
+ ...(currentModel.multimodal
222
+ ? (currentModel.multimodalAcceptedMimetypes ?? ["image/*"])
223
+ : []), // if its a multimodal model, we always accept images
224
+ ])
225
+ )
226
  );
227
+ let isFileUploadEnabled = $derived(activeMimeTypes.length > 0);
228
  </script>
229
 
230
  <svelte:window
231
+ ondragenter={onDragEnter}
232
+ ondragleave={onDragLeave}
233
+ ondragover={(e) => {
234
+ e.preventDefault();
235
+ bubble("dragover");
236
+ }}
237
+ ondrop={(e) => {
238
+ e.preventDefault();
239
+ onDrag = false;
240
+ }}
241
  />
242
 
243
  <div class="relative min-h-0 min-w-0">
 
369
  <div class="w-full">
370
  <div class="flex w-full *:mb-3">
371
  {#if loading}
372
+ <StopGeneratingBtn classNames="ml-auto" onClick={() => dispatch("stop")} />
373
  {:else if lastIsError}
374
  <RetryBtn
375
  classNames="ml-auto"
376
+ onClick={() => {
377
  if (lastMessage && lastMessage.ancestors) {
378
  dispatch("retry", {
379
  id: lastMessage.id,
 
384
  {:else if messages && lastMessage && lastMessage.interrupted && !isReadOnly}
385
  <div class="ml-auto gap-2">
386
  <ContinueBtn
387
+ onClick={() => {
388
  if (lastMessage && lastMessage.ancestors) {
389
  dispatch("continue", {
390
  id: lastMessage?.id,
 
398
  <form
399
  tabindex="-1"
400
  aria-label={isFileUploadEnabled ? "file dropzone" : undefined}
401
+ onsubmit={(e) => {
402
+ e.preventDefault();
403
+ handleSubmit();
404
+ }}
405
  class="relative flex w-full max-w-4xl flex-1 items-center rounded-xl border bg-gray-100 dark:border-gray-600 dark:bg-gray-700
406
  {isReadOnly ? 'opacity-30' : ''}"
407
  >
 
423
  bind:files
424
  mimeTypes={activeMimeTypes}
425
  on:submit={handleSubmit}
426
+ {onPaste}
 
 
 
 
 
 
427
  disabled={isReadOnly || lastIsError}
428
  modelHasTools={currentModel.tools}
429
  modelIsMultimodal={currentModel.multimodal}
 
503
  class="flex flex-none items-center hover:text-gray-400 max-sm:rounded-lg max-sm:bg-gray-50 max-sm:px-2.5 dark:max-sm:bg-gray-800"
504
  type="button"
505
  class:hover:underline={!isSharedRecently}
506
+ onclick={onShare}
507
  disabled={isSharedRecently}
508
  >
509
  {#if isSharedRecently}
src/lib/components/chat/FileDropzone.svelte CHANGED
@@ -1,14 +1,25 @@
1
  <script lang="ts">
 
 
 
2
  import { useSettingsStore } from "$lib/stores/settings";
3
  import { documentParserToolId } from "$lib/utils/toolIds";
4
  import CarbonImage from "~icons/carbon/image";
5
- // import EosIconsLoading from "~icons/eos-icons/loading";
6
 
7
- export let files: File[];
8
- export let mimeTypes: string[] = [];
 
 
 
 
 
9
 
10
- export let onDrag = false;
11
- export let onDragInner = false;
 
 
 
 
12
 
13
  const settings = useSettingsStore();
14
 
@@ -74,10 +85,13 @@
74
  <div
75
  id="dropzone"
76
  role="form"
77
- on:drop={dropHandle}
78
- on:dragenter={() => (onDragInner = true)}
79
- on:dragleave={() => (onDragInner = false)}
80
- on:dragover|preventDefault
 
 
 
81
  class="relative flex h-28 w-full max-w-4xl flex-col items-center justify-center gap-1 rounded-xl border-2 border-dotted {onDragInner
82
  ? 'border-blue-200 !bg-blue-500/10 text-blue-600 *:pointer-events-none dark:border-blue-600 dark:bg-blue-500/20 dark:text-blue-500'
83
  : 'bg-gray-100 text-gray-500 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-400'}"
 
1
  <script lang="ts">
2
+ import { createBubbler } from "svelte/legacy";
3
+
4
+ const bubble = createBubbler();
5
  import { useSettingsStore } from "$lib/stores/settings";
6
  import { documentParserToolId } from "$lib/utils/toolIds";
7
  import CarbonImage from "~icons/carbon/image";
 
8
 
9
+ interface Props {
10
+ // import EosIconsLoading from "~icons/eos-icons/loading";
11
+ files: File[];
12
+ mimeTypes?: string[];
13
+ onDrag?: boolean;
14
+ onDragInner?: boolean;
15
+ }
16
 
17
+ let {
18
+ files = $bindable(),
19
+ mimeTypes = [],
20
+ onDrag = $bindable(false),
21
+ onDragInner = $bindable(false),
22
+ }: Props = $props();
23
 
24
  const settings = useSettingsStore();
25
 
 
85
  <div
86
  id="dropzone"
87
  role="form"
88
+ ondrop={dropHandle}
89
+ ondragenter={() => (onDragInner = true)}
90
+ ondragleave={() => (onDragInner = false)}
91
+ ondragover={(e) => {
92
+ e.preventDefault();
93
+ bubble("dragover");
94
+ }}
95
  class="relative flex h-28 w-full max-w-4xl flex-col items-center justify-center gap-1 rounded-xl border-2 border-dotted {onDragInner
96
  ? 'border-blue-200 !bg-blue-500/10 text-blue-600 *:pointer-events-none dark:border-blue-600 dark:bg-blue-500/20 dark:text-blue-500'
97
  : 'bg-gray-100 text-gray-500 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-400'}"
src/lib/components/chat/MarkdownRenderer.svelte CHANGED
@@ -7,8 +7,12 @@
7
  import type { Tokens, TokenizerExtension, RendererExtension } from "marked";
8
  import CodeBlock from "../CodeBlock.svelte";
9
 
10
- export let content: string;
11
- export let sources: WebSearchSource[] = [];
 
 
 
 
12
 
13
  interface katexBlockToken extends Tokens.Generic {
14
  type: "katexBlock";
@@ -135,7 +139,7 @@
135
  "&": "&amp;",
136
  "'": "&#39;",
137
  '"': "&quot;",
138
- }[x] || x)
139
  );
140
  }
141
 
 
7
  import type { Tokens, TokenizerExtension, RendererExtension } from "marked";
8
  import CodeBlock from "../CodeBlock.svelte";
9
 
10
+ interface Props {
11
+ content: string;
12
+ sources?: WebSearchSource[];
13
+ }
14
+
15
+ let { content, sources = [] }: Props = $props();
16
 
17
  interface katexBlockToken extends Tokens.Generic {
18
  type: "katexBlock";
 
139
  "&": "&amp;",
140
  "'": "&#39;",
141
  '"': "&quot;",
142
+ })[x] || x
143
  );
144
  }
145
 
src/lib/components/chat/ModelSwitch.svelte CHANGED
@@ -4,12 +4,16 @@
4
  import { base } from "$app/paths";
5
  import type { Model } from "$lib/types/Model";
6
 
7
- export let models: Model[];
8
- export let currentModel: Model;
 
 
 
 
9
 
10
- let selectedModelId = models.map((m) => m.id).includes(currentModel.id)
11
- ? currentModel.id
12
- : models[0].id;
13
 
14
  async function handleModelChange() {
15
  if (!$page.params.id) return;
@@ -50,7 +54,7 @@
50
  {/each}
51
  </select>
52
  <button
53
- on:click={handleModelChange}
54
  disabled={selectedModelId === currentModel.id}
55
  class="rounded-md bg-gray-100 px-2 py-1 dark:bg-gray-900"
56
  >
 
4
  import { base } from "$app/paths";
5
  import type { Model } from "$lib/types/Model";
6
 
7
+ interface Props {
8
+ models: Model[];
9
+ currentModel: Model;
10
+ }
11
+
12
+ let { models, currentModel }: Props = $props();
13
 
14
+ let selectedModelId = $state(
15
+ models.map((m) => m.id).includes(currentModel.id) ? currentModel.id : models[0].id
16
+ );
17
 
18
  async function handleModelChange() {
19
  if (!$page.params.id) return;
 
54
  {/each}
55
  </select>
56
  <button
57
+ onclick={handleModelChange}
58
  disabled={selectedModelId === currentModel.id}
59
  class="rounded-md bg-gray-100 px-2 py-1 dark:bg-gray-900"
60
  >
src/lib/components/chat/OpenReasoningResults.svelte CHANGED
@@ -2,9 +2,13 @@
2
  import MarkdownRenderer from "./MarkdownRenderer.svelte";
3
  import CarbonCaretDown from "~icons/carbon/caret-down";
4
 
5
- export let summary: string;
6
- export let content: string;
7
- export let loading: boolean = false;
 
 
 
 
8
  </script>
9
 
10
  <details
 
2
  import MarkdownRenderer from "./MarkdownRenderer.svelte";
3
  import CarbonCaretDown from "~icons/carbon/caret-down";
4
 
5
+ interface Props {
6
+ summary: string;
7
+ content: string;
8
+ loading?: boolean;
9
+ }
10
+
11
+ let { summary, content, loading = false }: Props = $props();
12
  </script>
13
 
14
  <details
src/lib/components/chat/ToolUpdate.svelte CHANGED
@@ -12,35 +12,41 @@
12
  import { onDestroy } from "svelte";
13
  import { browser } from "$app/environment";
14
 
15
- export let tool: MessageToolUpdate[];
16
- export let loading: boolean = false;
 
 
 
 
17
 
18
  const toolFnName = tool.find(isMessageToolCallUpdate)?.call.name;
19
- $: toolError = tool.some(isMessageToolErrorUpdate);
20
- $: toolDone = tool.some(isMessageToolResultUpdate);
21
 
22
- $: eta = tool.find((el) => el.subtype === MessageToolUpdateType.ETA)?.eta;
23
 
24
  const availableTools: ToolFront[] = $page.data.tools;
25
 
26
- let loadingBarEl: HTMLDivElement;
27
- let animation: Animation | undefined = undefined;
28
-
29
- let isShowingLoadingBar = false;
30
-
31
- $: !toolError &&
32
- !toolDone &&
33
- loading &&
34
- loadingBarEl &&
35
- eta &&
36
- (() => {
37
- loadingBarEl.classList.remove("hidden");
38
- isShowingLoadingBar = true;
39
- animation = loadingBarEl.animate([{ width: "0%" }, { width: "calc(100%+1rem)" }], {
40
- duration: eta * 1000,
41
- fill: "forwards",
42
- });
43
- })();
 
 
44
 
45
  onDestroy(() => {
46
  if (animation) {
@@ -49,28 +55,30 @@
49
  });
50
 
51
  // go to 100% quickly if loading is done
52
- $: (!loading || toolDone || toolError) &&
53
- browser &&
54
- loadingBarEl &&
55
- isShowingLoadingBar &&
56
- (() => {
57
- isShowingLoadingBar = false;
58
-
59
- loadingBarEl.classList.remove("hidden");
60
-
61
- animation?.cancel();
62
- animation = loadingBarEl.animate(
63
- [{ width: loadingBarEl.style.width }, { width: "calc(100%+1rem)" }],
64
- {
65
- duration: 300,
66
- fill: "forwards",
67
- }
68
- );
69
-
70
- setTimeout(() => {
71
- loadingBarEl.classList.add("hidden");
72
- }, 300);
73
- })();
 
 
74
  </script>
75
 
76
  {#if toolFnName && toolFnName !== "websearch"}
@@ -84,7 +92,7 @@
84
  <div
85
  bind:this={loadingBarEl}
86
  class="absolute -m-1 hidden h-full w-[calc(100%+1rem)] rounded-lg bg-purple-500/5 transition-all dark:bg-purple-500/10"
87
- />
88
 
89
  <div
90
  class="relative grid size-[22px] place-items-center rounded bg-purple-600/10 dark:bg-purple-600/20"
@@ -122,7 +130,7 @@
122
  {#if toolUpdate.subtype === MessageToolUpdateType.Call}
123
  <div class="mt-1 flex items-center gap-2 opacity-80">
124
  <h3 class="text-sm">Parameters</h3>
125
- <div class="h-px flex-1 bg-gradient-to-r from-gray-500/20" />
126
  </div>
127
  <ul class="py-1 text-sm">
128
  {#each Object.entries(toolUpdate.call.parameters ?? {}) as [k, v]}
@@ -137,13 +145,13 @@
137
  {:else if toolUpdate.subtype === MessageToolUpdateType.Error}
138
  <div class="mt-1 flex items-center gap-2 opacity-80">
139
  <h3 class="text-sm">Error</h3>
140
- <div class="h-px flex-1 bg-gradient-to-r from-gray-500/20" />
141
  </div>
142
  <p class="text-sm">{toolUpdate.message}</p>
143
  {:else if isMessageToolResultUpdate(toolUpdate) && toolUpdate.result.status === ToolResultStatus.Success && toolUpdate.result.display}
144
  <div class="mt-1 flex items-center gap-2 opacity-80">
145
  <h3 class="text-sm">Result</h3>
146
- <div class="h-px flex-1 bg-gradient-to-r from-gray-500/20" />
147
  </div>
148
  <ul class="py-1 text-sm">
149
  {#each toolUpdate.result.outputs as output}
 
12
  import { onDestroy } from "svelte";
13
  import { browser } from "$app/environment";
14
 
15
+ interface Props {
16
+ tool: MessageToolUpdate[];
17
+ loading?: boolean;
18
+ }
19
+
20
+ let { tool, loading = false }: Props = $props();
21
 
22
  const toolFnName = tool.find(isMessageToolCallUpdate)?.call.name;
23
+ let toolError = $derived(tool.some(isMessageToolErrorUpdate));
24
+ let toolDone = $derived(tool.some(isMessageToolResultUpdate));
25
 
26
+ let eta = $derived(tool.find((el) => el.subtype === MessageToolUpdateType.ETA)?.eta);
27
 
28
  const availableTools: ToolFront[] = $page.data.tools;
29
 
30
+ let loadingBarEl: HTMLDivElement | undefined = $state();
31
+ let animation: Animation | undefined = $state(undefined);
32
+
33
+ let isShowingLoadingBar = $state(false);
34
+
35
+ $effect(() => {
36
+ !toolError &&
37
+ !toolDone &&
38
+ loading &&
39
+ loadingBarEl &&
40
+ eta &&
41
+ (() => {
42
+ loadingBarEl.classList.remove("hidden");
43
+ isShowingLoadingBar = true;
44
+ animation = loadingBarEl.animate([{ width: "0%" }, { width: "calc(100%+1rem)" }], {
45
+ duration: eta * 1000,
46
+ fill: "forwards",
47
+ });
48
+ })();
49
+ });
50
 
51
  onDestroy(() => {
52
  if (animation) {
 
55
  });
56
 
57
  // go to 100% quickly if loading is done
58
+ $effect(() => {
59
+ (!loading || toolDone || toolError) &&
60
+ browser &&
61
+ loadingBarEl &&
62
+ isShowingLoadingBar &&
63
+ (() => {
64
+ isShowingLoadingBar = false;
65
+
66
+ loadingBarEl.classList.remove("hidden");
67
+
68
+ animation?.cancel();
69
+ animation = loadingBarEl.animate(
70
+ [{ width: loadingBarEl.style.width }, { width: "calc(100%+1rem)" }],
71
+ {
72
+ duration: 300,
73
+ fill: "forwards",
74
+ }
75
+ );
76
+
77
+ setTimeout(() => {
78
+ loadingBarEl?.classList.add("hidden");
79
+ }, 300);
80
+ })();
81
+ });
82
  </script>
83
 
84
  {#if toolFnName && toolFnName !== "websearch"}
 
92
  <div
93
  bind:this={loadingBarEl}
94
  class="absolute -m-1 hidden h-full w-[calc(100%+1rem)] rounded-lg bg-purple-500/5 transition-all dark:bg-purple-500/10"
95
+ ></div>
96
 
97
  <div
98
  class="relative grid size-[22px] place-items-center rounded bg-purple-600/10 dark:bg-purple-600/20"
 
130
  {#if toolUpdate.subtype === MessageToolUpdateType.Call}
131
  <div class="mt-1 flex items-center gap-2 opacity-80">
132
  <h3 class="text-sm">Parameters</h3>
133
+ <div class="h-px flex-1 bg-gradient-to-r from-gray-500/20"></div>
134
  </div>
135
  <ul class="py-1 text-sm">
136
  {#each Object.entries(toolUpdate.call.parameters ?? {}) as [k, v]}
 
145
  {:else if toolUpdate.subtype === MessageToolUpdateType.Error}
146
  <div class="mt-1 flex items-center gap-2 opacity-80">
147
  <h3 class="text-sm">Error</h3>
148
+ <div class="h-px flex-1 bg-gradient-to-r from-gray-500/20"></div>
149
  </div>
150
  <p class="text-sm">{toolUpdate.message}</p>
151
  {:else if isMessageToolResultUpdate(toolUpdate) && toolUpdate.result.status === ToolResultStatus.Success && toolUpdate.result.display}
152
  <div class="mt-1 flex items-center gap-2 opacity-80">
153
  <h3 class="text-sm">Result</h3>
154
+ <div class="h-px flex-1 bg-gradient-to-r from-gray-500/20"></div>
155
  </div>
156
  <ul class="py-1 text-sm">
157
  {#each toolUpdate.result.outputs as output}
src/lib/components/chat/UploadedFile.svelte CHANGED
@@ -11,11 +11,16 @@
11
  import EosIconsLoading from "~icons/eos-icons/loading";
12
  import { base } from "$app/paths";
13
 
14
- export let file: MessageFile;
15
- export let canClose = true;
 
 
 
 
16
 
17
- $: showModal = false;
18
- $: urlNotTrailing = $page.url.pathname.replace(/\/$/, "");
 
19
 
20
  const dispatch = createEventDispatcher<{ close: void }>();
21
 
@@ -47,7 +52,7 @@
47
  mime === "application/xml" ||
48
  mime === "application/vnd.chatui.clipboard";
49
 
50
- $: isClickable = isImage(file.mime) || isPlainText(file.mime);
51
  </script>
52
 
53
  {#if showModal && isClickable}
@@ -80,7 +85,7 @@
80
  {/if}
81
  <button
82
  class="absolute right-4 top-4 text-xl text-gray-500 hover:text-gray-800"
83
- on:click={() => (showModal = false)}
84
  >
85
  <CarbonClose class="text-xl" />
86
  </button>
@@ -110,7 +115,20 @@
110
  </Modal>
111
  {/if}
112
 
113
- <button on:click={() => (showModal = true)} disabled={!isClickable} class:clickable={isClickable}>
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  <div class="group relative flex items-center rounded-xl shadow-sm">
115
  {#if isImage(file.mime)}
116
  <div class="size-48 overflow-hidden rounded-xl">
@@ -133,18 +151,18 @@
133
  <div
134
  class="border-1 w-72 overflow-clip rounded-xl border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
135
  >
136
- <!-- svelte-ignore a11y-media-has-caption -->
137
  <video
138
  src={file.type === "base64"
139
  ? `data:${file.mime};base64,${file.value}`
140
  : urlNotTrailing + "/output/" + file.value}
141
  controls
142
- />
143
  </div>
144
  {:else if isPlainText(file.mime)}
145
  <div
146
  class="flex h-14 w-72 items-center gap-2 overflow-hidden rounded-xl border border-gray-200 bg-white p-2 dark:border-gray-800 dark:bg-gray-900"
147
- class:hoverable={isClickable}
148
  >
149
  <div
150
  class="grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800"
@@ -165,7 +183,7 @@
165
  {:else if file.mime === "octet-stream"}
166
  <div
167
  class="flex h-14 w-72 items-center gap-2 overflow-hidden rounded-xl border border-gray-200 bg-white p-2 dark:border-gray-800 dark:bg-gray-900"
168
- class:hoverable={isClickable}
169
  >
170
  <div
171
  class="grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800"
@@ -191,7 +209,7 @@
191
  {:else}
192
  <div
193
  class="flex h-14 w-72 items-center gap-2 overflow-hidden rounded-xl border border-gray-200 bg-white p-2 dark:border-gray-800 dark:bg-gray-900"
194
- class:hoverable={isClickable}
195
  >
196
  <div
197
  class="grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800"
@@ -211,16 +229,14 @@
211
  <button
212
  class="absolute -right-2 -top-2 z-10 grid size-6 place-items-center rounded-full border bg-black group-hover:visible dark:border-gray-700"
213
  class:invisible={navigator.maxTouchPoints === 0}
214
- on:click|stopPropagation|preventDefault={() => dispatch("close")}
 
 
 
 
215
  >
216
  <CarbonClose class=" text-xs text-white" />
217
  </button>
218
  {/if}
219
  </div>
220
- </button>
221
-
222
- <style lang="postcss">
223
- .hoverable {
224
- @apply hover:bg-gray-500/10;
225
- }
226
- </style>
 
11
  import EosIconsLoading from "~icons/eos-icons/loading";
12
  import { base } from "$app/paths";
13
 
14
+ interface Props {
15
+ file: MessageFile;
16
+ canClose?: boolean;
17
+ }
18
+
19
+ let { file, canClose = true }: Props = $props();
20
 
21
+ let showModal = $state(false);
22
+
23
+ let urlNotTrailing = $derived($page.url.pathname.replace(/\/$/, ""));
24
 
25
  const dispatch = createEventDispatcher<{ close: void }>();
26
 
 
52
  mime === "application/xml" ||
53
  mime === "application/vnd.chatui.clipboard";
54
 
55
+ let isClickable = $derived(isImage(file.mime) || isPlainText(file.mime));
56
  </script>
57
 
58
  {#if showModal && isClickable}
 
85
  {/if}
86
  <button
87
  class="absolute right-4 top-4 text-xl text-gray-500 hover:text-gray-800"
88
+ onclick={() => (showModal = false)}
89
  >
90
  <CarbonClose class="text-xl" />
91
  </button>
 
115
  </Modal>
116
  {/if}
117
 
118
+ <div
119
+ onclick={() => isClickable && (showModal = true)}
120
+ onkeydown={(e) => {
121
+ if (!isClickable) {
122
+ return;
123
+ }
124
+ if (e.key === "Enter" || e.key === " ") {
125
+ showModal = true;
126
+ }
127
+ }}
128
+ class:clickable={isClickable}
129
+ role="button"
130
+ tabindex="0"
131
+ >
132
  <div class="group relative flex items-center rounded-xl shadow-sm">
133
  {#if isImage(file.mime)}
134
  <div class="size-48 overflow-hidden rounded-xl">
 
151
  <div
152
  class="border-1 w-72 overflow-clip rounded-xl border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
153
  >
154
+ <!-- svelte-ignore a11y_media_has_caption -->
155
  <video
156
  src={file.type === "base64"
157
  ? `data:${file.mime};base64,${file.value}`
158
  : urlNotTrailing + "/output/" + file.value}
159
  controls
160
+ ></video>
161
  </div>
162
  {:else if isPlainText(file.mime)}
163
  <div
164
  class="flex h-14 w-72 items-center gap-2 overflow-hidden rounded-xl border border-gray-200 bg-white p-2 dark:border-gray-800 dark:bg-gray-900"
165
+ class:file-hoverable={isClickable}
166
  >
167
  <div
168
  class="grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800"
 
183
  {:else if file.mime === "octet-stream"}
184
  <div
185
  class="flex h-14 w-72 items-center gap-2 overflow-hidden rounded-xl border border-gray-200 bg-white p-2 dark:border-gray-800 dark:bg-gray-900"
186
+ class:file-hoverable={isClickable}
187
  >
188
  <div
189
  class="grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800"
 
209
  {:else}
210
  <div
211
  class="flex h-14 w-72 items-center gap-2 overflow-hidden rounded-xl border border-gray-200 bg-white p-2 dark:border-gray-800 dark:bg-gray-900"
212
+ class:file-hoverable={isClickable}
213
  >
214
  <div
215
  class="grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800"
 
229
  <button
230
  class="absolute -right-2 -top-2 z-10 grid size-6 place-items-center rounded-full border bg-black group-hover:visible dark:border-gray-700"
231
  class:invisible={navigator.maxTouchPoints === 0}
232
+ onclick={(e) => {
233
+ e.preventDefault();
234
+ e.stopPropagation();
235
+ dispatch("close");
236
+ }}
237
  >
238
  <CarbonClose class=" text-xs text-white" />
239
  </button>
240
  {/if}
241
  </div>
242
+ </div>