summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/chat')
-rw-r--r--src/features/chat/model-select.test.ts58
-rw-r--r--src/features/chat/model-select.ts49
-rw-r--r--src/features/chat/ui.test.ts50
-rw-r--r--src/features/chat/ui/ModelSelector.svelte48
4 files changed, 179 insertions, 26 deletions
diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts
new file mode 100644
index 0000000..109cae1
--- /dev/null
+++ b/src/features/chat/model-select.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+import { joinModelName, modelKeys, modelsForKey, splitModelName } from "./model-select";
+
+describe("splitModelName", () => {
+ it("splits on the first slash", () => {
+ expect(splitModelName("openai/gpt-4")).toEqual({ key: "openai", model: "gpt-4" });
+ });
+
+ it("keeps slashes in the model part (splits only the first)", () => {
+ expect(splitModelName("openrouter/anthropic/claude")).toEqual({
+ key: "openrouter",
+ model: "anthropic/claude",
+ });
+ });
+
+ it("treats a slashless name as all key", () => {
+ expect(splitModelName("local")).toEqual({ key: "local", model: "" });
+ });
+});
+
+describe("joinModelName", () => {
+ it("recombines key + model", () => {
+ expect(joinModelName("openai", "gpt-4")).toBe("openai/gpt-4");
+ });
+
+ it("returns just the key when the model is empty", () => {
+ expect(joinModelName("local", "")).toBe("local");
+ });
+
+ it("round-trips with splitModelName", () => {
+ const full = "openrouter/anthropic/claude";
+ const { key, model } = splitModelName(full);
+ expect(joinModelName(key, model)).toBe(full);
+ });
+});
+
+describe("modelKeys", () => {
+ it("returns distinct keys in first-seen order", () => {
+ expect(
+ modelKeys(["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]),
+ ).toEqual(["openai", "anthropic", "google"]);
+ });
+
+ it("is empty for no models", () => {
+ expect(modelKeys([])).toEqual([]);
+ });
+});
+
+describe("modelsForKey", () => {
+ it("returns the model suffixes under a key, in order", () => {
+ const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"];
+ expect(modelsForKey(models, "openai")).toEqual(["gpt-4", "gpt-4o"]);
+ });
+
+ it("returns empty for an unknown key", () => {
+ expect(modelsForKey(["openai/gpt-4"], "anthropic")).toEqual([]);
+ });
+});
diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts
new file mode 100644
index 0000000..b1d70b9
--- /dev/null
+++ b/src/features/chat/model-select.ts
@@ -0,0 +1,49 @@
+/**
+ * Pure helpers for the two-step model picker.
+ *
+ * Models arrive from `GET /models` as `<key>/<model>` strings, where `key` is
+ * the credential name (the part before the FIRST slash) and `model` is the rest.
+ * These pure functions split that into a key selector + a model selector and
+ * recombine the choice — zero DOM, zero Svelte.
+ */
+
+export interface SplitModel {
+ readonly key: string;
+ readonly model: string;
+}
+
+/** Split `<key>/<model>` on the first slash. A slashless name is all-key. */
+export function splitModelName(full: string): SplitModel {
+ const i = full.indexOf("/");
+ if (i === -1) return { key: full, model: "" };
+ return { key: full.slice(0, i), model: full.slice(i + 1) };
+}
+
+/** Recombine a key + model into a `<key>/<model>` name (key-only if no model). */
+export function joinModelName(key: string, model: string): string {
+ return model === "" ? key : `${key}/${model}`;
+}
+
+/** Distinct keys across all models, in first-seen order. */
+export function modelKeys(models: readonly string[]): string[] {
+ const seen = new Set<string>();
+ const out: string[] = [];
+ for (const full of models) {
+ const { key } = splitModelName(full);
+ if (!seen.has(key)) {
+ seen.add(key);
+ out.push(key);
+ }
+ }
+ return out;
+}
+
+/** The model suffixes available under a given key, in order. */
+export function modelsForKey(models: readonly string[], key: string): string[] {
+ const out: string[] = [];
+ for (const full of models) {
+ const split = splitModelName(full);
+ if (split.key === key) out.push(split.model);
+ }
+ return out;
+}
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index ddec388..278b2cf 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -1,5 +1,5 @@
import type { StepId } from "@dispatch/wire";
-import { render, screen } from "@testing-library/svelte";
+import { render, screen, within } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { RenderedChunk } from "../../core/chunks";
@@ -605,36 +605,54 @@ describe("Composer", () => {
});
describe("ModelSelector", () => {
- it("renders the options and current selection", () => {
- const models = ["openai/gpt-4", "anthropic/claude-3", "google/gemini"];
+ const optionValues = (el: HTMLElement): string[] =>
+ within(el)
+ .getAllByRole("option")
+ .map((o) => (o as HTMLOptionElement).value);
+
+ it("renders a key selector (distinct keys) and a model selector (models for the current key)", () => {
+ const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"];
render(ModelSelector, {
props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() },
});
- const select = screen.getByRole("combobox", { name: "Model selector" });
- expect(select).toBeInTheDocument();
- expect(select).toHaveValue("anthropic/claude-3");
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ const modelSelect = screen.getByRole("combobox", { name: "Model selector" });
+ expect(keySelect).toHaveValue("anthropic");
+ expect(modelSelect).toHaveValue("claude-3");
- const options = screen.getAllByRole("option");
- expect(options).toHaveLength(3);
- expect(options[0]).toHaveValue("openai/gpt-4");
- expect(options[1]).toHaveValue("anthropic/claude-3");
- expect(options[2]).toHaveValue("google/gemini");
+ expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]);
+ // only the models under the selected key
+ expect(optionValues(modelSelect)).toEqual(["claude-3"]);
});
- it("calls onSelect on change", async () => {
+ it("selecting a key switches to the first model under it", async () => {
const onSelect = vi.fn();
const user = userEvent.setup();
- const models = ["openai/gpt-4", "anthropic/claude-3"];
+ const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3"];
render(ModelSelector, {
- props: { models, selected: "openai/gpt-4", onSelect },
+ props: { models, selected: "openai/gpt-4o", onSelect },
});
- const select = screen.getByRole("combobox", { name: "Model selector" });
- await user.selectOptions(select, "anthropic/claude-3");
+ await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "anthropic");
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3");
});
+
+ it("selecting a model keeps the current key", async () => {
+ const onSelect = vi.fn();
+ const user = userEvent.setup();
+ const models = ["openai/gpt-4", "openai/gpt-4o"];
+
+ render(ModelSelector, {
+ props: { models, selected: "openai/gpt-4", onSelect },
+ });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Model selector" }), "gpt-4o");
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o");
+ });
});
diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte
index 3e25ec3..a288cb8 100644
--- a/src/features/chat/ui/ModelSelector.svelte
+++ b/src/features/chat/ui/ModelSelector.svelte
@@ -1,4 +1,6 @@
<script lang="ts">
+ import { joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select";
+
let {
models,
selected,
@@ -8,15 +10,41 @@
selected: string;
onSelect: (model: string) => void;
} = $props();
+
+ const keys = $derived(modelKeys(models));
+ const current = $derived(splitModelName(selected));
+ const keyModels = $derived(modelsForKey(models, current.key));
+
+ // 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));
+ }
</script>
-<select
- class="select"
- value={selected}
- onchange={(e) => onSelect(e.currentTarget.value)}
- aria-label="Model selector"
->
- {#each models as model (model)}
- <option value={model}>{model}</option>
- {/each}
-</select>
+<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}</option>
+ {/each}
+ </select>
+</div>