diff --git a/src/bots.css b/src/bots.css index 09a4eed..85a2f2c 100644 --- a/src/bots.css +++ b/src/bots.css @@ -79,6 +79,15 @@ --color-accent-soft: rgb(139 124 246 / 0.16); --color-accent-ink: #fff; --color-surface-2: var(--island-card-hover); + /* The chips, popovers and effort track of Boite's model picker (`components/model-picker/`), at Boite's dark values. */ + --color-surface-3: #1f1f26; + --color-active: rgb(255 255 255 / 0.09); + --control-glaze: linear-gradient(145deg, rgb(255 255 255 / 0.055), rgb(255 255 255 / 0.012) 48%, transparent); + --color-control-hover: var(--color-surface-3); + --color-reasoning: var(--color-accent); + --color-reasoning-on: #fff; + --shadow-e1: 0 1px 2px rgb(0 0 0 / 0.35), inset 0 1px 0 rgb(255 255 255 / 0.05); + --shadow-e2: 0 8px 24px rgb(0 0 0 / 0.45), 0 0 0 1px rgb(255 255 255 / 0.05); --color-success: var(--island-done); --color-live: var(--island-attention); --color-danger: var(--island-danger); @@ -87,6 +96,11 @@ font-family: var(--island-font); } +/* Logos drawn for both themes take their dark colours on the island's black, whatever Boite's theme. */ +:root[data-theme] .island-tokens [data-logo] .themed { + fill: var(--fill-dark); +} + /* A card of the island's views and of Settings, and the small heading over it. */ .island-card { padding: 10px 12px; diff --git a/src/components/island/Island.svelte b/src/components/island/Island.svelte index 8397a3d..e6a0694 100644 --- a/src/components/island/Island.svelte +++ b/src/components/island/Island.svelte @@ -204,6 +204,7 @@ return frame.call(key); }, hold: (key, on) => frame.hold(key, on), + resized: () => onresize(), reveal: (threadId) => showMain(threadId), show: (tab) => frame.show(tab), sessions, diff --git a/src/components/island/assistant/AssistantChat.svelte b/src/components/island/assistant/AssistantChat.svelte index 862ee2f..6488655 100644 --- a/src/components/island/assistant/AssistantChat.svelte +++ b/src/components/island/assistant/AssistantChat.svelte @@ -29,6 +29,7 @@ const copy = strings.island.assistant; let list = $state(null); + let head = $state(null); let field = $state(null); /** The reader is at the end: what streams in keeps it there. */ let pinned = true; @@ -115,10 +116,10 @@ {:else} -
+
{#if agent} - + head} /> - - {#if open} - - {/if} - +{#if store} +
+ (pickerOpen = shown)} onpick={(patch) => void assistant.setModel(patch.model, patch)} /> + {#if levels.length > 0} + (effortOpen = shown)} onpick={(id) => void assistant.setEffort(id)} /> + {/if} +
+{/if} diff --git a/src/components/model-picker/AccountSeats.svelte b/src/components/model-picker/AccountSeats.svelte new file mode 100644 index 0000000..944b5c9 --- /dev/null +++ b/src/components/model-picker/AccountSeats.svelte @@ -0,0 +1,144 @@ + + + +{#if seats.length > 1} +
+ {#each seats as seat (seat.id)} + + {/each} +
+{/if} + + diff --git a/src/components/model-picker/CompanionModelPicker.svelte b/src/components/model-picker/CompanionModelPicker.svelte new file mode 100644 index 0000000..ac5b21e --- /dev/null +++ b/src/components/model-picker/CompanionModelPicker.svelte @@ -0,0 +1,68 @@ + + + +
+ save(companionPick('auto')) }} onpick={(patch: PickPatch) => save(companionPick(patch))} /> + {#if choice && levels.length > 0} + save({ effort: id })} /> + {/if} + {#if picker.error}{/if} +
+ + diff --git a/src/components/model-picker/EffortSlider.svelte b/src/components/model-picker/EffortSlider.svelte new file mode 100644 index 0000000..bc12bff --- /dev/null +++ b/src/components/model-picker/EffortSlider.svelte @@ -0,0 +1,343 @@ + + + + + +
+ + + {#if popover.shown} + + {/if} +
+ + diff --git a/src/components/model-picker/ModelPicker.svelte b/src/components/model-picker/ModelPicker.svelte new file mode 100644 index 0000000..7b91b29 --- /dev/null +++ b/src/components/model-picker/ModelPicker.svelte @@ -0,0 +1,815 @@ + + + + + +
+ {#if !choice && !auto && store.owner && store.opensSettings} + + + {:else} + + {/if} + + {#if popover.shown} + + {/if} + {#if popover.shown && legacy.shown && !favoritesOpen && !autoOpen && !pending && filteredLegacy.length > 0} + + {/if} +
+ + diff --git a/src/components/model-picker/ProviderTiles.svelte b/src/components/model-picker/ProviderTiles.svelte new file mode 100644 index 0000000..3bcd026 --- /dev/null +++ b/src/components/model-picker/ProviderTiles.svelte @@ -0,0 +1,159 @@ + + + +{#if auto} + +{/if} + +{#each tiles as tile (tile.provider.id)} + +{/each} +{#if onmore && listed.length < store.providers.length} + +{/if} + + diff --git a/src/lib/core.svelte.ts b/src/lib/core.svelte.ts index a65ce2a..84b7b66 100644 --- a/src/lib/core.svelte.ts +++ b/src/lib/core.svelte.ts @@ -19,7 +19,7 @@ import { type ProviderSummary, type Settings } from '@boite/contracts'; -import { WsClient, type ClientState, type ObservableClient } from '@boite/ui/lib/client'; +import { WsClient, type Client, type ClientState, type ObservableClient } from '@boite/ui/lib/client'; import { waitForCore, watchEndpoint } from './connection'; const probeKey = (providerId: ProviderId, accountId: string) => `${providerId}::${accountId}`; @@ -35,6 +35,8 @@ export class CoreLink { projects = $state.raw([]); settings = $state.raw(null); probedModels = $state.raw>({}); + /** The model reads running, by `providerId::accountId`: the model picker says it is reading. */ + probing = $state.raw([]); #off: (() => void)[] = []; #stopped = false; /** The model reads asked of the core, by agent and account, answered or not. */ @@ -49,19 +51,33 @@ export class CoreLink { async start(make: () => Promise = () => this.#open()): Promise { const client = await make(); if (!client || this.#stopped) return client?.close(); - this.client = client; - this.#off.push( - client.onState((state) => { + this.#off.push(this.follow(client)); + await client.connect().catch(() => {}); + } + + /** + * Keeps the lists current from a client someone else opened and closes: the + * island's, which the companion owns. Read at once when it is connected + * already, and again on each connection. Returns the release. + */ + follow(client: Client & { onState?: ObservableClient['onState'] }): () => void { + this.client = client as ObservableClient; + const offs = [ + client.onState?.((state) => { this.connection = state; if (state === 'ready') void this.load(); - }), + }) ?? (() => {}), client.on('providers.updated', ({ loaded }) => (this.providers = loaded)), client.on('accounts.updated', (account) => (this.accounts = [...this.accounts.filter((a) => a.id !== account.id), account])), client.on('accounts.removed', ({ accountId }) => (this.accounts = this.accounts.filter((a) => a.id !== accountId))), client.on('settings.updated', (settings) => (this.settings = settings)), client.on('providers.probed', ({ providerId, accountId, models }) => (this.probedModels = { ...this.probedModels, [probeKey(providerId, accountId)]: models })) - ); - await client.connect().catch(() => {}); + ]; + if (client.state === 'ready') { + this.connection = 'ready'; + void this.load(); + } + return () => offs.forEach((off) => off()); } async #open(): Promise { @@ -111,21 +127,38 @@ export class CoreLink { * Reads the models an agent lists with this account. Each read starts the * agent's executable for a moment, so it runs once per window, a failed one * included: account updates come with every quota change and must not start - * it again. + * it again. `refresh` asks the agent again, past the core's cache: the model + * picker's refresh button. */ - probeModels(providerId: ProviderId, accountId: string): Promise { + probeModels(providerId: ProviderId, accountId: string, refresh = false): Promise { const client = this.client; const key = probeKey(providerId, accountId); - if (!client || this.probedModels[key]) return Promise.resolve(); const known = this.#probes.get(key); - if (known) return known; - const read = client.call('providers.probe', { providerId, accountId }).then(({ models }) => { - if (client === this.client) this.probedModels = { ...this.probedModels, [key]: models }; - }); + if (!client || (!refresh && this.probedModels[key])) return Promise.resolve(); + if (known && (!refresh || this.probing.includes(key))) return known; + this.probing = [...this.probing, key]; + const read = client + .call('providers.probe', { providerId, accountId, ...(refresh ? { refresh: true } : {}) }) + .then(({ models }) => { + if (client === this.client) this.probedModels = { ...this.probedModels, [key]: models }; + }) + .finally(() => (this.probing = this.probing.filter((entry) => entry !== key))); this.#probes.set(key, read); return read; } + isProbing(providerId: ProviderId, accountId: string | null): boolean { + return accountId !== null && this.probing.includes(probeKey(providerId, accountId)); + } + + /** Stores the starred models in the core's settings, where Boite reads them. */ + async saveSettings(patch: Partial & Record): Promise { + const client = this.client; + if (!client) return; + const settings = await client.call('settings.set', patch as Partial); + if (client === this.client) this.settings = settings; + } + stop(): void { this.#stopped = true; this.#off.forEach((off) => off()); diff --git a/src/lib/fake-client/assistant-demo.ts b/src/lib/fake-client/assistant-demo.ts index d2e5af4..7a6969b 100644 --- a/src/lib/fake-client/assistant-demo.ts +++ b/src/lib/fake-client/assistant-demo.ts @@ -145,6 +145,20 @@ export function seedAssistantDemo(ctx: FakeContext, methods: FakeMethods): void }); } + // Boite's starred models, kept in the core's settings where its picker and Bots's read them. + ctx.settings = { + ...ctx.settings, + modelFavorites: { + at: now, + items: [ + { providerId: 'claude', model: { id: 'claude-opus-5-5', name: 'Opus 5.5' }, account: 'Default login' }, + { providerId: 'claude', model: { id: 'claude-sonnet-5-5', name: 'Sonnet 5.5' }, account: 'Default login' }, + { providerId: 'opencode', model: { id: 'nvidia/gpt-oss-120b', name: 'GPT-OSS 120B' }, account: 'Default' }, + { providerId: 'opencode', model: { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5' }, account: 'Default' } + ] + } + } as typeof ctx.settings; + // Two subscriptions, the first one under a quarter left on its week. const quotas = (): AccountQuota[] => [ { diff --git a/src/lib/island/assistant-pose.ts b/src/lib/island/assistant-pose.ts index 539942c..13b5082 100644 --- a/src/lib/island/assistant-pose.ts +++ b/src/lib/island/assistant-pose.ts @@ -4,7 +4,11 @@ * * - `running`: an answer streaming in, a tool at work and Stop showing; * - `chips`: two files waiting for the next message; - * - `menu` / `agents`: the model and effort menu, or the agents menu, open; + * - `menu` / `agents`: the model picker open (on the favorites, as Boite + * opens it while some are starred), or the agents menu; + * - `models`: the picker on the agent's provider, its models listed; + * - `search`: the picker on OpenCode's long list, searched for `nvidia`; + * - `effort`: the effort chip's popover open; * - `quotas`: the quotas pill's panel open; * - `recap`: Home scrolled down to the Today card. */ @@ -42,8 +46,12 @@ function streaming(): Message { const file = (name: string, mimeType: string): Attachment => ({ kind: 'file', mimeType, data: btoa('demo'), name }) as Attachment; function click(testId: string): Promise { - return until(() => document.querySelector(`[data-testid="${testId}"]`) !== null).then(() => { - document.querySelector(`[data-testid="${testId}"]`)?.click(); + return clickSelector(`[data-testid="${testId}"]`); +} + +function clickSelector(selector: string): Promise { + return until(() => document.querySelector(selector) !== null).then(() => { + document.querySelector(selector)?.click(); }); } @@ -57,7 +65,18 @@ export async function strikeAssistantPose(companion: Companion, params: URLSearc assistant.sending = true; assistant.stream = [streaming()]; } - if (scenes.has('menu')) await click('assistant-model'); + if (scenes.has('menu') || scenes.has('models') || scenes.has('search')) await click('composer-picker'); + if (scenes.has('models')) await clickSelector('[data-testid="composer-picker-menu"] [data-provider="claude"]'); + if (scenes.has('search')) { + await clickSelector('[data-testid="composer-picker-menu"] [data-provider="opencode"]'); + await until(() => document.querySelector('[data-testid="picker-search"]') !== null); + const search = document.querySelector('[data-testid="picker-search"]'); + if (search) { + search.value = 'nvidia'; + search.dispatchEvent(new Event('input', { bubbles: true })); + } + } + if (scenes.has('effort')) await click('composer-effort'); if (scenes.has('agents')) await click('assistant-agent'); if (scenes.has('quotas')) await click('island-quotas'); if (scenes.has('recap')) { diff --git a/src/lib/island/assistant.svelte.test.ts b/src/lib/island/assistant.svelte.test.ts index b278f0e..ce566c4 100644 --- a/src/lib/island/assistant.svelte.test.ts +++ b/src/lib/island/assistant.svelte.test.ts @@ -10,9 +10,8 @@ import { Assistant, bubbleOf, conversation, - effortOf, - effortsOf, failureOf, + levelsOf, liveText, offeredModels, openWork, @@ -134,12 +133,11 @@ test('the stream builds the answer: started, deltas, a part replaced, completed' expect(messages).toHaveLength(1); }); -test('efforts are low, medium and high, none for the default, narrowed to what the model takes', () => { - expect(effortOf(null)).toBeNull(); - expect(effortOf('xhigh')).toBeNull(); - expect(effortOf('low')).toBe('low'); - expect(effortsOf(undefined)).toEqual(['low', 'medium', 'high']); - expect(effortsOf({ id: 'm', name: 'M', effort: { levels: [{ id: 'medium', label: 'M' }, { id: 'high', label: 'H' }, { id: 'xhigh', label: 'X' }], default: 'high' } })).toEqual(['medium', 'high']); +test('the levels are the model’s own, none when it names none', () => { + expect(levelsOf(undefined)).toEqual([]); + expect(levelsOf({ id: 'm', name: 'M' })).toEqual([]); + const levels = [{ id: 'medium', label: 'M' }, { id: 'high', label: 'H' }, { id: 'xhigh', label: 'X' }]; + expect(levelsOf({ id: 'm', name: 'M', effort: { levels, default: 'high' } })).toEqual(levels); const models = [{ id: 'default', name: 'Default' }, { id: 'new', name: 'New' }, { id: 'old', name: 'Old', legacy: true }]; expect(offeredModels(models, 'new').map((model) => model.id)).toEqual(['new']); expect(offeredModels(models, 'old').map((model) => model.id)).toEqual(['new', 'old']); @@ -247,6 +245,20 @@ test('the model and the effort change on the agent itself', async () => { client.close(); }); +test('a model of another provider moves the agent there, on that model’s own level', async () => { + const client = new FakeClient({ delayMs: 0, assistantDemo: true }); + await client.connect(); + const { assistant, load, close } = harness(client); + assistant.open(ASSISTANT_DEMO_AGENT); + await load(); + expect(assistant.effort).toBe('high'); + await assistant.setModel('nvidia/gpt-oss-120b', { providerId: 'opencode', accountId: 'a-opencode' }); + await vi.waitFor(() => expect(assistant.agent?.selection).toMatchObject({ providerId: 'opencode', accountId: 'a-opencode', model: 'nvidia/gpt-oss-120b', effort: null })); + expect(assistant.problem).toBe(''); + close(); + client.close(); +}); + test('picking another agent keeps it for the next launch and starts clean', async () => { const client = new FakeClient({ delayMs: 0, assistantDemo: true }); await client.connect(); diff --git a/src/lib/island/assistant.svelte.ts b/src/lib/island/assistant.svelte.ts index ef73ab9..a15956b 100644 --- a/src/lib/island/assistant.svelte.ts +++ b/src/lib/island/assistant.svelte.ts @@ -16,10 +16,11 @@ * - The answer as it comes, and the tool at work: the thread the agent's * session works in (`AgentSession.threadId`), through `message.*` and * `thread.updated`. Stop cancels the agent's open work (`agents.work.control`). - * - Model and effort: the agent's own `selection`, changed for the agent - * everywhere with `agents.profile.save`, the call Boite's agent editor makes. + * - Model and effort: the agent's own `selection` (its provider, account, + * model and level), changed for the agent everywhere with + * `agents.profile.save`, the call Boite's agent editor makes. */ -import type { AgentConversationMessage, AgentProfile, AgentSave, AgentsSnapshot, AgentWork, Attachment, Message, MessagePart, ModelInfo, RpcEvents, ThreadSummary } from '@boite/contracts'; +import type { AgentConversationMessage, AgentProfile, AgentSave, AgentSelection, AgentsSnapshot, AgentWork, Attachment, EffortLevel, Message, MessagePart, ModelInfo, RpcEvents, ThreadSummary } from '@boite/contracts'; import type { Client } from '@boite/ui/lib/client'; import { directScope, replyTo, sessionThreadOf, workFor } from '../companion/crew'; import { visibleReply } from '../companion/directives'; @@ -27,8 +28,6 @@ import { describeTool } from '@boite/ui/lib/tool-summary'; import { fill, strings } from '../strings'; import { toolLabel } from './session-text'; -export const EFFORTS = ['low', 'medium', 'high'] as const; -export type Effort = (typeof EFFORTS)[number]; /** Messages per page of history, each scroll up. */ export const ASSISTANT_PAGE = 40; /** Fewer messages than this in hand: the conversation reads a page back, once per agent. */ @@ -209,17 +208,13 @@ export function applyCompleted(messages: Message[], { messageId, state }: RpcEve // Models and efforts // --------------------------------------------------------------------------- -/** One of the three efforts, null for the provider's default or another level. */ -export function effortOf(value: string | null | undefined): Effort | null { - return EFFORTS.includes(value as Effort) ? (value as Effort) : null; +/** The reasoning levels the model offers, as Boite's effort chip shows them; none when it names none. */ +export function levelsOf(model: ModelInfo | null | undefined): EffortLevel[] { + return model?.effort?.levels ?? []; } -/** The efforts the model takes among low, medium and high; all three when it names none. */ -export function effortsOf(model: ModelInfo | undefined): Effort[] { - const levels = model?.effort?.levels.map((level) => level.id) ?? []; - const offered = EFFORTS.filter((effort) => levels.includes(effort)); - return offered.length > 0 ? offered : [...EFFORTS]; -} +/** What a change of model or level touches in the agent's selection. */ +export type SelectionChange = Partial>; /** The models worth offering: the current ones, and a legacy one only when it is in use. */ export function offeredModels(models: ModelInfo[], current: string | null): ModelInfo[] { @@ -227,7 +222,7 @@ export function offeredModels(models: ModelInfo[], current: string | null): Mode } /** The agent saved with another model or effort, everything else as it stands. */ -export function selectionSave(profile: AgentProfile, change: { model?: string; effort?: Effort }): AgentSave { +export function selectionSave(profile: AgentProfile, change: SelectionChange): AgentSave { const { name, domain, instructions, avatar, selection, status, tools, accountIntegration } = profile; return { id: profile.id, expectedRevision: profile.revision, value: { name, domain, instructions, avatar, status, tools, accountIntegration, selection: { ...selection, ...change } } }; } @@ -299,9 +294,12 @@ export class Assistant { readonly failure = $derived(this.agentId && !this.running ? failureOf(this.snapshot, this.messages, this.agentId) : null); readonly hasOlder = $derived(this.more ?? this.snapshot?.more.message ?? false); readonly model = $derived(this.agent?.selection.model ?? null); - readonly effort = $derived(effortOf(this.agent?.selection.effort)); + /** The level the agent runs, null for its model's own default. */ + readonly effort = $derived(this.agent?.selection.effort ?? null); readonly offered = $derived(offeredModels(this.models, this.model)); - readonly efforts = $derived(effortsOf(this.models.find((model) => model.id === this.model))); + readonly current = $derived(this.models.find((model) => model.id === this.model) ?? null); + /** The levels of the agent's model, none when it names none: the effort chip is left out then. */ + readonly efforts = $derived(levelsOf(this.current)); /** What `follow` last saw, so a change of agent, thread or engine is acted on once. */ private followed: string | null = null; @@ -477,19 +475,30 @@ export class Assistant { } } - async setModel(model: string): Promise { + /** + * Another model, on the agent's own provider and account or on the ones the + * picker names. On another provider or account the level goes back to that + * model's default, as in Boite's agent editor; on the same one it stays when + * the new model takes it. + */ + async setModel(model: string | null, engine?: { providerId: string; accountId: string }): Promise { + const selection = this.agent?.selection; + if (!selection) return; + if (engine && (engine.providerId !== selection.providerId || engine.accountId !== selection.accountId)) { + return this.update({ providerId: engine.providerId, accountId: engine.accountId, model, effort: null }); + } if (model === this.model) return; - const efforts = effortsOf(this.models.find((entry) => entry.id === model)); - const effort = this.effort === null || efforts.includes(this.effort) ? this.effort : efforts.at(-1)!; - await this.update(effort === null ? { model } : { model, effort }); + const levels = levelsOf(this.models.find((entry) => entry.id === model)).map((level) => level.id); + const effort = this.effort === null || levels.length === 0 || levels.includes(this.effort) ? this.effort : null; + await this.update({ model, effort }); } - async setEffort(effort: Effort): Promise { + async setEffort(effort: string): Promise { if (effort !== this.effort) await this.update({ effort }); } /** The agent's own model or effort, changed for it everywhere. */ - private async update(change: { model?: string; effort?: Effort }): Promise { + private async update(change: SelectionChange): Promise { const client = this.host.client(); const agent = this.agent; if (!client || !agent) return; diff --git a/src/lib/island/contract.ts b/src/lib/island/contract.ts index f99d224..3dfef0b 100644 --- a/src/lib/island/contract.ts +++ b/src/lib/island/contract.ts @@ -15,6 +15,9 @@ * Permissions and questions are the frame's own: they need no call. * - `hold(key, on)`: the view wants the island to stay open while the pointer * is away (a field with the keyboard, a menu, a drag over it); `false` lets it go. + * - `resized()`: something of the view that takes clicks (`data-hit`) came, + * went or moved outside the island, a popover over its edge: the window + * reads its hit areas again. * - `reveal(threadId)`: shows a thread in Boite's window. * - `show(tab)`: turns the deck to another of its tabs (`deck-tabs.ts`). * - `sessions`: the live sessions the threads tab lists and the peek speaks of, read-only. @@ -53,6 +56,7 @@ export interface ViewContext { readonly island: IslandState; attention(key: string, options?: { quiet?: boolean }): () => void; hold(key: string, on: boolean): void; + resized?(): void; reveal(threadId: string): Promise; show(tab: IslandTab): void; /** The live sessions (`lib/island/sessions.svelte.ts`): one row per thread at work or waiting, its last steps, the lead one. */ diff --git a/src/lib/model-picker/companion.ts b/src/lib/model-picker/companion.ts new file mode 100644 index 0000000..20c7303 --- /dev/null +++ b/src/lib/model-picker/companion.ts @@ -0,0 +1,29 @@ +/* + * The companion's model as Boite's picker shows and edits it. The companion's + * prefs (`lib/companion/prefs.ts`) keep a provider, an account, a model and a + * level; a null provider is "Automatic": the first agent that is on and signed + * in, on its small model. A null model on a chosen provider is that + * provider's small model too, so the picker marks it as the one in use. + */ +import { defaultTitleModel } from '@boite/contracts'; +import type { CompanionPrefs } from '../companion/prefs'; +import type { PickerChoice, PickPatch, PickerStore } from './store.svelte'; + +type Choosing = Pick; + +/** What the picker shows as chosen; null while the companion chooses by itself or the provider has no account here. */ +export function companionChoice(prefs: Choosing, store: Pick): PickerChoice | null { + if (prefs.providerId === null) return null; + const accounts = store.accountsOf(prefs.providerId); + const account = accounts.find((entry) => entry.id === prefs.accountId) ?? store.usableAccountOf(prefs.providerId) ?? accounts[0]; + if (!account) return null; + const provider = store.providerOf(prefs.providerId); + const small = provider ? defaultTitleModel(provider, store.modelsOf(provider.id, account.id).filter((model) => model.id !== 'default')) : null; + return { providerId: prefs.providerId, accountId: account.id, model: prefs.model ?? small, effort: prefs.effort }; +} + +/** The prefs a pick writes: a model starts on its own default level, as in Boite's agent editor. */ +export function companionPick(patch: PickPatch | 'auto'): Choosing { + if (patch === 'auto') return { providerId: null, accountId: null, model: null, effort: null }; + return { providerId: patch.providerId, accountId: patch.accountId, model: patch.model, effort: null }; +} diff --git a/src/lib/model-picker/favorites.ts b/src/lib/model-picker/favorites.ts new file mode 100644 index 0000000..5ef0711 --- /dev/null +++ b/src/lib/model-picker/favorites.ts @@ -0,0 +1,141 @@ +/* + * The starred models, Boite's own list: every core keeps it in its settings + * (`Settings.modelFavorites`) and Boite's model picker reads and writes it + * there, so a star set in Boite shows in Bots and back. + * + * From Boite at 4b6335b1: the types (packages/contracts/src/index.ts), + * `compareModelFavorites` and the check of a stored list (packages/contracts/ + * src/settings-validation.ts), `resolveFavorites` (packages/ui/src/lib/ + * model-favorites.svelte.ts) and the star toggle (packages/ui/src/lib/store/ + * models.svelte.ts). The contracts Bots vendors are older and do not carry them. + */ +import type { Account, ProviderId, Settings } from '@boite/contracts'; + +/** One starred model. Accounts are named by label: an id means nothing on another machine. */ +export interface ModelFavorite { + providerId: ProviderId; + model: { id: string; name: string }; + /** The label of the account it was starred under. Missing: the account the picker uses for that provider. */ + account?: string; +} + +/** The starred models, with the time they last changed. */ +export interface ModelFavorites { + /** Milliseconds since the epoch of the last change: between two lists, the later one wins. */ + at: number; + items: ModelFavorite[]; +} + +/** One favorite as this machine's picker offers it: on one of its accounts. */ +export interface FavoriteModel { + providerId: ProviderId; + accountId: string; + model: { id: string; name: string }; +} + +/** One favorite as one machine's picker offers it. */ +export interface ResolvedFavorite extends FavoriteModel { + /** Every shared item shown as this row: unstarring it removes them all. */ + items: ModelFavorite[]; +} + +/** What resolving a favorite reads of one machine. */ +export interface FavoriteMachine { + providerOn(id: ProviderId): boolean; + accountsOf(providerId: ProviderId): Account[]; + usableAccountOf(providerId: ProviderId): Account | null; +} + +/** Starred models kept for all of the owner's machines. */ +export const MODEL_FAVORITES_MAX = 200; +const FAVORITE_TEXT_MAX = 200; +export const EMPTY_FAVORITES: ModelFavorites = { at: 0, items: [] }; + +/** One line of printable text of 1 to `FAVORITE_TEXT_MAX` characters, trimmed, or null. */ +function favoriteText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const text = value.trim(); + return text.length > 0 && text.length <= FAVORITE_TEXT_MAX && !/[\u0000-\u001f]/.test(text) ? text : null; +} + +/** The list as a core stores it: each field trimmed, unknown keys dropped, a repeat kept once. Null when malformed. */ +export function cleanFavorites(value: unknown): ModelFavorites | null { + if (typeof value !== 'object' || value === null) return null; + const { at, items } = value as { at?: unknown; items?: unknown }; + if (typeof at !== 'number' || !Number.isSafeInteger(at) || at < 0 || !Array.isArray(items) || items.length > MODEL_FAVORITES_MAX) return null; + const kept: ModelFavorite[] = []; + const seen = new Set(); + for (const raw of items as Partial[]) { + if (typeof raw !== 'object' || raw === null) return null; + const providerId = favoriteText(raw.providerId), id = favoriteText(raw.model?.id), name = favoriteText(raw.model?.name); + const account = raw.account === undefined ? undefined : favoriteText(raw.account); + if (providerId === null || id === null || name === null || account === null) return null; + const key = itemKey({ providerId, model: { id, name }, ...(account === undefined ? {} : { account }) }); + if (seen.has(key)) continue; + seen.add(key); + kept.push({ providerId, model: { id, name }, ...(account === undefined ? {} : { account }) }); + } + return { at, items: kept }; +} + +/** The favorites a core's settings hold; the empty list on an older core or before the first star. */ +export function favoritesIn(settings: Settings | null): ModelFavorites { + return cleanFavorites((settings as { modelFavorites?: unknown } | null)?.modelFavorites) ?? EMPTY_FAVORITES; +} + +/** + * Which of two favorite lists wins, above zero when `a` does: the later `at`, + * then the greater content, so two lists of the same time never flip back and + * forth. Core and clients decide alike. + */ +export function compareFavorites(a: ModelFavorites, b: ModelFavorites): number { + if (a.at !== b.at) return a.at - b.at; + const content = (list: ModelFavorites) => JSON.stringify(list.items.map((item) => [item.providerId, item.model.id, item.model.name, item.account ?? null])); + const x = content(a), y = content(b); + return x < y ? -1 : x > y ? 1 : 0; +} + +function itemKey(item: ModelFavorite): string { + return JSON.stringify([item.providerId, item.model.id, item.account ?? null]); +} + +/** + * The list after a star (`add`) or an unstar (`remove`, every shared item the + * row stands for), as a change made at `now`: later than the list it replaces. + */ +export function toggledFavorites(current: ModelFavorites, add: ModelFavorite | null, remove: ModelFavorite[], now = Date.now()): ModelFavorites { + const gone = new Set(remove.map(itemKey)); + let items = current.items.filter((item) => !gone.has(itemKey(item))); + if (add && !items.some((item) => itemKey(item) === itemKey(add))) items = [...items, add]; + return { at: Math.max(now, current.at + 1), items: items.slice(-MODEL_FAVORITES_MAX) }; +} + +/** + * The favorites one machine offers. A provider that is off or has no account + * there offers none. Each runs on the account with the same label, else on + * the account the picker uses for that provider; two that land on the same + * account and model make one row, the exact label first. + */ +export function resolveFavorites(items: ModelFavorite[], machine: FavoriteMachine): ResolvedFavorite[] { + const placed = items.flatMap((item) => { + if (!machine.providerOn(item.providerId)) return []; + const accounts = machine.accountsOf(item.providerId); + const exact = item.account === undefined ? undefined : accounts.find((account) => account.label === item.account); + const account = exact ?? machine.usableAccountOf(item.providerId) ?? accounts[0]; + return account ? [{ item, accountId: account.id, exact: exact !== undefined }] : []; + }); + const rows = new Map(); + for (const pass of [true, false]) { + for (const { item, accountId, exact } of placed) { + if (exact !== pass) continue; + const key = JSON.stringify([item.providerId, accountId, item.model.id]); + const row = rows.get(key); + if (row) row.items.push(item); + else rows.set(key, { providerId: item.providerId, accountId, model: item.model, items: [item] }); + } + } + // The shared order, not the passes' order. + const order = new Map(items.map((item, index) => [item, index])); + const first = (row: ResolvedFavorite) => Math.min(...row.items.map((item) => order.get(item)!)); + return [...rows.values()].sort((a, b) => first(a) - first(b)); +} diff --git a/src/lib/model-picker/link.ts b/src/lib/model-picker/link.ts new file mode 100644 index 0000000..f48c47c --- /dev/null +++ b/src/lib/model-picker/link.ts @@ -0,0 +1,22 @@ +/* + * The island's link to the core for the model picker: the companion owns the + * client, so one `CoreLink` per client follows it (`CoreLink.follow`) and + * keeps the providers, accounts, settings and model lists the picker reads, + * for as long as that client lives. + */ +import type { Client } from '@boite/ui/lib/client'; +import { CoreLink } from '../core.svelte'; +import { PickerStore } from './store.svelte'; + +const stores = new WeakMap(); + +/** The picker's store over this client, made the first time it is asked for. */ +export function pickerStoreFor(client: Client): PickerStore { + const known = stores.get(client); + if (known) return known; + const link = new CoreLink(); + link.follow(client); + const store = new PickerStore(link); + stores.set(client, store); + return store; +} diff --git a/src/lib/model-picker/store.svelte.test.ts b/src/lib/model-picker/store.svelte.test.ts new file mode 100644 index 0000000..1f7b8fe --- /dev/null +++ b/src/lib/model-picker/store.svelte.test.ts @@ -0,0 +1,151 @@ +/* + * The model picker's store over Bots's core link: Boite's starred models go + * through the core's settings both ways, the agents' lists are read and + * refreshed, and the companion's "Automatic" choice. + */ +import { flushSync, mount, unmount } from 'svelte'; +import { afterEach, expect, test, vi } from 'vitest'; +import type { ObservableClient } from '@boite/ui/lib/client'; +import { CoreLink } from '../core.svelte'; +import { FakeClient } from '../fake-client'; +import { readCompanionPrefs, type CompanionPrefs } from '../companion/prefs'; +import { strings } from '../strings'; +import CompanionModelPicker from '../../components/model-picker/CompanionModelPicker.svelte'; +import { companionChoice, companionPick } from './companion'; +import { favoritesIn } from './favorites'; +import { PickerStore } from './store.svelte'; + +const opus = { id: 'claude-opus-5-5', name: 'Opus 5.5' }; + +async function linked() { + const client = new FakeClient({ delayMs: 0 }); + await client.connect(); + const link = new CoreLink(); + const call = vi.spyOn(client, 'call'); + link.follow(client); + await vi.waitFor(() => expect(link.providers.length).toBeGreaterThan(0)); + await vi.waitFor(() => expect(link.settings).not.toBeNull()); + return { client, link, call, store: new PickerStore(link) }; +} + +let mounted: { component: Record; target: HTMLElement } | null = null; +afterEach(() => { + if (mounted) unmount(mounted.component); + mounted?.target.remove(); + mounted = null; + localStorage.clear(); +}); + +test('a star goes to the core’s settings, where Boite reads it, and Boite’s stars come back', async () => { + const { client, link, call, store } = await linked(); + const account = store.usableAccountOf('claude') ?? store.accountsOf('claude')[0]!; + expect(store.favorites).toEqual([]); + + store.toggleFavorite('claude', account.id, opus); + await vi.waitFor(() => expect(store.favorites.map((entry) => entry.model.id)).toEqual([opus.id])); + const written = call.mock.calls.find(([method]) => method === 'settings.set')?.[1] as { modelFavorites: { at: number; items: unknown[] } }; + expect(written.modelFavorites.items).toEqual([{ providerId: 'claude', model: opus, account: account.label }]); + + // Boite reads the same list from the core. + const boite = favoritesIn(await client.call('settings.get', {})); + expect(boite.items).toEqual(written.modelFavorites.items); + + // Boite stars another model later: the core tells Bots, whose picker shows both. + const sonnet = { providerId: 'claude', model: { id: 'claude-sonnet-5-5', name: 'Sonnet 5.5' }, account: account.label }; + await client.call('settings.set', { modelFavorites: { at: boite.at + 1, items: [...boite.items, sonnet] } } as never); + await vi.waitFor(() => expect(store.favorites.map((entry) => entry.model.id)).toEqual([opus.id, sonnet.model.id])); + expect(store.favorites[1]!.accountId).toBe(account.id); + + // Unstarring here removes it there too. + store.toggleFavorite('claude', account.id, opus); + await vi.waitFor(() => expect(favoritesIn(link.settings).items.map((item) => item.model.id)).toEqual([sonnet.model.id])); + expect(favoritesIn(await client.call('settings.get', {})).items.map((item) => item.model.id)).toEqual([sonnet.model.id]); + client.close(); +}); + +test('a paired device reads the stars and never writes them', async () => { + const { link, call } = await linked(); + Object.defineProperty(link, 'owner', { get: () => false }); + const store = new PickerStore(link); + store.toggleFavorite('claude', 'a-claude-main', opus); + expect(call.mock.calls.some(([method]) => method === 'settings.set')).toBe(false); +}); + +test('an agent’s list is read once, shown as pending meanwhile, and the refresh asks it again', async () => { + const { client, call, store } = await linked(); + const account = store.usableAccountOf('claude') ?? store.accountsOf('claude')[0]!; + // Claude has no catalogue of its own: nothing to offer before it is asked. + expect(store.modelsOf('claude', account.id)).toEqual([]); + expect(store.modelsPending('claude', account.id)).toBe(false); + + const read = store.probeModels('claude', account.id); + expect(store.isProbing('claude', account.id)).toBe(true); + expect(store.modelsPending('claude', account.id)).toBe(true); + await read; + expect(store.modelsPending('claude', account.id)).toBe(false); + expect(store.modelsOf('claude', account.id).map((model) => model.id)).toContain(opus.id); + expect(store.catalogHas('claude', account.id, opus.id)).toBe(true); + expect(store.modelOf({ providerId: 'claude', accountId: account.id, model: opus.id, effort: null })?.name).toBe('Opus 5.5'); + + await store.probeModels('claude', account.id); + const probes = () => call.mock.calls.filter(([method]) => method === 'providers.probe'); + expect(probes()).toHaveLength(1); + await store.probeModels('claude', account.id, true); + expect(probes()).toHaveLength(2); + expect(probes()[1]![1]).toMatchObject({ providerId: 'claude', accountId: account.id, refresh: true }); + client.close(); +}); + +test('a failed refresh is told, a failed background read is not', async () => { + const link = new CoreLink(); + const client = { principal: 'owner', state: 'ready', call: vi.fn(async () => { throw new Error('the agent did not answer'); }), on: () => () => {}, connect: async () => {}, close: () => {} }; + link.follow(client as unknown as ObservableClient); + const store = new PickerStore(link); + await store.probeModels('opencode', 'a2'); + expect(store.error).toBeNull(); + await store.probeModels('opencode', 'a2', true); + expect(store.error).toBe('the agent did not answer'); +}); + +test('Automatic is a null provider; a chosen provider without a model shows its small one', async () => { + const { client, store } = await linked(); + const none = { providerId: null, accountId: null, model: null, effort: null }; + expect(companionChoice(none, store)).toBeNull(); + expect(companionPick('auto')).toEqual(none); + + const account = store.usableAccountOf('claude') ?? store.accountsOf('claude')[0]!; + const chosen = companionChoice({ providerId: 'claude', accountId: null, model: null, effort: 'high' }, store); + expect(chosen).toMatchObject({ providerId: 'claude', accountId: account.id, effort: 'high' }); + expect(chosen!.model).toMatch(/haiku/); + expect(companionChoice({ providerId: 'claude', accountId: account.id, model: opus.id, effort: null }, store)?.model).toBe(opus.id); + // A model picked starts on its own default level. + expect(companionPick({ providerId: 'claude', accountId: account.id, model: opus.id })).toEqual({ providerId: 'claude', accountId: account.id, model: opus.id, effort: null }); + client.close(); +}); + +test('the companion’s picker shows Automatic, and a model picked in it is written to the prefs', async () => { + const { client, link, store } = await linked(); + const account = store.usableAccountOf('claude') ?? store.accountsOf('claude')[0]!; + await store.probeModels('claude', account.id); + const changes: CompanionPrefs[] = []; + const target = document.body.appendChild(document.createElement('div')); + const component = mount(CompanionModelPicker, { target, props: { store: link, prefs: readCompanionPrefs(), onchange: (next: CompanionPrefs) => changes.push(next) } }); + mounted = { component, target }; + flushSync(); + const trigger = target.querySelector('[data-testid="composer-picker"]')!; + expect(trigger.textContent).toContain(strings.modelPicker.auto); + + trigger.click(); + flushSync(); + // Open on the automatic column, its row marked as the one in use. + expect(document.querySelector('[data-provider="auto"]')?.classList.contains('current')).toBe(true); + expect(document.querySelector('[data-testid="picker-auto"]')?.classList.contains('current')).toBe(true); + + document.querySelector('[data-provider="claude"]')!.click(); + flushSync(); + await vi.waitFor(() => expect(document.querySelector(`[data-model="${opus.id}"]`)).not.toBeNull()); + document.querySelector(`[data-model="${opus.id}"]`)!.click(); + await vi.waitFor(() => expect(changes.at(-1)).toMatchObject({ providerId: 'claude', accountId: account.id, model: opus.id, effort: null })); + expect(readCompanionPrefs()).toMatchObject({ providerId: 'claude', model: opus.id }); + client.close(); +}); diff --git a/src/lib/model-picker/store.svelte.ts b/src/lib/model-picker/store.svelte.ts new file mode 100644 index 0000000..e487270 --- /dev/null +++ b/src/lib/model-picker/store.svelte.ts @@ -0,0 +1,176 @@ +/* + * What Boite's model picker asks of Boite's `Store`, on top of Bots's own + * link to the core (`lib/core.svelte.ts`): the providers the picker may + * offer, their accounts, the models each agent lists, the reads of those + * lists, and Boite's starred models. + * + * Each member follows the one of the same name in Boite at 4b6335b1: + * packages/ui/src/lib/store/accounts.svelte.ts (offeredProviders, providerOf, + * providerOn, accountsOf, accountOf, usableAccountOf, installOf) and + * packages/ui/src/lib/store/models.svelte.ts (modelsOf, modelOf, + * modelsPending, isProbing, probeModels, catalogHas, defaultModelOf, + * favorites, toggleFavorite). + * + * The favorites are Boite's own list, read from the core's settings and + * written back with `settings.set { modelFavorites }`, as Boite writes them: + * a star set in either app shows in the other. Boite's settings pages cannot + * be opened from here (`ui.reveal` shows a thread, an agent or the updates), + * so `opensSettings` is false and the picker leaves out what leads there. + */ +import { providerEnabled, type Account, type ModelInfo, type ProviderId, type ProviderInstallState, type ProviderSummary } from '@boite/contracts'; +import { DEFAULT_MODEL_NAMES, INITIAL_MODEL_DEFAULTS, BUILT_IN_MODELS, fallbackModelDefault, resolveModelDefault } from '@boite/ui/lib/model-defaults'; +import type { CoreLink } from '../core.svelte'; +import { favoritesIn, resolveFavorites, toggledFavorites, type ResolvedFavorite } from './favorites'; + +/** What a picker shows as chosen: Boite's `Choice` without the permission mode. */ +export interface PickerChoice { + providerId: ProviderId; + accountId: string; + model: string | null; + /** A level id of that model, or null for the model's own default. */ + effort: string | null; +} + +/** What the picker hands back: the instance and its model together. */ +export interface PickPatch { + providerId: ProviderId; + accountId: string; + model: string | null; +} + +/** The part of `CoreLink` the picker reads. */ +export type PickerLink = Pick< + CoreLink, + 'client' | 'owner' | 'providers' | 'accounts' | 'settings' | 'probedModels' | 'accountsOf' | 'modelsOf' | 'probeModels' | 'isProbing' | 'saveSettings' +>; + +const probeKey = (providerId: ProviderId, accountId: string) => `${providerId}::${accountId}`; + +export class PickerStore { + /** A pick the picker could not make: shown under it, cleared by the next pick. */ + error = $state(null); + /** Boite counts its reconnections and page changes to drop a late pick; Bots's picker lives in one place. */ + readonly clientGeneration = 0; + readonly navigationGeneration = 0; + /** Boite's Providers page is not reachable from Bots. */ + readonly opensSettings = false; + + readonly favorites: ResolvedFavorite[] = $derived.by(() => resolveFavorites(favoritesIn(this.link.settings).items, this)); + + constructor(private readonly link: PickerLink) {} + + get owner(): boolean { + return this.link.owner; + } + + get providers(): ProviderSummary[] { + return this.link.providers; + } + + /** The providers a picker may offer: the ones turned on, in the core's order. */ + get offeredProviders(): ProviderSummary[] { + return this.link.providers.filter((provider) => providerEnabled(provider)); + } + + providerOf(id: ProviderId): ProviderSummary | null { + return this.link.providers.find((provider) => provider.id === id) ?? null; + } + + /** Whether this provider is known here and turned on. */ + providerOn(id: ProviderId): boolean { + return providerEnabled(this.providerOf(id)); + } + + accountsOf(providerId: ProviderId): Account[] { + return this.link.accountsOf(providerId); + } + + accountOf(id: string): Account | null { + const account = this.link.accounts.find((entry) => entry.id === id); + if (!account) return null; + return this.accountsOf(account.providerId).find((entry) => entry.id === id) ?? account; + } + + /** The account a turn of this provider runs on, as the core picks it: `preferred` unless signed out, else the best signed-in one. */ + usableAccountOf(providerId: ProviderId, preferred: string | null = null): Account | null { + const accounts = this.accountsOf(providerId); + const own = accounts.find((account) => account.id === preferred); + if (own && own.status !== 'unauthenticated') return own; + const rank = { ok: 0, unknown: 1, error: 2, unauthenticated: 3 } as const; + return accounts.filter((account) => account.id !== preferred && account.status !== 'unauthenticated').sort((a, b) => rank[a.status] - rank[b.status])[0] ?? null; + } + + /** Null when this provider ships no release for Boite to install. */ + installOf(id: ProviderId): ProviderInstallState | null { + return this.providerOf(id)?.install ?? null; + } + + modelsOf(providerId: ProviderId, accountId: string | null): ModelInfo[] { + return this.link.modelsOf(providerId, accountId); + } + + /** The model a choice runs on, out of what its own instance offers; the built-in default by name before a read. */ + modelOf(choice: PickerChoice | null): ModelInfo | null { + if (!choice) return null; + const offered = this.modelsOf(choice.providerId, choice.accountId).find((model) => model.id === choice.model); + if (offered) return offered; + if (!choice.model || choice.model !== INITIAL_MODEL_DEFAULTS[choice.providerId]?.model) return null; + const builtIn = BUILT_IN_MODELS[choice.providerId]; + return builtIn?.id === choice.model ? builtIn : { id: choice.model, name: DEFAULT_MODEL_NAMES[choice.model] ?? choice.model }; + } + + isProbing(providerId: ProviderId, accountId: string | null): boolean { + return this.link.isProbing(providerId, accountId); + } + + /** The first read of this instance runs and nothing has answered yet: the picker shows it reading. */ + modelsPending(providerId: ProviderId, accountId: string | null): boolean { + return accountId !== null && !this.link.probedModels[probeKey(providerId, accountId)] && this.isProbing(providerId, accountId); + } + + /** Asks the agent what it can run; a failure leaves the list as it was, as in Boite. */ + probeModels(providerId: ProviderId, accountId: string, refresh = false): Promise { + const account = this.accountOf(accountId); + if (!refresh && (account?.status === 'unauthenticated' || account?.status === 'error')) return Promise.resolve(); + return this.link.probeModels(providerId, accountId, refresh).catch((error: unknown) => { + if (refresh) this.error = error instanceof Error ? error.message : String(error); + }); + } + + /** The core already listed this model for this account: a pick lands at once. */ + catalogHas(providerId: ProviderId, accountId: string, model: string): boolean { + return this.link.probedModels[probeKey(providerId, accountId)]?.some((entry) => entry.id === model) ?? false; + } + + /** Boite keeps the user's own defaults in its window; Bots goes by the built-in ones and the catalogue. */ + defaultModelOf(provider: ProviderSummary, accountId?: string): string | null { + const models = accountId ? this.modelsOf(provider.id, accountId) : provider.models; + if (accountId && this.link.probedModels[probeKey(provider.id, accountId)]) { + const fallback = fallbackModelDefault(provider.id, models); + if (fallback) return fallback.model; + } + return INITIAL_MODEL_DEFAULTS[provider.id]?.model ?? resolveModelDefault(provider.id, models, {})?.model ?? null; + } + + /** + * Stars or unstars a model of this account, in Boite's list. The star names + * the account by label; unstarring removes every shared item the row stands + * for. A paired device reads the favorites and never writes them. + */ + toggleFavorite(providerId: ProviderId, accountId: string, model: { id: string; name: string }): void { + if (!this.owner) return; + const current = favoritesIn(this.link.settings); + const shown = this.favorites.find((entry) => entry.providerId === providerId && entry.accountId === accountId && entry.model.id === model.id); + const label = this.accountOf(accountId)?.label; + const next = shown + ? toggledFavorites(current, null, shown.items) + : toggledFavorites(current, { providerId, model: { id: model.id, name: model.name }, ...(label ? { account: label } : {}) }, []); + this.link.saveSettings({ modelFavorites: next }).catch((error: unknown) => { + this.error = error instanceof Error ? error.message : String(error); + }); + } + + /** Boite's Providers page and connection dialog live in Boite's window: nothing opens from here. */ + showSettings(_page: 'accounts'): void {} + openConnect(): void {} +} diff --git a/src/lib/strings.en.ts b/src/lib/strings.en.ts index 3b8c711..2fcaffa 100644 --- a/src/lib/strings.en.ts +++ b/src/lib/strings.en.ts @@ -121,14 +121,6 @@ export const strings = { notKept: 'The files could not be passed on outside the app.', notStopped: 'The answer could not be stopped: {reason}', notChanged: 'The model could not be changed: {reason}', - picker: 'Model and effort', - model: 'Model', - effort: 'Effort', - efforts: { low: 'Low', medium: 'Medium', high: 'High' }, - defaultModel: 'Default model', - defaultEffort: 'Default effort', - everywhere: 'This applies to {name} everywhere, in Boite too.', - noModels: 'The agent lists no models yet.', quotas: { label: 'Quotas: {percent}% left on {name}, {window}. Show every account.', title: 'Quotas', @@ -821,6 +813,38 @@ export const strings = { attachTotalTooLarge: 'The files of one turn weigh {max} at most together, so {name} was left out.' }, + modelPicker: { + connectProvider: 'Connect a provider', + picker: 'Provider and model', + models: 'Models', + noModels: 'No model matches', + noModelsAvailable: 'The agent has not listed any models. Refresh to retry.', + showAllModels: 'Show all {count} models', + legacyModels: 'Legacy models', + refreshModels: 'Refresh models', + probing: "Reading the agent's models", + notInstalled: 'Not installed on this machine', + installInSettings: 'Install in Settings', + favorites: 'Favorites', + favorite: 'Add to favorites', + unfavorite: 'Remove from favorites', + favoritesEmpty: 'Star a model to find it here.', + favoriteUnavailable: 'This favorite is no longer offered by the account. Choose another model.', + newBadge: 'new', + lockedHint: 'A thread keeps its provider and account', + unavailable: 'not installed', + noAccount: 'no account', + signedOut: 'not signed in', + noProvider: 'No AI connected', + accounts: 'Providers', + back: 'Back', + effortTitle: 'Reasoning effort', + standardSpeed: 'Standard speed', + speed: 'Response speed', + auto: 'Automatic', + autoHint: 'The first agent that is on and signed in, on its small model.' + }, + quotas: { remaining: '{percent}% left' } diff --git a/src/lib/strings.fr.ts b/src/lib/strings.fr.ts index 1caed5f..47af3e3 100644 --- a/src/lib/strings.fr.ts +++ b/src/lib/strings.fr.ts @@ -109,14 +109,6 @@ export const fr: Messages = { notKept: 'Les fichiers ne peuvent pas être transmis hors de l’application.', notStopped: 'Impossible d’arrêter la réponse : {reason}', notChanged: 'Impossible de changer de modèle : {reason}', - picker: 'Modèle et effort', - model: 'Modèle', - effort: 'Effort', - efforts: { low: 'Faible', medium: 'Moyen', high: 'Élevé' }, - defaultModel: 'Modèle par défaut', - defaultEffort: 'Effort par défaut', - everywhere: 'Ce choix s’applique à {name} partout, dans Boite aussi.', - noModels: 'L’agent n’indique encore aucun modèle.', quotas: { label: 'Quotas : il reste {percent} % sur {name}, {window}. Afficher tous les comptes.', title: 'Quotas', @@ -799,6 +791,38 @@ export const fr: Messages = { attachTotalTooLarge: 'Les fichiers d’un tour pèsent {max} au maximum à eux tous, donc {name} a été laissé de côté.' }, + modelPicker: { + connectProvider: 'Connecter un fournisseur', + picker: 'Fournisseur et modèle', + models: 'Modèles', + noModels: 'Aucun modèle ne correspond', + noModelsAvailable: "L'agent n'a renvoyé aucun modèle. Actualisez pour réessayer.", + showAllModels: 'Afficher les {count} modèles', + legacyModels: 'Anciens modèles', + refreshModels: 'Actualiser les modèles', + probing: "Lecture des modèles de l'agent", + notInstalled: 'Pas installé sur cette machine', + installInSettings: 'Installer dans les réglages', + favorites: 'Favoris', + favorite: 'Ajouter aux favoris', + unfavorite: 'Retirer des favoris', + favoritesEmpty: 'Mettez une étoile à un modèle pour le retrouver ici.', + favoriteUnavailable: "Ce favori n'est plus proposé par le compte. Choisissez un autre modèle.", + newBadge: 'nouveau', + lockedHint: 'Une conversation garde son fournisseur et son compte', + unavailable: 'pas installé', + noAccount: 'aucun compte', + signedOut: 'non connecté', + noProvider: 'Aucune IA connectée', + accounts: 'Fournisseurs', + back: 'Retour', + effortTitle: 'Niveau de raisonnement', + standardSpeed: 'Vitesse normale', + speed: 'Vitesse de réponse', + auto: 'Automatique', + autoHint: 'Le premier agent activé et connecté, sur son petit modèle.' + }, + quotas: { remaining: '{percent} % restants' }