Merge pull request #101 from agnosticeng/feat/ai-assistant

This commit is contained in:
Yann Amsellem
2025-04-10 18:56:30 +02:00
committed by GitHub
35 changed files with 1618 additions and 91 deletions

59
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View 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>;

View 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);
};
});
}

View 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;

View 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>

View 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>

View 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>

View 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>

View 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;
}

View 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 };
}

View 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>

View File

@@ -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}

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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;
}

View 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;
}
}
}

View 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
);

View File

@@ -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 }
];

View File

@@ -21,6 +21,8 @@ export interface ColumnDescriptor {
export interface Table {
name: string;
engine: string;
short: string;
url: string;
columns: ColumnDescriptor[];
}

View 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
};
}
}

View File

@@ -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) => [

View File

@@ -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';

View 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;
};
});
}

View File

@@ -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;