From e5d5ee94f85b440d5d8bf53267ca1c5574f73401 Mon Sep 17 00:00:00 2001 From: Yann Amsellem Date: Thu, 3 Apr 2025 19:34:24 +0200 Subject: [PATCH 1/5] feat(ai): add ai chat panel --- package-lock.json | 33 ++++ package.json | 3 + src/lib/actions/autoresize.svelte.ts | 12 ++ src/lib/actions/scrollToBottom.svelte.ts | 17 ++ src/lib/components/Ai/Chat.svelte | 219 +++++++++++++++++++++ src/lib/components/Ai/Loader.svelte | 37 ++++ src/lib/components/Ai/index.ts | 1 + src/lib/components/Ai/types.ts | 12 ++ src/lib/icons/ArrowUp.svelte | 15 ++ src/lib/icons/{Bold.svelte => Bolt.svelte} | 0 src/lib/icons/PanelRight.svelte | 16 ++ src/lib/icons/Send.svelte | 15 ++ src/lib/icons/Sparkles.svelte | 26 +++ src/lib/icons/UserCircle.svelte | 15 ++ src/lib/markdown/index.ts | 18 ++ src/lib/markdown/markdown.css | 72 +++++++ src/routes/+page.svelte | 218 ++++++++++++-------- 17 files changed, 648 insertions(+), 81 deletions(-) create mode 100644 src/lib/actions/autoresize.svelte.ts create mode 100644 src/lib/actions/scrollToBottom.svelte.ts create mode 100644 src/lib/components/Ai/Chat.svelte create mode 100644 src/lib/components/Ai/Loader.svelte create mode 100644 src/lib/components/Ai/index.ts create mode 100644 src/lib/components/Ai/types.ts create mode 100644 src/lib/icons/ArrowUp.svelte rename src/lib/icons/{Bold.svelte => Bolt.svelte} (100%) create mode 100644 src/lib/icons/PanelRight.svelte create mode 100644 src/lib/icons/Send.svelte create mode 100644 src/lib/icons/Sparkles.svelte create mode 100644 src/lib/icons/UserCircle.svelte create mode 100644 src/lib/markdown/index.ts create mode 100644 src/lib/markdown/markdown.css diff --git a/package-lock.json b/package-lock.json index 3a539cb..fb0c9a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,10 @@ "@tauri-apps/plugin-fs": "^2.2.0", "d3": "^7.9.0", "dayjs": "^1.11.13", + "highlight.js": "^11.11.1", "lodash": "^4.17.21", + "marked": "^15.0.7", + "marked-highlight": "^2.2.1", "monaco-editor": "^0.52.2", "normalize.css": "^8.0.1", "p-debounce": "^4.0.0", @@ -2144,6 +2147,15 @@ "dev": true, "license": "MIT" }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -2231,6 +2243,27 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/marked": { + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.7.tgz", + "integrity": "sha512-dgLIeKGLx5FwziAnsk4ONoGwHwGPJzselimvlVskE9XLN4Orv9u2VA3GWw/lYUqjfA0rUT/6fqKwfZJapP9BEg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/marked-highlight": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/marked-highlight/-/marked-highlight-2.2.1.tgz", + "integrity": "sha512-SiCIeEiQbs9TxGwle9/OwbOejHCZsohQRaNTY2u8euEXYt2rYUFoiImUirThU3Gd/o6Q1gHGtH9qloHlbJpNIA==", + "license": "MIT", + "peerDependencies": { + "marked": ">=4 <16" + } + }, "node_modules/monaco-editor": { "version": "0.52.2", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", diff --git a/package.json b/package.json index 63b8f9d..495bb45 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,10 @@ "@tauri-apps/plugin-fs": "^2.2.0", "d3": "^7.9.0", "dayjs": "^1.11.13", + "highlight.js": "^11.11.1", "lodash": "^4.17.21", + "marked": "^15.0.7", + "marked-highlight": "^2.2.1", "monaco-editor": "^0.52.2", "normalize.css": "^8.0.1", "p-debounce": "^4.0.0", diff --git a/src/lib/actions/autoresize.svelte.ts b/src/lib/actions/autoresize.svelte.ts new file mode 100644 index 0000000..477fbe0 --- /dev/null +++ b/src/lib/actions/autoresize.svelte.ts @@ -0,0 +1,12 @@ +import type { Action } from 'svelte/action'; +import { on } from 'svelte/events'; + +export const autoresize = ((tx) => { + function onInput() { + tx.style.height = '0px'; + tx.style.height = `${tx.scrollHeight}px`; + } + + onInput(); + $effect(() => on(tx, 'input', onInput)); +}) satisfies Action; diff --git a/src/lib/actions/scrollToBottom.svelte.ts b/src/lib/actions/scrollToBottom.svelte.ts new file mode 100644 index 0000000..2ab7983 --- /dev/null +++ b/src/lib/actions/scrollToBottom.svelte.ts @@ -0,0 +1,17 @@ +import { tick } from 'svelte'; +import type { Action } from 'svelte/action'; + +export const scroll_to_bottom = ((node) => { + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'childList') { + tick().then(() => node.scroll({ top: node.scrollHeight, behavior: 'smooth' })); + } + } + }); + + $effect(() => { + observer.observe(node, { childList: true }); + return () => observer.disconnect(); + }); +}) satisfies Action; diff --git a/src/lib/components/Ai/Chat.svelte b/src/lib/components/Ai/Chat.svelte new file mode 100644 index 0000000..417d52a --- /dev/null +++ b/src/lib/components/Ai/Chat.svelte @@ -0,0 +1,219 @@ + + +
+
+
+ {#each messages.filter((m) => m.role === 'user' || m.role === 'assistant') as { role, content }} +
+

+ {#if role === 'user'} + You + {:else if role === 'assistant'} + Ai Assistant + {/if} +

+

+ {@html transform(content)} +

+
+ {/each} + {#if loading} +
+ +
+ {/if} +
+
+
+ +
+ + +
+
+
+
+ + diff --git a/src/lib/components/Ai/Loader.svelte b/src/lib/components/Ai/Loader.svelte new file mode 100644 index 0000000..acdceb6 --- /dev/null +++ b/src/lib/components/Ai/Loader.svelte @@ -0,0 +1,37 @@ + + + + + + + + + diff --git a/src/lib/components/Ai/index.ts b/src/lib/components/Ai/index.ts new file mode 100644 index 0000000..8509214 --- /dev/null +++ b/src/lib/components/Ai/index.ts @@ -0,0 +1 @@ +export { default as Chat } from './Chat.svelte'; diff --git a/src/lib/components/Ai/types.ts b/src/lib/components/Ai/types.ts new file mode 100644 index 0000000..fad6e5d --- /dev/null +++ b/src/lib/components/Ai/types.ts @@ -0,0 +1,12 @@ +export interface ChatInput { + messages: { + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; + }[]; + stream?: false | undefined; +} + +export interface ChatOutput { + created_at: string; + message: { role: 'assistant'; content: string }; +} diff --git a/src/lib/icons/ArrowUp.svelte b/src/lib/icons/ArrowUp.svelte new file mode 100644 index 0000000..dd26b24 --- /dev/null +++ b/src/lib/icons/ArrowUp.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/src/lib/icons/Bold.svelte b/src/lib/icons/Bolt.svelte similarity index 100% rename from src/lib/icons/Bold.svelte rename to src/lib/icons/Bolt.svelte diff --git a/src/lib/icons/PanelRight.svelte b/src/lib/icons/PanelRight.svelte new file mode 100644 index 0000000..3cb13fb --- /dev/null +++ b/src/lib/icons/PanelRight.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/src/lib/icons/Send.svelte b/src/lib/icons/Send.svelte new file mode 100644 index 0000000..652468d --- /dev/null +++ b/src/lib/icons/Send.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/src/lib/icons/Sparkles.svelte b/src/lib/icons/Sparkles.svelte new file mode 100644 index 0000000..77a3836 --- /dev/null +++ b/src/lib/icons/Sparkles.svelte @@ -0,0 +1,26 @@ + + + + + diff --git a/src/lib/icons/UserCircle.svelte b/src/lib/icons/UserCircle.svelte new file mode 100644 index 0000000..d9703b0 --- /dev/null +++ b/src/lib/icons/UserCircle.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/src/lib/markdown/index.ts b/src/lib/markdown/index.ts new file mode 100644 index 0000000..265494b --- /dev/null +++ b/src/lib/markdown/index.ts @@ -0,0 +1,18 @@ +import hljs from 'highlight.js'; +import { Marked } from 'marked'; +import { markedHighlight } from 'marked-highlight'; +import './markdown.css'; + +export function transform(content: string) { + const marked = new Marked( + markedHighlight({ + langPrefix: 'hljs language-', + highlight(code, lang, _info) { + const language = hljs.getLanguage(lang) ? lang : 'text'; + return hljs.highlight(code, { language }).value; + } + }) + ); + + return marked.parse(content, { async: false }); +} diff --git a/src/lib/markdown/markdown.css b/src/lib/markdown/markdown.css new file mode 100644 index 0000000..722f7b9 --- /dev/null +++ b/src/lib/markdown/markdown.css @@ -0,0 +1,72 @@ +@import 'highlight.js/styles/default.min.css'; +@import 'highlight.js/styles/github-dark.min.css'; + +.markdown-body { + width: 100%; + overflow-x: auto; + word-break: break-word; + + * { + font-family: 'Source Serif Pro', 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', + 'Times New Roman', 'Droid Serif', Times, serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol'; + } + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 0; + } + + a { + color: palevioletred; + text-decoration: underline; + } + + code { + font-family: monospace; + font-size: 13px; + } + + code:not(pre > code) { + background-color: #333; + border-radius: 3px; + padding: 0 2px; + } + + li { + margin-bottom: 10px; + } + + pre { + font-family: Menlo, Consolas, monospace; + margin: 0; + position: relative; + width: 100%; + box-sizing: border-box; + overflow-x: auto; + } + + pre code { + display: block; + padding: 0; + margin: 0; + top: 0; + width: 100%; + background: transparent; + } + + code.hljs { + text-align: left; + border-radius: 8px; + word-wrap: normal; + hyphens: none; + line-height: 1.5; + tab-size: 4; + white-space: pre; + word-break: normal; + word-spacing: normal; + } +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 35c9d19..1899a97 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,4 +1,6 @@ @@ -353,12 +367,19 @@ LIMIT 100;`; /> {/snippet} +{#snippet ai()} + +{/snippet} +
{#if isMobile} - + {@render sidebar()} + + {@render ai()} + {/if} {#snippet a()} -
- - {#each tabs as tab, i (tab.id)} -
- -
- {/each} -
+ {/snippet} + {#snippet b()} + (errors = [])} + /> + {/snippet} +
{/snippet} {#snippet b()} - (errors = [])} - /> + {#if !isMobile} + {@render ai()} + {/if} {/snippet} {/snippet} @@ -479,10 +529,16 @@ LIMIT 100;`; + {/if}
From 4b95f81d92dec8b76428c60044a9ff4b4b5469af Mon Sep 17 00:00:00 2001 From: Yann Amsellem Date: Mon, 7 Apr 2025 11:51:19 +0200 Subject: [PATCH 2/5] feat(ai): add dataset - add table schema to context - Select table --- package-lock.json | 26 +++ package.json | 1 + src/lib/actions/portal.svelte.ts | 37 +++ src/lib/components/Ai/Chat.svelte | 211 ++++++++++++++---- src/lib/components/Ai/DatasetsBox.svelte | 120 ++++++++++ src/lib/components/Select.svelte | 87 ++++++++ src/lib/icons/CircleStack.svelte | 27 +++ src/lib/icons/PaperClip.svelte | 27 +++ src/lib/markdown/markdown.css | 27 +-- src/lib/olap-engine/index.ts | 2 + src/lib/utilities/useResizeObserver.svelte.ts | 20 ++ src/routes/+page.svelte | 25 ++- 12 files changed, 537 insertions(+), 73 deletions(-) create mode 100644 src/lib/actions/portal.svelte.ts create mode 100644 src/lib/components/Ai/DatasetsBox.svelte create mode 100644 src/lib/components/Select.svelte create mode 100644 src/lib/icons/CircleStack.svelte create mode 100644 src/lib/icons/PaperClip.svelte create mode 100644 src/lib/utilities/useResizeObserver.svelte.ts diff --git a/package-lock.json b/package-lock.json index fb0c9a4..6ca9851 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@agnosticeng/dv": "^0.0.9", "@agnosticeng/migrate": "^0.0.2", "@agnosticeng/sqlite": "^0.0.5", + "@floating-ui/dom": "^1.6.13", "@observablehq/plot": "^0.6.16", "@rich_harris/svelte-split-pane": "^2.0.0", "@tauri-apps/api": "^2.2.0", @@ -514,6 +515,31 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", diff --git a/package.json b/package.json index 495bb45..9f9d18c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@agnosticeng/dv": "^0.0.9", "@agnosticeng/migrate": "^0.0.2", "@agnosticeng/sqlite": "^0.0.5", + "@floating-ui/dom": "^1.6.13", "@observablehq/plot": "^0.6.16", "@rich_harris/svelte-split-pane": "^2.0.0", "@tauri-apps/api": "^2.2.0", diff --git a/src/lib/actions/portal.svelte.ts b/src/lib/actions/portal.svelte.ts new file mode 100644 index 0000000..fd4718d --- /dev/null +++ b/src/lib/actions/portal.svelte.ts @@ -0,0 +1,37 @@ +import { tick } from 'svelte'; + +type Target = HTMLElement | string; + +export function portal(node: HTMLElement, target: Target = 'body') { + let targetNode; + + async function update() { + if (typeof target === 'string') { + targetNode = document.querySelector(target); + if (targetNode === null) { + await tick(); + targetNode = document.querySelector(target); + } + if (targetNode === null) { + throw new Error(`Invalid CSS selector: "${target}"`); + } + } else if (target instanceof HTMLElement) { + targetNode = target; + } else { + throw new TypeError( + `Unknown portal target type: ${ + target === null ? 'null' : typeof target + }. Allowed types: string (CSS selector) or HTMLElement.` + ); + } + targetNode.appendChild(node); + node.setAttribute('data-portal', ''); + } + + $effect(() => { + update(); + return () => { + if (node.parentNode) node.parentNode.removeChild(node); + }; + }); +} diff --git a/src/lib/components/Ai/Chat.svelte b/src/lib/components/Ai/Chat.svelte index 417d52a..edf68da 100644 --- a/src/lib/components/Ai/Chat.svelte +++ b/src/lib/components/Ai/Chat.svelte @@ -1,22 +1,43 @@
-
-
+ +
-
- -
- - + {#if dataset} +
+ + {dataset.name}
- + {/if} +
+ + + + + +
diff --git a/src/lib/components/Ai/DatasetsBox.svelte b/src/lib/components/Ai/DatasetsBox.svelte new file mode 100644 index 0000000..e7a793b --- /dev/null +++ b/src/lib/components/Ai/DatasetsBox.svelte @@ -0,0 +1,120 @@ + + +
+
    + {#each filtered as d (d.name)} +
  • + +
  • + {/each} +
+ +
+ + diff --git a/src/lib/components/Select.svelte b/src/lib/components/Select.svelte new file mode 100644 index 0000000..faf1b9b --- /dev/null +++ b/src/lib/components/Select.svelte @@ -0,0 +1,87 @@ + + + + +{#if opened} + +{/if} + + diff --git a/src/lib/icons/CircleStack.svelte b/src/lib/icons/CircleStack.svelte new file mode 100644 index 0000000..a3aaaf0 --- /dev/null +++ b/src/lib/icons/CircleStack.svelte @@ -0,0 +1,27 @@ + + + diff --git a/src/lib/icons/PaperClip.svelte b/src/lib/icons/PaperClip.svelte new file mode 100644 index 0000000..5a365ac --- /dev/null +++ b/src/lib/icons/PaperClip.svelte @@ -0,0 +1,27 @@ + + + diff --git a/src/lib/markdown/markdown.css b/src/lib/markdown/markdown.css index 722f7b9..ff0df89 100644 --- a/src/lib/markdown/markdown.css +++ b/src/lib/markdown/markdown.css @@ -1,16 +1,11 @@ @import 'highlight.js/styles/default.min.css'; @import 'highlight.js/styles/github-dark.min.css'; -.markdown-body { +.markdown { width: 100%; overflow-x: auto; word-break: break-word; - * { - font-family: 'Source Serif Pro', 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', - 'Times New Roman', 'Droid Serif', Times, serif, 'Apple Color Emoji', 'Segoe UI Emoji', - 'Segoe UI Symbol'; - } h1, h2, h3, @@ -27,7 +22,7 @@ code { font-family: monospace; - font-size: 13px; + font-size: 12px; } code:not(pre > code) { @@ -49,15 +44,6 @@ overflow-x: auto; } - pre code { - display: block; - padding: 0; - margin: 0; - top: 0; - width: 100%; - background: transparent; - } - code.hljs { text-align: left; border-radius: 8px; @@ -69,4 +55,13 @@ word-break: normal; word-spacing: normal; } + + .hljs-attribute, + .hljs-doctag, + .hljs-keyword, + .hljs-meta .hljs-keyword, + .hljs-name, + .hljs-selector-tag { + font-weight: normal; + } } diff --git a/src/lib/olap-engine/index.ts b/src/lib/olap-engine/index.ts index d9fd7d1..e6f9276 100644 --- a/src/lib/olap-engine/index.ts +++ b/src/lib/olap-engine/index.ts @@ -21,6 +21,8 @@ export interface ColumnDescriptor { export interface Table { name: string; engine: string; + short: string; + url: string; columns: ColumnDescriptor[]; } diff --git a/src/lib/utilities/useResizeObserver.svelte.ts b/src/lib/utilities/useResizeObserver.svelte.ts new file mode 100644 index 0000000..a94c52c --- /dev/null +++ b/src/lib/utilities/useResizeObserver.svelte.ts @@ -0,0 +1,20 @@ +export function useResizeObserver( + target: () => HTMLElement | undefined | null, + callback: ResizeObserverCallback, + options: ResizeObserverOptions = {} +) { + let observer: ResizeObserver | undefined; + + const target_ = $derived(target()); + + $effect(() => { + if (!target_) return; + observer = new ResizeObserver(callback); + observer.observe(target_, options); + + return () => { + observer?.disconnect(); + observer = undefined; + }; + }); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 1899a97..280e384 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -341,13 +341,12 @@ LIMIT 100;`; else currentTab.content = content; } - let messages = $state.raw([ - { - role: 'system', - content: - "You are a ClickHouse expert specializing in OLAP databases, SQL format, and functions. You can produce SQL queries using knowledge of ClickHouse's architecture, data modeling, performance optimization, query execution, and advanced analytical functions." - } - ]); + let messages = $state.raw([]); + + let aiSelectedTable = $state.raw(); + $effect(() => { + aiSelectedTable ??= tables.at(0); + }); @@ -368,7 +367,12 @@ LIMIT 100;`; {/snippet} {#snippet ai()} - + (messages = [])} + datasets={tables} + bind:dataset={aiSelectedTable} + /> {/snippet}
@@ -520,7 +524,7 @@ LIMIT 100;`;
{#if cached} - from cache + from cache {/if} {#if BUILD} @@ -658,7 +662,7 @@ LIMIT 100;`; border-top: 1px solid hsl(0deg 0% 20%); display: flex; place-items: center; - gap: 8px; + gap: 4px; font-family: monospace; color: hsl(0deg 0% 70%); font-size: 9px; @@ -675,6 +679,7 @@ LIMIT 100;`; & > button { height: 100%; + padding: 0; aspect-ratio: 1; flex-shrink: 0; background-color: transparent; From 6a5b5b0903aca75878c185664389b2df25e81540 Mon Sep 17 00:00:00 2001 From: Yann Amsellem Date: Tue, 8 Apr 2025 00:13:28 +0200 Subject: [PATCH 3/5] feat(ai): make chat request cancelable --- src/lib/actions/autoresize.svelte.ts | 4 +- src/lib/components/Ai/Chat.svelte | 187 ++++++++++++++++----------- src/lib/icons/CircleStopSolid.svelte | 25 ++++ src/lib/markdown/markdown.css | 2 +- src/routes/+page.svelte | 8 +- 5 files changed, 147 insertions(+), 79 deletions(-) create mode 100644 src/lib/icons/CircleStopSolid.svelte diff --git a/src/lib/actions/autoresize.svelte.ts b/src/lib/actions/autoresize.svelte.ts index 477fbe0..af70bdc 100644 --- a/src/lib/actions/autoresize.svelte.ts +++ b/src/lib/actions/autoresize.svelte.ts @@ -1,8 +1,10 @@ +import { tick } from 'svelte'; import type { Action } from 'svelte/action'; import { on } from 'svelte/events'; export const autoresize = ((tx) => { - function onInput() { + async function onInput() { + await tick(); tx.style.height = '0px'; tx.style.height = `${tx.scrollHeight}px`; } diff --git a/src/lib/components/Ai/Chat.svelte b/src/lib/components/Ai/Chat.svelte index edf68da..0c1d128 100644 --- a/src/lib/components/Ai/Chat.svelte +++ b/src/lib/components/Ai/Chat.svelte @@ -3,11 +3,10 @@ import { scroll_to_bottom } from '$lib/actions/scrollToBottom.svelte'; import Select from '$lib/components/Select.svelte'; import CircleStack from '$lib/icons/CircleStack.svelte'; - import PaperClip from '$lib/icons/PaperClip.svelte'; + import CircleStopSolid from '$lib/icons/CircleStopSolid.svelte'; + import Plus from '$lib/icons/Plus.svelte'; import Send from '$lib/icons/Send.svelte'; - import Sparkles from '$lib/icons/Sparkles.svelte'; import Trash from '$lib/icons/Trash.svelte'; - import UserCircle from '$lib/icons/UserCircle.svelte'; import { transform } from '$lib/markdown'; import type { Table } from '$lib/olap-engine'; import DatasetsBox from './DatasetsBox.svelte'; @@ -33,6 +32,8 @@ let message = $state(''); let select = $state>(); let textarea = $state(); + let abortController: AbortController | undefined; + let chatMessages = $derived(messages.filter((m) => m.role === 'user' || m.role === 'assistant')); function getContextFromTable(table: Table): string { const columns = table.columns.map((col) => `- ${col.name} (${col.type})`).join('\n'); @@ -55,6 +56,7 @@ loading = true; try { + abortController = new AbortController(); const response = await fetch(event.currentTarget.action, { method: event.currentTarget.method, headers: { 'Content-type': 'application/json' }, @@ -63,7 +65,8 @@ ? [{ role: 'user', content: getContextFromTable(dataset) }, ...messages] : messages, stream: false - }) + }), + signal: abortController.signal }); if (!response.ok) { @@ -73,8 +76,18 @@ const output: ChatOutput = await response.json(); messages = messages.concat(output.message); + } catch (e) { + if (e === 'Canceled by user') { + const last = messages.at(-1); + messages = messages.slice(0, -1); + if (last?.content) { + message = last.content; + textarea?.dispatchEvent(new InputEvent('input')); + } + } } finally { loading = false; + abortController = undefined; } } @@ -82,7 +95,10 @@
@@ -90,19 +106,20 @@ class="conversation" use:scroll_to_bottom role="presentation" - onclick={(e) => { - if (e.target === e.currentTarget) textarea?.focus(); - }} + onclick={(e) => e.target === e.currentTarget && textarea?.focus()} > - {#each messages.filter((m) => m.role === 'user' || m.role === 'assistant') as { role, content }} + {#each chatMessages as { role, content }, index}

{#if role === 'user'} - You + You {:else if role === 'assistant'} - Assistant + Assistant {/if}

+ {#if index === 0 && dataset} +

{dataset.name}

+ {/if}

{@html transform(content)}

@@ -110,12 +127,15 @@ {/each} {#if loading}
-

Assistant

+

Assistant

{:else}
-

You

+

You

+ {#if chatMessages.length === 0 && dataset} +

{dataset.name}

+ {/if}
{ e.stopPropagation(); - if (e.code === 'Enter' && !e.shiftKey) { + if (e.code === 'Enter' && e.metaKey) { e.preventDefault(); submitter?.click(); } @@ -143,37 +163,44 @@
{/if}
+
- {#if dataset} -
- - {dataset.name} -
- {/if} -
- + + + + + {#if loading} + - - - + {:else} -
+ {/if}
@@ -216,48 +243,52 @@ .conversation { overflow-y: auto; padding-bottom: 36px; - padding: 8px 20px 0; + padding: 15px 20px 0; } .conversation > article { - margin-bottom: 18px; - padding-bottom: 8px; - - &:not(:last-child) { - border-bottom: 1px solid hsl(0deg 0% 20%); + & ~ article { + border-top: 1px solid hsl(0deg 0% 20%); + padding-top: 18px; } - & h2 { + &:last-child { + padding-bottom: 18px; + } + + & :is(h2, h3) { margin: 0; - font-size: 13px; + margin-bottom: 12px; + + font-size: 12px; + font-weight: 500; + padding: 3px 5px; + border-radius: 4px; + background-color: hsl(0deg 0% 17%); + display: flex; align-items: center; - gap: 8px; + gap: 4px; + max-width: fit-content; + overflow: hidden; + + & > :global(svg) { + flex-shrink: 0; + } + + & > span { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } - } - .submitter { - border-top: 1px solid hsl(0deg 0% 20%); - padding: 10px 20px; - width: 100%; - overflow: hidden; - } - - .context { - display: flex; - flex-wrap: nowrap; - align-items: center; - gap: 4px; - overflow: hidden; - margin-bottom: 8px; - } - - .context > span { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex: 1; - min-width: 0; + & > p { + margin: 0; + margin-bottom: 18px; + } } textarea { @@ -276,18 +307,28 @@ outline: none; } - .actions { + .submitter { + border-top: 1px solid hsl(0deg 0% 20%); + padding: 8px; + width: 100%; + overflow: hidden; + display: flex; align-items: center; gap: 4px; - margin-top: 8px; + + & > span.separator { + height: 100%; + width: 1px; + background-color: hsl(0deg 0% 20%); + } & > span.spacer { flex: 1; } } - .actions > select { + .submitter > select { cursor: pointer; border: none; outline: none; @@ -295,14 +336,14 @@ color: hsl(0deg 0% 65%); font-size: 11px; border-radius: 4px; - padding: 4px; + padding: 2px; &:hover { background-color: hsl(0deg 0% 10%); } } - .actions > button { + .submitter > button { display: grid; place-items: center; aspect-ratio: 1; diff --git a/src/lib/icons/CircleStopSolid.svelte b/src/lib/icons/CircleStopSolid.svelte new file mode 100644 index 0000000..f0db648 --- /dev/null +++ b/src/lib/icons/CircleStopSolid.svelte @@ -0,0 +1,25 @@ + + + diff --git a/src/lib/markdown/markdown.css b/src/lib/markdown/markdown.css index ff0df89..aa9cf31 100644 --- a/src/lib/markdown/markdown.css +++ b/src/lib/markdown/markdown.css @@ -1,5 +1,5 @@ @import 'highlight.js/styles/default.min.css'; -@import 'highlight.js/styles/github-dark.min.css'; +@import 'highlight.js/styles/vs2015.min.css'; .markdown { width: 100%; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 280e384..4af4784 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -282,9 +282,9 @@ $effect(() => void saveTabs($state.snapshot(tabs), selectedTabIndex).catch(console.error)); - const bottomPanel = new PanelState('50%', false, '100%'); + const bottomPanel = new PanelState('-50%', false); const leftPanel = new PanelState('260px', true); - const rightPanel = new PanelState('-300px', false); + const rightPanel = new PanelState('-300px', true); let bottomPanelTab = $state<'data' | 'chart' | 'logs'>('data'); let errors = $state.raw([]); @@ -408,8 +408,8 @@ LIMIT 100;`; {#snippet a()} Date: Tue, 8 Apr 2025 19:37:37 +0200 Subject: [PATCH 4/5] feat(ai): handle multiple chat tab --- src/lib/components/Ai/Chat.svelte | 115 ++++++--------- src/lib/components/Ai/DatasetsBox.svelte | 24 +++- src/lib/components/Ai/Panel.svelte | 136 ++++++++++++++++++ src/lib/components/Ai/index.ts | 12 +- src/lib/components/Tab.svelte | 6 +- src/lib/markdown/markdown.css | 8 ++ src/lib/migrations/004_create_chats_table.sql | 8 ++ src/lib/migrations/index.ts | 4 +- src/lib/repositories/chats.ts | 71 +++++++++ src/lib/repositories/tabs.ts | 2 +- src/lib/store/database.ts | 1 - src/routes/+page.svelte | 51 +++++-- 12 files changed, 342 insertions(+), 96 deletions(-) create mode 100644 src/lib/components/Ai/Panel.svelte create mode 100644 src/lib/migrations/004_create_chats_table.sql create mode 100644 src/lib/repositories/chats.ts diff --git a/src/lib/components/Ai/Chat.svelte b/src/lib/components/Ai/Chat.svelte index 0c1d128..39dd0bf 100644 --- a/src/lib/components/Ai/Chat.svelte +++ b/src/lib/components/Ai/Chat.svelte @@ -5,8 +5,6 @@ import CircleStack from '$lib/icons/CircleStack.svelte'; import CircleStopSolid from '$lib/icons/CircleStopSolid.svelte'; import Plus from '$lib/icons/Plus.svelte'; - import Send from '$lib/icons/Send.svelte'; - import Trash from '$lib/icons/Trash.svelte'; import { transform } from '$lib/markdown'; import type { Table } from '$lib/olap-engine'; import DatasetsBox from './DatasetsBox.svelte'; @@ -40,6 +38,10 @@ return `## Table schema:\n${table.name}\nColumns:\n${columns}`; } + $effect(() => { + dataset ??= datasets?.at(0); + }); + async function handleSubmit( event: SubmitEvent & { currentTarget: EventTarget & HTMLFormElement } ) { @@ -92,16 +94,11 @@ } -
- +{#snippet context(dataset: Table)} +

{dataset.name.split('__').pop()}

+{/snippet} + +
- {#if index === 0 && dataset} -

{dataset.name}

- {/if} + {#if index === 0 && dataset}{@render context(dataset)}{/if}

{@html transform(content)}

@@ -133,9 +128,7 @@ {:else}

You

- {#if chatMessages.length === 0 && dataset} -

{dataset.name}

- {/if} + {#if chatMessages.length === 0 && dataset}{@render context(dataset)}{/if}
- - + {#if loading} @@ -188,56 +181,22 @@ title="Cancel" onclick={() => abortController?.abort('Canceled by user')} > - + {:else} - {/if}
diff --git a/src/lib/components/Ai/index.ts b/src/lib/components/Ai/index.ts index 8509214..e851ae8 100644 --- a/src/lib/components/Ai/index.ts +++ b/src/lib/components/Ai/index.ts @@ -1 +1,11 @@ -export { default as Chat } from './Chat.svelte'; +import type { Table } from '$lib/olap-engine'; +import type { ChatInput } from './types'; + +export { default as AiPanel } from './Panel.svelte'; + +export interface Chat { + id: string; + name: string; + messages: ChatInput['messages']; + dataset?: Table; +} diff --git a/src/lib/components/Tab.svelte b/src/lib/components/Tab.svelte index d11c58f..6d9ff24 100644 --- a/src/lib/components/Tab.svelte +++ b/src/lib/components/Tab.svelte @@ -6,13 +6,13 @@ onSelect: () => void; onClose: () => void; active: boolean; - 'hide-close': boolean; + 'hide-close'?: boolean; } - let { active, label, onClose, onSelect, 'hide-close': hideClose }: Props = $props(); + let { active, label, onClose, onSelect, 'hide-close': hideClose = false }: Props = $props(); -
{}}> +
{}}> {label} {#if !hideClose} diff --git a/src/lib/markdown/markdown.css b/src/lib/markdown/markdown.css index aa9cf31..04c8147 100644 --- a/src/lib/markdown/markdown.css +++ b/src/lib/markdown/markdown.css @@ -15,6 +15,14 @@ margin: 0; } + & > p:first-child { + margin-top: 0; + } + + & > p:last-child { + margin-bottom: 0; + } + a { color: palevioletred; text-decoration: underline; diff --git a/src/lib/migrations/004_create_chats_table.sql b/src/lib/migrations/004_create_chats_table.sql new file mode 100644 index 0000000..cd90624 --- /dev/null +++ b/src/lib/migrations/004_create_chats_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS chats ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + messages TEXT NOT NULL, + dataset TEXT, + idx INTEGER NOT NULL, + active BOOL UNIQUE +); diff --git a/src/lib/migrations/index.ts b/src/lib/migrations/index.ts index 52aad25..43f082e 100644 --- a/src/lib/migrations/index.ts +++ b/src/lib/migrations/index.ts @@ -3,9 +3,11 @@ import type { Migration } from '@agnosticeng/migrate'; import CREATE_HISTORY_TABLE_SCRIPT from './001_create_history_table.sql?raw'; import CREATE_QUERIES_TABLE_SCRIPT from './002_create_queries_table.sql?raw'; import CREATE_TABS_TABLE_SCRIPT from './003_create_tabs_table.sql?raw'; +import CREATE_CHATS_TABLE_SCRIPT from './004_create_chats_table.sql?raw'; export const MIGRATIONS: Migration[] = [ { name: 'create_history_table', script: CREATE_HISTORY_TABLE_SCRIPT }, { name: 'create_queries_table', script: CREATE_QUERIES_TABLE_SCRIPT }, - { name: 'create_tabs_table', script: CREATE_TABS_TABLE_SCRIPT } + { name: 'create_tabs_table', script: CREATE_TABS_TABLE_SCRIPT }, + { name: 'create_chats_table', script: CREATE_CHATS_TABLE_SCRIPT } ]; diff --git a/src/lib/repositories/chats.ts b/src/lib/repositories/chats.ts new file mode 100644 index 0000000..4844aac --- /dev/null +++ b/src/lib/repositories/chats.ts @@ -0,0 +1,71 @@ +import type { ChatInput } from '$lib/components/Ai/types'; +import type { Table } from '$lib/olap-engine'; +import type { Database } from '$lib/store/database'; +import type { SqlValue } from '@sqlite.org/sqlite-wasm'; + +export interface Chat { + id: string; + name: string; + messages: ChatInput['messages']; + dataset?: Table; +} + +export interface ChatsRepository { + list(): Promise<[chats: Chat[], active: number]>; + save(chats: Chat[], active: number): Promise; +} + +export class SQLiteChatsRepository implements ChatsRepository { + constructor(private db: Database) {} + + async list(): Promise<[chats: Chat[], active: number]> { + const rows = await this.db.exec('select * from chats order by idx'); + const index = rows.findIndex((r) => r.active); + + return [rows.map((r) => this.fromRowToChat(r)), Math.max(0, index)]; + } + + private fromRowToChat(row: { [columnName: string]: SqlValue }): Chat { + return { + id: row.id as string, + messages: JSON.parse(row.messages as string), + name: row.name as string, + dataset: row.dataset ? JSON.parse(row.dataset as string) : undefined + }; + } + + async save(chats: Chat[], active: number): Promise { + const rows = chats.map((c, i) => this.fromChatToRow(c, i)); + + try { + await this.db.exec('BEGIN TRANSACTION;'); + await this.db.exec('DELETE FROM chats;'); + + if (rows.length) { + const values = new Array(chats.length).fill('(?,?,?,?,?,?)').join(',\n'); + const params = rows + .map((r) => [r.id, r.name, r.messages, r.dataset, r.idx, r.idx === active || null]) + .flat(); + await this.db.exec( + `INSERT INTO chats (id, name, messages, dataset, idx, active) VALUES ${values}`, + params + ); + } + + await this.db.exec('COMMIT;'); + } catch (e) { + await this.db.exec('ROLLBACK;'); + throw e; + } + } + + private fromChatToRow(chat: Chat, index: number): { [columnName: string]: SqlValue } { + return { + id: chat.id, + name: chat.name, + messages: JSON.stringify(chat.messages), + dataset: chat.dataset ? JSON.stringify(chat.dataset) : null, + idx: index + }; + } +} diff --git a/src/lib/repositories/tabs.ts b/src/lib/repositories/tabs.ts index edf3c75..7a8c424 100644 --- a/src/lib/repositories/tabs.ts +++ b/src/lib/repositories/tabs.ts @@ -27,7 +27,7 @@ export class SQLiteTabRepository implements TabRepository { await this.db.exec( `DELETE FROM tabs; INSERT INTO tabs (id, name, content, query_id, tab_index, active) -VALUES ${Array.from({ length: rows.length }).fill('(?,?,?,?,?, ?)').join(',\n')} +VALUES ${Array.from({ length: rows.length }).fill('(?,?,?,?,?,?)').join(',\n')} `, rows .map((r) => [ diff --git a/src/lib/store/database.ts b/src/lib/store/database.ts index 1577f09..c057f40 100644 --- a/src/lib/store/database.ts +++ b/src/lib/store/database.ts @@ -1,4 +1,3 @@ -import { MIGRATIONS } from '$lib/migrations'; import { IndexedDBCache } from '@agnosticeng/cache'; import { SQLite } from '@agnosticeng/sqlite'; import type { BindingSpec } from '@sqlite.org/sqlite-wasm'; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 4af4784..9481da0 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,6 +1,5 @@ @@ -367,11 +382,14 @@ LIMIT 100;`; {/snippet} {#snippet ai()} - (messages = [])} + { + if (isMobile) rightDrawerOpened = false; + else rightPanel.open = false; + }} /> {/snippet} @@ -480,7 +498,10 @@ LIMIT 100;`; @@ -538,7 +559,10 @@ LIMIT 100;`;
diff --git a/src/lib/icons/arrow-top-right-on-square.svg b/src/lib/icons/arrow-top-right-on-square.svg new file mode 100644 index 0000000..8387c51 --- /dev/null +++ b/src/lib/icons/arrow-top-right-on-square.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/icons/check.svg b/src/lib/icons/check.svg new file mode 100644 index 0000000..ef5253e --- /dev/null +++ b/src/lib/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/icons/copy.svg b/src/lib/icons/copy.svg new file mode 100644 index 0000000..74347ba --- /dev/null +++ b/src/lib/icons/copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/markdown/index.ts b/src/lib/markdown/index.ts index 265494b..5eb3372 100644 --- a/src/lib/markdown/index.ts +++ b/src/lib/markdown/index.ts @@ -1,18 +1,45 @@ import hljs from 'highlight.js'; import { Marked } from 'marked'; -import { markedHighlight } from 'marked-highlight'; import './markdown.css'; export function transform(content: string) { - const marked = new Marked( - markedHighlight({ - langPrefix: 'hljs language-', - highlight(code, lang, _info) { + const marked = new Marked({ + renderer: { + code({ text, lang = 'text' }) { const language = hljs.getLanguage(lang) ? lang : 'text'; - return hljs.highlight(code, { language }).value; + + let html = '
'; + if (lang === 'sql') { + html += '
'; + html += ''; + html += ''; + html += '
'; + } + html += `
`;
+				html += hljs.highlight(text, { language }).value;
+				html += '
'; + html += '
'; + + return html; } - }) - ); + } + }); return marked.parse(content, { async: false }); } + +export function getTextFromElement(element: HTMLElement) { + let result = ''; + + for (const child of element.childNodes ?? []) { + if (child.nodeType === Node.TEXT_NODE) { + result += (child as Text).data; + } + + if (child.nodeType === Node.ELEMENT_NODE) { + result += getTextFromElement(child as HTMLElement); + } + } + + return result; +} diff --git a/src/lib/markdown/markdown.css b/src/lib/markdown/markdown.css index 04c8147..2944ca5 100644 --- a/src/lib/markdown/markdown.css +++ b/src/lib/markdown/markdown.css @@ -64,6 +64,103 @@ word-spacing: normal; } + .code-block { + position: relative; + + &:hover > .controls { + opacity: 1; + } + + .controls { + opacity: 0; + transition: opacity 0.2s; + + z-index: 1; + position: absolute; + top: 6px; + right: 6px; + + display: flex; + align-items: center; + justify-content: end; + + & > button { + height: 20px; + aspect-ratio: 1; + border: 1px solid hsl(0 0% 20%); + border-radius: 4px; + background-color: hsl(0deg 0% 5%); + + &:hover { + background-color: hsl(0deg 0% 20%); + } + + &:has(~ button) { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + } + + & ~ button { + border-left: none; + border-bottom-left-radius: 0; + border-top-left-radius: 0; + } + + &.copy { + position: relative; + + &::before, + &::after { + content: ''; + display: block; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + background: no-repeat 50% 50% / 60% 60%; + transition: opacity 0.2s; + transition-delay: 0.6s; + } + + &::before { + background-image: url($lib/icons/copy.svg); + } + + &::after { + background-image: url($lib/icons/check.svg); + opacity: 0; + } + + &:active::before { + opacity: 0; + transition: none; + } + + &:active::after { + opacity: 1; + transition: none; + } + } + + &.open { + position: relative; + &::before { + content: ''; + display: block; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + background: no-repeat 50% 50% / 60% 60%; + background-image: url($lib/icons/arrow-top-right-on-square.svg); + } + } + } + } + } + .hljs-attribute, .hljs-doctag, .hljs-keyword, @@ -72,4 +169,16 @@ .hljs-selector-tag { font-weight: normal; } + + button { + appearance: none; + outline: none; + border: none; + background: none; + padding: 0; + + &:not(:disabled):hover { + cursor: pointer; + } + } } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9481da0..775f27d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -390,6 +390,7 @@ LIMIT 100;`; if (isMobile) rightDrawerOpened = false; else rightPanel.open = false; }} + onOpenInEditor={openNewTabIfNeeded} /> {/snippet}