feat(ai): use custom select model

This commit is contained in:
Yann Amsellem
2025-04-25 16:53:10 +02:00
parent 5b5e8e964b
commit 24b62aa6ab
4 changed files with 184 additions and 44 deletions

View File

@@ -0,0 +1,120 @@
<script lang="ts">
import type { Model } from './types';
interface Props {
models: Model[];
model: Model;
onSelect?: (model: Model) => void;
}
let { models, model = $bindable(), onSelect }: Props = $props();
</script>
<div>
<ul role="listbox">
{#each models as m (m.name + m.brand)}
{@const isSelected =
m.brand === model.brand && m.name === model.name && m.endpoint === model.endpoint}
<li role="option" aria-selected={isSelected}>
<button
title={[m.brand, m.name].filter(Boolean).join(' • ')}
type="button"
onclick={() => ((model = m), onSelect?.(m))}
>
<span class="name">{m.name}</span>
<span class="description">
<span class="tag">{m.brand}</span>
</span>
</button>
</li>
{/each}
</ul>
</div>
<style>
div {
background-color: hsl(0, 0%, 10%);
border: 1px solid hsl(0, 0%, 15%);
border-radius: 6px;
overflow: hidden;
}
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

@@ -2,12 +2,13 @@
import { autoresize } from '$lib/actions/autoresize.svelte';
import { scroll_to_bottom } from '$lib/actions/scrollToBottom.svelte';
import Select from '$lib/components/Select.svelte';
import ChevronDown from '$lib/icons/ChevronDown.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 { deserializeModel, serializeModel } from '.';
import ChangeModelBox from './ChangeModelBox.svelte';
import DatasetsBox from './DatasetsBox.svelte';
import Loader from './Loader.svelte';
import type { ChatInput, ChatOutput, Model } from './types';
@@ -37,11 +38,11 @@
let loading = $state(false);
let submitter = $state<HTMLButtonElement>();
let message = $state('');
let select = $state<ReturnType<typeof Select>>();
let datasetSelectbox = $state<ReturnType<typeof Select>>();
let textarea = $state<HTMLTextAreaElement>();
let abortController: AbortController | undefined;
let chatMessages = $derived(messages.filter((m) => m.role === 'user' || m.role === 'assistant'));
let modelsOptions = $derived(Object.entries(Object.groupBy(models, (item) => item.brand)));
let modelSelectbox = $state<ReturnType<typeof Select>>();
function getContextFromTable(table: Table): string {
const columns = table.columns.map((col) => `- ${col.name} (${col.type})`).join('\n');
@@ -195,38 +196,47 @@
</section>
<div class="submitter">
<button type="button" title="Add context" onclick={(e) => select?.open(e.currentTarget)}>
<button
type="button"
title="Add context"
onclick={(e) => datasetSelectbox?.open(e.currentTarget)}
>
<Plus size="12" />
</button>
<span class="separator"></span>
<Select bind:this={select} placement="top-start">
<Select bind:this={datasetSelectbox} placement="top-start">
<DatasetsBox
{datasets}
onSelect={() => (
select?.close(), abortController?.abort('Context changed'), onClearConversation?.()
datasetSelectbox?.close(),
abortController?.abort('Context changed'),
onClearConversation?.()
)}
bind:dataset
/>
</Select>
<select
onchange={(e) => {
const next = deserializeModel(e.currentTarget.value);
if (next) onModelChange(next);
}}
<span class="separator"></span>
<button
type="button"
title="Change model"
onclick={(e) => modelSelectbox?.open(e.currentTarget)}
disabled={models.length === 1}
class="select-trigger"
>
{#each modelsOptions as [brand, models]}
<optgroup label={brand}>
{#each models ?? [] as model}
{@const isSelected =
model.brand === selectedModel.brand &&
model.name === selectedModel.name &&
model.endpoint === selectedModel.endpoint}
<option selected={isSelected} value={serializeModel(model)}>{model.name}</option>
{/each}
</optgroup>
{/each}
</select>
<span>{selectedModel.name}</span>
{#if models.length > 1}
<ChevronDown size="12" />
{/if}
</button>
<Select bind:this={modelSelectbox} placement="top-start" id="change-model">
<ChangeModelBox
{models}
bind:model={selectedModel}
onSelect={() => {
modelSelectbox?.close();
abortController?.abort('Model changed');
}}
/>
</Select>
<span class="spacer"></span>
{#if loading}
<button
@@ -340,25 +350,6 @@
}
}
.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;
@@ -389,6 +380,18 @@
background-color: transparent;
}
}
&.select-trigger {
aspect-ratio: initial;
padding: 0 4px;
display: flex;
gap: 4px;
}
}
:global(body:has(#change-model)) .select-trigger :global(> svg) {
transform-origin: center;
transform: rotate(180deg);
}
button {

View File

@@ -10,9 +10,10 @@
children?: Snippet;
onClose?: () => void;
anchor_size?: boolean;
id?: HTMLElement['id'];
}
let { placement = 'bottom-start', children, onClose, anchor_size = false }: Props = $props();
let { placement = 'bottom-start', children, onClose, anchor_size = false, id }: Props = $props();
let opened = $state(false);
let dropdown = $state<HTMLElement>();
@@ -65,6 +66,7 @@
transition:fade={{ duration: 150 }}
role="presentation"
onclick={(e) => e.target === e.currentTarget && close()}
{id}
>
<div role="dialog" bind:this={dropdown} transition:fly={{ duration: 150, y: -10 }}>
{@render children?.()}

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} fill="currentColor" viewBox="0 0 24 24" {...rest}>
<path
d="M4.22 8.47a.75.75 0 0 1 1.06 0L12 15.19l6.72-6.72a.75.75 0 1 1 1.06 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L4.22 9.53a.75.75 0 0 1 0-1.06Z"
/>
</svg>