Merge pull request #101 from agnosticeng/feat/ai-assistant
This commit is contained in:
59
package-lock.json
generated
59
package-lock.json
generated
@@ -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",
|
||||
@@ -20,7 +21,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",
|
||||
@@ -511,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",
|
||||
@@ -2144,6 +2173,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 +2269,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -26,7 +27,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",
|
||||
|
||||
14
src/lib/actions/autoresize.svelte.ts
Normal file
14
src/lib/actions/autoresize.svelte.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { tick } from 'svelte';
|
||||
import type { Action } from 'svelte/action';
|
||||
import { on } from 'svelte/events';
|
||||
|
||||
export const autoresize = ((tx) => {
|
||||
async function onInput() {
|
||||
await tick();
|
||||
tx.style.height = '0px';
|
||||
tx.style.height = `${tx.scrollHeight}px`;
|
||||
}
|
||||
|
||||
onInput();
|
||||
$effect(() => on(tx, 'input', onInput));
|
||||
}) satisfies Action<HTMLTextAreaElement>;
|
||||
37
src/lib/actions/portal.svelte.ts
Normal file
37
src/lib/actions/portal.svelte.ts
Normal file
@@ -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);
|
||||
};
|
||||
});
|
||||
}
|
||||
17
src/lib/actions/scrollToBottom.svelte.ts
Normal file
17
src/lib/actions/scrollToBottom.svelte.ts
Normal file
@@ -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;
|
||||
378
src/lib/components/Ai/Chat.svelte
Normal file
378
src/lib/components/Ai/Chat.svelte
Normal file
@@ -0,0 +1,378 @@
|
||||
<script lang="ts">
|
||||
import { autoresize } from '$lib/actions/autoresize.svelte';
|
||||
import { scroll_to_bottom } from '$lib/actions/scrollToBottom.svelte';
|
||||
import Select from '$lib/components/Select.svelte';
|
||||
import CircleStack from '$lib/icons/CircleStack.svelte';
|
||||
import CircleStopSolid from '$lib/icons/CircleStopSolid.svelte';
|
||||
import Plus from '$lib/icons/Plus.svelte';
|
||||
import { getTextFromElement, transform } from '$lib/markdown';
|
||||
import type { Table } from '$lib/olap-engine';
|
||||
import DatasetsBox from './DatasetsBox.svelte';
|
||||
import Loader from './Loader.svelte';
|
||||
import type { ChatInput, ChatOutput } from './types';
|
||||
|
||||
interface Props {
|
||||
messages?: ChatInput['messages'];
|
||||
onClearConversation?: () => void;
|
||||
datasets: Table[];
|
||||
dataset?: Table;
|
||||
onOpenInEditor?: (sql: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
messages = $bindable([]),
|
||||
onClearConversation,
|
||||
datasets,
|
||||
dataset = $bindable(),
|
||||
onOpenInEditor
|
||||
}: Props = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let submitter = $state<HTMLButtonElement>();
|
||||
let message = $state('');
|
||||
let select = $state<ReturnType<typeof Select>>();
|
||||
let textarea = $state<HTMLTextAreaElement>();
|
||||
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');
|
||||
return `## Table schema:\n${table.name}\nColumns:\n${columns}`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
dataset ??= datasets?.at(0);
|
||||
});
|
||||
|
||||
async function handleSubmit(
|
||||
event: SubmitEvent & { currentTarget: EventTarget & HTMLFormElement }
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
const data = new FormData(event.currentTarget);
|
||||
let content = data.get('message');
|
||||
if (!content || typeof content !== 'string') return;
|
||||
content = content.trim();
|
||||
if (!content.length) return;
|
||||
|
||||
message = '';
|
||||
messages = messages.concat({ content, role: 'user' });
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
abortController = new AbortController();
|
||||
const response = await fetch(event.currentTarget.action, {
|
||||
method: event.currentTarget.method,
|
||||
headers: { 'Content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: dataset
|
||||
? [{ role: 'user', content: getContextFromTable(dataset) }, ...messages]
|
||||
: messages,
|
||||
stream: false
|
||||
}),
|
||||
signal: abortController.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClick(e: Event) {
|
||||
if ((e.target as HTMLButtonElement).classList.contains('copy')) {
|
||||
const parent = e
|
||||
.composedPath()
|
||||
.find((node) => (node as HTMLElement).classList.contains('code-block')) as HTMLElement;
|
||||
if (!parent) return;
|
||||
|
||||
const code = parent.querySelector('pre code') as HTMLElement;
|
||||
if (!code) return;
|
||||
|
||||
navigator.clipboard.writeText(getTextFromElement(code));
|
||||
}
|
||||
|
||||
if ((e.target as HTMLButtonElement).classList.contains('open')) {
|
||||
const parent = e
|
||||
.composedPath()
|
||||
.find((node) => (node as HTMLElement).classList.contains('code-block')) as HTMLElement;
|
||||
if (!parent) return;
|
||||
|
||||
const code = parent.querySelector('pre code') as HTMLElement;
|
||||
if (!code) return;
|
||||
|
||||
onOpenInEditor?.(getTextFromElement(code));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet context(dataset: Table)}
|
||||
<h3><CircleStack size="12" /><span>{dataset.name.split('__').pop()}</span></h3>
|
||||
{/snippet}
|
||||
|
||||
<div class="chat-container">
|
||||
<section
|
||||
class="conversation"
|
||||
use:scroll_to_bottom
|
||||
role="presentation"
|
||||
onclick={(e) => e.target === e.currentTarget && textarea?.focus()}
|
||||
>
|
||||
{#each chatMessages as { role, content }, index}
|
||||
<article data-role={role}>
|
||||
<h2>
|
||||
{#if role === 'user'}
|
||||
You
|
||||
{:else if role === 'assistant'}
|
||||
Assistant
|
||||
{/if}
|
||||
</h2>
|
||||
{#if index === 0 && dataset}{@render context(dataset)}{/if}
|
||||
<p class="markdown" onclickcapture={handleClick}>
|
||||
{@html transform(content)}
|
||||
</p>
|
||||
</article>
|
||||
{/each}
|
||||
{#if loading}
|
||||
<article>
|
||||
<h2>Assistant</h2>
|
||||
<Loader />
|
||||
</article>
|
||||
{:else}
|
||||
<article>
|
||||
<h2>You</h2>
|
||||
{#if chatMessages.length === 0 && dataset}{@render context(dataset)}{/if}
|
||||
<form
|
||||
id="user-message"
|
||||
action="https://ai.agx.app/api/chat"
|
||||
method="POST"
|
||||
onsubmit={handleSubmit}
|
||||
>
|
||||
<textarea
|
||||
name="message"
|
||||
tabindex="0"
|
||||
rows="1"
|
||||
placeholder="Ask Agnostic AI"
|
||||
disabled={loading}
|
||||
use:autoresize
|
||||
bind:value={message}
|
||||
bind:this={textarea}
|
||||
onkeydown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.code === 'Enter' && e.metaKey) {
|
||||
e.preventDefault();
|
||||
submitter?.click();
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
</form>
|
||||
</article>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<div class="submitter">
|
||||
<button type="button" title="Add context" onclick={(e) => select?.open(e.currentTarget)}>
|
||||
<Plus size="12" />
|
||||
</button>
|
||||
<span class="separator"></span>
|
||||
<Select bind:this={select} placement="top-start">
|
||||
<DatasetsBox
|
||||
{datasets}
|
||||
onSelect={() => (
|
||||
select?.close(), abortController?.abort('Context changed'), onClearConversation?.()
|
||||
)}
|
||||
bind:dataset
|
||||
/>
|
||||
</Select>
|
||||
<select disabled>
|
||||
<option selected>Agnostic AI (v0)</option>
|
||||
</select>
|
||||
<span class="spacer"></span>
|
||||
{#if loading}
|
||||
<button
|
||||
type="button"
|
||||
title="Cancel"
|
||||
onclick={() => abortController?.abort('Canceled by user')}
|
||||
>
|
||||
<CircleStopSolid size="12" />
|
||||
</button>
|
||||
{:else}
|
||||
<button form="user-message" type="submit" bind:this={submitter} title="Send ⌘⏎">
|
||||
Send ⌘⏎
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chat-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr minmax(0, auto);
|
||||
}
|
||||
|
||||
.conversation {
|
||||
overflow-y: auto;
|
||||
padding-bottom: 36px;
|
||||
padding: 15px 20px 0;
|
||||
}
|
||||
|
||||
.conversation > article {
|
||||
& ~ article {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
& :is(h2, h3) {
|
||||
margin: 0;
|
||||
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: 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;
|
||||
}
|
||||
}
|
||||
|
||||
& > p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
appearance: none;
|
||||
resize: none;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.submitter {
|
||||
border-top: 1px solid hsl(0deg 0% 20%);
|
||||
padding: 6px 4px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
& > span.separator {
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background-color: hsl(0deg 0% 20%);
|
||||
}
|
||||
|
||||
& > span.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.submitter > select {
|
||||
border: none;
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
color: hsl(0deg 0% 65%);
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
padding: 2px 0;
|
||||
|
||||
&:disabled {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
cursor: pointer;
|
||||
background-color: hsl(0deg 0% 10%);
|
||||
}
|
||||
}
|
||||
|
||||
.submitter > button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: 1;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
background-color: transparent;
|
||||
color: hsl(0deg 0% 80%);
|
||||
border: none;
|
||||
|
||||
&:disabled {
|
||||
color: hsl(0deg 0% 65%);
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
color: hsl(0deg 0% 90%);
|
||||
background-color: hsl(0deg 0% 10%);
|
||||
}
|
||||
|
||||
&[type='submit'] {
|
||||
aspect-ratio: initial;
|
||||
font-size: 11px;
|
||||
padding: 0 4px;
|
||||
background-color: hsl(0deg 0% 10%);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
color: hsl(0deg 0% 90%);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
136
src/lib/components/Ai/DatasetsBox.svelte
Normal file
136
src/lib/components/Ai/DatasetsBox.svelte
Normal file
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import type { Table } from '$lib/olap-engine';
|
||||
|
||||
interface Props {
|
||||
datasets: Table[];
|
||||
dataset?: Table;
|
||||
onSelect?: (d: Table) => void;
|
||||
}
|
||||
|
||||
let { datasets, dataset = $bindable(), onSelect }: Props = $props();
|
||||
let filter = $state('');
|
||||
const filtered = $derived(
|
||||
filter ? datasets.filter((d) => d.name.toLowerCase().includes(filter.toLowerCase())) : datasets
|
||||
);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<ul role="listbox">
|
||||
{#each filtered as d (d.name)}
|
||||
{@const name = d.name.split('__').pop()}
|
||||
<li role="option" aria-selected={dataset?.name === d.name}>
|
||||
<button
|
||||
title={[name, d.short].filter(Boolean).join(' • ')}
|
||||
type="button"
|
||||
onclick={() => onSelect?.((dataset = d))}
|
||||
>
|
||||
<span class="name">{name}</span>
|
||||
<span class="description">
|
||||
{#each d.name.split('__').slice(0, -1) as tag}
|
||||
<span class="tag">{tag}</span>
|
||||
{/each}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<input type="text" placeholder="Select a dataset" bind:value={filter} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
background-color: hsl(0, 0%, 10%);
|
||||
border: 1px solid hsl(0, 0%, 15%);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
input {
|
||||
height: 30px;
|
||||
width: 100%;
|
||||
background-color: hsl(0, 0%, 10%);
|
||||
border: none;
|
||||
border-top: 1px solid hsl(0, 0%, 15%);
|
||||
outline: none;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
ul[role='listbox'] {
|
||||
padding: 4px;
|
||||
width: 265px;
|
||||
max-height: 265px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
li[role='option'] {
|
||||
width: 100%;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
& > button {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-align: start;
|
||||
padding: 6px;
|
||||
color: hsl(0, 0%, 80%);
|
||||
border-radius: 4px;
|
||||
|
||||
& > span {
|
||||
display: block;
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:first-of-type {
|
||||
font-family: monospace;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
color: hsl(0, 0%, 75%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:is(:hover, :focus-within) > button:not(:disabled) {
|
||||
background-color: hsl(0deg 0% 15%);
|
||||
color: hsl(0deg 0% 90%);
|
||||
}
|
||||
|
||||
&:is([aria-selected='true']) > button:not(:disabled) {
|
||||
background-color: hsl(0deg 0% 20%);
|
||||
color: hsl(0deg 0% 90%);
|
||||
}
|
||||
}
|
||||
|
||||
span.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background-color: hsl(0deg 0% 17%);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
37
src/lib/components/Ai/Loader.svelte
Normal file
37
src/lib/components/Ai/Loader.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { size = 24 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} fill="currentColor" viewBox="0 0 24 24">
|
||||
<circle class="dot" cx="4" cy="12" r="3" />
|
||||
<circle class="dot dot_1" cx="12" cy="12" r="3" />
|
||||
<circle class="dot dot_2" cx="20" cy="12" r="3" />
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
svg > circle {
|
||||
animation: fade 0.8s linear infinite;
|
||||
}
|
||||
|
||||
svg > circle:nth-child(2) {
|
||||
animation-delay: -0.65s;
|
||||
}
|
||||
|
||||
svg > circle:nth-child(3) {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
|
||||
@keyframes fade {
|
||||
93.75%,
|
||||
100% {
|
||||
r: 3px;
|
||||
}
|
||||
46.875% {
|
||||
r: 0.2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
144
src/lib/components/Ai/Panel.svelte
Normal file
144
src/lib/components/Ai/Panel.svelte
Normal file
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import Plus from '$lib/icons/Plus.svelte';
|
||||
import type { Table } from '$lib/olap-engine';
|
||||
import { tick } from 'svelte';
|
||||
import type { Chat } from '.';
|
||||
import Tab from '../Tab.svelte';
|
||||
import ChatComponent from './Chat.svelte';
|
||||
|
||||
interface Props {
|
||||
datasets: Table[];
|
||||
chats?: Chat[];
|
||||
focused?: number;
|
||||
onCloseAllTab?: () => void;
|
||||
onOpenInEditor?: (sql: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
chats = $bindable([]),
|
||||
focused = $bindable(0),
|
||||
datasets,
|
||||
onCloseAllTab,
|
||||
onOpenInEditor
|
||||
}: Props = $props();
|
||||
|
||||
const current = $derived(chats.at(focused));
|
||||
let scrollContainer = $state<HTMLElement>();
|
||||
|
||||
function close(index: number) {
|
||||
chats = chats.toSpliced(index, 1);
|
||||
focused = Math.max(0, focused - 1);
|
||||
if (chats.length === 0) onCloseAllTab?.();
|
||||
}
|
||||
|
||||
async function add(name: string) {
|
||||
chats = [...chats, { id: crypto.randomUUID(), messages: [], name, dataset: datasets.at(0) }];
|
||||
focused = chats.length - 1;
|
||||
await tick();
|
||||
scrollContainer?.scroll({ left: scrollContainer.scrollWidth, behavior: 'smooth' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<nav>
|
||||
<div class="scroll-tab-container" bind:this={scrollContainer}>
|
||||
{#each chats as chat, index (chat.id)}
|
||||
<Tab
|
||||
active={index === focused}
|
||||
label={chat.name}
|
||||
onClose={() => close(index)}
|
||||
onSelect={() => (focused = index)}
|
||||
/>
|
||||
{/each}
|
||||
<button class="add-chat" onclick={() => add('New Chat')}><Plus size="14" /></button>
|
||||
</div>
|
||||
</nav>
|
||||
<div>
|
||||
{#if current}
|
||||
<ChatComponent
|
||||
bind:dataset={current.dataset}
|
||||
{datasets}
|
||||
bind:messages={current.messages}
|
||||
onClearConversation={() => (current.messages = [])}
|
||||
{onOpenInEditor}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
background-color: hsl(0deg 0% 5%);
|
||||
border-left: 1px solid hsl(0deg 0% 20%);
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: 28px 1fr;
|
||||
}
|
||||
|
||||
nav {
|
||||
height: 100%;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
& > div {
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
scrollbar-width: none;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 1px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: hsl(0deg 0% 20%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
& button.add-chat {
|
||||
margin-left: 4px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: 1;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
color: hsl(0deg 0% 80%);
|
||||
border: none;
|
||||
|
||||
&:disabled {
|
||||
color: hsl(0deg 0% 65%);
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
color: hsl(0deg 0% 90%);
|
||||
background-color: hsl(0deg 0% 10%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Reset button style */
|
||||
button {
|
||||
appearance: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
11
src/lib/components/Ai/index.ts
Normal file
11
src/lib/components/Ai/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
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;
|
||||
}
|
||||
12
src/lib/components/Ai/types.ts
Normal file
12
src/lib/components/Ai/types.ts
Normal file
@@ -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 };
|
||||
}
|
||||
87
src/lib/components/Select.svelte
Normal file
87
src/lib/components/Select.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { portal } from '$lib/actions/portal.svelte';
|
||||
import { useResizeObserver } from '$lib/utilities/useResizeObserver.svelte';
|
||||
import { computePosition, flip, offset, shift, size, type Placement } from '@floating-ui/dom';
|
||||
import { type Snippet } from 'svelte';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
placement?: Placement;
|
||||
children?: Snippet;
|
||||
onClose?: () => void;
|
||||
anchor_size?: boolean;
|
||||
}
|
||||
|
||||
let { placement = 'bottom-start', children, onClose, anchor_size = false }: Props = $props();
|
||||
|
||||
let opened = $state(false);
|
||||
let dropdown = $state<HTMLElement>();
|
||||
let anchor = $state.raw<HTMLElement>();
|
||||
|
||||
$effect(() => void updatePosition());
|
||||
|
||||
export function close() {
|
||||
opened = false;
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
export function open(target?: HTMLElement) {
|
||||
if (anchor !== target) anchor = target;
|
||||
opened = true;
|
||||
}
|
||||
|
||||
async function updatePosition() {
|
||||
if (opened && anchor && dropdown) {
|
||||
const { x, y } = await computePosition(anchor, dropdown, {
|
||||
placement,
|
||||
middleware: [
|
||||
size({
|
||||
apply({ elements, rects }) {
|
||||
if (anchor_size)
|
||||
Object.assign(elements.floating.style, { minWidth: `${rects.reference.width}px` });
|
||||
}
|
||||
}),
|
||||
offset(5),
|
||||
flip(),
|
||||
shift({ padding: 5 })
|
||||
]
|
||||
});
|
||||
Object.assign(dropdown.style, { left: `${x}px`, top: `${y}px` });
|
||||
}
|
||||
}
|
||||
|
||||
useResizeObserver(
|
||||
() => dropdown,
|
||||
() => updatePosition()
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:window onresize={updatePosition} onscroll={updatePosition} />
|
||||
|
||||
{#if opened}
|
||||
<div
|
||||
use:portal
|
||||
class="backdrop"
|
||||
transition:fade={{ duration: 150 }}
|
||||
role="presentation"
|
||||
onclick={(e) => e.target === e.currentTarget && close()}
|
||||
>
|
||||
<div role="dialog" bind:this={dropdown} transition:fly={{ duration: 150, y: -10 }}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
div.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: transparent;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
div[role='dialog'] {
|
||||
position: absolute;
|
||||
box-shadow: 0 2px 12px 0 hsl(0deg 0% 0% / 20%);
|
||||
}
|
||||
</style>
|
||||
@@ -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();
|
||||
</script>
|
||||
|
||||
<div class="tab" class:active role="button" onclick={onSelect} tabindex="0" onkeyup={() => {}}>
|
||||
<div class:active role="tab" onclick={onSelect} tabindex="0" onkeyup={() => {}}>
|
||||
<span>{label}</span>
|
||||
|
||||
{#if !hideClose}
|
||||
|
||||
15
src/lib/icons/ArrowUp.svelte
Normal file
15
src/lib/icons/ArrowUp.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { SvelteHTMLElements } from 'svelte/elements';
|
||||
|
||||
interface Props extends Omit<SvelteHTMLElements['svg'], 'width' | 'height' | 'fill'> {
|
||||
size?: string | number | null;
|
||||
}
|
||||
|
||||
let { size = 24, color = 'currentColor', ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} fill={color} viewBox="0 0 256 256" {...rest}>
|
||||
<path
|
||||
d="M205.66,117.66a8,8,0,0,1-11.32,0L136,59.31V216a8,8,0,0,1-16,0V59.31L61.66,117.66a8,8,0,0,1-11.32-11.32l72-72a8,8,0,0,1,11.32,0l72,72A8,8,0,0,1,205.66,117.66Z"
|
||||
/>
|
||||
</svg>
|
||||
27
src/lib/icons/CircleStack.svelte
Normal file
27
src/lib/icons/CircleStack.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type { SvelteHTMLElements } from 'svelte/elements';
|
||||
|
||||
interface Props extends Omit<SvelteHTMLElements['svg'], 'width' | 'height'> {
|
||||
size?: string | number | null;
|
||||
}
|
||||
|
||||
let { size = 24, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
width={size}
|
||||
height={size}
|
||||
{...rest}
|
||||
data-name="circle-stack"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"
|
||||
/>
|
||||
</svg>
|
||||
25
src/lib/icons/CircleStopSolid.svelte
Normal file
25
src/lib/icons/CircleStopSolid.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { SvelteHTMLElements } from 'svelte/elements';
|
||||
|
||||
interface Props extends Omit<SvelteHTMLElements['svg'], 'width' | 'height'> {
|
||||
size?: string | number | null;
|
||||
}
|
||||
|
||||
let { size = 24, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
width={size}
|
||||
height={size}
|
||||
{...rest}
|
||||
data-name="circle-stop-solid"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 0 1-1.313-1.313V9.564Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
16
src/lib/icons/PanelRight.svelte
Normal file
16
src/lib/icons/PanelRight.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { SvelteHTMLElements } from 'svelte/elements';
|
||||
|
||||
interface Props extends Omit<SvelteHTMLElements['svg'], 'width' | 'height'> {
|
||||
size?: string | number | null;
|
||||
}
|
||||
|
||||
let { size = 24, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} viewBox="0 0 20 20" {...rest} data-name="panel-right">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M15 3a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3zM5 4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h6.5V4z"
|
||||
/>
|
||||
</svg>
|
||||
27
src/lib/icons/PaperClip.svelte
Normal file
27
src/lib/icons/PaperClip.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type { SvelteHTMLElements } from 'svelte/elements';
|
||||
|
||||
interface Props extends Omit<SvelteHTMLElements['svg'], 'width' | 'height'> {
|
||||
size?: string | number | null;
|
||||
}
|
||||
|
||||
let { size = 24, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
width={size}
|
||||
height={size}
|
||||
{...rest}
|
||||
data-name="paper-clip"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"
|
||||
/>
|
||||
</svg>
|
||||
15
src/lib/icons/Send.svelte
Normal file
15
src/lib/icons/Send.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { SvelteHTMLElements } from 'svelte/elements';
|
||||
|
||||
interface Props extends Omit<SvelteHTMLElements['svg'], 'width' | 'height'> {
|
||||
size?: string | number | null;
|
||||
}
|
||||
|
||||
let { size = 24, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor" {...rest}>
|
||||
<path
|
||||
d="M1 1.91L1.78 1.5L15 7.44899V8.3999L1.78 14.33L1 13.91L2.58311 8L1 1.91ZM3.6118 8.5L2.33037 13.1295L13.5 7.8999L2.33037 2.83859L3.6118 7.43874L9 7.5V8.5H3.6118Z"
|
||||
/>
|
||||
</svg>
|
||||
26
src/lib/icons/Sparkles.svelte
Normal file
26
src/lib/icons/Sparkles.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { SvelteHTMLElements } from 'svelte/elements';
|
||||
|
||||
interface Props extends Omit<SvelteHTMLElements['svg'], 'width' | 'height'> {
|
||||
size?: string | number | null;
|
||||
}
|
||||
|
||||
let { size = 24, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
{...rest}
|
||||
fill="none"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
data-name="sparkles"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z"
|
||||
/>
|
||||
</svg>
|
||||
15
src/lib/icons/UserCircle.svelte
Normal file
15
src/lib/icons/UserCircle.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { SvelteHTMLElements } from 'svelte/elements';
|
||||
|
||||
interface Props extends Omit<SvelteHTMLElements['svg'], 'width' | 'height' | 'fill'> {
|
||||
size?: string | number | null;
|
||||
}
|
||||
|
||||
let { size = 24, color = 'currentColor', ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} fill={color} viewBox="0 0 256 256" {...rest}>
|
||||
<path
|
||||
d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24ZM74.08,197.5a64,64,0,0,1,107.84,0,87.83,87.83,0,0,1-107.84,0ZM96,120a32,32,0,1,1,32,32A32,32,0,0,1,96,120Zm97.76,66.41a79.66,79.66,0,0,0-36.06-28.75,48,48,0,1,0-59.4,0,79.66,79.66,0,0,0-36.06,28.75,88,88,0,1,1,131.52,0Z"
|
||||
/>
|
||||
</svg>
|
||||
3
src/lib/icons/arrow-top-right-on-square.svg
Normal file
3
src/lib/icons/arrow-top-right-on-square.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="#e6e6e6" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 326 B |
3
src/lib/icons/check.svg
Normal file
3
src/lib/icons/check.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="#e6e6e6" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 215 B |
3
src/lib/icons/copy.svg
Normal file
3
src/lib/icons/copy.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="#e6e6e6" viewBox="0 0 256 256">
|
||||
<path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |
45
src/lib/markdown/index.ts
Normal file
45
src/lib/markdown/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import hljs from 'highlight.js';
|
||||
import { Marked } from 'marked';
|
||||
import './markdown.css';
|
||||
|
||||
export function transform(content: string) {
|
||||
const marked = new Marked({
|
||||
renderer: {
|
||||
code({ text, lang = 'text' }) {
|
||||
const language = hljs.getLanguage(lang) ? lang : 'text';
|
||||
|
||||
let html = '<div class="code-block">';
|
||||
if (lang === 'sql') {
|
||||
html += '<div class="controls">';
|
||||
html += '<button type="button" class="copy" title="copy to clipboard"></button>';
|
||||
html += '<button type="button" class="open" title="open in editor"></button>';
|
||||
html += '</div>';
|
||||
}
|
||||
html += `<pre><code class="hljs language-${language}">`;
|
||||
html += hljs.highlight(text, { language }).value;
|
||||
html += '</code></pre>';
|
||||
html += '</div>';
|
||||
|
||||
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;
|
||||
}
|
||||
184
src/lib/markdown/markdown.css
Normal file
184
src/lib/markdown/markdown.css
Normal file
@@ -0,0 +1,184 @@
|
||||
@import 'highlight.js/styles/default.min.css';
|
||||
@import 'highlight.js/styles/vs2015.min.css';
|
||||
|
||||
.markdown {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
word-break: break-word;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& > p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
& > p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: palevioletred;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.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,
|
||||
.hljs-meta .hljs-keyword,
|
||||
.hljs-name,
|
||||
.hljs-selector-tag {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/lib/migrations/004_create_chats_table.sql
Normal file
8
src/lib/migrations/004_create_chats_table.sql
Normal file
@@ -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
|
||||
);
|
||||
@@ -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 }
|
||||
];
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface ColumnDescriptor {
|
||||
export interface Table {
|
||||
name: string;
|
||||
engine: string;
|
||||
short: string;
|
||||
url: string;
|
||||
columns: ColumnDescriptor[];
|
||||
}
|
||||
|
||||
|
||||
71
src/lib/repositories/chats.ts
Normal file
71
src/lib/repositories/chats.ts
Normal file
@@ -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<void>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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';
|
||||
|
||||
20
src/lib/utilities/useResizeObserver.svelte.ts
Normal file
20
src/lib/utilities/useResizeObserver.svelte.ts
Normal file
@@ -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;
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { AiPanel, type Chat } from '$lib/components/Ai';
|
||||
import type { Log } from '$lib/components/Console.svelte';
|
||||
import { ContextMenuState } from '$lib/components/ContextMenu';
|
||||
import ContextMenu from '$lib/components/ContextMenu/ContextMenu.svelte';
|
||||
@@ -12,20 +13,22 @@
|
||||
import TabComponent from '$lib/components/Tab.svelte';
|
||||
import TimeCounter from '$lib/components/TimeCounter.svelte';
|
||||
import { setAppContext } from '$lib/context';
|
||||
import { store } from '$lib/store';
|
||||
import { FileDropEventManager } from '$lib/FileDropEventManager';
|
||||
import Bars3 from '$lib/icons/Bars3.svelte';
|
||||
import Bold from '$lib/icons/Bold.svelte';
|
||||
import Bolt from '$lib/icons/Bolt.svelte';
|
||||
import Copy from '$lib/icons/Copy.svelte';
|
||||
import MagicWand from '$lib/icons/MagicWand.svelte';
|
||||
import PanelBottom from '$lib/icons/PanelBottom.svelte';
|
||||
import PanelLeft from '$lib/icons/PanelLeft.svelte';
|
||||
import PanelRight from '$lib/icons/PanelRight.svelte';
|
||||
import Play from '$lib/icons/Play.svelte';
|
||||
import Plus from '$lib/icons/Plus.svelte';
|
||||
import Save from '$lib/icons/Save.svelte';
|
||||
import Sparkles from '$lib/icons/Sparkles.svelte';
|
||||
import type { Table } from '$lib/olap-engine';
|
||||
import { engine, type OLAPResponse } from '$lib/olap-engine';
|
||||
import { PanelState } from '$lib/PanelState.svelte';
|
||||
import { SQLiteChatsRepository, type ChatsRepository } from '$lib/repositories/chats';
|
||||
import {
|
||||
SQLiteHistoryRepository,
|
||||
type HistoryEntry,
|
||||
@@ -37,6 +40,7 @@
|
||||
type QueryRepository
|
||||
} from '$lib/repositories/queries';
|
||||
import { SQLiteTabRepository, type Tab, type TabRepository } from '$lib/repositories/tabs';
|
||||
import { store } from '$lib/store';
|
||||
import { IndexedDBCache } from '@agnosticeng/cache';
|
||||
import { SplitPane } from '@rich_harris/svelte-split-pane';
|
||||
import debounce from 'p-debounce';
|
||||
@@ -46,6 +50,7 @@
|
||||
const historyRepository: HistoryRepository = new SQLiteHistoryRepository(store);
|
||||
const queryRepository: QueryRepository = new SQLiteQueryRepository(store);
|
||||
const tabRepository: TabRepository = new SQLiteTabRepository(store);
|
||||
const chatsRepository: ChatsRepository = new SQLiteChatsRepository(store);
|
||||
|
||||
let response = $state.raw<OLAPResponse>();
|
||||
let loading = $state(false);
|
||||
@@ -137,7 +142,7 @@
|
||||
selectedTabIndex =
|
||||
tabs.push({ id: crypto.randomUUID(), content: entry.content, name: 'Untitled' }) - 1;
|
||||
} else tabs[selectedTabIndex] = { ...currentTab, content: entry.content };
|
||||
if (isMobile) drawerOpened = false;
|
||||
if (isMobile) leftDrawerOpened = false;
|
||||
}
|
||||
|
||||
async function handleHistoryDelete(entry: HistoryEntry) {
|
||||
@@ -195,7 +200,7 @@
|
||||
};
|
||||
} else selectedTabIndex = index;
|
||||
|
||||
if (isMobile) drawerOpened = false;
|
||||
if (isMobile) leftDrawerOpened = false;
|
||||
}
|
||||
|
||||
async function handleQueryRename(query: Query) {
|
||||
@@ -227,7 +232,8 @@
|
||||
|
||||
let screenWidth = $state(0);
|
||||
let isMobile = $derived(screenWidth < 768 && PLATFORM === 'WEB');
|
||||
let drawerOpened = $state(false);
|
||||
let leftDrawerOpened = $state(false);
|
||||
let rightDrawerOpened = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (isMobile) {
|
||||
@@ -277,8 +283,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', true);
|
||||
|
||||
let bottomPanelTab = $state<'data' | 'chart' | 'logs'>('data');
|
||||
let errors = $state.raw<Log[]>([]);
|
||||
@@ -334,6 +341,27 @@ LIMIT 100;`;
|
||||
selectedTabIndex = tabs.push({ id: crypto.randomUUID(), content, name: 'Untitled' }) - 1;
|
||||
else currentTab.content = content;
|
||||
}
|
||||
|
||||
let chats = $state<Chat[]>([]);
|
||||
let focusedChat = $state(0);
|
||||
function onRightPanelOpen() {
|
||||
if (chats.length === 0) {
|
||||
chats = [{ id: crypto.randomUUID(), messages: [], name: 'New Chat', dataset: tables.at(0) }];
|
||||
}
|
||||
}
|
||||
|
||||
const saveChat = debounce(
|
||||
(chats: Chat[], active: number) => chatsRepository.save(chats, active),
|
||||
2_000
|
||||
);
|
||||
$effect(() => void saveChat($state.snapshot(chats), focusedChat).catch(console.error));
|
||||
$effect(
|
||||
() =>
|
||||
void chatsRepository.list().then(([c, active]) => {
|
||||
if (c.length) (chats = c), (focusedChat = active);
|
||||
else onRightPanelOpen();
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} bind:innerWidth={screenWidth} />
|
||||
@@ -353,12 +381,28 @@ LIMIT 100;`;
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet ai()}
|
||||
<AiPanel
|
||||
bind:chats
|
||||
bind:focused={focusedChat}
|
||||
datasets={tables}
|
||||
onCloseAllTab={() => {
|
||||
if (isMobile) rightDrawerOpened = false;
|
||||
else rightPanel.open = false;
|
||||
}}
|
||||
onOpenInEditor={openNewTabIfNeeded}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<section class="screen" class:is-mobile={isMobile}>
|
||||
<div class="workspace">
|
||||
{#if isMobile}
|
||||
<Drawer bind:open={drawerOpened} width={242}>
|
||||
<Drawer bind:open={leftDrawerOpened} width={242}>
|
||||
{@render sidebar()}
|
||||
</Drawer>
|
||||
<Drawer position="right" bind:open={rightDrawerOpened} width={300}>
|
||||
{@render ai()}
|
||||
</Drawer>
|
||||
{/if}
|
||||
<SplitPane
|
||||
type="horizontal"
|
||||
@@ -374,86 +418,118 @@ LIMIT 100;`;
|
||||
{/snippet}
|
||||
{#snippet b()}
|
||||
<SplitPane
|
||||
type="vertical"
|
||||
min="20%"
|
||||
max={bottomPanel.open ? '80%' : '100%'}
|
||||
bind:pos={bottomPanel.position}
|
||||
disabled={!bottomPanel.open}
|
||||
--color="hsl(0deg 0% 20%)"
|
||||
type="horizontal"
|
||||
disabled={!rightPanel.open || isMobile}
|
||||
bind:pos={rightPanel.position}
|
||||
max="-300px"
|
||||
min={rightPanel.open && !isMobile ? '-48rem' : '100%'}
|
||||
>
|
||||
{#snippet a()}
|
||||
<div>
|
||||
<nav class="navigation">
|
||||
<div class="tabs-container" bind:this={tabContainer}>
|
||||
{#if isMobile}
|
||||
<button class="action burger" onclick={() => (drawerOpened = true)}>
|
||||
<Bars3 size="12" />
|
||||
</button>
|
||||
{/if}
|
||||
{#each tabs as tab, i}
|
||||
<TabComponent
|
||||
hide-close={tabs.length === 1}
|
||||
active={i === selectedTabIndex}
|
||||
label={tab.name}
|
||||
onClose={() => closeTab(i)}
|
||||
onSelect={() => (selectedTabIndex = i)}
|
||||
/>
|
||||
<SplitPane
|
||||
type="vertical"
|
||||
max="-20%"
|
||||
min={bottomPanel.open ? '-80%' : '100%'}
|
||||
bind:pos={bottomPanel.position}
|
||||
disabled={!bottomPanel.open}
|
||||
--color="hsl(0deg 0% 20%)"
|
||||
>
|
||||
{#snippet a()}
|
||||
<div>
|
||||
<nav class="navigation">
|
||||
<div class="tabs-container" bind:this={tabContainer}>
|
||||
{#if isMobile}
|
||||
<button class="action burger" onclick={() => (leftDrawerOpened = true)}>
|
||||
<Bars3 size="12" />
|
||||
</button>
|
||||
{/if}
|
||||
{#each tabs as tab, i}
|
||||
<TabComponent
|
||||
hide-close={tabs.length === 1}
|
||||
active={i === selectedTabIndex}
|
||||
label={tab.name}
|
||||
onClose={() => closeTab(i)}
|
||||
onSelect={() => (selectedTabIndex = i)}
|
||||
/>
|
||||
{/each}
|
||||
<button
|
||||
onclick={addNewTab}
|
||||
class="add-new"
|
||||
aria-label="Open new tab"
|
||||
title="Open new tab"
|
||||
>
|
||||
<Plus size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="workspace-actions">
|
||||
<button
|
||||
class="action"
|
||||
title="Copy"
|
||||
onclick={() => navigator.clipboard.writeText(currentTab.content)}
|
||||
>
|
||||
<Copy size="12" />
|
||||
</button>
|
||||
<button class="action" title="Format" onclick={handleFormat}>
|
||||
<MagicWand size="12" />
|
||||
</button>
|
||||
<button
|
||||
class="action"
|
||||
title="Save"
|
||||
onclick={handleSaveQuery}
|
||||
disabled={!canSave}
|
||||
>
|
||||
<Save size="12" />
|
||||
</button>
|
||||
<button
|
||||
class="action"
|
||||
title="Run"
|
||||
onclick={() => handleExec()}
|
||||
disabled={loading}
|
||||
>
|
||||
<Play size="12" />
|
||||
</button>
|
||||
<button
|
||||
class="action"
|
||||
title="Force run"
|
||||
onclick={() => handleExec(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Bolt size="12" />
|
||||
</button>
|
||||
{#if isMobile}
|
||||
<button
|
||||
class="action"
|
||||
title="Toggle AI chat"
|
||||
onclick={() => {
|
||||
rightDrawerOpened = true;
|
||||
onRightPanelOpen();
|
||||
}}
|
||||
>
|
||||
<Sparkles size="12" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
{#each tabs as tab, i (tab.id)}
|
||||
<div style:display={selectedTabIndex == i ? 'block' : 'none'}>
|
||||
<Editor bind:value={tab.content} />
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
onclick={addNewTab}
|
||||
class="add-new"
|
||||
aria-label="Open new tab"
|
||||
title="Open new tab"
|
||||
>
|
||||
<Plus size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="workspace-actions">
|
||||
<button
|
||||
class="action"
|
||||
title="Copy"
|
||||
onclick={() => navigator.clipboard.writeText(currentTab.content)}
|
||||
>
|
||||
<Copy size="12" />
|
||||
</button>
|
||||
<button class="action" title="Format" onclick={handleFormat}>
|
||||
<MagicWand size="12" />
|
||||
</button>
|
||||
<button class="action" title="Save" onclick={handleSaveQuery} disabled={!canSave}>
|
||||
<Save size="12" />
|
||||
</button>
|
||||
<button
|
||||
class="action"
|
||||
title="Run"
|
||||
onclick={() => handleExec()}
|
||||
disabled={loading}
|
||||
>
|
||||
<Play size="12" />
|
||||
</button>
|
||||
<button
|
||||
class="action"
|
||||
title="Force run"
|
||||
onclick={() => handleExec(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Bold size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
{#each tabs as tab, i (tab.id)}
|
||||
<div style:display={selectedTabIndex == i ? 'block' : 'none'}>
|
||||
<Editor bind:value={tab.content} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet b()}
|
||||
<Result
|
||||
{response}
|
||||
logs={errors}
|
||||
bind:tab={bottomPanelTab}
|
||||
onClearLogs={() => (errors = [])}
|
||||
/>
|
||||
{/snippet}
|
||||
</SplitPane>
|
||||
{/snippet}
|
||||
{#snippet b()}
|
||||
<Result
|
||||
{response}
|
||||
logs={errors}
|
||||
bind:tab={bottomPanelTab}
|
||||
onClearLogs={() => (errors = [])}
|
||||
/>
|
||||
{#if !isMobile}
|
||||
{@render ai()}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SplitPane>
|
||||
{/snippet}
|
||||
@@ -470,7 +546,7 @@ LIMIT 100;`;
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
{#if cached}
|
||||
<span>from cache</span>
|
||||
<span class="label">from cache</span>
|
||||
{/if}
|
||||
<TimeCounter bind:this={counter} />
|
||||
{#if BUILD}
|
||||
@@ -479,10 +555,19 @@ LIMIT 100;`;
|
||||
<button
|
||||
class:active={bottomPanel.open}
|
||||
onclick={() => (bottomPanel.open = !bottomPanel.open)}
|
||||
style:margin-right="7px"
|
||||
>
|
||||
<PanelBottom size="12" />
|
||||
</button>
|
||||
<button
|
||||
class:active={rightPanel.open}
|
||||
onclick={() => {
|
||||
rightPanel.open = !rightPanel.open;
|
||||
onRightPanelOpen();
|
||||
}}
|
||||
style:margin-right="7px"
|
||||
>
|
||||
<PanelRight size="12" />
|
||||
</button>
|
||||
</footer>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -512,7 +597,6 @@ LIMIT 100;`;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
@@ -602,7 +686,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;
|
||||
@@ -619,6 +703,7 @@ LIMIT 100;`;
|
||||
|
||||
& > button {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
aspect-ratio: 1;
|
||||
flex-shrink: 0;
|
||||
background-color: transparent;
|
||||
|
||||
Reference in New Issue
Block a user