summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui/ModelSelector.svelte
blob: 11b9feb71c9c32f41597188f0cc26d29191df3d3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
<script lang="ts">
  import type { ModelMetadata } from "@dispatch/transport-contract";
  import { isVisionModel, joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select";

  let {
    models,
    selected,
    onSelect,
    modelInfo = {},
  }: {
    models: readonly string[];
    selected: string;
    onSelect: (model: string) => void;
    /**
     * Per-model metadata from `GET /models` (`{ [name]: ModelMetadata }`).
     * Used to show a "vision" badge next to models with `vision: true` (they
     * natively accept images; others rely on the server's vision handoff).
     * Optional — absent metadata → no badge (treated as non-vision).
     */
    modelInfo?: Readonly<Record<string, ModelMetadata>>;
  } = $props();

  const keys = $derived(modelKeys(models));
  const current = $derived(splitModelName(selected));
  const keyModels = $derived(modelsForKey(models, current.key));

  // Whether the currently-selected full model name is vision-capable.
  const selectedVision = $derived(isVisionModel(modelInfo, selected));

  // Switching key jumps to the first model available under it.
  function selectKey(key: string): void {
    const first = modelsForKey(models, key)[0] ?? "";
    onSelect(joinModelName(key, first));
  }

  function selectModel(model: string): void {
    onSelect(joinModelName(current.key, model));
  }

  // The full `<key>/<model>` name for a model suffix under the current key.
  function fullNameFor(modelSuffix: string): string {
    return joinModelName(current.key, modelSuffix);
  }
</script>

<div class="flex flex-col gap-2">
  <select
    class="select w-full"
    value={current.key}
    onchange={(e) => selectKey(e.currentTarget.value)}
    aria-label="Key selector"
  >
    {#each keys as key (key)}
      <option value={key}>{key}</option>
    {/each}
  </select>
  <select
    class="select w-full"
    value={current.model}
    onchange={(e) => selectModel(e.currentTarget.value)}
    aria-label="Model selector"
  >
    {#each keyModels as model (model)}
      <option value={model}>
        {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if}
      </option>
    {/each}
  </select>
  {#if selectedVision}
    <div class="flex items-center gap-1 text-xs text-base-content/60">
      <svg
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        class="h-3.5 w-3.5"
        aria-hidden="true"
      >
        <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
        <circle cx="12" cy="12" r="3"></circle>
      </svg>
      <span>Vision — this model sees images natively</span>
    </div>
  {:else}
    <div class="text-xs text-base-content/40">
      Pasted images are auto-described (vision handoff)
    </div>
  {/if}
</div>