diff --git a/docs/plans/2026-09-20-web-settings-model-section.md b/docs/plans/2026-09-20-web-settings-model-section.md new file mode 100644 index 000000000..5aba03bda --- /dev/null +++ b/docs/plans/2026-09-20-web-settings-model-section.md @@ -0,0 +1,910 @@ +# Settings model section: provider and model pages, kinds, one picker - implementation plan + +> For the executor: follow `dev-workflow` stage 4; tick steps as `- [ ]`; when +> a situation is not covered here, check Global Constraints first, then the +> three tiers of `unattended-run` section 7 (stop / fallback / assumption +> mismatch), and record before acting. Do not guess. + +**Goal**: when this plan is done, the settings dialog served by `raven serve` +on `refactor/ui_web_architecture` has a "Model providers" page (the whole +catalogue in two columns, marks, six filters, a detail pane with the add-model +popover) and a "Model settings" page (the roles card alone); every model +carries a kind that decides where it is offered; the composer chip and every +role slot open one and the same picker; and the cases of +`docs/specs/2026-09-20-web-settings-model-section-acceptance.md` pass on a real +host. + +**Design document**: `docs/specs/2026-09-20-web-settings-model-section-design.md` +(acceptance items A1..A38, constraints C1..C22, Decisions, open questions). + +**Approach**: two backend fields first, contract first, pinned by pytest and a +mutation. Then the picker gains its offer and the roles card switches to it, +which deletes the second picker. Then the section split and the catalogue +column, the detail pane, the add-model popover. Then fixtures, gates, the four +glossary terms, the real-host run, the PR. Every front-end task ends with +`npm test` green: the class-namespace and css-one-owner gates fail on half-moved +CSS, so a task is not done while they are red. + +**Stack**: Python 3 / pydantic v2 (`raven/`), pytest via `uv run`; React 19 + +TypeScript + Vite (`ui-web/`), vitest + happy-dom, `playwright-cli` for the +real browser; `rpc-schema/openrpc.json` as the contract, `npm run gen` for the +client. + +**Change map**: the design's "Change map" table, one task per row group: +Task 1 = Backend fields; Task 2 = Picker; Task 3 = Roles + Deleted; Tasks 4-6 = +Catalogue (column and pages, detail pane, popover); Task 7 = Data + Gates + +`ui-web/CONTEXT.md`; Task 8 = the acceptance run; Task 9 = the PR. + +**Line numbers** cited below were read on 2026-09-20 at `b63f35087`; grep the symbol before editing, the numbers drift by a few lines as tasks land. + +**Deviations already recorded**: D1 -- `providers/Providers.tsx` and its +`AddBlock` are the onboarding wizard's model step (`SetupBodies.tsx:27`) and +stay untouched; the catalogue column is the new `ProviderSide.tsx`. `Roles.tsx` +is shared with the wizard too, so Task 3 changes the wizard's picker as well +(A37 covers it). + +**Baselines (copied from real runs on `feat/settings_model_section` at +`b63f35087`, 2026-09-20)**: + +``` +$ uv run pytest tests/test_rpc_model.py -q -p no:cacheprovider +209 passed in 8.10s +$ uv run pytest tests/test_provider_registry_data.py -q -p no:cacheprovider +24 passed in 2.83s +$ (cd ui-web && npm run gen:check) +generated.ts matches the contract (189 methods) +$ (cd ui-web && npm test) + Test Files 186 passed (186) + Tests 2544 passed (2544) +``` + +`npm test` prints happy-dom `fetch()` abort traces from an unrelated test +before the tally; the tally is the result. + +## Global Constraints + +Copied verbatim from the design's Constraints section. Every task's +requirements include this section. + +Each constraint says how it is checked. `BASE` below is +`$(git merge-base HEAD refactor/ui_web_architecture)`. + +### Architecture + +| # | Constraint | How to check | +|---|---|---| +| C1 | The prior design's architecture constraints hold unchanged: domain skeleton, RPC only in `source.ts`, cross-domain reach through another domain's `source.ts` or `types.ts` only, `settings-` prefixed classes and ids, `t(key)` everywhere, contract-first RPC edits, fixtures typed. | The gates that now enforce them: `import-direction`, `domain-shape`, `store-shape`, `check-class-namespace`, `css-one-owner`, `i18n-keys`, `fixture-shape`, `offline-coverage`, `rpc-names` (`ui-web/scripts/gates/`), all green in `npm test`; `npm run gen:check`; `tests/test_rpc_schema_match.py`. | +| C2 | Settings reaches the picker through `features/model/source.ts`, which exports the opener; it does not import `features/model/store.ts` or the component. | `grep -rn "model/store\|model/ModelPicker" ui-web/src/features/settings` prints nothing; `import-direction` gate. | +| C3 | One picker. After the change `ui-web/src/components/ModelPicker.tsx`, `ModelPicker.test.tsx`, the `.model-picker` and `model-picker-*` CSS rules, and `lib/popover.ts`'s exported `anchorRow` with the two private helpers only it used (`bounds`, `DIALOG`) do not exist. | `test ! -e ui-web/src/components/ModelPicker.tsx`; `grep -rn "components/ModelPicker\|anchorRow\|model-picker" ui-web/src` prints nothing; `css-one-owner` gate. | +| C4 | Not modified: `App.tsx`, `state/`, `chrome/`, `app/`, `features/model/chip.ts`, `main.tsx`, the gate scripts, `raven/providers/`. One exemption: `ui-web/scripts/check-class-namespace.mjs` changes only the two pin numbers for `settings` and `model` (C22). The composer chip keeps opening the picker with the same call. | `git diff BASE..HEAD --stat -- ui-web/src/App.tsx ui-web/src/state ui-web/src/chrome ui-web/src/app ui-web/src/features/model/chip.ts ui-web/src/main.tsx ui-web/scripts/gates raven/providers` is empty; `git diff BASE..HEAD -- ui-web/scripts/check-class-namespace.mjs` has changed lines of the form ` settings: N,` / ` model: N,` only. | +| C5 | Contract first: the two new wire fields are added to `rpc-schema/openrpc.json`, then `npm run gen`, then the pydantic models, then `source.ts`. Every fixture row that carries the parent object carries them. | `npm run gen:check`; `tests/test_rpc_schema_match.py`; `fixture-shape` gate. | +| C6 | One classifier. The page reads a model's kind from `model_labels[m].kind` (`model.options`) or `kind` (`model.fetch_models`); a model with no label entry is `text`. No TypeScript derives a kind from capabilities or modalities. The only name-based guess is `guessKind(id)` for a typed id, a copy of `_RERANK_NAME` / `_EMBEDDING_NAME`. | `grep -rnE "capabilities|_modalities|image-generation" ui-web/src/features/model ui-web/src/features/settings --include=*.ts --include=*.tsx` matches only three things: the two wire-to-row mappers passing `model_labels` through, the add-model popover writing a typed id's stated tags (A17), and tests -- no line reads a capability or a modality to decide a kind; `kinds.test.ts` and `tests/test_provider_registry_data.py` assert the same id-to-kind table (A33). | +| C7 | Backend writes stay in the writers that exist. The two new fields are read-only facts on `model.options`; no new method, no new config key. | `git diff BASE..HEAD --stat -- raven rpc-schema tests` lists `raven/rpc/models.py`, `raven/rpc/methods/model.py`, `rpc-schema/openrpc.json` and files under `tests/`, nothing else. | + +### Design constraints + +| # | Constraint | How to check | +|---|---|---| +| C8 | A slot offers its kind and nothing else, per the table in "Frontend": text slots list `text`, the embedding slot `embedding`, rerank `reranker`, image `image`, speech `audio`, video `video`; the composer offers `text`. Every connected provider the role allows is listed; one with no model of the kind shows an empty column that says so and the typed-id row. The model a slot (or the conversation) currently holds is listed in its provider's column even when that provider's list does not carry it. | A22-A25, A28 | +| C9 | The catalogue is every row `model.options` returns, `hosted_vllm` and `custom` included; the composer still lists connected providers only, which is what hid the two generic rows in practice. Confirmed by the owner 2026-09-20: people running their own servers must not be shut out. | A3; `grep -rn HIDDEN_PROVIDERS ui-web/src` prints nothing. | +| C10 | Secrets never round-trip (prior C10). The key pane sends a key only when the person typed one; the eye toggle reveals typed text, never a stored key. | A8 | +| C11 | The composer's picker keeps every behaviour its test file pins except the two this change adds (a kind filter, a typed-id row). | `ModelPicker.test.tsx`: no case deleted; each changed expectation named in the commit body; the diff of `__snapshots__/ModelPicker.test.tsx.snap` is read line by line and every changed line belongs to the kind filter, the typed row or the title -- a regenerated snapshot carrying any other change fails this. | +| C12 | A model a role uses cannot be removed from its provider (prior A15). The add-model popover's per-row toggle honours it. | A13, A14 | + +### Assumptions + +| # | Assumption | Status | +|---|---|---| +| C13 | `model.options` already lists the whole registry, connected or not. | Verified 2026-09-20: `_entries_off_loop` iterates `list_providers()`, whose `_listable_provider_names` returns every declared provider plus config extras; `features/settings/providers/Providers.tsx` filters to `on` client-side. | +| C14 | The backend already classifies. `registry_data.kind_of`, `inferred_tags`, `KINDS` exist; `model.fetch_models` returns `kind` per row; `model.add_model` accepts `capabilities` / `input_modalities` / `output_modalities` and writes them as a stated overlay. | Verified 2026-09-20: `registry_data.py:257-300`; `rpc/methods/model.py:728`; `rpc/models.py:1681` `ModelAddModelParams`; `_stated_overlay`. | +| C15 | A typed id nothing describes reads back with its name-inferred tags, so a model added as `bge-m3` with no stated kind is `embedding` on the next `model.options`. | Verified 2026-09-20: `providers/catalog.py:137,155` pass `inferred_tags(ref)` into `describe`. | +| C16 | The picker floats over the settings dialog: `--z-picker` (46) is above `--z-veil` (40). | Verified 2026-09-20: `ui-web/src/styles/page.css:144,147`. | +| C17 | Twenty-one registry specs carry `is_gateway=True`, five `is_local=True`; `custom` is a gateway. | Verified 2026-09-20 by reading `PROVIDERS` in `raven/providers/registry.py`. | +| C18 | The only consumer of the shared `SectionId` is the settings domain; nothing else deep-links to the `model` tab. | Verified 2026-09-20: `grep -rn SectionId ui-web/src` lists `SettingsApp.tsx` and `settings/store.ts`; `state/settings.ts` defaults `settingsTab.id` to `usage`; `onboard/store.ts:128`'s `'model'` is an onboarding step id. | +| C19 | Removing `HIDDEN_PROVIDERS` changes nothing for the composer beyond letting a connected `custom` or `hosted_vllm` section be picked: the composer already lists connected providers only. | Verified 2026-09-20: `features/model/store.ts` `authed()` filters `p.on && offered(p).length`; the set has two readers, `features/model/source.ts:85` and `source.test.ts` (:117, :325, :334 -- the last asserts the two names and goes with the set). Onboarding reads `setupState`, not the list. | +| C20 | A click on the settings scrim closes the dialog and, with it, an open picker -- the same as today, where the picker is inside the dialog. No handling is added. | Verified 2026-09-20: `App.tsx` `useScrim` listens to `click` on the veil; the picker closes on a capture-phase `pointerdown` outside itself first. | +| C21 | Adding a navigation entry does not move the boot DOM golden `build.py` compares: the nav is a portal into `#snavList` filled at runtime. | **Unverified.** Checked at the first front-end commit; a change is explained in the commit body, not silenced (prior open question 5). | +| C22 | The `check-class-namespace` counts for `settings` and `model` shift by the classes this change adds and deletes; the pin in `ui-web/scripts/check-class-namespace.mjs` is updated with the diff (the C4 exemption) and the new numbers are explained in the commit body. | **Unverified** until the classes exist. | + +### Authorization (this run) + +Baseline: `unattended-run` section 1. Pre-authorized here: rebasing onto +`refactor/ui_web_architecture`; running the real-host cases with the +operator's real keys copied into a temporary home (deleted at teardown, +recorded in `deviations.md`); temporary directories and processes (cleaned up +and recorded). Never pre-authorized: editing committed team-rule files +(`AGENTS.md`, `CONTRIBUTING.md`, the gate scripts beyond the C4 exemption). + +This project (AGENTS.md sections 3.4, 3.6, 3.7): while the owner is present +and no `/goal` is running, commit and push happen only on the owner's word, +one at a time; a `/goal` the owner starts carries that authorization for its +duration. The PR is opened only after its description passed the ASCII scan +and the owner saw the text. + +### Limits and goal + +Rounds 40, time 48 hours (the defaults). Goal text, for `/goal`, verbatim: + +> Every real-host case of `docs/specs/2026-09-20-web-settings-model-section-acceptance.md` passes with its evidence rings and every command block is rerunnable verbatim; the PR to `refactor/ui_web_architecture` is open; every blocker from the self-review is handled; every external review that arrived is handled or recorded in `deviations.md`; **the owner confirms acceptance in the conversation**. Or a stop-tier entry appears in `deviations.md`. Or 40 rounds. Or 48 hours. + +--- + +## Task 1: the two wire fields + +**Delivers**: A33 (the `gateway` and `kind` assertions), the wire half of A34; C5, C7. + +**Files**: +- modify: `rpc-schema/openrpc.json` (`components.schemas.ModelLabel.properties`, `components.schemas.ModelOptionProvider.properties`) +- modify: `raven/rpc/models.py:1518-1540` (`ModelLabel`), the `ModelOptionProvider` class below it +- modify: `raven/rpc/methods/model.py:227-268` (`_model_labels`), `:360-400` (`_build_provider_entry`'s returned dict) +- tests: `tests/test_rpc_model.py`, `tests/test_provider_registry_data.py` +- generated: `ui-web/src/rpc/generated.ts` (by `npm run gen`, never by hand) + +**Interfaces**: +- produces, on the wire: `ModelOptionProvider.gateway: boolean` (default `false`); `ModelLabel.kind: "text" | "image" | "audio" | "video" | "embedding" | "reranker"` (required on every emitted label) +- produces, in TypeScript after `npm run gen`: `ModelOptionProvider.gateway?: boolean`, `ModelLabel.kind: string` (the generator emits `string` for a Literal; `components/ModelTags.tsx` cannot import `features/model`'s `Kind`, so `ModelTagFacts.kind?: string` stays a string and Task 2's `modelKind()` validates it at run time -- an unknown value is `text`) +- consumed by: Task 2 (`kind` through `model_labels`), Task 4 (`gateway`) + +- [ ] **Step 1: the contract** + +In `rpc-schema/openrpc.json`, under `ModelLabel.properties` add, and add `"kind"` to `ModelLabel.required`: + +```json +"kind": { + "type": "string", + "enum": ["text", "image", "audio", "video", "embedding", "reranker"], + "description": "The bucket a model list files this model under, from what it writes: a model that reads images is still text. Absent label means text." +} +``` + +Under `ModelOptionProvider.properties` add (not required; readers default it to false): + +```json +"gateway": { + "type": "boolean", + "description": "Resells other vendors' models under vendor/model ids (the registry's is_gateway). The catalogue's gateway filter reads this." +} +``` + +- [ ] **Step 2: the pydantic models** + +```python +# raven/rpc/models.py, class ModelLabel + kind: Literal["text", "image", "audio", "video", "embedding", "reranker"] + #: from registry_data.kind_of: what the model writes decides the bucket. + +# raven/rpc/models.py, class ModelOptionProvider, beside needs_api_base + #: The registry's is_gateway: the catalogue filter's fact, which no client + #: can derive from a slug. + gateway: bool = False +``` + +- [ ] **Step 3: the handlers** + +```python +# raven/rpc/methods/model.py, _model_labels: import kind_of beside describe, +# and set the key on every emitted entry + entry: dict[str, Any] = {"label": row.label, "kind": kind_of(row.capabilities, row.output_modalities)} + +# raven/rpc/methods/model.py, _build_provider_entry, in the returned dict beside "needs_api_base" + "gateway": bool(spec and spec.is_gateway), +``` + +- [ ] **Step 4: the tests** + +```python +# tests/test_rpc_model.py, beside test_options_oauth_provider_warning_and_auth_type +async def test_options_rows_carry_the_gateway_flag(fake_home: Path) -> None: + _write_config(fake_home, {"agents": {"defaults": {"model": "anthropic/claude-sonnet-4-5"}}}) + result = await model_options({}) + assert _entry(result, "openrouter")["gateway"] is True + assert _entry(result, "custom")["gateway"] is True + assert _entry(result, "anthropic")["gateway"] is False + + +async def test_model_labels_carry_a_kind(fake_home: Path) -> None: + # openrouter with a key and one configured model whose name is the only + # thing that says what it is + _write_config( + fake_home, + { + "agents": {"defaults": {"model": "openrouter/anthropic/claude-sonnet-4-5"}}, + "providers": {"openrouter": {"apiKey": "sk-or-xxx", "models": ["openai/text-embedding-3-small"]}}, + }, + ) + result = await model_options({}) + labels = _entry(result, "openrouter")["model_labels"] + assert labels["openai/text-embedding-3-small"]["kind"] == "embedding" + assert all("kind" in v for v in labels.values()) +``` + +`_write_config(home, payload)` and `_entry(result, slug)` are this file's own +helpers (`tests/test_rpc_model.py:47-58`); do not add a second pair. + +```python +# tests/test_provider_registry_data.py: the table kinds.test.ts asserts too (Task 2) +NAME_GUESSES = [ + ("openai/text-embedding-3-small", "embedding"), + ("BAAI/bge-reranker-v2-m3", "reranker"), + ("my-team/bge-reranker-custom", "reranker"), + ("jina-embeddings-v3", "embedding"), + ("bge-m3", "embedding"), + ("gte-large", "embedding"), + ("rerank-co/gpt-4", "text"), # the last path segment decides + ("google/gemini-2.5-flash-image", "text"), # a name says nothing; the registry row does + ("deepseek-v4-pro", "text"), +] + + +@pytest.mark.parametrize(("model_id", "kind"), NAME_GUESSES) +def test_kind_of_a_name_alone(model_id: str, kind: str) -> None: + assert kind_of(inferred_tags(model_id), ()) == kind +``` + +- [ ] **Step 5: run, regenerate, prove it fails** + +Run: `uv run pytest tests/test_rpc_model.py tests/test_provider_registry_data.py -q -p no:cacheprovider` +Expected: the two baselines plus the eleven new cases, all passing -- copy the real tally line; do not type a number. +Run: `cd ui-web && npm run gen && npm run gen:check && git diff --stat src/rpc/generated.ts` +Expected: `generated.ts matches the contract (189 methods)`; one file changed. +Run: `uv run pytest tests/test_rpc_schema_match.py -q` +Expected: green (copy the line). +Mutation: comment out the `"gateway":` line -> `test_options_rows_carry_the_gateway_flag` fails on `KeyError: 'gateway'` or the `is True` assertion; comment out the `"kind"` key -> `test_model_labels_carry_a_kind` fails on `KeyError: 'kind'`. Restore; `git diff` shows only the intended edits. + +- [ ] **Step 6: record** + +Nothing expected. If `test_rpc_schema_match.py` wants `gateway` in `required`, it is the schema's rule and the field becomes required with `False` in every fixture row (Task 7); record as an assumption mismatch. + +--- + +## Task 2: the picker's offer + +**Delivers**: A28 (the composer opening), A29 (placement, both openings), A31, the picker half of A22-A27; C3 groundwork, C6, C8, C11. + +**Files**: +- modify: `ui-web/src/features/model/types.ts` (add `Kind`, `Offer`) +- new: `ui-web/src/features/model/kinds.ts`, `ui-web/src/features/model/kinds.test.ts` +- modify: `ui-web/src/features/model/store.ts:18-45` (`OpenAt`), `:79-107` (`open`), `:112` (`authed` -> `listed`), `:129-160` (`choose`) +- modify: `ui-web/src/features/model/source.ts:22-34` (delete `HIDDEN_PROVIDERS` and its comment), `:85-100` (mapper: drop the filter, add `gateway`), new export `openPicker` +- modify: `ui-web/src/features/model/source.test.ts:117,325,334` (the hidden-set assertions go; add "a connected custom is listed") +- modify: `ui-web/src/features/model/ModelPicker.tsx` (kind filter, typed row, title, placement branch, scroll/resize) +- modify: `ui-web/src/features/model/ModelPicker.test.tsx` (new cases; snapshot reviewed line by line, C11) +- modify: `ui-web/src/components/ModelTags.tsx:~150` (`ModelTagFacts` gains `kind?: string`) +- modify: `ui-web/src/styles/page.css` (`.mpick` block: `.model-kind-row`, `.model-kind`, `.model-typed`) +- i18n: reuse `gui.model.type.*`, `gui.model.kind_all`, `gui.model.pick_use`, `gui.model.pick_add_to`; one new key `gui.picker.empty_kind` ("No {kind} model added here yet" / "这一家还没有添加{kind}模型") + +**Interfaces**: +- produces (`types.ts`): + +```ts +export type Kind = 'text' | 'image' | 'audio' | 'video' | 'embedding' | 'reranker' +export const KIND_ORDER: Kind[] = ['text', 'image', 'embedding', 'reranker', 'audio', 'video'] // the chip's cycle and the tab order + +export interface Offer { + kind: Kind + providers?: string[] + title?: string + current?: { model: string; provider: string } + pick?(model: string, provider: string, typed: boolean): Promise +} +``` + +- produces (`kinds.ts`): + +```ts +export const modelKind = (facts: ModelTagFacts | undefined): Kind => + (KIND_ORDER as string[]).includes(facts?.kind ?? '') ? (facts!.kind as Kind) : 'text' +export const KIND_LABEL: Record = { + text: 'gui.model.type.text', image: 'gui.model.type.image', audio: 'gui.model.type.audio', + video: 'gui.model.type.video', embedding: 'gui.model.type.embedding', reranker: 'gui.model.type.reranker', +} +/* The sprite id in components/ModelTags.tsx for each kind: the sprite has + `text`, `embedding`, `rerank` and the `*-generation` compounds, never a bare + `image` / `audio` / `video` / `reranker`. Every TagGlyph drawn for a kind + goes through this table, not the Kind value. */ +export const KIND_GLYPH: Record = { + text: 'text', image: 'image-generation', audio: 'audio-generation', + video: 'video-generation', embedding: 'embedding', reranker: 'rerank', +} +/* registry_data.inferred_tags, copied: the last path segment, rerank first. */ +const RERANK = /rerank/i +const EMBED = /(?:^|[-_/])(?:bge|gte|e5|m3e|text2vec|uae|jina-clip)(?:[-_.]|$)|embed/i +export function guessKind(id: string): Kind { + const bare = id.split('/').pop() ?? id + if (RERANK.test(bare)) return 'reranker' + if (EMBED.test(bare)) return 'embedding' + return 'text' +} +``` + +- produces (`store.ts`): `open(anchor?: HTMLElement | null, after?: () => void, marked?: string, offer?: Offer): void` -- the fourth argument defaults to `{ kind: 'text' }`; `OpenAt.offer: Offer`; `listed(): Provider[]` replaces `authed()`; `choose(m, provider, typed = false)`. +- produces (`source.ts`): `export function openPicker(anchor: HTMLElement, offer: Offer, after?: () => void): void { store.open(anchor, after, offer.current?.model, offer) }` -- the one door the settings domain uses (C2). `Provider` gains `gateway: boolean` and `current: boolean` (the wire's `is_current`). +- consumed by: Task 3 (`openPicker`, `Offer`, `Kind`), Task 6 (`KIND_ORDER`, `KIND_LABEL`, `guessKind`, `modelKind`), Task 4 (`Provider.gateway` via the settings mapper) + +- [ ] **Step 1: kinds.ts and its test first** (pure functions; the table is Task 1's `NAME_GUESSES`, same nine rows, same expected kinds -- two literal copies, one assertion each side) + +```ts +// kinds.test.ts +const TABLE: Array<[string, Kind]> = [ + ['openai/text-embedding-3-small', 'embedding'], ['BAAI/bge-reranker-v2-m3', 'reranker'], + ['my-team/bge-reranker-custom', 'reranker'], ['jina-embeddings-v3', 'embedding'], ['bge-m3', 'embedding'], + ['gte-large', 'embedding'], ['rerank-co/gpt-4', 'text'], ['google/gemini-2.5-flash-image', 'text'], ['deepseek-v4-pro', 'text'], +] +it.each(TABLE)('guesses %s as %s, the way registry_data.inferred_tags does', (id, kind) => { expect(guessKind(id)).toBe(kind) }) +it('files a model with no label, or an unknown kind, under text', () => { + expect(modelKind(undefined)).toBe('text'); expect(modelKind({ kind: 'weird' })).toBe('text'); expect(modelKind({ kind: 'audio' })).toBe('audio') +}) +``` + +- [ ] **Step 2: the store** + +```ts +// store.ts +export interface OpenAt { host; after; footer; scope; marked; offer: Offer } +const DEFAULT_OFFER: Offer = { kind: 'text' } +const CLOSED: OpenAt = { host: null, after: null, footer: false, scope: 'session', marked: null, offer: DEFAULT_OFFER } + +/* Connected, and within the offer's providers when it names any. The kind + never hides a provider (the prototype's rule, and the tester's 2026-09-20 + report: a provider with a key but no list built vanished): it narrows the + column, see `column`. */ +export const listed = (offer: Offer = at.offer): Provider[] => + source().providers().filter((p) => p.on && (!offer.providers || offer.providers.includes(p.id))) + +/* One provider's column: its added models of the offer's kind, with the + current model first when the list does not carry it -- onboarding and the + CLI set agents.defaults.model without adding it, and a model the chip names + must be somewhere to be marked. */ +export const column = (p: Provider, offer: Offer = at.offer): string[] => { + const rows = offered(p).filter((m) => modelKind(p.labels?.[m]) === offer.kind) + const cur = offer.current ? (offer.current.provider === p.id ? offer.current.model : null) : (p.current ? selected : null) + return cur && !rows.includes(cur) ? [cur, ...rows] : rows +} + +export function open(anchor?: HTMLElement | null, after?: () => void, marked?: string, offer: Offer = DEFAULT_OFFER): void { + if (!installed()) return + const accounts = source().providers().filter((p) => p.on) + const rows = listed(offer) + if (!rows.length) { /* the two dead-end toasts, unchanged */ ... return } + const host = anchor || document.getElementById('modelChip') + if (!host) return + at = { host, after: after || null, footer: !anchor, scope: anchor ? 'default' : 'session', marked: marked || null, offer } + announce() +} + +export async function choose(m: string, provider: string, typed = false): Promise { + const { offer } = at + if (offer.pick) { // a slot: its own write path, before any of the scope logic + close() + try { await offer.pick(m, provider, typed) } catch (e) { toast(t('gui.op.switch_failed', { detail: detail(e) })) } + return + } + if (typed) await source().addModel?.(m, provider) // the composer's typed row adds first (ModelSource gains addModel) + ... /* today's body, unchanged */ +} +``` + +`ModelSource` (types.ts) gains `addModel?(m: string, provider: string): Promise`; `modelSource` (source.ts) implements it as `gateway().call('model.add_model', { slug: provider, model: m }).then(() => loadProviders())`. + +- [ ] **Step 3: the component** + +In `ModelPicker.tsx`: +- `const providers = store.listed()`; `narrow(term)` maps `store.column(p)` then the term; the provider row's count is `column(p).length`. A provider whose `column` is empty while no term is typed shows `t('gui.picker.empty_kind', { kind: t(KIND_LABEL[at.offer.kind]) })` in the model pane above the typed-id row (the pane keeps `gui.picker.no_match` for a term that matched nothing). +- Title: `{at.offer.title ? {at.offer.title} : null}` in `.find`, before the input. +- Typed row, after the model list, when `q && !list.some((m) => store.short(m).toLowerCase() === q)`: + +```tsx + +``` + + `typedKind` state starts as `at.offer.title ? at.offer.kind : guessKind(q)` and re-derives when `q` changes; `KindChip` is a small local component (label from `KIND_LABEL`, glyph ``, `aria-label` = `gui.model.add_type`). The stated tags for a non-text kind go with the add: `store.choose` passes `typed` and the source's `addModel` takes an optional kind -> `capabilities` / `output_modalities` as the design's A17 says (`{embedding: [['embedding'], ['vector']], reranker: [['rerank'], ['text']], image: [['image-generation'], ['text','image']], audio: [['audio-generation'], ['audio']], video: [['video-generation'], ['video']]}`; text sends nothing). +- Placement (the layout effect): keep today's above-first branch when `!at.offer.title`; otherwise below-first: + +```ts +const r = host.getBoundingClientRect(); const b = el.getBoundingClientRect() +let top = r.bottom + 6 +if (top + b.height > vh - 12 && r.top - 6 - b.height >= 12) top = r.top - 6 - b.height +el.style.top = `${Math.max(12, Math.min(top, vh - b.height - 12))}px` +el.style.left = `${Math.max(12, Math.min(r.left, vw - b.width - 12))}px` +``` + +- Scroll and resize, only when `at.offer.title`: the #541 effect moved here verbatim from the deleted `components/ModelPicker.tsx:75-98` (document `scroll` in capture closes when `host.getBoundingClientRect().top` moved more than 1px; `resize` re-runs the placement and refreshes the baseline). + +- [ ] **Step 4: the source and its test** + +Delete `HIDDEN_PROVIDERS` and the filter at `source.ts:85`; add `gateway: !!p.gateway` to the mapper; add `openPicker` and `addModel`. In `source.test.ts`: the harness line 117 that exposes `HIDDEN_PROVIDERS` goes; the three cases at 324-327, 330-335 and 337-342 that assert the set or its effect go too -- the last, `it('leaves a hidden provider configured and routable, only unlisted', ...)`, asserts `expect(rows).toEqual([])` for a connected `custom`, which is now the opposite of the design (C9). Replace the three with: + +```ts +it('lists a connected custom section like any other provider', async () => { + const rows = await listed([row('anthropic'), row('custom', { authenticated: true })]) + expect(rows.map((p) => p.id)).toEqual(['anthropic', 'custom']) +}) +``` + +- [ ] **Step 5: the picker tests** + +Add to `ModelPicker.test.tsx` (harness at lines 60-104; `openIt` gains a fourth argument): + +```ts +it('lists every connected provider and narrows each column to the kind; a column with none says so', () => { /* two providers, one with only an embedding model; open({kind:'text'}); two provider rows, the second counted 0 and its pane showing gui.picker.empty_kind */ }) +it('lists the current model in its provider column even when the list does not carry it', () => { /* provider p1 is_current with models ['a']; store.setCurrent('b'); open(); p1 column == ['b','a'], 'b' marked */ }) +it('a slot opening lists the slot kind and calls its pick, never persist', async () => { /* open(anchor, undefined, 'x', {kind:'embedding', providers:['p1'], title:'Embedding', pick}) ; click row; expect pick called, h.persisted empty */ }) +it('a typed id adds to the provider and then picks', async () => { /* type 'my-model'; click .model-typed; expect addModel then persist in order */ }) +it('a slot opening hangs below its anchor and closes when the anchor scrolls away', () => { /* boxes via mocked getBoundingClientRect; dispatch scroll after moving the mock */ }) +it('the composer opening is unchanged: above the card, no title, no typed-kind chip when nothing is typed', () => { /* snapshot */ }) +``` + +Run: `cd ui-web && npx vitest run src/features/model -q` +Expected: every existing case still passing plus the new ones (copy the tally); the `.snap` diff shows only the kind row / typed row / title lines. +Mutation: drop the kind filter inside `column` -> the first new case fails on the second provider's count; drop the `cur` prepend -> the current-model case fails; make `choose` ignore `offer.pick` -> the slot case fails on `h.persisted`. Restore. + +- [ ] **Step 6: record** + +Open question 5 (the `scope: 'default'` derivation): this task leaves it. If a test in `ModelPicker.test.tsx` that pins it turns red because of the offer default, record and fix the test's call, not the derivation. + +--- + +## Task 3: the roles card on the shared picker; the second picker goes + +**Delivers**: A2 (the empty-state half), A22-A27 (the roles half), A31; C2, C3. + +**Files**: +- modify: `ui-web/src/features/settings/providers/Roles.tsx:1-60` (imports, `ROLE_KIND`), `:130-135` (delete `pickerProviders`), `:174-232` (`RolePill`) +- modify: `ui-web/src/features/settings/providers/Roles.test.tsx` (the picker cases spy `openPicker`) +- modify: `ui-web/src/features/settings/store.ts:83-84,115,234` (delete `picker`); `ui-web/src/features/settings/store.test.ts:39` (drop `s.picker` from the asserted array) +- delete: `ui-web/src/components/ModelPicker.tsx`, `ui-web/src/components/ModelPicker.test.tsx` +- modify: `ui-web/src/lib/popover.ts:57-113` (delete `DIALOG`, `bounds`, `anchorRow`; trim the header comment's paragraph about them) +- modify: `ui-web/src/styles/page.css:2317-2341` (delete the `.model-picker*` rules), `ui-web/src/features/settings/styles.css:257-265` (delete the nine rules) +- check: `ui-web/scripts/check-class-namespace.mjs` pins (run the gate; update only the `settings` / `model` numbers if it asks, C4 exemption) + +**Interfaces**: +- consumes: `openPicker(anchor, offer, after)`, `Offer`, `Kind` from `features/model/source.ts` / `types.ts` (Task 2) +- produces: `ROLE_KIND: Record` (exported for the tests) + +- [ ] **Step 1: the table and the pill** + +```tsx +// Roles.tsx +import { openPicker } from '../../model/source' +import type { Kind, Offer } from '../../model/types' + +export const ROLE_KIND: Record = { + chat: 'text', curator: 'text', title: 'text', memllm: 'text', gate: 'text', multimodal: 'text', + embedding: 'embedding', rerank: 'reranker', image: 'image', speech: 'audio', video: 'video', +} + +export function RolePill({ role }: { role: Role }): JSX.Element { + const pill = useRef(null) + const s = store.get() + const val = roleValue(role, s.snap) + const kind = ROLE_KIND[role.id] + const provs = roleProviders(role, s.snap) + const inherit = !!role.keys + const chat = roleValue(ROLES[0]!, s.snap) + if (!provs.length && !val) { + return ( + + ) + } + const offer: Offer = { + kind, providers: provs.map((p) => p.id), title: roleName(role), + current: val ?? (inherit ? chat : undefined), + pick: (m, p, typed) => store.run(`role:${role.id}`, () => setRole(role, m, p, typed)).then(() => undefined), + } + return ( + + + {clearable && + + ))} + + ) :
{t('gui.model.prov_no_match')}
} + + ) +} +``` + +`FilterMenu` is a ``; also the onboarding wizard's model step (`SetupBodies.tsx` renders ``) | untouched: the wizard keeps rendering it (D1). The settings page stops rendering it; `kindOf` / `takesKey` / `needsKey` / `takesBase` are imported from it as today | +| `providers/ProviderSide.tsx` (new) | -- | the catalogue column: search box, filter menu, rows (mark, name, default badge, dot; each row element carries `data-id={p.id}` so a driver can read the listed set without parsing names), connected first then registry order | +| `providers/ProviderDetail.tsx` | pane with key / address / models card with a checkbox `Sheet` / advanced | head per prototype (mark, name, link, status line); key pane with eye toggle and "get a key"; address in body or Advanced by A9; `Sheet` replaced by `AddModelPop` (A14-A19); disconnect footer | +| `providers/AddModelPop.tsx` (new) | -- | the add-model popover (A14-A19), lifted out of `ProviderDetail.tsx` where the `Sheet` was | +| `providers/Roles.tsx` | own picker | shared picker (above) | +| `source.ts` | maps `model.options` rows | plus `gateway`; label `kind` passes through | +| `types.ts` | `ProviderRow` | plus `gateway: boolean` | +| `styles.css` | -- | `.settings-tp*` (two columns, side, row, find, filter, main, section, tile, dot, badge) and `.settings-ap*` (popover, kind row, row, group head, footer), copied from the prototype with the prefix | + +Filter semantics (A5), on `p.kind`, the wire's `auth_type` -- not +`Providers.tsx`'s `kindOf(p)`, which folds `endpoint` into `local` for the +pane's shape: `on` = `p.on`; `gateway` = `p.gateway`; `oauth` = `p.kind === +'oauth'`; `local` = `p.kind === 'local'`; `direct` = `!p.gateway && (p.kind === +'key' || p.kind === 'endpoint')`. + +**The add-model popover** (`providers/AddModelPop.tsx`): opens under the "add +model" button inside the dialog (it is the dialog's own element, positioned +like the prototype's `placeAddPop`: below the button, flipping above only when +fewer than 220px remain below and more remain above, max-height from the +room). Fetches +`model.fetch_models` on open; rows as A14 with `ModelTags`; kind tabs as A15 +counted over the search result; groups as A16 for gateways; typed row as A17; +"add all" as A18. A row click writes immediately: `model.add_model` or +`model.remove_model` (with the A13 refusal), one write per click; "add all" +and "add group" use `model.add_models`. + +### Page contracts + +| Surface | Reads | Writes | +|---|---|---| +| Catalogue rows | `model.options` rows; `agents.defaults.provider` for the badge | -- | +| Detail: key / address / Azure / disconnect / headers / display names | as prior design | as prior design (`model.save_key`, `model.set_fields`, `model.disconnect`, `model.add_model` for overlays) | +| Add-model popover | `model.fetch_models` (`kind` per row); configured list | `model.add_model` (with stated tags for a non-text typed id), `model.remove_model`, `model.add_models` | +| Roles card | snapshot as today; `model_labels[m].kind` through the picker | as prior design, through `setRole` / `clearRole` | +| Picker (composer opening) | `model.options` rows and labels | `config.set` model (as today); `model.add_model` then the switch for a typed id | + +### Change map + +| Area | Files | Nature | +|---|---|---| +| Backend fields | `raven/rpc/models.py`, `raven/rpc/methods/model.py`, `rpc-schema/openrpc.json`, `tests/test_rpc_model.py`, `tests/test_provider_registry_data.py` | add two fields, pin them | +| Picker | `features/model/{types,store,source,kinds,ModelPicker}.ts(x)`, `ModelPicker.test.tsx`, `kinds.test.ts` (new), `source.test.ts` (the `HIDDEN_PROVIDERS` assertions go), `styles/page.css` (`.mpick` block gains `model-kind-row`, `model-kind`, `model-typed`; loses `.model-picker` and `model-picker-*`) | one input, two additions, one deletion | +| Deleted | `components/ModelPicker.tsx`, `components/ModelPicker.test.tsx`, `lib/popover.ts` (one export and its two private helpers), `features/settings/styles.css` (nine rules, 257-265) | delete | +| Roles | `features/settings/providers/Roles.tsx`, `Roles.test.tsx` | swap the picker call | +| Catalogue | `features/settings/{store,SettingsApp,source,types}.ts(x)`, `pages/Provider.tsx` (new), `pages/Model.tsx`, `providers/ProviderSide.tsx` (new), `providers/ProviderDetail.tsx`, `providers/AddModelPop.tsx` (new), their tests, `styles.css`; `providers/Providers.tsx` untouched (the wizard's, D1) | the page | +| Data | `rpc/fixtures/model.ts`, `test/settingsHarness.ts`, `rpc/generated.ts` (generated), `i18n/messages.json`, `ui-tui/src/i18n/messages.generated.ts` (generated), `ui-web/CONTEXT.md` (four terms) | fields, keys, terms | +| Gates | `ui-web/scripts/check-class-namespace.mjs` pin (C22, the C4 exemption) | pins | + +Sibling sites enumerated for the shared changes: `SectionId` (C18, two +files); `Provider` / `ProviderRow` mappers (two `source.ts`); every reader of +`HIDDEN_PROVIDERS` (two, C19); every caller of `store.open` (the chip, unchanged; +the tests); every place that draws a model row (`ModelPicker.tsx`, +`AddModelPop`, `InstanceModel.tsx` -- the last is out of scope, C-list in +Non-goals). + +## Cost + +- Who pays. Composer users see two changes: embedding and rerank models + leave the chip's picker, and a typed id becomes possible there. Settings + users learn two entries where there was one. The backend gains two fields + and no method. The front-end takeover inherits one picker and one page + skeleton fewer. +- Reversibility. The split is a data change in `SECTIONS`; the merge deletes + a component that `git revert` restores; the two wire fields are additive + and optional on the reader's side. +- The minimal half. The split and the catalogue page can ship without kinds + (the popover would show a flat list); kinds cannot ship without the merge + without forking the filter, which is the order the plan will take: fields, + merge, kinds, page. +- Not free. The kind filter hides a wrongly classified model from every slot + it should be in; the escape is the popover's kind chip (a stated overlay + wins over inference) and, for a model the registry describes wrongly, a + registry refresh. Named in Open questions. + +## Decisions + +- **Two pages, `provider` before `model`, the prototype's order.** The + prototype's own comment states the split: accounts versus assignments. The + section id is `provider`, the roles page keeps `model` so `settingsTab.id` + values written elsewhere still land on a model page. +- **The catalogue lists every row `model.options` returns, `hosted_vllm` and + `custom` included; `HIDDEN_PROVIDERS` is deleted.** The set was hiding an + unconnected generic row from a list that had no filter; the catalogue has + one, and `custom` is the page's only entry for a vendor Raven carries no spec + for. The composer is unaffected beyond a connected `custom` becoming + pickable (C19). The owner's reason: a person running their own server is a + likely user and must not be shut out. Rejected: keeping the set. +- **Gateway comes from the registry, on the wire, as `gateway: bool`.** The + filter needs a fact the page cannot derive; `ProviderSpec.is_gateway` has it. + Rejected: a front-end list of gateway ids. +- **Kind is computed once, in Python, and travels as `model_labels[m].kind`.** + `kind_of` exists, `fetch_models` already emits it; a TypeScript copy of the + bucket logic would be a second source (Non-goals). A model with no label is + `text`, which is also `kind_of`'s answer for nothing. Rejected: deriving + kind from capabilities in TypeScript. +- **The typed-id kind chip guesses from the name with a copy of the two + registry patterns, pinned by a shared table.** The alternative was a new + `model.kind_of` RPC for one chip's default; the patterns are two regular + expressions and the drift guard is one test on each side (A33). A text + guess states nothing on add, so the backend's own inference (C15) is what + is stored. Rejected: an RPC per keystroke; always writing a stated kind. +- **Kinds filter the slots, hard, and the composer with them.** A kind that + only decorates is a tag; the owner asked for a distinction, and the + prototype's comment names the slot filter as the one consumer. The + prototype's own `drawPicker` does not filter -- the comment and the + behaviour disagree, and this design follows the comment. Rejected: tabs in + the popover only; filtering slots but not the composer. +- **Text slots take `text`; vision does not make an image model.** `kind_of` + files a model by what it writes, so the multimodal slot lists `text` models + like the others; capability data is incomplete ("absent means unknown, not + cannot", `ModelTags.tsx`) and filtering by it would hide working models. + Rejected: a capability filter per slot. +- **A provider with nothing added at all offers the registry's shortlist, and + only for text.** The picker this one replaced carried that fallback for the + first-run wizard, whose model step is the one place a model is picked before + any list exists; dropping it in the merge left that step with an empty + column, which is the whole step. Restricted to text because the wizard only + ever asks for a chat model, and because a kind-filtered slice of a vendor's + whole catalogue is a worse answer than saying the list is empty. Rejected: + the fallback for every kind; no fallback (a non-goal crossed -- the wizard is + not redesigned here, so it must keep working). +- **Every connected provider the offer allows is listed; a column with no + model of the kind says so and offers the typed-id row; the current model is + always in its provider's column.** The prototype's `drawPicker` lists every + connected provider and writes "no model added here yet" in an empty column. + A tester's report (2026-09-20) showed why the alternative fails: a provider + whose key works but whose list nobody built vanished from the composer's + picker, and the model the chip named -- set by onboarding, never "added" -- + was in no column to be marked; `agents.defaults.model` is routinely set + without a matching entry in `providers..models`. The owner reversed + the earlier ruling to hide such providers. Rejected: hiding providers with + none of the kind; a special case for the current provider alone. +- **The typed-id row is in both openings.** One control, one behaviour; the + prototype's reason for the popover -- "fetching and typing are two routes to + one thing, not two buttons" -- applies to the composer too. Rejected: a row + only under slots. +- **`features/model` survives; `components/ModelPicker` goes.** One new + input (the offer) versus moving 254 lines of drawing and re-pinning a + 585-line test. The prior design's decision "one shared picker in + `components/`, props only" is reversed here: it produced two pickers, not + one. The cross-domain rule is kept by an opener in `features/model/source.ts` + (C2). Rejected: direction B; keeping both. +- **Body-level portal for the settings opening; `anchorRow` deleted.** The + #541 positioning existed because the picker rendered inside the dialog's + scroller and had to escape clipping through a transformed containing block. + A body-level fixed element has no such ancestor; the row placement is a + branch in the existing layout effect, and #541's scroll-closes / resize- + re-places rules are kept for that branch. Rejected: portalling into + `.smodal`; keeping `anchorRow` for one caller. +- **Scrim click closes dialog and picker, as today.** The picker closes on + `pointerdown`, the scrim on `click`; both fire; today's in-dialog picker + dies with the dialog anyway (C20). Rejected: swallowing the click, which + reaches into `App.tsx`. +- **Per-row add and remove in the popover, batch for "add all" and "add + group".** The prototype toggles on click; `model.add_models` stays for the + two batch controls. Rejected: keeping the checkbox sheet with an "add n" + button. +- **The OAuth pane keeps the browser device flow in the prototype's layout.** + The prototype regressed to a CLI instruction; the prior design built the + flow from the page on the owner's decision. Rejected: the CLI text. +- **Media slots stay OpenRouter-only, narrowed by kind.** Prior C14. +- **No two-column abstraction for pages that do not exist yet.** The class + family is named `settings-tp*` after the prototype's `tp-*` so channels and + memory can lift it; it is one page's CSS until a second page needs it. +- **The four coined terms go into `ui-web/CONTEXT.md` in this change.** + AGENTS.md section 6: kind (of a model), offer, gateway provider and provider + catalogue are new names; each entry names the code it is read from + (`registry_data.KINDS`, `features/model/types.ts`, `ProviderSpec.is_gateway`, + `model.options`), and "gateway provider" carries an _Avoid_ line because + **Gateway** there already means the Runtime's WebSocket server. Rejected: + leaving the glossary to the takeover. +- **No whole-design alternatives round.** The prototype is the mockup and the + owner's answer to the merge question fixed the shape; two grill rounds + settled the rest. + +## Open questions + +Assumptions the execution will confirm; each is a likely row of the +deviations ledger. + +1. C21: whether the ninth nav entry moves the boot DOM golden. +2. C22: the exact `check-class-namespace` count changes. +3. `custom` filters as a gateway because the registry says so; whether the + owner wants it under "direct" or its own bucket is decided on sight. +4. A model the registry classifies wrongly -- an embedding model with no + `embedding` tag and a name the patterns miss -- is invisible to the + embedding slot until an overlay states its kind. The popover's chip is the + escape; whether a "show every kind" switch is wanted on a slot is decided + on the first such case. +5. Whether the `scope: 'default'` derivation in `features/model/store.ts` + (reached by no caller once every anchored opening carries a pick; three + tests pin it) is deleted in this change or left for the composer's owner. +6. The `ModelPicker.test.tsx` snapshot ("keeps its rendered shape") changes + with the kind row and typed row; the new snapshot is reviewed, not + accepted blind (C11). + +## Sources + +- Prototype: the claude.ai artifact + `https://claude.ai/artifact/DVhkghoDZucJPmzsCvPwQx` (title "Raven + sub-agents"), whose settings page is a separate document embedded as + `SETTINGS_B64` (title "Raven settings prototype", twelve nav entries as of + 2026-09-20). The relevant code: `PAGES` (the split), `PROVIDERS` and + `PV_FILT` / `pvMatch` / `pvRows` / `pvRow` (the catalogue), `pvDetail` and + its sections (the right pane), `KINDS` / `kindOf` / `inferredCaps` / + `modelMeta` and `drawAddPop` (kinds and the popover), `roleProviders` / + `drawPicker` (the slots). Not committed (repository rule on HTML assets); the + artifact link is the shared pointer, and a decoded copy sits in the task's + working directory. +- Prior design: `docs/specs/2026-09-17-web-settings-page-design.md`, whose + model-page items A9-A23 and constraints C10, C14 this document cites. +- Backend: `raven/providers/registry_data.py` (`KINDS`, `kind_of`, + `inferred_tags`, `CAPABILITIES`), `raven/providers/registry.py` + (`ProviderSpec.is_gateway`, `auth_shape`), `raven/rpc/methods/model.py` + (`model.options`, `model.fetch_models`, `model.add_model`). diff --git a/i18n/messages.json b/i18n/messages.json index 21241fdb3..c86e563be 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -3279,6 +3279,10 @@ "en": "No models under this account", "zh": "这个账号下还没有模型" }, + "gui.picker.empty_kind": { + "en": "No {kind} model added here yet", + "zh": "这一家还没有添加{kind}模型" + }, "gui.picker.manage": { "en": "Manage models & accounts...", "zh": "管理模型与账号…" @@ -6276,8 +6280,12 @@ "zh": "用量" }, "gui.settings.nav.model": { - "en": "Models", - "zh": "模型" + "en": "Model settings", + "zh": "模型配置" + }, + "gui.settings.nav.provider": { + "en": "Model providers", + "zh": "模型服务商" }, "gui.settings.nav.skills": { "en": "Skills", @@ -6579,6 +6587,10 @@ "en": "Paste an API key", "zh": "粘贴 API Key" }, + "gui.settings.providers.pick_one": { + "en": "Choose a provider on the left", + "zh": "在左边选一家服务商" + }, "gui.settings.providers.key_first": { "en": "Enter the API key first", "zh": "先填入 API Key" @@ -6615,6 +6627,18 @@ "en": "Disconnect", "zh": "断开" }, + "gui.settings.providers.disconnect_key": { + "en": "Disconnect and clear the key", + "zh": "断开并清除密钥" + }, + "gui.settings.providers.disconnect_auth": { + "en": "Disconnect authorisation", + "zh": "断开授权" + }, + "gui.settings.providers.disconnect_local": { + "en": "Disconnect this server", + "zh": "断开这台服务" + }, "gui.settings.providers.connection": { "en": "Connection", "zh": "连接" @@ -6671,10 +6695,30 @@ "en": "added", "zh": "已添加" }, + "gui.settings.providers.add_model": { + "en": "Add model", + "zh": "添加模型" + }, + "gui.settings.providers.showing_added": { + "en": "showing {n} · added {m}", + "zh": "显示 {n} 个 · 已添加 {m}" + }, "gui.settings.providers.fetching": { "en": "Fetching the model list from {name}", "zh": "正在从 {name} 获取模型列表" }, + "gui.settings.providers.filter_direct": { + "en": "Direct vendors", + "zh": "直连厂商" + }, + "gui.settings.providers.filter_gateway": { + "en": "Aggregators", + "zh": "聚合网关" + }, + "gui.settings.providers.filter_oauth": { + "en": "Browser sign-in", + "zh": "浏览器授权" + }, "gui.settings.providers.no_list": { "en": "This vendor has no model list endpoint; type a model id", "zh": "这家没有模型列表接口,直接输入模型 id" @@ -7175,6 +7219,10 @@ "en": "Overrides the default", "zh": "覆盖默认" }, + "gui.settings.providers.base_reset": { + "en": "Reset", + "zh": "恢复默认" + }, "gui.settings.providers.key_optional_ph": { "en": "API key, if the server wants one", "zh": "API Key(服务要求时填)" diff --git a/raven/rpc/methods/model.py b/raven/rpc/methods/model.py index 5b73dfc8f..1cb0d91f0 100644 --- a/raven/rpc/methods/model.py +++ b/raven/rpc/methods/model.py @@ -243,6 +243,7 @@ def _model_labels(slug: str, models: "list[str]", *, section: Any = _UNLOADED) - """ from raven.providers.catalog import describe from raven.providers.rates import resolve_context_window + from raven.providers.registry_data import kind_of overlays = _configured_overlays(slug, section=section) out: dict[str, dict[str, Any]] = {} @@ -251,7 +252,7 @@ def _model_labels(slug: str, models: "list[str]", *, section: Any = _UNLOADED) - window = resolve_context_window(model, allow_fetch=False) if not (row.described or row.tagged or window): continue - entry: dict[str, Any] = {"label": row.label} + entry: dict[str, Any] = {"label": row.label, "kind": kind_of(row.capabilities, row.output_modalities)} if row.description: entry["description"] = row.description if row.capabilities: @@ -397,6 +398,7 @@ def _build_provider_entry( "protocols": protocols, "protocol_overrides": overrides, "total_models": len(models), + "gateway": bool(spec and spec.is_gateway), # "An address must be supplied" -- the gate's answer, not the shape's: # an endpoint-credential spec that ships a usable default (custom's # localhost gateway) runs on a bare key, and the picker must not diff --git a/raven/rpc/models.py b/raven/rpc/models.py index 13b9edd99..843bd41eb 100644 --- a/raven/rpc/models.py +++ b/raven/rpc/models.py @@ -1558,6 +1558,9 @@ class ModelLabel(_Strict): capabilities: list[str] = Field(default_factory=list) input_modalities: list[str] = Field(default_factory=list) output_modalities: list[str] = Field(default_factory=list) + #: The bucket a model list files this model under, from what it writes + #: (``registry_data.kind_of``): a model that reads images is still text. + kind: Literal["text", "image", "audio", "video", "embedding", "reranker"] #: Tokens the model reads in one request. Resolved from the tables that also #: route, never from the display registry -- a window sizes trimming, so the #: number a picker shows has to be the number a request is sized with. None @@ -1613,6 +1616,10 @@ class ModelOptionProvider(_Strict): extra_headers: dict[str, str] = Field(default_factory=dict) total_models: int needs_api_base: bool + #: The registry's ``is_gateway``: resells other vendors' models under + #: vendor/model ids. The catalogue's filter reads it; no client can derive + #: it from a slug. + gateway: bool = False #: Addresses to pick between, empty for the providers that have only one. #: A row that states these is drawn with the list in place of a host field. platforms: list[ModelOptionPlatform] = Field(default_factory=list) diff --git a/rpc-schema/openrpc.json b/rpc-schema/openrpc.json index b75c6402c..89cd04c71 100644 --- a/rpc-schema/openrpc.json +++ b/rpc-schema/openrpc.json @@ -10651,6 +10651,10 @@ "needs_api_base": { "type": "boolean" }, + "gateway": { + "type": "boolean", + "description": "The registry's is_gateway: resells other vendors' models under vendor/model ids. The catalogue's gateway filter reads this; absent means false." + }, "platforms": { "type": "array", "items": { @@ -10744,7 +10748,8 @@ "additionalProperties": false, "description": "How a model reads to a person, and what it can do. Absent for a model the registry knows nothing about -- one released since the bundled files, or served by a local deployment -- in which case the id is all there is to show. An empty tag list means nothing is published, not that the model cannot: a surface renders absence as no icon, never as a denial.", "required": [ - "label" + "label", + "kind" ], "properties": { "label": { @@ -10774,6 +10779,18 @@ "type": "string" } }, + "kind": { + "type": "string", + "enum": [ + "text", + "image", + "audio", + "video", + "embedding", + "reranker" + ], + "description": "The bucket a model list files this model under, from what it writes (registry_data.kind_of): a model that reads images is still text. A model with no label entry is text." + }, "context_window": { "type": "integer", "description": "Tokens the model reads in one request, from the tables that also route rather than from the display registry -- the number shown has to be the number a request is sized with. Absent where no such table names the model." diff --git a/tests/test_provider_registry_data.py b/tests/test_provider_registry_data.py index e45d4a4cd..f48154845 100644 --- a/tests/test_provider_registry_data.py +++ b/tests/test_provider_registry_data.py @@ -13,7 +13,15 @@ import pytest from raven.providers import registry_data -from raven.providers.registry_data import CAPABILITIES, MODALITIES, curated_for, provider_metadata, row_for +from raven.providers.registry_data import ( + CAPABILITIES, + MODALITIES, + curated_for, + inferred_tags, + kind_of, + provider_metadata, + row_for, +) def test_a_reseller_reads_the_vendors_row_without_carrying_a_copy() -> None: @@ -430,3 +438,24 @@ def test_a_model_tagged_from_its_endpoint_reaches_the_same_bucket_as_a_catalogue assert kind_of(["video-generation"], []) == "video" # Reading pictures is still something a text model does. assert kind_of(["image-recognition"], ["text"]) == "text" + + +# The same nine rows, with the same kinds, sit in ui-web/src/features/model/types.test.ts: +# the page copies inferred_tags' two patterns for a typed id's chip, and this +# table is what keeps the copy honest. +NAME_GUESSES = [ + ("openai/text-embedding-3-small", "embedding"), + ("BAAI/bge-reranker-v2-m3", "reranker"), + ("my-team/bge-reranker-custom", "reranker"), + ("jina-embeddings-v3", "embedding"), + ("bge-m3", "embedding"), + ("gte-large", "embedding"), + ("rerank-co/gpt-4", "text"), + ("google/gemini-2.5-flash-image", "text"), + ("deepseek-v4-pro", "text"), +] + + +@pytest.mark.parametrize(("model_id", "kind"), NAME_GUESSES) +def test_kind_of_a_name_alone(model_id: str, kind: str) -> None: + assert kind_of(inferred_tags(model_id), ()) == kind diff --git a/tests/test_rpc_model.py b/tests/test_rpc_model.py index edf63e1ef..d53377799 100644 --- a/tests/test_rpc_model.py +++ b/tests/test_rpc_model.py @@ -141,6 +141,30 @@ async def test_options_current_provider_derived_from_model(fake_home: Path) -> N assert _entry(result, "anthropic")["is_current"] is True +async def test_options_rows_carry_the_gateway_flag(fake_home: Path) -> None: + _write_config(fake_home, {"agents": {"defaults": {"model": "anthropic/claude-sonnet-4-5"}}}) + result = await model_options({}) + assert _entry(result, "openrouter")["gateway"] is True + assert _entry(result, "custom")["gateway"] is True + assert _entry(result, "anthropic")["gateway"] is False + + +async def test_model_labels_carry_a_kind(fake_home: Path) -> None: + # openrouter with a key and one configured model whose name is the only + # thing that says what it is + _write_config( + fake_home, + { + "agents": {"defaults": {"model": "openrouter/anthropic/claude-sonnet-4-5"}}, + "providers": {"openrouter": {"apiKey": "sk-or-xxx", "models": ["openai/text-embedding-3-small"]}}, + }, + ) + result = await model_options({}) + labels = _entry(result, "openrouter")["model_labels"] + assert labels["openai/text-embedding-3-small"]["kind"] == "embedding" + assert all("kind" in v for v in labels.values()) + + async def test_options_oauth_provider_warning_and_auth_type(fake_home: Path) -> None: _write_config(fake_home, {"agents": {"defaults": {"model": "anthropic/claude-sonnet-4-5"}}}) result = await model_options({}) @@ -1351,7 +1375,7 @@ async def test_a_user_written_overlay_reaches_the_picker(fake_home: Path) -> Non ) entry = _entry(await model_options({}), "hosted_vllm") label = (entry.get("model_labels") or {}).get("hosted-vllm/my-finetune-v3") - assert label == {"label": "Our finetune", "description": "tuned on tickets"} + assert label == {"label": "Our finetune", "description": "tuned on tickets", "kind": "text"} async def test_the_picker_gets_the_tags_it_draws_as_icons(fake_home: Path) -> None: diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index a9c70fd92..b910a6023 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -378,6 +378,10 @@ export interface ModelOptionProvider { picker's offer -- config plus a curated shortlist plus a catalogue -- which is what the TUI picker draws; a surface managing the list reads this one. */ configured_models?: string[] + /* Resells other vendors' models under `vendor/model` ids (the registry's + `is_gateway`). The web settings page filters its catalogue on it; here it + is declared so the drift check stays honest about what the wire sends. */ + gateway?: boolean model_labels?: Record< string, { @@ -385,6 +389,9 @@ export interface ModelOptionProvider { context_window?: number description?: string input_modalities?: string[] + /* Which bucket a model list files this under, from what it writes + (`registry_data.kind_of`): reading pictures leaves a model in `text`. */ + kind: 'audio' | 'embedding' | 'image' | 'reranker' | 'text' | 'video' label: string output_modalities?: string[] } diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index 8a2d89d33..04e4b76c5 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -1010,6 +1010,7 @@ export const UI_TEXT: Record> = { 'gui.perm.smart_h': 'An AI reviewer pre-screens each action and asks you only when unsure.', 'gui.perm.title': 'Permission mode', 'gui.picker.empty_provider': 'No models under this account', + 'gui.picker.empty_kind': 'No {kind} model added here yet', 'gui.picker.manage': 'Manage models & accounts...', 'gui.picker.no_account': 'No model account yet - connect one in settings first', 'gui.picker.no_models': 'No models added yet. Add some in Settings > Models.', @@ -1775,7 +1776,8 @@ export const UI_TEXT: Record> = { 'gui.settings.more': 'More', 'gui.settings.nav.general': 'General', 'gui.settings.nav.usage': 'Usage', - 'gui.settings.nav.model': 'Models', + 'gui.settings.nav.model': 'Model settings', + 'gui.settings.nav.provider': 'Model providers', 'gui.settings.nav.skills': 'Skills', 'gui.settings.nav.tools': 'Tools', 'gui.settings.nav.plugins': 'Plugins', @@ -1851,6 +1853,7 @@ export const UI_TEXT: Record> = { 'gui.settings.providers.oauth_expired': 'The code expired before the sign-in finished. Start again.', 'gui.settings.providers.api_key': 'API Key', 'gui.settings.providers.paste_key': 'Paste an API key', + 'gui.settings.providers.pick_one': 'Choose a provider on the left', 'gui.settings.providers.key_first': 'Enter the API key first', 'gui.settings.providers.base': 'Service address', 'gui.settings.providers.base_default': 'Default', @@ -1860,6 +1863,9 @@ export const UI_TEXT: Record> = { 'gui.settings.providers.api_version': 'API version', 'gui.settings.providers.connect': 'Connect', 'gui.settings.providers.disconnect': 'Disconnect', + 'gui.settings.providers.disconnect_key': 'Disconnect and clear the key', + 'gui.settings.providers.disconnect_auth': 'Disconnect authorisation', + 'gui.settings.providers.disconnect_local': 'Disconnect this server', 'gui.settings.providers.connection': 'Connection', 'gui.settings.providers.account': 'Account', 'gui.settings.providers.billing': 'Billing', @@ -1874,7 +1880,12 @@ export const UI_TEXT: Record> = { 'gui.settings.providers.remove_model': 'Remove {model}', 'gui.settings.providers.typed': 'typed', 'gui.settings.providers.added': 'added', + 'gui.settings.providers.add_model': 'Add model', + 'gui.settings.providers.showing_added': 'showing {n} · added {m}', 'gui.settings.providers.fetching': 'Fetching the model list from {name}', + 'gui.settings.providers.filter_direct': 'Direct vendors', + 'gui.settings.providers.filter_gateway': 'Aggregators', + 'gui.settings.providers.filter_oauth': 'Browser sign-in', 'gui.settings.providers.no_list': 'This vendor has no model list endpoint; type a model id', 'gui.settings.providers.no_match': 'No matching model', 'gui.settings.providers.n_available': '{n} available', @@ -2001,6 +2012,7 @@ export const UI_TEXT: Record> = { 'gui.model.pick_add_to': 'add to {name}', 'gui.model.pick_none': 'No models added here yet', 'gui.settings.providers.base_override': 'Overrides the default', + 'gui.settings.providers.base_reset': 'Reset', 'gui.settings.providers.key_optional_ph': 'API key, if the server wants one', 'gui.settings.providers.api_key_optional': 'API Key (optional)', 'gui.tasks.tab_context': 'Context', @@ -2799,6 +2811,7 @@ export const UI_TEXT: Record> = { 'gui.perm.smart_h': 'AI 预审每个操作,拿不准才请求你审批', 'gui.perm.title': '权限模式', 'gui.picker.empty_provider': '这个账号下还没有模型', + 'gui.picker.empty_kind': '这一家还没有添加{kind}模型', 'gui.picker.manage': '管理模型与账号…', 'gui.picker.no_account': '还没有可用的模型账号,先去设置里连接一个', 'gui.picker.no_models': '还没有添加模型,请在「设置 > 模型」中添加。', @@ -3551,7 +3564,8 @@ export const UI_TEXT: Record> = { 'gui.settings.more': '加', 'gui.settings.nav.general': '通用', 'gui.settings.nav.usage': '用量', - 'gui.settings.nav.model': '模型', + 'gui.settings.nav.model': '模型配置', + 'gui.settings.nav.provider': '模型服务商', 'gui.settings.nav.skills': '技能', 'gui.settings.nav.tools': '工具', 'gui.settings.nav.plugins': '插件', @@ -3627,6 +3641,7 @@ export const UI_TEXT: Record> = { 'gui.settings.providers.oauth_expired': '代码在授权完成前过期了,请重新开始。', 'gui.settings.providers.api_key': 'API Key', 'gui.settings.providers.paste_key': '粘贴 API Key', + 'gui.settings.providers.pick_one': '在左边选一家服务商', 'gui.settings.providers.key_first': '先填入 API Key', 'gui.settings.providers.base': '服务地址', 'gui.settings.providers.base_default': '默认', @@ -3636,6 +3651,9 @@ export const UI_TEXT: Record> = { 'gui.settings.providers.api_version': 'API 版本', 'gui.settings.providers.connect': '连接', 'gui.settings.providers.disconnect': '断开', + 'gui.settings.providers.disconnect_key': '断开并清除密钥', + 'gui.settings.providers.disconnect_auth': '断开授权', + 'gui.settings.providers.disconnect_local': '断开这台服务', 'gui.settings.providers.connection': '连接', 'gui.settings.providers.account': '账号', 'gui.settings.providers.billing': '计费', @@ -3650,7 +3668,12 @@ export const UI_TEXT: Record> = { 'gui.settings.providers.remove_model': '移除 {model}', 'gui.settings.providers.typed': '手动填写', 'gui.settings.providers.added': '已添加', + 'gui.settings.providers.add_model': '添加模型', + 'gui.settings.providers.showing_added': '显示 {n} 个 · 已添加 {m}', 'gui.settings.providers.fetching': '正在从 {name} 获取模型列表', + 'gui.settings.providers.filter_direct': '直连厂商', + 'gui.settings.providers.filter_gateway': '聚合网关', + 'gui.settings.providers.filter_oauth': '浏览器授权', 'gui.settings.providers.no_list': '这家没有模型列表接口,直接输入模型 id', 'gui.settings.providers.no_match': '没有匹配的模型', 'gui.settings.providers.n_available': '{n} 个可选', @@ -3776,6 +3799,7 @@ export const UI_TEXT: Record> = { 'gui.model.pick_add_to': '加入 {name}', 'gui.model.pick_none': '这一家还没有添加模型', 'gui.settings.providers.base_override': '覆盖默认', + 'gui.settings.providers.base_reset': '恢复默认', 'gui.settings.providers.key_optional_ph': 'API Key(服务要求时填)', 'gui.settings.providers.api_key_optional': 'API Key(可选)', 'gui.tasks.tab_context': '上下文', diff --git a/ui-tui/src/rpc/generated.ts b/ui-tui/src/rpc/generated.ts index 1a8a28b30..d75fe83ab 100644 --- a/ui-tui/src/rpc/generated.ts +++ b/ui-tui/src/rpc/generated.ts @@ -998,6 +998,10 @@ export interface ModelOptionProvider { }; total_models: number; needs_api_base: boolean; + /** + * The registry's is_gateway: resells other vendors' models under vendor/model ids. The catalogue's gateway filter reads this; absent means false. + */ + gateway?: boolean; platforms?: { label: string; api_base: string; @@ -1032,6 +1036,10 @@ export interface ModelLabel { * What the model writes: text, image, video, audio, vector. */ output_modalities?: string[]; + /** + * The bucket a model list files this model under, from what it writes (registry_data.kind_of): a model that reads images is still text. A model with no label entry is text. + */ + kind: 'text' | 'image' | 'audio' | 'video' | 'embedding' | 'reranker'; /** * Tokens the model reads in one request, from the tables that also route rather than from the display registry -- the number shown has to be the number a request is sized with. Absent where no such table names the model. */ diff --git a/ui-web/CONTEXT.md b/ui-web/CONTEXT.md index f9574266c..592f4b0a8 100644 --- a/ui-web/CONTEXT.md +++ b/ui-web/CONTEXT.md @@ -235,6 +235,42 @@ and `src/chrome/PermPopover.tsx` and `TierPopover.tsx` are the two the dock raises. _Avoid_: "pop" and "panel" for this. +**Kind**: +Which bucket a model list files a model under: `text`, `image`, `audio`, +`video`, `embedding` or `reranker`. Derived once, in Python, by +`raven/providers/registry_data.py`'s `kind_of`, from what a model WRITES -- so +a model that reads pictures is still `text` -- and carried on the wire as +`model_labels[].kind`. `features/model/types.ts` reads it (`modelKind`), +and `guessKind` there is the one place that guesses: a translation of +`inferred_tags`, for an id a person just typed that no catalogue describes. +A model with no label entry is `text`. +_Avoid_: "type" or "category" for this; "kind" alone for a provider's auth +shape, which the rows call `auth_type`. + +**Offer**: +What ONE opening of the model picker lists, and what a pick there means +(`Offer` in `features/model/types.ts`): a kind, optionally the providers the +caller allows, optionally a title and the pair to mark, optionally the write to +make. The composer's chip opens with none of it and gets the default -- text +models, every connected provider, switch this conversation. A settings role +slot opens with its own through `openPicker` in `features/model/source.ts`. +The kind narrows each provider's column, never the provider list. + +**Gateway provider**: +A provider that resells other vendors' models under `vendor/model` ids -- +`ProviderSpec.is_gateway` in `raven/providers/registry.py`, `gateway` on a +`model.options` row. Twenty-one of the fifty-five. +_Avoid_: "gateway" alone, which in this document is the page's one data entry +point above. + +**Provider catalogue**: +Every provider `model.options` returns, connected or not: the left column of +the settings dialog's Model providers page +(`features/settings/providers/ProviderSide.tsx`). Distinct from what a picker +offers, which is the connected ones. +_Avoid_: "catalogue" alone, which in the Runtime's `CONTEXT.md` is the Session +Mode catalogue. + **Task**: One row of `tasks.list` as the desk's tasks tab draws it (`features/tasks/`): a spawn or a DAG run this conversation started, with its nodes inline. The row is the list's, the pane it diff --git a/ui-web/scripts/gates/fixture-shape.test.mjs b/ui-web/scripts/gates/fixture-shape.test.mjs index dc4b5592e..0bdc883d4 100644 --- a/ui-web/scripts/gates/fixture-shape.test.mjs +++ b/ui-web/scripts/gates/fixture-shape.test.mjs @@ -190,20 +190,20 @@ const UNSENT = new Set([ 'knowledge.search.embed_ms', 'knowledge.search.search_ms', // knowledge.status: 1 'knowledge.status.extensions', - // model.add_model: 9 - 'model.add_model.provider.accepts_api_key', 'model.add_model.provider.api_base', 'model.add_model.provider.default_api_base', 'model.add_model.provider.docs', 'model.add_model.provider.key_env', 'model.add_model.provider.model_labels', 'model.add_model.provider.platforms', 'model.add_model.provider.protocol_overrides', 'model.add_model.provider.protocols', + // model.add_model: 8 + 'model.add_model.provider.accepts_api_key', 'model.add_model.provider.api_base', 'model.add_model.provider.default_api_base', 'model.add_model.provider.docs', 'model.add_model.provider.key_env', 'model.add_model.provider.platforms', 'model.add_model.provider.protocol_overrides', 'model.add_model.provider.protocols', // model.fetch_models: 7 'model.fetch_models.error', 'model.fetch_models.models[].capabilities', 'model.fetch_models.models[].context_window', 'model.fetch_models.models[].description', 'model.fetch_models.models[].input_modalities', 'model.fetch_models.models[].output_modalities', 'model.fetch_models.models[].source', - // model.options: 8 - 'model.options.providers[].accepts_api_key', 'model.options.providers[].api_base', 'model.options.providers[].docs', 'model.options.providers[].key_env', 'model.options.providers[].model_labels', 'model.options.providers[].platforms', 'model.options.providers[].protocol_overrides', 'model.options.providers[].protocols', - // model.add_models: 9 -- the same provider row as model.options, plus the default address the row above states - 'model.add_models.provider.accepts_api_key', 'model.add_models.provider.api_base', 'model.add_models.provider.default_api_base', 'model.add_models.provider.docs', 'model.add_models.provider.key_env', 'model.add_models.provider.model_labels', 'model.add_models.provider.platforms', 'model.add_models.provider.protocol_overrides', 'model.add_models.provider.protocols', - // model.remove_model: 9 - 'model.remove_model.provider.accepts_api_key', 'model.remove_model.provider.api_base', 'model.remove_model.provider.default_api_base', 'model.remove_model.provider.docs', 'model.remove_model.provider.key_env', 'model.remove_model.provider.model_labels', 'model.remove_model.provider.platforms', 'model.remove_model.provider.protocol_overrides', 'model.remove_model.provider.protocols', - // model.save_key: 9 - 'model.save_key.provider.accepts_api_key', 'model.save_key.provider.api_base', 'model.save_key.provider.default_api_base', 'model.save_key.provider.docs', 'model.save_key.provider.key_env', 'model.save_key.provider.model_labels', 'model.save_key.provider.platforms', 'model.save_key.provider.protocol_overrides', 'model.save_key.provider.protocols', - // model.set_protocol: 9 - 'model.set_protocol.provider.accepts_api_key', 'model.set_protocol.provider.api_base', 'model.set_protocol.provider.default_api_base', 'model.set_protocol.provider.docs', 'model.set_protocol.provider.key_env', 'model.set_protocol.provider.model_labels', 'model.set_protocol.provider.platforms', 'model.set_protocol.provider.protocol_overrides', 'model.set_protocol.provider.protocols', + // model.options: 7 + 'model.options.providers[].accepts_api_key', 'model.options.providers[].api_base', 'model.options.providers[].docs', 'model.options.providers[].key_env', 'model.options.providers[].platforms', 'model.options.providers[].protocol_overrides', 'model.options.providers[].protocols', + // model.add_models: 8 -- the same provider row as model.options, plus the default address the row above states + 'model.add_models.provider.accepts_api_key', 'model.add_models.provider.api_base', 'model.add_models.provider.default_api_base', 'model.add_models.provider.docs', 'model.add_models.provider.key_env', 'model.add_models.provider.platforms', 'model.add_models.provider.protocol_overrides', 'model.add_models.provider.protocols', + // model.remove_model: 8 + 'model.remove_model.provider.accepts_api_key', 'model.remove_model.provider.api_base', 'model.remove_model.provider.default_api_base', 'model.remove_model.provider.docs', 'model.remove_model.provider.key_env', 'model.remove_model.provider.platforms', 'model.remove_model.provider.protocol_overrides', 'model.remove_model.provider.protocols', + // model.save_key: 8 + 'model.save_key.provider.accepts_api_key', 'model.save_key.provider.api_base', 'model.save_key.provider.default_api_base', 'model.save_key.provider.docs', 'model.save_key.provider.key_env', 'model.save_key.provider.platforms', 'model.save_key.provider.protocol_overrides', 'model.save_key.provider.protocols', + // model.set_protocol: 8 + 'model.set_protocol.provider.accepts_api_key', 'model.set_protocol.provider.api_base', 'model.set_protocol.provider.default_api_base', 'model.set_protocol.provider.docs', 'model.set_protocol.provider.key_env', 'model.set_protocol.provider.platforms', 'model.set_protocol.provider.protocol_overrides', 'model.set_protocol.provider.protocols', // playbooks.get: 1 'playbooks.get.playbook.mcp_servers', // playbooks.oauth.authorize: 1 diff --git a/ui-web/src/components/ModelPicker.tsx b/ui-web/src/components/ModelPicker.tsx index f78495279..3c335022a 100644 --- a/ui-web/src/components/ModelPicker.tsx +++ b/ui-web/src/components/ModelPicker.tsx @@ -1,7 +1,25 @@ /* The model picker: providers on the left, that provider's models on the * right, a typed id at the bottom. Props only -- no store, no transport -- so - * the settings dialog opens it for a role and the composer can open it for a - * conversation with the same component. + * a caller that owns its own list opens it with that list. + * + * DEBT, and the plan to pay it. `features/model/ModelPicker` is the same + * control, reimplemented: search, the two columns, the count, the tick, the + * typed row and the empty state all correspond, class for class. The composer + * and the settings roles card moved there; this copy survives for its last + * caller, the agents page, because that picker's list is not the model store's + * -- an ACP agent advertises its own choices, bucketed the way it bucketed + * them, and none of that is in `model.options`. + * + * The block is one optional field, not a difference in kind: the newer picker + * reads `source().providers()` where this one takes them as a prop, so it needs + * an `Offer` that carries a caller-supplied list (plus this file's + * `allowTyped`). Its `column()` already copes -- `offered` falls back to + * `models` when nothing is `configured`, and an untagged model reads as text. + * What makes this more than an afternoon is the verification: moving the agents + * page's picker means re-testing the agents page, and its acceptance is not + * part of the run that produced this file's other half. + * + * Until then both exist, and `page.css` keeps both sets of rules. * * A floating panel anchored to the control that opened it, hanging below it * where there is room and above it where there is not. The caller passes that diff --git a/ui-web/src/components/ModelTags.tsx b/ui-web/src/components/ModelTags.tsx index 45542e956..6a883ce2c 100644 --- a/ui-web/src/components/ModelTags.tsx +++ b/ui-web/src/components/ModelTags.tsx @@ -193,6 +193,11 @@ export interface ModelTagFacts { input_modalities?: string[] output_modalities?: string[] context_window?: number + /* Which bucket a model list files this under, straight off the wire + (`registry_data.kind_of`). A string rather than the model domain's `Kind` + union: this is `src/components/`, which may not import a feature, and the + one reader that cares validates it (`features/model/types.ts`). */ + kind?: string } function modalitySentence(facts: ModelTagFacts): string { diff --git a/ui-web/src/features/model/ModelPicker.test.tsx b/ui-web/src/features/model/ModelPicker.test.tsx index 10a678195..fa759a6a3 100644 --- a/ui-web/src/features/model/ModelPicker.test.tsx +++ b/ui-web/src/features/model/ModelPicker.test.tsx @@ -36,6 +36,9 @@ interface Harness { local: string[] settings: number providerSettings: string[] + /* [model, provider, kind] per call: what the typed-id row asked the source to + add, and what kind it stated for it. */ + added: Array<[string, string, string | undefined]> after: number model: () => string } @@ -43,7 +46,7 @@ interface Harness { function install(over: Partial = {}, providers = PROVIDERS): Harness { const h: Harness = { toasts: [], persisted: [], persistedProviders: [], persistedScopes: [], - local: [], settings: 0, providerSettings: [], after: 0, model: store.current, + local: [], settings: 0, providerSettings: [], added: [], after: 0, model: store.current, } toastWriter.items = h.toasts let last = store.current() @@ -58,6 +61,9 @@ function install(over: Partial = {}, providers = PROVIDERS): Harnes h.persistedProviders.push(provider) h.persistedScopes.push(scope) }, + addModel: async (m, provider, kind) => { + h.added.push([m, provider, kind]) + }, openSettings: () => { h.settings += 1 }, @@ -77,7 +83,12 @@ function install(over: Partial = {}, providers = PROVIDERS): Harnes const mount = () => render(, { container: document.body.appendChild(document.createElement('div')) }) const pick = (): HTMLElement | null => document.querySelector('.mpick') -const rows = (col: string): HTMLElement[] => [...document.querySelectorAll(`.mpick .${col} .row`)] +/* The rows of a column, which since 2026-09-20 means the models in it: the + typed-id row lives in the same list and is reached by `typedRow` instead, so + every case that counts models keeps counting models. */ +const rows = (col: string): HTMLElement[] => + [...document.querySelectorAll(`.mpick .${col} .row:not(.model-typed)`)] +const typedRow = (): HTMLElement | null => document.querySelector('.mpick .model-typed') const field = (): HTMLInputElement => document.querySelector('.mpick .find input')! const providerSelected = (index: number): string | null | undefined => rows('provs')[index]?.querySelector('.model-provider-action')?.getAttribute('aria-selected') @@ -109,11 +120,16 @@ describe('the model picker', () => { expect(pick()).toBeNull() }) - it('offers only providers with an account and something to offer', () => { + it('offers every provider with an account, including one with nothing added', () => { + /* Reversed 2026-09-20: a provider with a working key and an empty list used + to be dropped here, which told a reader it was not connected and left a + model set by onboarding with no column to be marked in. */ install() mount() openIt() - expect(rows('provs').map((b) => b.querySelector('.nm')!.textContent)).toEqual(['MiniMax (Global)', 'Anthropic']) + /* 'NNothing': a provider the mark table has no drawing for gets a lettered + tile, and the tile's letter sits inside the name element. */ + expect(rows('provs').map((b) => b.querySelector('.nm')!.textContent)).toEqual(['MiniMax (Global)', 'Anthropic', 'NNothing']) expect(rows('provs')[0]!.querySelector('.nm')?.firstElementChild?.getAttribute('src')).toBe('assets/providers/minimax.svg') /* A name, not a link: the row's whole job is to change the column beside it, and an anchor in the middle of it sent the reader out to a marketing @@ -158,7 +174,7 @@ describe('the model picker', () => { openIt() type('m2') const provs = rows('provs') - expect(provs.map((b) => b.querySelector('.ct')!.textContent)).toEqual(['1', '0']) + expect(provs.map((b) => b.querySelector('.ct')!.textContent)).toEqual(['1', '0', '0']) /* The provider with no hits dims rather than disappearing: what is installed must not move around while the reader types. */ expect(provs[1]!.className).toContain('dim') @@ -219,16 +235,70 @@ describe('the model picker', () => { expect(document.querySelector('.mpick .models .empty')!.textContent).toBe('gui.picker.no_match') }) - it('distinguishes an empty search result from an empty provider', () => { + it('distinguishes an empty search result from a column with nothing of the kind', () => { install({}, [ { id: 'a', name: 'A', models: ['one'], on: true }, { id: 'b', name: 'B', models: [], on: true }, ]) mount() openIt() - /* The second provider is filtered out of the columns entirely, so the only - empty message reachable here is the search one. */ - expect(rows('provs').length).toBe(1) + expect(rows('provs').length).toBe(2) + /* Two different empty columns, and the words are the whole difference: one + is a term to delete, the other a list to go and build. */ + type('nothing-like-this') + expect(document.querySelector('.mpick .models .empty')!.textContent).toBe('gui.picker.no_match') + type('') + act(() => { fireEvent.click(rows('provs')[1]!.querySelector('.model-provider-action')!) }) + expect(document.querySelector('.mpick .models .empty')!.textContent).toBe( + 'gui.picker.empty_kind {"kind":"gui.model.type.text"}', + ) + }) + + it('offers a typed id beside the matches, and adds it to the provider before picking it', async () => { + const h = install() + mount() + openIt() + type('m2') + /* The term matches a model AND is not one: both rows are right, because + the reader may mean either. */ + expect(rows('models').map((b) => b.querySelector('.nm')!.textContent)).toEqual(['minimax-m2']) + expect(typedRow()!.querySelector('.nm')!.textContent).toBe('gui.model.pick_use {"id":"m2"}') + await act(async () => { fireEvent.click(typedRow()!) }) + expect(h.added).toEqual([['m2', 'minimax', 'text']]) + expect(h.persisted).toEqual(['m2']) + }) + + it('leaves the typed row out once the term is a model exactly', () => { + install() + mount() + openIt() + type('minimax-m2') + expect(typedRow()).toBeNull() + }) + + it('states the kind on the chip, cycles it, and sends what it says', async () => { + const h = install() + mount() + openIt() + type('my-team/bge-reranker-x') + const chip = (): HTMLElement => typedRow()!.querySelector('.model-kind')! + /* The name's own guess, the way registry_data.inferred_tags would read it. */ + expect(chip().textContent).toBe('gui.model.type.reranker') + act(() => { fireEvent.click(chip()) }) + expect(chip().textContent).toBe('gui.model.type.audio') + await act(async () => { fireEvent.click(typedRow()!) }) + expect(h.added).toEqual([['my-team/bge-reranker-x', 'minimax', 'audio']]) + }) + + it('a slot opening starts the chip on the slot kind, not on the name', () => { + install({}, [{ id: 'p', name: 'P', on: true, models: ['emb-1'], labels: { 'emb-1': { kind: 'embedding' } } }]) + mount() + act(() => { + store.open(document.getElementById('modelChip'), undefined, undefined, + { kind: 'embedding', title: 'Embedding', pick: async () => {} }) + }) + type('plain-name') + expect(typedRow()!.querySelector('.model-kind')!.textContent).toBe('gui.model.type.embedding') }) it('keeps its rendered shape', () => { @@ -542,15 +612,79 @@ describe('what the picker offers', () => { expect(rows('models').map((b) => b.querySelector('.nm')!.textContent)).toEqual(['opus']) }) - it('leaves out a provider that has an account but nothing added yet', () => { - /* It would otherwise sit in the rail as a column that opens empty. */ + it('lists a provider that has an account but nothing added yet', () => { + /* Counted 1 and 1: the first has nothing added, so a text opening offers + the registry's shortlist (see the wizard case above); the second offers + what was added. Zero is for a provider with neither. */ install({}, [ { id: 'anthropic', name: 'Anthropic', on: true, models: ['opus'], configured: [] }, { id: 'openai', name: 'OpenAI', on: true, models: ['gpt'], configured: ['gpt'] }, + { id: 'empty', name: 'Nothing', on: true, models: [], configured: [] }, ]) mount() openIt() - expect(rows('provs').map((b) => b.querySelector('.nm')!.textContent)).toEqual(['OpenAI']) + expect(rows('provs').map((b) => b.querySelector('.nm')!.textContent)).toEqual(['Anthropic', 'OpenAI', 'NNothing']) + expect(rows('provs').map((b) => b.querySelector('.ct')!.textContent)).toEqual(['1', '1', '0']) + }) + + it('lists the current model in its provider column even when the list does not carry it', () => { + /* onboarding and the CLI set agents.defaults.model without adding it to + the provider, so the model the chip names has to be somewhere it can be + seen marked. */ + install({}, [{ id: 'p1', name: 'P1', on: true, models: ['a'], configured: ['a'], current: true }]) + store.setCurrent('b') + mount() + openIt() + expect(rows('models').map((x) => x.querySelector('.nm')!.textContent)).toEqual(['b', 'a']) + expect(rows('models')[0]!.querySelector('.tick')).not.toBeNull() + }) + + it('does not list the current model twice when its two spellings differ', () => { + /* A role stores the spelling it was handed; `model.add_model` stores the + one it derived. Comparing the strings drew one model as two rows, both + reading the same because the provider half is not shown. */ + install({}, [{ + id: 'openrouter', name: 'OpenRouter', on: true, + models: ['openrouter/my-embedder'], configured: ['openrouter/my-embedder'], + labels: { 'openrouter/my-embedder': { kind: 'embedding' } }, + }]) + mount() + act(() => { + store.open(document.getElementById('modelChip'), undefined, undefined, { + kind: 'embedding', title: 'Embedding', pick: async () => {}, + current: { model: 'my-embedder', provider: 'openrouter' }, + }) + }) + expect(rows('models').map((x) => x.querySelector('.nm')!.textContent)).toEqual(['my-embedder']) + }) + + it('offers the registry shortlist for a text opening on a provider with nothing added', () => { + /* The first-run wizard's whole model step: a vendor is connected and a + chat model picked before anyone has built a list. An empty column there + is the step. */ + install({}, [{ id: 'anthropic', name: 'Anthropic', on: true, models: ['opus', 'sonnet'], configured: [] }]) + mount() + openIt() + expect(rows('models').map((b) => b.querySelector('.nm')!.textContent)).toEqual(['opus', 'sonnet']) + }) + + it('does not fall back for a kind the wizard never asks for', () => { + /* An embedding slot on a provider with nothing added should say so, not + offer the vendor's whole catalogue filtered to whatever happens to be + tagged -- "nothing here yet, type an id" is the honest answer. */ + install({}, [{ + id: 'anthropic', name: 'Anthropic', on: true, configured: [], + models: ['emb-1'], labels: { 'emb-1': { kind: 'embedding' } }, + }]) + mount() + act(() => { + store.open(document.getElementById('modelChip'), undefined, undefined, + { kind: 'embedding', title: 'Embedding', pick: async () => {} }) + }) + expect(rows('models')).toHaveLength(0) + expect(document.querySelector('.mpick .models .empty')!.textContent).toBe( + 'gui.picker.empty_kind {"kind":"gui.model.type.embedding"}', + ) }) it('falls back to the offer for a source that never learned the difference', () => { @@ -564,15 +698,30 @@ describe('what the picker offers', () => { }) describe('the picker with nothing to offer', () => { - it('tells a connected account with no models added where to go', () => { - /* Two different dead ends. Saying "no account" to somebody whose keys all - work sends them to fix what is not broken. */ - const h = install({}, [{ id: 'anthropic', name: 'Anthropic', on: true, models: ['opus'], configured: [] }]) + it('opens for a connected account with no models added, rather than refusing', () => { + /* Reversed 2026-09-20 with the rule above: "go and build a list" is now + answered inside the picker -- an empty column that says so, and a row to + type an id into -- so it is no longer a dead end. */ + const h = install({}, [{ id: 'anthropic', name: 'Anthropic', on: true, models: [], configured: [] }]) mount() openIt() + expect(pick()).not.toBeNull() + expect(h.providerSettings).toEqual([]) + expect(h.toasts).toEqual([]) + expect(document.querySelector('.mpick .models .empty')!.textContent).toBe( + 'gui.picker.empty_kind {"kind":"gui.model.type.text"}', + ) + }) + + it('says "no models for" when the one vendor a slot allows is not connected', () => { + /* The remaining shape of that message: a media role may only run on + OpenRouter, so "no account" would send the reader to add any key at all. */ + const h = install({}, [{ id: 'openrouter', name: 'OpenRouter', on: false, models: ['x'], configured: ['x'] }]) + mount() + act(() => { store.open(undefined, undefined, undefined, { kind: 'image', providers: ['openrouter'] }) }) expect(pick()).toBeNull() - expect(h.providerSettings).toEqual(['anthropic']) - expect(h.toasts).toEqual(['gui.picker.no_models_for {"name":"Anthropic"}']) + expect(h.providerSettings).toEqual(['openrouter']) + expect(h.toasts).toEqual(['gui.picker.no_models_for {"name":"OpenRouter"}']) }) it('still says "no account" when nothing is connected at all', () => { diff --git a/ui-web/src/features/model/ModelPicker.tsx b/ui-web/src/features/model/ModelPicker.tsx index 9b67dc2e4..b62576285 100644 --- a/ui-web/src/features/model/ModelPicker.tsx +++ b/ui-web/src/features/model/ModelPicker.tsx @@ -8,17 +8,18 @@ * move while the reader types. */ -import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react' -import { ModelTagDefs, ModelTags } from '../../components/ModelTags' +import { ModelTagDefs, ModelTags, TagGlyph } from '../../components/ModelTags' import { ProviderIcon, ProviderStatus } from '../../components/ProviderMark' import { t } from '../../i18n/t' import { clearance } from '../../lib/popover' import * as lang from '../../state/lang' import * as store from './store' -import { offered } from './types' +import { KIND_GLYPH, KIND_LABEL, guessKind } from './types' +import { nextKind } from './types' -import type { ApiProtocol, Provider } from './types' +import type { ApiProtocol, Kind, Provider } from './types' import type { JSX } from 'react' export function ModelApp(): JSX.Element | null { @@ -35,50 +36,110 @@ export function ModelApp(): JSX.Element | null { function Pick(): JSX.Element { const at = store.openAt() const host = at.host as HTMLElement - const providers = store.authed() + const providers: Provider[] = store.listed() /* What the picker marks: the chip's model, unless the opener named a different one to highlight (the settings control marks the default it edits). */ const current = at.marked ?? store.current() const box = useRef(null) const field = useRef(null) const [query, setQuery] = useState('') - const [prov, setProv] = useState(() => Math.max(0, providers.findIndex((p) => offered(p).includes(current)))) + const [prov, setProv] = useState(() => Math.max(0, providers.findIndex((p) => store.column(p).includes(current)))) + /* What a typed id would be added as. The slot's kind when a slot opened the + picker -- typing an embedding id into the embedding row means an embedding + model, whatever the name looks like -- and the name's own guess otherwise. + Cleared on every keystroke so the guess follows the id being typed. */ + const [statedKind, setStatedKind] = useState(null) + /* The kind narrows each column, the term narrows what is left. A column + emptied by the kind is not a column emptied by a search: the first says + "nothing of this kind added here", which is a state to act on, and the + second says "no match", which is a term to delete. */ const narrow = (term: string): string[][] => { const needle = term.trim().toLowerCase() - return providers.map((p) => - needle ? offered(p).filter((m) => store.short(m).toLowerCase().includes(needle)) : offered(p), - ) + return providers.map((p) => { + const rows = store.column(p) + return needle ? rows.filter((m) => store.short(m).toLowerCase().includes(needle)) : rows + }) } const q = query.trim().toLowerCase() const hits = narrow(query) const list = hits[prov] || [] - const currentProvider = providers.find((p) => offered(p).includes(current)) - - /* Measured, so it has to run after the paint that gives it a size. Above the - anchor when it fits, which is where the composer chip wants it; clamped - into the viewport either way. documentElement metrics, not window.innerWidth - -- the latter reads 0 inside some embedded webviews and would push the - popover into the corner. - - The anchor sets the side, `clearance` the two edges: the model chip is on - the composer card's bottom bar, so raising the popover off the chip alone - put it over the line the reader types on, and dropping it below the chip - put it over the bar itself. Off a settings row `clearance` answers with the - row, which is the anchor again and changes nothing there. */ - useLayoutEffect(() => { + const currentProvider = providers.find((p) => store.column(p).includes(current)) + const selected = providers[prov] + const exact = !!selected && store.column(selected).some((m) => store.short(m).toLowerCase() === q) + const typedKind = statedKind ?? (at.offer.title ? at.offer.kind : guessKind(query.trim())) + + /* Measured, so it has to run after the paint that gives it a size. + documentElement metrics, not window.innerWidth -- the latter reads 0 inside + some embedded webviews and would push the popover into the corner. + + Two placements, chosen by which surface opened it. + + The composer chip hangs the panel ABOVE, and above the whole card rather + than above the chip: the chip is on the card's bottom bar, so clearing only + the chip put the panel over the line the reader is typing on, and dropping + it below put it over the bar itself. `clearance` answers with that card. + + A settings row hangs it BELOW and flips above only when the bottom edge + would pass the viewport: in a list, below is the reading direction, and the + row above the panel stays visible while a model is chosen. The settings + dialog cannot clip it -- the panel is a body-level fixed element with no + transformed ancestor, which is what makes this arithmetic plain. */ + const place = useCallback((): void => { const el = box.current if (!el) return const vw = document.documentElement.clientWidth const vh = document.documentElement.clientHeight const r = host.getBoundingClientRect() - const over = clearance(host) const b = el.getBoundingClientRect() + /* From a row, the panel's right edge lines up with the row's: a settings + row sits at the right of a dialog that is not the whole window, and + left-aligning hung the panel off the dialog's edge. From the composer + chip -- bottom left of a full-width card -- the left edges line up. */ + el.style.left = `${Math.max(12, Math.min(at.offer.title ? r.right - b.width : r.left, vw - b.width - 12))}px` + if (at.offer.title) { + const below = r.bottom + 6 + const above = r.top - 6 - b.height + const top = below + b.height > vh - 12 && above >= 12 ? above : below + el.style.top = `${Math.max(12, Math.min(top, vh - b.height - 12))}px` + return + } + const over = clearance(host) const above = over.top - b.height - 8 - el.style.left = `${Math.max(12, Math.min(r.left, vw - b.width - 12))}px` el.style.top = `${above >= 12 ? above : Math.min(over.bottom + 8, Math.max(12, vh - b.height - 12))}px` + }, [host, at.offer.title]) + + useLayoutEffect(() => { + place() field.current?.focus() - }, [host]) + }, [host, place]) + + /* Only for a row in a list: the composer's anchor does not move under it. + A scroll takes the row out from under the panel, and a panel pointing at a + row that is no longer there is worse than no panel; a resize leaves the row + where it is in the list and only moves the dialog around it, so there the + panel follows rather than vanishes. The scroll test is where the anchor + SITS, not that a scroll fired: focusing the search field scrolls the panel + a little, and closing on the event would close it on the frame it opened. + The baseline moves with every re-placement, or the next scroll would read a + resize as a row that had slid away. */ + useEffect(() => { + if (!at.offer.title) return + let was = host.getBoundingClientRect().top + const off = (): void => { + if (Math.abs(host.getBoundingClientRect().top - was) > 1) store.close() + } + const again = (): void => { + place() + was = host.getBoundingClientRect().top + } + document.addEventListener('scroll', off, true) + window.addEventListener('resize', again) + return () => { + document.removeEventListener('scroll', off, true) + window.removeEventListener('resize', again) + } + }, [host, at.offer.title, place]) /* Capture phase, and the anchor counts as inside: the chip's own click would otherwise close the popover it just opened. */ @@ -104,6 +165,7 @@ function Pick(): JSX.Element {
+ {at.offer.title ? {at.offer.title} : null} ⌕ { const next = e.target.value setQuery(next) + /* The chip follows the id being typed: a kind the reader picked + belongs to the id they picked it for. */ + setStatedKind(null) /* A term that empties the selected provider moves the selection to the first one that still has something, rather than showing "no match" beside a column that plainly has hits. @@ -140,6 +205,7 @@ function Pick(): JSX.Element { if (e.key === 'Enter') { const first = list[0] if (first) void store.choose(first, providers[prov]!.id) + else if (q && !exact && selected) void store.choose(query.trim(), selected.id, true, typedKind) } }} /> @@ -151,14 +217,15 @@ function Pick(): JSX.Element { key={p.id} className={'row model-provider-row' + (hits[i]!.length ? '' : ' dim')} > + {/* Always selectable, hits or none: a provider whose column is + empty is exactly the one the reader has to reach, because the + row for typing an id into lives in that column. */} + + ) : null}
diff --git a/ui-web/src/features/model/__snapshots__/ModelPicker.test.tsx.snap b/ui-web/src/features/model/__snapshots__/ModelPicker.test.tsx.snap index ba4def523..ea3783d4c 100644 --- a/ui-web/src/features/model/__snapshots__/ModelPicker.test.tsx.snap +++ b/ui-web/src/features/model/__snapshots__/ModelPicker.test.tsx.snap @@ -154,6 +154,13 @@ exports[`the model picker > keeps its rendered shape 1`] = ` span span.ct span.provider-status.on + div.row.model-provider-row.dim + button.model-provider-action + span.nm + span.provider-icon.fallback + span + span.ct + span.provider-status.on div.model-pane div.model-toolbar div.model-toolbar-copy diff --git a/ui-web/src/features/model/source.test.ts b/ui-web/src/features/model/source.test.ts index 0bffcdaff..1b759d3ec 100644 --- a/ui-web/src/features/model/source.test.ts +++ b/ui-web/src/features/model/source.test.ts @@ -114,7 +114,6 @@ async function live({ session = null, answers = null }: Options = {}) { loadPermMode: settingsModule.loadPermMode, loadSettings: settingsModule.loadSettings, settingsSnapshot: settingsModule.settingsSnapshot, - HIDDEN_PROVIDERS: model.HIDDEN_PROVIDERS, get providersLive() { return model.providers() }, get defaultModelLive() { return model.defaultModel() }, get defaultProviderLive() { return model.defaultProvider() }, @@ -321,26 +320,27 @@ describe('providers the page does not offer', () => { return h.settings.providersLive } - it('keeps the generic endpoint rows out of the rail and every other row in', async () => { + it('keeps every row the wire reports, the two generic ones included', async () => { + /* `hosted_vllm` and `custom` used to be dropped here, on the reasoning that + a row whose name says nothing about what it reaches is noise beside fifty + that do. The settings catalogue has a search box and six filters, and + `custom` is the page's only entry for a vendor Raven carries no spec for, + so the whole list travels and the surfaces choose. */ const rows = await listed([row('anthropic'), row('custom'), row('hosted_vllm'), row('ollama_chat')]) - expect(rows.map((p) => p.id)).toEqual(['anthropic', 'ollama_chat']) + expect(rows.map((p) => p.id)).toEqual(['anthropic', 'custom', 'hosted_vllm', 'ollama_chat']) }) - it('hides exactly the two it declares, so the list cannot drift from the page', async () => { - /* Read off the part rather than restated: a copy here would agree with - itself while the page shipped something else. */ - const h = await live() - expect([...h.settings.HIDDEN_PROVIDERS]).toEqual(['hosted_vllm', 'custom']) - }) - - it('leaves a hidden provider configured and routable, only unlisted', async () => { - /* The row goes; the section does not. `model.options` still reports it -- - which is what keeps an existing `custom` deployment serving -- and the - page simply does not draw it. */ - const rows = await listed([{ ...row('custom'), authenticated: true, models: ['custom/local-7b'] }]) + it('carries the two facts only the registry has: gateway and which row is current', async () => { + const rows = await listed([ + { ...row('openrouter'), gateway: true }, + { ...row('anthropic'), is_current: true }, + ]) - expect(rows).toEqual([]) + expect(rows.map((p) => [p.id, !!p.gateway, !!p.current])).toEqual([ + ['openrouter', true, false], + ['anthropic', false, true], + ]) }) }) diff --git a/ui-web/src/features/model/source.ts b/ui-web/src/features/model/source.ts index d67620514..21e0f59cc 100644 --- a/ui-web/src/features/model/source.ts +++ b/ui-web/src/features/model/source.ts @@ -12,27 +12,14 @@ import { gateway } from '../../rpc/gateway' import { generation } from '../../state/session/generation' import { staging } from '../../state/session/staging' import { open as openSettings, openModels, openProviderModels } from '../settings/store' -import { setCurrent } from './store' +import { open as openPickerAt, setCurrent, statedTags } from './store' import type { ParamsOf, ResultOf } from '../../rpc/generated' import type { TierReply, TierSource } from '../../state/tier' -import type { ApiProtocol, ModelSource, Provider } from './types' +import type { ApiProtocol, Kind, ModelSource, Offer, Provider } from './types' type ProviderWire = ResultOf<'model.options'>['providers'][number] -/* Providers the page does not offer. Both are the generic "some endpoint of - your own" row, and the page answers that question twice over without them: - the runtimes have named rows of their own -- Ollama, LM Studio, GPUStack, - OpenVINO -- and every row carries an API Host field for pointing a vendor at - a gateway you run. What was left was a row whose name says nothing about - what it reaches, next to fifty that do. - - Hidden here rather than dropped from the registry: a section already - configured under either name keeps loading, keeps being served, and keeps - routing, and `raven provider list` and the onboarding wizard still offer - both -- which is also where a vendor Raven carries no spec for is set up. */ -export const HIDDEN_PROVIDERS = new Set(['hosted_vllm', 'custom']) - let providersLive: Provider[] = [] let defaultModelLive = '' let defaultProviderLive = '' @@ -70,6 +57,14 @@ export const showModel = (model: string): void => { paintChip() } +/* The one door another domain opens the picker through, so nothing outside + this feature touches its store (`features/settings` is the caller: a role + slot passes its kind, the providers that role may use, its name and its own + write). The offer's `current` doubles as the marked model. */ +export function openPicker(anchor: HTMLElement, offer: Offer, after?: () => void): void { + openPickerAt(anchor, after, offer.current?.model, offer) +} + export function openModelsForMissingProvider(): boolean { const connected = providersLive.some((p) => p.on) const knownMissing = setupState.providerConfigured === false || providersLive.length > 0 @@ -82,7 +77,7 @@ export function openModelsForMissingProvider(): boolean { loads below: the per-conversation one, which may drop its answer, and the default-scoped one, which never does. */ const rowsOf = (list: ProviderWire[]): Provider[] => - list.filter((p) => !HIDDEN_PROVIDERS.has(p.slug)).map((p) => ({ + list.map((p) => ({ id: p.slug, name: p.name, homepage: p.homepage || '', models: p.models || [], on: p.authenticated, docs: p.docs || '', keyUrl: p.key_url || '', headers: p.extra_headers || {}, @@ -96,6 +91,9 @@ const rowsOf = (list: ProviderWire[]): Provider[] => labels: p.model_labels || {}, protocols: p.protocols || {}, protocolOverrides: p.protocol_overrides || {}, kind: p.auth_type || 'api_key', needsBase: !!p.needs_api_base, + // The catalogue page's filter, and the picker's answer to "whose column + // does the running model belong in": both are facts only the registry has. + gateway: !!p.gateway, current: !!p.is_current, // Addresses to choose between. A provider that has them is asked which // storefront the key came from instead of being handed a host field -- // the key does not say, and the three are separate accounts. @@ -209,6 +207,10 @@ export async function persistModel( export const modelSource: ModelSource = { providers: () => providersLive, persist: persistModel, + addModel: async (m: string, provider: string, kind?: Kind) => { + await gateway().call('model.add_model', { slug: provider, model: m, ...statedTags(kind ?? 'text') }) + await loadProviders() + }, setProtocol: async (model: string, provider: string, protocol: ApiProtocol) => { await gateway().call('model.set_protocol', { model, slug: provider, protocol }) await loadProviders() diff --git a/ui-web/src/features/model/store.ts b/ui-web/src/features/model/store.ts index 1fc42117e..4b82a74a0 100644 --- a/ui-web/src/features/model/store.ts +++ b/ui-web/src/features/model/store.ts @@ -11,9 +11,9 @@ import { t } from '../../i18n/t' import { ds } from '../../state/sources' import { sources } from '../../state/sources' import { show as toast } from '../../state/toast' -import { offered } from './types' +import { KIND_ORDER, modelKind, offered, sameModel, statedTags } from './types' -import type { ApiProtocol, ModelSource, Provider } from './types' +import type { ApiProtocol, Kind, ModelSource, Offer, Provider } from './types' export interface OpenAt { /* What the popover is anchored to and must not close on a click inside. @@ -37,9 +37,15 @@ export interface OpenAt { it here to mark the right row without moving the chip. Null means mark `current`. */ marked: string | null + /* What this opening lists and what a pick means: see `Offer` in types.ts. */ + offer: Offer } -const CLOSED: OpenAt = { host: null, after: null, footer: false, scope: 'session', marked: null } +/* Text models, every connected provider, a pick switches this conversation: + the composer chip's opening, and what any caller that names nothing gets. */ +const DEFAULT_OFFER: Offer = { kind: 'text' } + +const CLOSED: OpenAt = { host: null, after: null, footer: false, scope: 'session', marked: null, offer: DEFAULT_OFFER } let at: OpenAt = CLOSED let epoch = 0 @@ -76,22 +82,21 @@ export function setCurrent(model: string): void { /* Every open replaces the one before it rather than stacking: the chip and the settings button can both be reached while a picker is up. */ -export function open(anchor?: HTMLElement | null, after?: () => void, marked?: string): void { +export function open(anchor?: HTMLElement | null, after?: () => void, marked?: string, offer: Offer = DEFAULT_OFFER): void { if (!installed()) return - const accounts = source() - .providers() - .filter((p) => p.on) - const authed = accounts.filter((p) => offered(p).length) - if (!authed.length) { - /* Two different dead ends, and telling them apart is the whole value of - the message: nothing connected is a credential to go and add, while - connected with nothing added is a list to go and build. Saying "no - account" to somebody whose keys all work sends them to fix what is not - broken. */ - if (accounts.length) { - const provider = accounts[0]! - source().openProviderModels?.(provider.id) - toast(t('gui.picker.no_models_for', { name: provider.name })) + const rows = listed(offer) + if (!rows.length) { + /* Nothing to open onto is now one dead end, not two: a provider connected + with nothing added is listed, with a column that says so and a row to + type an id into, so "go and build a list" is answered inside the picker. + What is left is having no account the offer allows -- a credential to go + and add, or, for a slot restricted to one vendor, that vendor. */ + const only = offer.providers?.length === 1 + ? source().providers().find((p) => p.id === offer.providers![0]) + : undefined + if (only && !only.on) { + source().openProviderModels?.(only.id) + toast(t('gui.picker.no_models_for', { name: only.name })) } else { toast(t('gui.picker.no_account')) } @@ -102,7 +107,7 @@ export function open(anchor?: HTMLElement | null, after?: () => void, marked?: s /* The composer chip opens with no anchor and means this conversation; the settings control passes its button and means the default, and marks the default model rather than the chip's. */ - at = { host, after: after || null, footer: !anchor, scope: anchor ? 'default' : 'session', marked: marked || null } + at = { host, after: after || null, footer: !anchor, scope: anchor ? 'default' : 'session', marked: marked || null, offer } announce() } @@ -112,9 +117,50 @@ export function close(): void { announce() } -/* Only providers with an account and something to offer. Exported because the - list decides both columns and the initial selection. */ -export const authed = (): Provider[] => source().providers().filter((p) => p.on && offered(p).length) +/* The providers one opening lists: connected, and named by the offer when it + names any. The kind does NOT filter here -- it narrows each provider's column + (`column` below) instead. + A provider whose key works but whose model list nobody has built yet stays + visible: hiding it told a reader with a working DeepSeek key that DeepSeek + was not connected (reported 2026-09-20), and the model the chip named -- set + by onboarding, never "added" -- had no column to be marked in. */ +export const listed = (offer: Offer = at.offer): Provider[] => + source() + .providers() + .filter((p) => p.on && (!offer.providers || offer.providers.includes(p.id))) + +/* One provider's column: its models of the offer's kind, with the current one + first when the list does not carry it. `agents.defaults.model` is routinely + set by onboarding or the CLI without a matching entry under + `providers..models`, and a model the page is showing as current has to + be somewhere the reader can see it marked. */ +export const column = (p: Provider, offer: Offer = at.offer): string[] => { + /* A provider with nothing added yet offers the registry's own shortlist, + for text and text only. The first-run wizard connects a vendor and picks a + chat model in one step, before anyone has visited the providers page to + build a list, and an empty column there is the whole of that step. Once + something is added, the added list is what is offered, as before -- and + the fallback is never taken for a kind the wizard does not ask for, where + the vendor's whole catalogue would be a worse answer than "nothing here + yet, type an id". */ + const listed = offered(p) + const source = listed.length || offer.kind !== 'text' ? listed : p.models + const rows = source.filter((m) => modelKind(p.labels?.[m]) === offer.kind) + /* Whose model to pin. A slot states its own pair or holds none -- an unset + slot has nothing to pin, and borrowing the conversation's model would put + a chat model at the top of the embedding column. Only the composer falls + back to the conversation's, in the provider the wire marks as serving it. + `pick` is what tells them apart: every slot writes through one. */ + const cur = offer.current + ? (offer.current.provider === p.id ? offer.current.model : null) + : (offer.pick ? null : (p.current ? selected : null)) + /* By the backend's identity, not by string: a role stores the spelling it was + handed while `model.add_model` stores the one it derived, so comparing the + strings put the same model in the column twice, under one visible name. */ + return cur && !rows.some((m) => sameModel(p.id, m, cur)) ? [cur, ...rows] : rows +} + +export { KIND_ORDER, statedTags } export const protocolFor = (provider: Provider, model: string): ApiProtocol => { const configured = provider.protocols?.[model] @@ -131,12 +177,37 @@ export async function setProtocol(model: string, provider: string, protocol: Api /* Optimistic: the chip has to say the new model before the round trip, because the next turn already uses it. A rejected write puts the old one back and says so rather than leaving the page claiming a model the config never took. */ -export async function choose(m: string, provider: string): Promise { +export async function choose(m: string, provider: string, typed = false, kind?: Kind): Promise { const src = source() const prev = current() const after = at.after const scope = at.scope + const { offer } = at close() + /* A slot writes through its own path -- a role key, an EverOS section, a + media pair -- and never through the conversation switch below, whose scope + logic answers a different question. Returned before it: the two are + alternatives, not steps. */ + if (offer.pick) { + try { + await offer.pick(m, provider, typed, kind ?? offer.kind) + } catch (e) { + toast(t('gui.op.switch_failed', { detail: detail(e) })) + } + return + } + /* The typed id joins the provider's list before the switch: a conversation + must not name a model its provider does not list. */ + if (typed) { + try { + /* The kind the chip was showing when it was clicked, which is what the + person said this id is -- not the offer's, which is only its default. */ + await src.addModel?.(m, provider, kind ?? offer.kind) + } catch (e) { + toast(t('gui.op.switch_failed', { detail: detail(e) })) + return + } + } /* A session switch is optimistic: the chip -- which reads the same `current` -- says the new model before the round trip, and rolls back if it is refused. A default switch must not touch the chip (a different value), so it diff --git a/ui-web/src/features/model/types.test.ts b/ui-web/src/features/model/types.test.ts new file mode 100644 index 000000000..0e2ec1b53 --- /dev/null +++ b/ui-web/src/features/model/types.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { KIND_GLYPH, KIND_LABEL, guessKind, modelKind } from './types' +import { KIND_ORDER } from './types' + +import type { Kind } from './types' + +/* The same nine rows as tests/test_provider_registry_data.py's NAME_GUESSES. + Two literal copies rather than a shared fixture: the point is that a change + to either side shows up as a disagreement, which a shared file would hide. */ +const TABLE: Array<[string, Kind]> = [ + ['openai/text-embedding-3-small', 'embedding'], + ['BAAI/bge-reranker-v2-m3', 'reranker'], + ['my-team/bge-reranker-custom', 'reranker'], + ['jina-embeddings-v3', 'embedding'], + ['bge-m3', 'embedding'], + ['gte-large', 'embedding'], + ['rerank-co/gpt-4', 'text'], + ['google/gemini-2.5-flash-image', 'text'], + ['deepseek-v4-pro', 'text'], +] + +describe('guessing a kind from an id alone', () => { + it.each(TABLE)('files %s as %s, the way registry_data.inferred_tags does', (id, kind) => { + expect(guessKind(id)).toBe(kind) + }) +}) + +describe('reading the kind the registry sent', () => { + it('files a model with no label, or a kind this page has never heard of, under text', () => { + expect(modelKind(undefined)).toBe('text') + expect(modelKind({})).toBe('text') + expect(modelKind({ kind: 'holographic' })).toBe('text') + }) + + it('passes through every kind the wire may send', () => { + for (const kind of KIND_ORDER) expect(modelKind({ kind })).toBe(kind) + }) +}) + +describe('the two tables', () => { + it('name a label and a sprite id for every kind', () => { + for (const kind of KIND_ORDER) { + expect(KIND_LABEL[kind]).toMatch(/^gui\.model\.type\./) + expect(KIND_GLYPH[kind]).toBeTruthy() + } + }) +}) diff --git a/ui-web/src/features/model/types.ts b/ui-web/src/features/model/types.ts index f343c14d5..bfd4fa246 100644 --- a/ui-web/src/features/model/types.ts +++ b/ui-web/src/features/model/types.ts @@ -28,6 +28,13 @@ export interface Provider { its models would fail on the next turn rather than at the click. */ on: boolean kind?: string + /* Resells other vendors' models under vendor/model ids (the registry's + `is_gateway`). The settings catalogue filters on it. */ + gateway?: boolean + /* The provider the running conversation's model is served by, as the wire + marks it (`is_current`). The picker uses it to place that model in the + right column when the column does not carry it. */ + current?: boolean protocols?: Record protocolOverrides?: Record /* Keyed by the id as it appears in `models`. Absent for a model the registry @@ -36,6 +43,137 @@ export interface Provider { labels?: Record } +/* The buckets a model list filters by, `registry_data.KINDS`. Ordered as the + add-model row draws its tabs and as the typed-id chip cycles. */ +export type Kind = 'text' | 'image' | 'embedding' | 'reranker' | 'audio' | 'video' +export const KIND_ORDER: readonly Kind[] = ['text', 'image', 'embedding', 'reranker', 'audio', 'video'] + +/* What ONE opening of the picker lists, and what a pick there means. + * + * The composer opens with the default offer -- text models, every connected + * provider, a pick switches this conversation. A settings role slot opens with + * its own: its kind, the providers that role may use, its name in the header, + * the model it holds today, and its own write in place of the switch. + * + * The kind narrows each provider's COLUMN, never the provider list: a provider + * whose key works but whose model list nobody has built yet still has to be + * visible, or the reader cannot tell it is connected (and a model set by + * onboarding, never "added", would have nowhere to be marked). */ +/* What one opening of the picker lists and what picking means. + * + * DEBT: every opening draws from the model store, so a caller that owns its + * own list cannot use this picker at all -- which is why + * `components/ModelPicker` is still in the tree for the agents page, where an + * ACP agent advertises its own choices. Paying it means a field here for a + * caller-supplied list and one for suppressing the typed row; `store.column()` + * needs nothing, since `offered` already falls back to `models` and an + * untagged model already reads as text. That file's header has the rest. */ +export interface Offer { + kind: Kind + /* Provider ids this opening may list; absent means every connected one. */ + providers?: string[] + /* The slot's name, drawn in the header; absent for the composer, which also + selects the below-the-anchor placement and the settings footer's absence. */ + title?: string + /* The pair this opening marks: the slot's model and the provider serving it. + Listed in that provider's column even when the column does not carry it. + Absent means this slot holds nothing yet -- or, for an opening with no + `pick` (the composer's), the conversation's current model. */ + current?: { model: string; provider: string } + /* What a pick does. Absent means switch this conversation (`store.choose`). + `typed` is true for an id the provider does not list yet, and `kind` is + what the chip beside it was showing -- what the person says that id IS, + which no catalogue can tell the caller. */ + pick?(model: string, provider: string, typed: boolean, kind: Kind): Promise +} + +/* Which bucket a model is in, and what a name alone says it is. + + One classifier, in Python: `registry_data.kind_of` files a model by what it + WRITES -- reading pictures is something a text model does -- and the answer + rides on every `model.options` label as `kind`. Nothing here derives one from + capabilities or modalities; two classifiers eventually disagree and only one + of them is the one that routes. + + `guessKind` is the exception, and it guesses about a model nobody has + described: an id a person just typed. It is a translation of + `registry_data.inferred_tags` -- same two patterns, same last path segment, + rerank asked first because a reranker is usually named after the embedding + family it reranks for. `tests/test_provider_registry_data.py` and + `types.test.ts` assert the same nine rows so the copy cannot drift. */ + +/* The registry's answer, or text. A model with no label entry is one nothing + describes, which `kind_of((), ())` also calls text; an unknown string is a + catalogue newer than this page, and text is the honest fallback. */ +export const modelKind = (facts: ModelTagFacts | undefined): Kind => + (KIND_ORDER as readonly string[]).includes(facts?.kind ?? '') ? (facts!.kind as Kind) : 'text' + +/* One model, however it was spelled. The backend's identity rule is + `providers/wire.py`'s `merge_key`: strip a leading `/`, lowercase, + compare. Two surfaces need it -- a provider's list mixes ids added by hand + (as typed) with ids the vendor reports (qualified), and a role stores the + spelling it was given while `model.add_model` stores the one it derived. Both + drew the same model twice before this. */ +export const bareModel = (slug: string, id: string): string => + (id.toLowerCase().startsWith(`${slug.toLowerCase()}/`) ? id.slice(slug.length + 1) : id).toLowerCase() + +export const sameModel = (slug: string, a: string, b: string): boolean => + bareModel(slug, a) === bareModel(slug, b) + +/* The next kind in the cycle. Both surfaces that let an id be typed offer the + same wheel, and the arithmetic was written four times between them. */ +export const nextKind = (kind: Kind): Kind => + KIND_ORDER[(KIND_ORDER.indexOf(kind) + 1) % KIND_ORDER.length]! + +export const KIND_LABEL: Record = { + text: 'gui.model.type.text', + image: 'gui.model.type.image', + audio: 'gui.model.type.audio', + video: 'gui.model.type.video', + embedding: 'gui.model.type.embedding', + reranker: 'gui.model.type.reranker', +} + +/* The sprite id in components/ModelTags.tsx for each kind. The sprite is a + table of capabilities plus `text`, so it has `embedding` and `rerank` and the + `*-generation` compounds, and no bare `image` / `audio` / `video` / + `reranker`: every glyph drawn for a kind goes through here, never through the + Kind value. */ +export const KIND_GLYPH: Record = { + text: 'text', + image: 'image-generation', + audio: 'audio-generation', + video: 'video-generation', + embedding: 'embedding', + reranker: 'rerank', +} + +const RERANK = /rerank/i +const EMBEDDING = /(?:^|[-_/])(?:bge|gte|e5|m3e|text2vec|uae|jina-clip)(?:[-_.]|$)|embed/i + +export function guessKind(id: string): Kind { + const bare = id.split('/').pop() ?? id + if (RERANK.test(bare)) return 'reranker' + if (EMBEDDING.test(bare)) return 'embedding' + return 'text' +} + +/* The tags a person states for a typed id, by the kind they named. Text states + nothing: the backend infers from the name (`registry_data.inferred_tags`), + and an overlay of empty lists would overwrite a description somebody wrote. */ +export const statedTags = (kind: Kind): { capabilities?: string[]; output_modalities?: string[] } => { + if (kind === 'text') return {} + const table: Record, [string, string[]]> = { + image: ['image-generation', ['text', 'image']], + audio: ['audio-generation', ['audio']], + video: ['video-generation', ['video']], + embedding: ['embedding', ['vector']], + reranker: ['rerank', ['text']], + } + const [capability, outputs] = table[kind] + return { capabilities: [capability], output_modalities: outputs } +} + export type ApiProtocol = 'auto' | 'chat' | 'responses' | 'anthropic' /* The models a picker offers for a provider: the added ones. Older sources @@ -54,6 +192,12 @@ export interface ModelSource { means the pick was held rather than applied (a draft has no session yet), and the picker words its toast accordingly. */ persist(m: string, provider: string, scope: 'session' | 'default'): Promise + /* Add a model to a provider's list. The picker's typed-id row calls it + before the switch, so a conversation never names a model its provider does + not list. `kind` states what the person said it is, for an id no catalogue + describes; absent (text) states nothing and leaves the backend's own + name-based inference standing. */ + addModel?(m: string, provider: string, kind?: Kind): Promise setProtocol?(m: string, provider: string, protocol: ApiProtocol): Promise /* The settings door, for the picker's own footer. Only offered when the picker was opened from the composer chip, since the settings page opening diff --git a/ui-web/src/features/settings/SettingsApp.test.tsx b/ui-web/src/features/settings/SettingsApp.test.tsx index aef59dd17..ea915e7b9 100644 --- a/ui-web/src/features/settings/SettingsApp.test.tsx +++ b/ui-web/src/features/settings/SettingsApp.test.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as settingsDialog from '../../state/settings' import { resetSources, setSources } from '../../state/sources' -import { install, mount, settle, snap, source as settingsSource } from '../../test/settingsHarness' +import { install, modelSource, mount, settle, snap, source as settingsSource } from '../../test/settingsHarness' import { SettingsApp } from './SettingsApp' import * as store from './store' @@ -15,7 +15,7 @@ vi.mock('../../state/toast', () => ({ show: () => {}, subscribe: () => () => {}, beforeEach(() => { - setSources({ settings: settingsSource }) + setSources({ settings: settingsSource, model: modelSource }) }) afterEach(() => { @@ -48,13 +48,14 @@ describe('settings root', () => { expect(document.querySelectorAll('.settings-row').length).toBeGreaterThan(0) }) - it('portals eight nav entries into the shell and titles the header with the open section', async () => { + it('portals nine nav entries into the shell and titles the header with the open section', async () => { install() await mount('general') const nav = document.querySelectorAll('#snavList .settings-nitem') expect([...nav].map((b) => b.textContent)).toEqual([ - 'gui.settings.nav.general', 'gui.settings.nav.usage', 'gui.settings.nav.model', 'gui.settings.nav.skills', - 'gui.settings.nav.tools', 'gui.settings.nav.plugins', 'gui.settings.nav.archive', 'gui.settings.nav.about', + 'gui.settings.nav.general', 'gui.settings.nav.usage', 'gui.settings.nav.provider', 'gui.settings.nav.model', + 'gui.settings.nav.skills', 'gui.settings.nav.tools', 'gui.settings.nav.plugins', 'gui.settings.nav.archive', + 'gui.settings.nav.about', ]) expect(nav[0]!.getAttribute('aria-current')).toBe('true') expect(document.getElementById('setTitle')!.textContent).toBe('gui.settings.nav.general') diff --git a/ui-web/src/features/settings/SettingsApp.tsx b/ui-web/src/features/settings/SettingsApp.tsx index d6dc9ba73..39297bb24 100644 --- a/ui-web/src/features/settings/SettingsApp.tsx +++ b/ui-web/src/features/settings/SettingsApp.tsx @@ -13,6 +13,7 @@ import { Archive } from './pages/Archive' import { General } from './pages/General' import { Model } from './pages/Model' import { Plugins } from './pages/Plugins' +import { Provider } from './pages/Provider' import { Skills } from './pages/Skills' import { Tools } from './pages/Tools' import { Usage } from './pages/Usage' @@ -27,7 +28,11 @@ import type { JSX } from 'react' const ICON: Record = { general: <>, usage: <>, - model: <>, + /* The cube stays with the providers -- it is the mark the old single page + carried, and that page's list is this one's -- and the assignment glyph + goes to the roles, which is what the prototype does. */ + provider: <>, + model: <>, skills: <>, tools: <>, plugins: <>, @@ -39,6 +44,7 @@ const ICON: Record = { const NAV: Record = { general: 'gui.settings.nav.general', usage: 'gui.settings.nav.usage', + provider: 'gui.settings.nav.provider', model: 'gui.settings.nav.model', skills: 'gui.settings.nav.skills', tools: 'gui.settings.nav.tools', @@ -63,7 +69,7 @@ function Nav({ tab }: { tab: SectionId }): JSX.Element { } const PAGE: Record JSX.Element> = { - general: General, usage: Usage, model: Model, skills: Skills, + general: General, usage: Usage, provider: Provider, model: Model, skills: Skills, tools: Tools, plugins: Plugins, archive: Archive, about: About, } diff --git a/ui-web/src/features/settings/SetupBodies.test.tsx b/ui-web/src/features/settings/SetupBodies.test.tsx index e6fd65807..21921bc54 100644 --- a/ui-web/src/features/settings/SetupBodies.test.tsx +++ b/ui-web/src/features/settings/SetupBodies.test.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setGateway } from '../../rpc/gateway' import { resetSources, setSources } from '../../state/sources' -import { install, mount, snap, source as settingsSource } from '../../test/settingsHarness' +import { install, modelSource, mount, snap, source as settingsSource } from '../../test/settingsHarness' import { _resetForTests as resetModelSource, setDefaultPair } from '../model/source' import { ModelStepBody, WebStepBody } from './SetupBodies' import { _resetForTests as resetSettingsSource, loadSettingsWithProviders, modelStepDone, webStepDone } from './source' @@ -20,7 +20,7 @@ import type { JSX } from 'react' vi.mock('../../state/toast', () => ({ show: () => {}, subscribe: () => () => {}, get: () => [] })) beforeEach(() => { - setSources({ settings: settingsSource }) + setSources({ settings: settingsSource, model: modelSource }) }) afterEach(() => { @@ -128,11 +128,18 @@ describe('WebStepBody', () => { }) describe('the dialog path', () => { - it('still shows the manage button, not disconnect, on a connected row', async () => { + it('opens the provider on the catalogue page, where the wizard disconnects in place', async () => { + /* Was: the settings model page showed the same list with a "manage" button + that paged into the detail. The catalogue page draws the detail beside + the list instead, so there is nothing to page to and no manage button -- + the wizard, which has no detail pane, keeps its disconnect. */ install() - await mount('model') - expect(screen.getAllByText('gui.settings.providers.manage').length).toBeGreaterThan(0) - expect(screen.queryByText('gui.settings.providers.disconnect')).toBeNull() + await mount('provider') + expect(screen.queryByText('gui.settings.providers.manage')).toBeNull() + const rows = [...document.querySelectorAll('.settings-tp-row')] + expect(rows.length).toBeGreaterThan(0) + /* Two columns: the list, and the provider it opened on. */ + expect(document.querySelector('.settings-tp')!.children.length).toBe(2) }) }) diff --git a/ui-web/src/features/settings/pages/About.test.tsx b/ui-web/src/features/settings/pages/About.test.tsx index c7b9e2d9e..19cd5e82c 100644 --- a/ui-web/src/features/settings/pages/About.test.tsx +++ b/ui-web/src/features/settings/pages/About.test.tsx @@ -3,7 +3,7 @@ import { act, cleanup, fireEvent, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resetSources, setSources } from '../../../state/sources' -import { install, mount, snap, source as settingsSource } from '../../../test/settingsHarness' +import { install, modelSource, mount, snap, source as settingsSource } from '../../../test/settingsHarness' import * as store from '../store' import { workspacePath } from './About' @@ -15,7 +15,7 @@ vi.mock('../../../lib/openUrl', () => ({ open: (v: string) => { copied.values.pu beforeEach(() => { - setSources({ settings: settingsSource }) + setSources({ settings: settingsSource, model: modelSource }) }) afterEach(() => { diff --git a/ui-web/src/features/settings/pages/Archive.test.tsx b/ui-web/src/features/settings/pages/Archive.test.tsx index 0d13997a1..151b317d8 100644 --- a/ui-web/src/features/settings/pages/Archive.test.tsx +++ b/ui-web/src/features/settings/pages/Archive.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as confirm from '../../../state/confirm' import { resetSources, setSources } from '../../../state/sources' -import { install, mount, snap, source as settingsSource } from '../../../test/settingsHarness' +import { install, modelSource, mount, snap, source as settingsSource } from '../../../test/settingsHarness' import * as store from '../store' import { AUTO_ARCHIVE_DAYS } from './Archive' @@ -21,7 +21,7 @@ const rows: ArchivedSession[] = [ beforeEach(() => { - setSources({ settings: settingsSource }) + setSources({ settings: settingsSource, model: modelSource }) }) afterEach(() => { diff --git a/ui-web/src/features/settings/pages/General.test.tsx b/ui-web/src/features/settings/pages/General.test.tsx index a7335846c..ee7752ac5 100644 --- a/ui-web/src/features/settings/pages/General.test.tsx +++ b/ui-web/src/features/settings/pages/General.test.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as notifications from '../../../lib/notifications' import * as look from '../../../state/look' import { resetSources, setSources } from '../../../state/sources' -import { install, mount, source as settingsSource } from '../../../test/settingsHarness' +import { install, modelSource, mount, source as settingsSource } from '../../../test/settingsHarness' import * as store from '../store' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -15,7 +15,7 @@ vi.mock('../../../state/toast', () => ({ show: (t: string) => { toasts.calls.pus beforeEach(() => { - setSources({ settings: settingsSource }) + setSources({ settings: settingsSource, model: modelSource }) }) afterEach(() => { diff --git a/ui-web/src/features/settings/pages/Model.tsx b/ui-web/src/features/settings/pages/Model.tsx index e8d6825b6..354d09839 100644 --- a/ui-web/src/features/settings/pages/Model.tsx +++ b/ui-web/src/features/settings/pages/Model.tsx @@ -1,15 +1,10 @@ -/* Model: the connected providers and the model roles, or one provider's - detail when a row is opened. */ -import { ProviderDetail } from '../providers/ProviderDetail' -import { Providers } from '../providers/Providers' +/* Model settings: which model each job runs on. The accounts those models come + from are the Model providers page -- "what do I have" and "how do I spend it" + are two questions, and one page answering both buried the second. */ import { Roles } from '../providers/Roles' -import * as store from '../store' import type { JSX } from 'react' export function Model(): JSX.Element { - const s = store.get() - return ( - <>{s.provider ? : <>} - ) + return } diff --git a/ui-web/src/features/settings/pages/Plugins.test.tsx b/ui-web/src/features/settings/pages/Plugins.test.tsx index 38375354b..9f5dfd8e5 100644 --- a/ui-web/src/features/settings/pages/Plugins.test.tsx +++ b/ui-web/src/features/settings/pages/Plugins.test.tsx @@ -3,7 +3,7 @@ import { act, cleanup, fireEvent, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resetSources, setSources } from '../../../state/sources' -import { install, mount, source as settingsSource } from '../../../test/settingsHarness' +import { install, modelSource, mount, source as settingsSource } from '../../../test/settingsHarness' import * as store from '../store' import { plugChip } from './Plugins' @@ -14,7 +14,7 @@ import type { McpSnapshot } from '../../../rpc/generated' vi.mock('../../../state/toast', () => ({ show: () => {}, subscribe: () => () => {}, get: () => [] })) beforeEach(() => { - setSources({ settings: settingsSource }) + setSources({ settings: settingsSource, model: modelSource }) }) afterEach(() => { diff --git a/ui-web/src/features/settings/pages/Provider.tsx b/ui-web/src/features/settings/pages/Provider.tsx new file mode 100644 index 000000000..115727dba --- /dev/null +++ b/ui-web/src/features/settings/pages/Provider.tsx @@ -0,0 +1,32 @@ +/* Model providers: which accounts this install holds, and how each connects. + Two columns, because the answer to "which do I have" is a list to scan and + the answer to "how is this one set up" is a form -- paging between them was + the shape this replaces, and twenty rows in it is already too many. */ +import { t } from '../../../i18n/t' +import { ProviderDetail } from '../providers/ProviderDetail' +import { ProviderSide } from '../providers/ProviderSide' +import * as store from '../store' + +import type { JSX } from 'react' + +export function Provider(): JSX.Element { + const s = store.get() + /* Opens on the provider serving the chat model, which is the row a reader + coming here to change something is most often after. */ + const slug = s.provider ?? s.snap.curProvider + const shown = s.snap.providers.some((p) => p.id === slug) ? slug : null + return ( +
+ + {/* Keyed by the provider: the pane holds a key field and an address field + in local state, seeded from the provider it mounted on. Re-rendering + the same instance for a different vendor kept the first one's values, + which on a page where the list is one click away means the address of + the provider you just left, sitting in the box of the one you opened. + A page that reached the pane by navigation never had this. */} + {shown + ? + :
{t('gui.settings.providers.pick_one')}
} +
+ ) +} diff --git a/ui-web/src/features/settings/pages/Skills.test.tsx b/ui-web/src/features/settings/pages/Skills.test.tsx index 82ae7cadc..c4f6b5675 100644 --- a/ui-web/src/features/settings/pages/Skills.test.tsx +++ b/ui-web/src/features/settings/pages/Skills.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as confirm from '../../../state/confirm' import { resetSources, setSources } from '../../../state/sources' -import { install, mount, source as settingsSource } from '../../../test/settingsHarness' +import { install, modelSource, mount, source as settingsSource } from '../../../test/settingsHarness' import * as store from '../store' import { stripFrontmatter } from './Skills' @@ -14,7 +14,7 @@ vi.mock('../../../state/toast', () => ({ show: () => {}, subscribe: () => () => beforeEach(() => { - setSources({ settings: settingsSource }) + setSources({ settings: settingsSource, model: modelSource }) }) afterEach(() => { diff --git a/ui-web/src/features/settings/pages/Tools.test.tsx b/ui-web/src/features/settings/pages/Tools.test.tsx index 4bb53d366..f8d99a870 100644 --- a/ui-web/src/features/settings/pages/Tools.test.tsx +++ b/ui-web/src/features/settings/pages/Tools.test.tsx @@ -3,7 +3,7 @@ import { act, cleanup, fireEvent, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resetSources, setSources } from '../../../state/sources' -import { install, mount, snap, source as settingsSource } from '../../../test/settingsHarness' +import { install, modelSource, mount, snap, source as settingsSource } from '../../../test/settingsHarness' import { legacyKey, vendorKey, webVendor } from '../source' import * as store from '../store' import { TOOL_GROUPS, blocker } from './Tools' @@ -14,7 +14,7 @@ vi.mock('../../../state/toast', () => ({ show: () => {}, subscribe: () => () => beforeEach(() => { - setSources({ settings: settingsSource }) + setSources({ settings: settingsSource, model: modelSource }) }) afterEach(() => { @@ -125,7 +125,8 @@ describe('tools page', () => { await mount('tools') await act(async () => { fireEvent.click(screen.getByText('image_generate')) }) await act(async () => { fireEvent.click(screen.getByLabelText('gui.settings.roles.change {"role":"gui.settings.roles.image"}')) }) - await act(async () => { fireEvent.click(screen.getByText('openai/gpt-4o')) }) - expect(calls[0]).toEqual(['set', { key: 'tools.media.image', value: { model: 'openai/gpt-4o', quality: '' } }]) + /* The image slot offers image models, here and on the model page alike. */ + await act(async () => { fireEvent.click(screen.getByText('gemini-2.5-flash-image')) }) + expect(calls[0]).toEqual(['set', { key: 'tools.media.image', value: { model: 'google/gemini-2.5-flash-image', quality: '' } }]) }) }) diff --git a/ui-web/src/features/settings/pages/Usage.test.tsx b/ui-web/src/features/settings/pages/Usage.test.tsx index 161b5c23e..60cd9914b 100644 --- a/ui-web/src/features/settings/pages/Usage.test.tsx +++ b/ui-web/src/features/settings/pages/Usage.test.tsx @@ -3,7 +3,7 @@ import { act, cleanup, fireEvent, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resetSources, setSources } from '../../../state/sources' -import { install, mount, source as settingsSource } from '../../../test/settingsHarness' +import { install, modelSource, mount, source as settingsSource } from '../../../test/settingsHarness' import * as store from '../store' import { lastDays } from '../store' import { MAX_DAYS, clampRange } from './Usage' @@ -34,7 +34,7 @@ function stats(days: string[], withWrite = false): UsageStats { beforeEach(() => { - setSources({ settings: settingsSource }) + setSources({ settings: settingsSource, model: modelSource }) }) afterEach(() => { diff --git a/ui-web/src/features/settings/providers/AddModelPop.tsx b/ui-web/src/features/settings/providers/AddModelPop.tsx new file mode 100644 index 000000000..040f616d6 --- /dev/null +++ b/ui-web/src/features/settings/providers/AddModelPop.tsx @@ -0,0 +1,308 @@ +/* Add a model to a provider: the vendor's own list, filtered by kind, plus a + * row for an id it does not carry. + * + * One control, two routes. Fetching a list and typing an id are the same act -- + * "this provider should serve this model" -- so they are one popover rather + * than a list and a separate "or type one" button; a vendor with no list + * endpoint simply arrives on the second route. + * + * A click on a row writes it. The sheet this replaces collected ticks and asked + * for a confirming press, which asks twice for one decision; the two batch + * controls that remain -- add everything shown, add one vendor's group -- are + * the cases where collecting IS the decision. + * + * The kind tabs count over the search result, not over the whole list: a row of + * counts that ignores the term cannot say how many pressing it would leave. + * + * Rendered at the body rather than beside its button. The dialog carries a + * transform (its entry animation), which makes it the containing block for a + * `position: fixed` child AND the box that clips one -- a 470px panel opened + * from the right-hand pane lost everything past the dialog's edge. The model + * picker solved this by living at the body; so does this. + */ +import { useEffect, useLayoutEffect, useRef } from 'react' +import { createPortal } from 'react-dom' + +import { ModelTags, TagGlyph } from '../../../components/ModelTags' +import { t } from '../../../i18n/t' +import { KIND_GLYPH, KIND_LABEL, KIND_ORDER, bareModel as bare, guessKind, modelKind, nextKind } from '../../model/types' +import { Rov } from '../Fields' +import * as store from '../store' +import { roleName, rolesUsing } from './Roles' + +import type { Kind } from '../../model/types' +import type { Sheet } from '../store' +import type { ModelCandidate, ProviderRow } from '../types' +import type { JSX } from 'react' + +/* Whether this row is on the provider already. `added` is the wire's own + answer, computed with `merge_key`; the second test covers a row the vendor's + list never named, which reaches us with no answer at all. */ +export const isAdded = (slug: string, m: ModelCandidate, configured: string[]): boolean => + m.added || configured.some((c) => bare(slug, c) === bare(slug, m.id)) + +/* Every row the popover may draw: what the vendor named, plus what is already + configured and the vendor did not name -- a model added by hand is only in + the second, and leaving it out would make it unremovable from here. */ +export function allRows(sheet: Sheet, configured: string[], labels?: ProviderRow['labels']): ModelCandidate[] { + const seen = new Set(sheet.items.map((m) => bare(sheet.slug, m.id))) + /* A model somebody added by hand is not in the vendor's list, and its kind is + not text just because the list did not name it -- the provider row carries + one, and filing it under Text would hide a hand-added embedding model from + the tab that exists to find it. */ + const extra = configured.filter((m) => !seen.has(bare(sheet.slug, m))) + .map((id): ModelCandidate => ({ id, label: id, kind: modelKind(labels?.[id]), added: true })) + return [...sheet.items, ...extra] +} + +const matches = (m: ModelCandidate, q: string): boolean => + !q || m.id.toLowerCase().includes(q) || (m.label || '').toLowerCase().includes(q) + +export function shownRows(sheet: Sheet, configured: string[], labels?: ProviderRow['labels']): ModelCandidate[] { + const q = sheet.q.trim().toLowerCase() + return allRows(sheet, configured, labels) + .filter((m) => matches(m, q)) + .filter((m) => sheet.kind === 'all' || m.kind === sheet.kind) +} + +/* 'all' first, then every kind present, in the order a reader meets them + elsewhere. A kind with nothing in it has no tab: a zero to press is a + promise of nothing. */ +export function kindCounts(sheet: Sheet, configured: string[], labels?: ProviderRow['labels']): Array<['all' | Kind, number]> { + const q = sheet.q.trim().toLowerCase() + const matched = allRows(sheet, configured, labels).filter((m) => matches(m, q)) + const out: Array<['all' | Kind, number]> = [['all', matched.length]] + for (const kind of KIND_ORDER) { + const n = matched.filter((m) => m.kind === kind).length + if (n) out.push([kind, n]) + } + return out +} + +/* A gateway's ids are `vendor/model`, so they group; a direct vendor's are not, + so they do not. Rows with no prefix trail the labelled groups rather than + sitting between two of them, where they read as the previous group's tail. */ +export function groups(rows: ModelCandidate[]): Array<[string, ModelCandidate[]]> { + const by = new Map() + for (const m of rows) { + const g = m.id.includes('/') ? m.id.split('/')[0]! : '' + if (!by.has(g)) by.set(g, []) + by.get(g)!.push(m) + } + /* One group means a flat list only when that group is the unprefixed one: a + gateway whose search narrows to a single vendor keeps its head, and with it + the control that adds the whole vendor. */ + if (by.size === 1 && [...by.keys()][0] === '') return [['', [...by.values()][0]!]] + return [...by.entries()].sort((a, b) => (a[0] === '' ? 1 : b[0] === '' ? -1 : a[0].localeCompare(b[0]))) +} + +function KindChip({ value, onCycle }: { value: Kind; onCycle(next: Kind): void }): JSX.Element { + return ( + { e.stopPropagation(); onCycle(nextKind(value)) }} + onKeyDown={(e) => { + if (e.key !== 'Enter' && e.key !== ' ') return + e.stopPropagation(); e.preventDefault() + onCycle(nextKind(value)) + }} + > + + {t(KIND_LABEL[value])} + + ) +} + +export function AddModelPop({ p }: { p: ProviderRow }): JSX.Element { + const sheet = store.get().sheet! + const box = useRef(null) + const configured = p.configured || [] + const rows = shownRows(sheet, configured, p.labels) + const counts = kindCounts(sheet, configured, p.labels) + const q = sheet.q.trim() + const exact = allRows(sheet, configured, p.labels).some((m) => bare(p.id, m.id) === bare(p.id, q)) + const typedKind = sheet.typed ?? guessKind(q) + const pending = rows.filter((m) => !isAdded(p.id, m, configured)) + + /* Below the button it hangs off, flipping above only when the room below is + too short to be useful and there is more of it above. The height follows + the room rather than the list, so the popover never grows past the dialog + and then scrolls the page. */ + useLayoutEffect(() => { + const el = box.current + if (!el) return + const anchor = document.querySelector(`[data-addmodel="${p.id}"]`) + if (!anchor) return + const a = anchor.getBoundingClientRect() + const vh = document.documentElement.clientHeight + const vw = document.documentElement.clientWidth + const below = vh - a.bottom - 18 + const above = a.top - 18 + const up = below < 220 && above > below + el.style.maxHeight = `${Math.max(160, Math.min(520, up ? above : below))}px` + const h = el.offsetHeight + el.style.top = `${Math.max(12, Math.min(up ? a.top - 6 - h : a.bottom + 6, vh - h - 12))}px` + el.style.left = `${Math.max(12, Math.min(a.right - el.offsetWidth, vw - el.offsetWidth - 12))}px` + }, [p.id, rows.length, sheet.state, sheet.kind]) + + /* Escape and a click outside close it; the button that opened it is inside, + so its own click does not reach here. */ + useEffect(() => { + const away = (e: PointerEvent): void => { + const target = e.target as HTMLElement | null + if (!target || target.closest('.settings-apop') || target.closest(`[data-addmodel="${p.id}"]`)) return + store.set({ sheet: null }) + } + const key = (e: KeyboardEvent): void => { if (e.key === 'Escape') store.set({ sheet: null }) } + document.addEventListener('pointerdown', away, true) + document.addEventListener('keydown', key) + return () => { + document.removeEventListener('pointerdown', away, true) + document.removeEventListener('keydown', key) + } + }, [p.id]) + + const toggle = (m: ModelCandidate): void => { + const listed = isAdded(p.id, m, configured) + if (listed) { + /* The same refusal the tag list beside this popover makes: a model a role + runs on does not come off by a click here either. */ + const used = rolesUsing(store.get().snap, p.id, m.id) + if (used.length) { + store.refuse(t('gui.settings.providers.model_in_use', { roles: used.map(roleName).join(', '), model: m.id })) + return + } + } + void store.sheetToggleModel(p.id, m.id, listed) + } + const addTyped = (): void => { + void store.sheetToggleModel(p.id, q, false, typedKind) + store.sheetPatch({ q: '', typed: null }) + } + const addMany = (ids: string[]): void => { + if (ids.length) void store.run(`prov:${p.id}`, () => store.source().addModels(p.id, ids)) + } + + return createPortal(( +
+
+ + store.sheetPatch({ q: e.currentTarget.value, typed: null })} + onKeyDown={(e) => { + if (e.key !== 'Enter') return + const first = pending[0] + if (first) toggle(first) + else if (q && !exact) addTyped() + }} + /> +
+ {counts.length > 2 && ( +
+ {counts.map(([kind, n]) => ( + + ))} +
+ )} +
+ {sheet.state === 'loading' &&
{t('gui.settings.providers.fetching', { name: p.name })}
} + {/* A vendor with no list endpoint still has models -- the ones somebody + typed in. Saying so above them beats replacing them with the + message, which is what hid a provider's own list behind its + failure to enumerate one. */} + {sheet.state === 'failed' &&
{t('gui.settings.providers.no_list')}
} + {sheet.state !== 'loading' && groups(rows).map(([name, ids]) => { + const folded = !!sheet.folded[name] + const unadded = ids.filter((m) => !isAdded(p.id, m, configured)) + return ( +
+ {name && ( +
+ + +
+ )} + {!folded && ids.map((m) => { + const has = isAdded(p.id, m, configured) + return ( + + ) + })} +
+ ) + })} + {sheet.state !== 'loading' && q && !exact && ( + <> + {rows.length > 0 &&
} + + + )} + {sheet.state !== 'loading' && !rows.length && !q && ( +
{t('gui.settings.providers.no_models_yet')}
+ )} +
+
+ {t('gui.settings.providers.showing_added', { n: String(rows.length), m: String(configured.length) })} + + {rows.length > 0 && ( + + )} +
+
+ ), document.body) +} diff --git a/ui-web/src/features/settings/providers/ProviderDetail.test.tsx b/ui-web/src/features/settings/providers/ProviderDetail.test.tsx index 819ba27c1..cffab7b82 100644 --- a/ui-web/src/features/settings/providers/ProviderDetail.test.tsx +++ b/ui-web/src/features/settings/providers/ProviderDetail.test.tsx @@ -3,16 +3,19 @@ import { act, cleanup, fireEvent, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resetSources, setSources } from '../../../state/sources' -import { install, mount, snap, source as settingsSource } from '../../../test/settingsHarness' +import { install, modelSource, mount, snap, source as settingsSource } from '../../../test/settingsHarness' import * as store from '../store' +import type { Call } from '../../../test/settingsHarness' +import type { ModelCandidate } from '../types' + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true vi.mock('../../../state/toast', () => ({ show: () => {}, subscribe: () => () => {}, get: () => [] })) beforeEach(() => { - setSources({ settings: settingsSource }) + setSources({ settings: settingsSource, model: modelSource }) }) afterEach(() => { @@ -26,8 +29,10 @@ afterEach(() => { const modelsAdd = (): HTMLElement => document.querySelectorAll('.settings-card')[1]!.querySelector('button.mini.ghost') as HTMLElement const formSave = (): HTMLElement => document.querySelector('.settings-kvform button.mini:not(.ghost)') as HTMLElement +/* The detail pane is the Model providers page's right column since the split; + the Model settings page holds the roles card alone. */ async function open(slug: string): Promise { - await mount('model') + await mount('provider') await act(async () => { store.set({ provider: slug }) }) } @@ -35,7 +40,7 @@ describe('provider detail', () => { it('refuses to disconnect a provider a role runs on, naming the roles, and writes nothing', async () => { const { calls } = install() await open('anthropic') - await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.disconnect')) }) + await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.disconnect_key')) }) expect(calls).toEqual([]) expect(screen.getByRole('alert').textContent) .toBe('gui.settings.providers.in_use {"roles":"gui.settings.roles.chat, gui.settings.roles.title, gui.settings.roles.gate"}') @@ -47,7 +52,7 @@ describe('provider detail', () => { data.everos = { available: true, sections: {} } const { calls } = install(data) await open('openrouter') - await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.disconnect')) }) + await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.disconnect_key')) }) expect(calls).toEqual([['provider', { op: 'disconnect', slug: 'openrouter' }]]) }) @@ -61,6 +66,20 @@ describe('provider detail', () => { expect(calls).toEqual([['provider', { op: 'remove_model', slug: 'anthropic', model: 'claude-sonnet-4-5' }]]) }) + it('words the disconnect by shape, so it says what it is about to drop', async () => { + const data = snap() + data.providers = data.providers.map((p) => ( + p.id === 'minimax_global' || p.id === 'ollama' ? { ...p, on: true } : p + )) + install(data) + await open('minimax_global') + expect(screen.getByText('gui.settings.providers.disconnect_auth')).toBeTruthy() + await open('ollama') + expect(screen.getByText('gui.settings.providers.disconnect_local')).toBeTruthy() + await open('anthropic') + expect(screen.getByText('gui.settings.providers.disconnect_key')).toBeTruthy() + }) + it('re-entering a key connects with the key alone, and an empty key on a new provider is refused first', async () => { const { calls } = install() await open('openai') @@ -118,31 +137,115 @@ describe('provider detail', () => { expect(calls).toEqual([['provider', { op: 'add_model', slug: 'anthropic', model: 'claude-sonnet-4-5', label: 'Sonnet 4.5', description: 'fast' }]]) }) - it('the model sheet fetches the vendor list, takes ticks plus a typed id, and adds them in one call', async () => { + /* The popover that replaced the tick-then-confirm sheet: a click on a row is + the write, and the two batch controls are what is left of collecting. */ + const popSearch = (): HTMLInputElement => document.querySelector('.settings-apop input') as HTMLInputElement + /* The catalogue rows; the typed-id row shares their shape and is reached + through `.settings-apadd` instead. */ + const popRows = (): HTMLElement[] => [...document.querySelectorAll('.settings-apm:not(.settings-apadd)')] + const catalogue = async (models: ModelCandidate[], status = 'ok'): Promise => { const { calls } = install(undefined, { - fetchModels: async (slug) => { calls.push(['fetchModels', slug]); return { models: [{ id: 'gpt-5', label: 'gpt-5', kind: 'chat', added: false }, { id: 'claude-opus-4-5', label: 'x', kind: 'chat', added: true }], status: 'ok' } }, + fetchModels: async (slug) => { calls.push(['fetchModels', slug]); return { models, status } }, }) await open('anthropic') await act(async () => { fireEvent.click(modelsAdd()) }) + return calls + } + + it('fetches the vendor list on open and writes one model per click', async () => { + const calls = await catalogue([ + { id: 'gpt-5', label: 'gpt-5', kind: 'text', added: false }, + { id: 'claude-opus-4-5', label: 'x', kind: 'text', added: true }, + ]) expect(calls).toEqual([['fetchModels', 'anthropic']]) - await act(async () => { fireEvent.click(screen.getByLabelText('gpt-5', { exact: false }).closest('label')!.querySelector('input')!) }) - const box = document.getElementById('mlq') as HTMLInputElement - await act(async () => { fireEvent.change(box, { target: { value: 'my-finetune' } }) }) - await act(async () => { fireEvent.click(screen.getByText('my-finetune').closest('label')!.querySelector('input')!) }) - await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.add_n {"n":2}')) }) - expect(calls[1]).toEqual(['addModels', { slug: 'anthropic', models: ['gpt-5', 'my-finetune'] }]) + await act(async () => { fireEvent.click(popRows()[0]!) }) + expect(calls[1]).toEqual(['provider', { op: 'add_model', slug: 'anthropic', model: 'gpt-5' }]) + }) + + it('counts the kinds over the search result and narrows the rows to the tab', async () => { + await catalogue([ + { id: 'gpt-5', label: 'gpt-5', kind: 'text', added: false }, + { id: 'gpt-embed', label: 'gpt-embed', kind: 'embedding', added: false }, + { id: 'other-embed', label: 'other-embed', kind: 'embedding', added: false }, + ]) + const tabs = (): string[] => [...document.querySelectorAll('.settings-mkind')].map((b) => b.textContent!) + /* Five, not three: the two models already on this provider are rows too, + or a model added by hand could never be taken off from here. */ + expect(tabs()).toEqual(['gui.model.kind_all5', 'gui.model.type.text3', 'gui.model.type.embedding2']) + await act(async () => { fireEvent.change(popSearch(), { target: { value: 'gpt' } }) }) + expect(tabs()).toEqual(['gui.model.kind_all2', 'gui.model.type.text1', 'gui.model.type.embedding1']) + await act(async () => { fireEvent.click([...document.querySelectorAll('.settings-mkind')][2]!) }) + expect(popRows().map((r) => r.querySelector('.settings-apnm')!.textContent)).toEqual(['gpt-embed']) + }) + + it('a typed id carries a kind chip that cycles, and states its tags on the add', async () => { + const calls = await catalogue([{ id: 'gpt-5', label: 'gpt-5', kind: 'text', added: false }]) + await act(async () => { fireEvent.change(popSearch(), { target: { value: 'my-team/bge-x' } }) }) + const chip = (): HTMLElement => document.querySelector('.settings-apkind') as HTMLElement + expect(chip().textContent).toBe('gui.model.type.embedding') + await act(async () => { fireEvent.click(chip()) }) + expect(chip().textContent).toBe('gui.model.type.reranker') + await act(async () => { fireEvent.click(document.querySelector('.settings-apadd')!) }) + expect(calls[calls.length - 1]).toEqual(['provider', { + op: 'add_model', slug: 'anthropic', model: 'my-team/bge-x', + capabilities: ['rerank'], output_modalities: ['text'], + }]) }) it('a vendor with no list endpoint says so and still takes a typed id', async () => { - const { calls } = install(undefined, { fetchModels: async () => ({ models: [], status: 'unsupported' }) }) - await open('anthropic') - await act(async () => { fireEvent.click(modelsAdd()) }) + const calls = await catalogue([], 'unsupported') expect(screen.getByText('gui.settings.providers.no_list')).toBeTruthy() - const box = document.getElementById('mlq') as HTMLInputElement - await act(async () => { fireEvent.change(box, { target: { value: 'typed-one' } }) }) - await act(async () => { fireEvent.click(screen.getByText('typed-one').closest('label')!.querySelector('input')!) }) - await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.add_n {"n":1}')) }) - expect(calls[calls.length - 1]).toEqual(['addModels', { slug: 'anthropic', models: ['typed-one'] }]) + await act(async () => { fireEvent.change(popSearch(), { target: { value: 'typed-one' } }) }) + await act(async () => { fireEvent.click(document.querySelector('.settings-apadd')!) }) + expect(calls[calls.length - 1]).toEqual(['provider', { op: 'add_model', slug: 'anthropic', model: 'typed-one' }]) + }) + + it('treats one model written two ways as one row, already added', async () => { + /* `model.fetch_models` returns provider-qualified ids; a model added by + hand is stored as it was typed. The backend calls them the same model + (`wire.py` merge_key); drawing them as two rows offered to add one that + was already there and left "add all" at a number it could never reach. */ + const calls = await catalogue([ + { id: 'anthropic/claude-opus-4-5', label: 'Opus', kind: 'text', added: true }, + { id: 'anthropic/claude-sonnet-4-5', label: 'Sonnet', kind: 'text', added: true }, + { id: 'anthropic/claude-haiku', label: 'Haiku', kind: 'text', added: false }, + ]) + /* The harness's anthropic carries the two bare spellings as configured. */ + expect(popRows().map((r) => r.getAttribute('aria-checked'))).toEqual(['true', 'true', 'false']) + expect(popRows()).toHaveLength(3) + expect(screen.getByText('gui.model.add_all {"n":"1"}')).toBeTruthy() + await act(async () => { fireEvent.click(screen.getByText('gui.model.add_all {"n":"1"}')) }) + expect(calls[calls.length - 1]).toEqual(['addModels', { slug: 'anthropic', models: ['anthropic/claude-haiku'] }]) + }) + + it('adds everything shown in one call', async () => { + const calls = await catalogue([ + { id: 'a', label: 'a', kind: 'text', added: false }, + { id: 'b', label: 'b', kind: 'text', added: false }, + ]) + await act(async () => { fireEvent.click(screen.getByText('gui.model.add_all {"n":"2"}')) }) + expect(calls[calls.length - 1]).toEqual(['addModels', { slug: 'anthropic', models: ['a', 'b'] }]) + }) + + it('heads each vendor group and trails the bare rows with none', async () => { + const calls = await catalogue([ + { id: 'zeta/one', label: 'Zeta One', kind: 'text', added: false }, + { id: 'alpha/two', label: 'Alpha Two', kind: 'text', added: false }, + { id: 'alpha/three', label: 'Alpha Three', kind: 'text', added: true }, + ]) + const heads = [...document.querySelectorAll('.settings-mgroup')] + expect(heads.map((g) => g.querySelector('.settings-gn')!.textContent)).toEqual(['alpha', 'zeta']) + expect(heads.map((g) => g.querySelector('.settings-gc')!.textContent)).toEqual(['2', '1']) + /* The harness's anthropic carries two bare configured ids. They trail the + labelled groups and are drawn without a head of their own: a bare row + between two vendor heads reads as the previous vendor's tail, and an + "add this whole vendor" control over rows with no vendor means nothing. */ + expect(popRows().map((r) => r.querySelector('.settings-apnm')!.textContent)) + .toEqual(['Alpha Two', 'Alpha Three', 'Zeta One', 'claude-opus-4-5', 'claude-sonnet-4-5']) + /* The group's control offers the group, not the page: `alpha/three` is + already on the provider and stays out of the frame. */ + await act(async () => { fireEvent.click(heads[0]!.querySelector('.settings-ga')!) }) + expect(calls[calls.length - 1]).toEqual(['addModels', { slug: 'anthropic', models: ['alpha/two'] }]) }) it('an OAuth provider authorizes in the browser and shows the code until it lands or expires', async () => { diff --git a/ui-web/src/features/settings/providers/ProviderDetail.tsx b/ui-web/src/features/settings/providers/ProviderDetail.tsx index 98add2336..589216c08 100644 --- a/ui-web/src/features/settings/providers/ProviderDetail.tsx +++ b/ui-web/src/features/settings/providers/ProviderDetail.tsx @@ -4,9 +4,11 @@ import { useState } from 'react' import { KeyInput } from '../../../components/KeyInput' +import { ProviderIcon } from '../../../components/ProviderMark' import { t } from '../../../i18n/t' -import { Card, Chip, Crumb, Grow, IconBtn, KeyLink, Row, Rov, Spin, Tag } from '../Fields' +import { Card, IconBtn, KeyLink, Row, Rov, Tag } from '../Fields' import * as store from '../store' +import { AddModelPop } from './AddModelPop' import { AZURE, OauthNote, kindLabel, kindOf, needsKey, takesBase, takesKey } from './Providers' import { roleName, rolesUsing } from './Roles' @@ -15,6 +17,43 @@ import type { JSX } from 'react' const busy = (slug: string): string => `prov:${slug}` +/* The address a provider is reached at, wherever it is drawn. + * + * An aggregator's address is a first-class field -- you point one at a proxy or + * a regional host the way you would type a key -- so it sits in the body beside + * the key. Every other vendor has one address that works, and a box for + * overriding it belongs under Advanced with the other things few people touch. + * Both need the same control, and the same way back: a reset appears once the + * value differs from what the registry ships, because an address typed by hand + * is not one you can retype from memory. */ +function AddressRow({ p, sub, placeholder }: { p: ProviderRow; sub?: string; placeholder: string }): JSX.Element { + const [base, setBase] = useState(p.apiBase || '') + const write = (value: string): void => { + void store.run(busy(p.id), () => store.source().setFields(p.id, { api_base: value })) + } + const commit = (): void => { + const b = base.trim() + if (b === (p.apiBase || '')) return + write(b) + } + const shipped = p.defaultApiBase || '' + const resettable = !!shipped && (p.apiBase || '') !== '' && (p.apiBase || '') !== shipped + return ( + + + setBase(e.currentTarget.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === 'Enter') commit() }} /> + {resettable && ( + + )} + + + ) +} + function Connection({ p }: { p: ProviderRow }): JSX.Element { const [key, setKey] = useState('') const [base, setBase] = useState(p.apiBase || rawStr(store.get().snap.raw, p.id, 'apiBase') || p.defaultApiBase || '') @@ -34,13 +73,14 @@ function Connection({ p }: { p: ProviderRow }): JSX.Element { if (b) params.api_base = b void store.run(busy(p.id), () => store.source().provider('save_key', params)).then((ok) => { if (ok) setKey('') }) } - const setBaseField = (): void => { - const b = base.trim() - if (b === (p.apiBase || '')) return - void store.run(busy(p.id), () => store.source().setFields(p.id, { api_base: b })) - } const btn = p.on ? t('gui.settings.update') : t('gui.settings.providers.connect') - const off = p.on && + const off = p.on && ( + + ) return ( {kind === 'oauth' && ( @@ -90,12 +130,8 @@ function Connection({ p }: { p: ProviderRow }): JSX.Element { )} - {kind !== 'oauth' && needsKey(p) && (takesBase(p) || p.kind === 'endpoint') && ( - - setBase(e.currentTarget.value)} onBlur={setBaseField} onKeyDown={(e) => { if (e.key === 'Enter') setBaseField() }} /> - + {kind !== 'oauth' && needsKey(p) && (takesBase(p) || p.gateway || p.kind === 'endpoint') && ( + )} {p.id === AZURE && } @@ -132,70 +168,6 @@ function AzureFields({ p }: { p: ProviderRow }): JSX.Element { ) } -/* The vendor's list, filtered, plus a typed id; one click adds them all. */ -function Sheet({ p }: { p: ProviderRow }): JSX.Element { - const sheet = store.get().sheet! - const listed = p.configured || [] - const q = sheet.q.trim() - const ql = q.toLowerCase() - const items = sheet.items.filter((m) => !ql || m.id.toLowerCase().includes(ql)) - const exact = sheet.items.some((m) => m.id.toLowerCase() === ql) || listed.some((m) => m.toLowerCase() === ql) - const picks = sheet.sel.filter((m) => !listed.includes(m)) - const manual = q && !exact ? ( - - ) : null - const add = (): void => { - void store.run(busy(p.id), () => store.source().addModels(p.id, picks)).then((ok) => { if (ok) store.set({ sheet: null }) }) - } - return ( -
-
- - store.sheetPatch({ q: e.currentTarget.value })} /> -
-
- {sheet.state === 'loading' &&
{t('gui.settings.providers.fetching', { name: p.name })}
} - {sheet.state !== 'loading' && ( - <> - {!items.length && manual} - {items.map((m) => { - const has = listed.includes(m.id) - return ( - - ) - })} - {items.length > 0 && manual} - {!items.length && !manual && ( -
- {sheet.state === 'failed' ? t('gui.settings.providers.no_list') : t('gui.settings.providers.no_match')} -
- )} - - )} -
-
- - {sheet.state === 'ready' ? t('gui.settings.providers.n_available', { n: sheet.items.length }) : ''} - {picks.length ? `${sheet.state === 'ready' ? ' · ' : ''}${t('gui.settings.providers.n_picked', { n: picks.length })}` : ''} - - - - -
-
- ) -} - function Models({ p }: { p: ProviderRow }): JSX.Element { const s = store.get() const listed = p.configured || [] @@ -206,19 +178,24 @@ function Models({ p }: { p: ProviderRow }): JSX.Element { void store.run(busy(p.id), () => store.source().provider('remove_model', { slug: p.id, model: m })) } return ( - + { if (open) store.set({ sheet: null }); else void store.sheetOpen(p.id) }}> + {t('gui.settings.providers.add_model')} + + }>
{listed.map((m) => ( - {m} remove(m)}>{'×'} + {m} remove(m)}>{'\u00d7'} ))} {!listed.length && {t('gui.settings.providers.no_models_yet')}} - {!open && }
- {open && } + {open && }
) } @@ -264,13 +241,7 @@ function Advanced({ p }: { p: ProviderRow }): JSX.Element { const listed = p.configured || [] /* The address of a vendor whose connection card has no address row: an override of the registry's default, written on its own. */ - const [base, setBase] = useState(p.apiBase || '') - const showBase = takesKey(p) && !takesBase(p) && p.kind !== 'endpoint' - const setBaseField = (): void => { - const b = base.trim() - if (b === (p.apiBase || '')) return - void store.run(busy(p.id), () => store.source().setFields(p.id, { api_base: b })) - } + const showBase = takesKey(p) && !takesBase(p) && !p.gateway && p.kind !== 'endpoint' const setHeader = (name: string, value: string | null): void => { void store.run(busy(p.id), () => store.source().setFields(p.id, { extra_headers: { [name]: value } })) } @@ -293,10 +264,8 @@ function Advanced({ p }: { p: ProviderRow }): JSX.Element { return ( {showBase && ( - - setBase(e.currentTarget.value)} onBlur={setBaseField} onKeyDown={(e) => { if (e.key === 'Enter') setBaseField() }} /> - + )}
@@ -355,23 +324,41 @@ function Advanced({ p }: { p: ProviderRow }): JSX.Element { ) } +/* The head of the pane: the vendor, where to reach it, and one line of state. + The breadcrumb this replaces was the way back to a list that is now beside + it -- on a two-column page there is nothing to go back to. */ +function Head({ p }: { p: ProviderRow }): JSX.Element { + const state = p.on + ? t('gui.settings.providers.connected') + : t(kindOf(p) === 'oauth' ? 'gui.settings.providers.needs_auth' : kindOf(p) === 'local' ? 'gui.settings.providers.needs_base' : 'gui.settings.providers.needs_key') + const link = p.keyUrl || p.homepage + return ( +
+ +
+
+ {p.name} + {link ? {'\u2197'} : null} + {kindLabel(p)} +
+
{state}
+
+
+ ) +} + export function ProviderDetail({ slug }: { slug: string }): JSX.Element | null { const s = store.get() const p = s.snap.providers.find((x) => x.id === slug) if (!p) { store.set({ provider: null }); return null } - const state: [ 'on' | 'off', string ] = p.on - ? ['on', t('gui.settings.providers.connected')] - : ['off', t(kindOf(p) === 'oauth' ? 'gui.settings.providers.needs_auth' : kindOf(p) === 'local' ? 'gui.settings.providers.needs_base' : 'gui.settings.providers.needs_key')] + /* One element, because the page around it is a two-column grid: a fragment + put every card in it into a column of its own. */ return ( - <> - store.set({ provider: null, sheet: null, err: '' })} name={p.name}> - {kindLabel(p)} - - {state[1]} - +
+ {kindOf(p) !== 'oauth' && } - +
) } diff --git a/ui-web/src/features/settings/providers/ProviderSide.tsx b/ui-web/src/features/settings/providers/ProviderSide.tsx new file mode 100644 index 000000000..887b3b24d --- /dev/null +++ b/ui-web/src/features/settings/providers/ProviderSide.tsx @@ -0,0 +1,94 @@ +/* The provider catalogue: every vendor the gateway knows, searchable and + filtered, with the selected one drawn beside it. + * + * The whole list rather than the connected ones: "which accounts do I have" and + * "which could I have" are one question asked twice, and the page used to + * answer only the first -- fifty-odd vendors Raven supports were reachable + * from a dropdown inside an "add" form and nowhere else. A column of + * fifty-five needs a search box and a filter, which is what turns the full + * list from noise into an index. + * + * Connected first because that is the question asked most often; registry + * order within each half, which is the order the wire sends and the priority + * the registry declares. + */ +import { ProviderIcon } from '../../../components/ProviderMark' +import { t } from '../../../i18n/t' +import * as store from '../store' + +import type { ProvFilter } from '../store' +import type { ProviderRow } from '../types' +import type { JSX } from 'react' + +/* The six the prototype offers. `direct` is the remainder rather than a fact of + its own: a vendor you hold an account with directly is one that is not a + reseller, not an OAuth sign-in and not something you run yourself. */ +const FILTERS: Array<[ProvFilter, string]> = [ + ['all', 'gui.model.prov_filter.all'], + ['on', 'gui.settings.providers.connected'], + ['direct', 'gui.settings.providers.filter_direct'], + ['gateway', 'gui.settings.providers.filter_gateway'], + ['oauth', 'gui.settings.providers.filter_oauth'], + ['local', 'gui.model.kind.local'], +] + +export function provMatch(p: ProviderRow, q: string, f: ProvFilter): boolean { + const needle = q.trim().toLowerCase() + if (needle && !`${p.name} ${p.id}`.toLowerCase().includes(needle)) return false + if (f === 'all') return true + if (f === 'on') return p.on + if (f === 'gateway') return !!p.gateway + if (f === 'oauth') return p.kind === 'oauth' + if (f === 'local') return p.kind === 'local' + return !p.gateway && (p.kind === 'key' || p.kind === 'endpoint') +} + +export const provRows = (rows: ProviderRow[], q: string, f: ProvFilter): ProviderRow[] => { + const kept = rows.filter((p) => provMatch(p, q, f)) + return [...kept.filter((p) => p.on), ...kept.filter((p) => !p.on)] +} + +export function ProviderSide(): JSX.Element { + const s = store.get() + const rows = provRows(s.snap.providers, s.provQ, s.provFilt) + return ( +
+
+ store.set({ provQ: e.currentTarget.value })} + /> + {/* A select, not a menu of our own: it is a list of exclusive choices, + which is what the element is for, and it arrives keyboard-reachable + and screen-reader-labelled without a line of our code. */} + +
+ {rows.length ? ( +
+ {rows.map((p) => ( +
+ +
+ ))} +
+ ) : ( +
{t('gui.model.prov_no_match')}
+ )} +
+ ) +} diff --git a/ui-web/src/features/settings/providers/Roles.test.tsx b/ui-web/src/features/settings/providers/Roles.test.tsx index 0bf344837..cc3a5a4ea 100644 --- a/ui-web/src/features/settings/providers/Roles.test.tsx +++ b/ui-web/src/features/settings/providers/Roles.test.tsx @@ -3,7 +3,7 @@ import { act, cleanup, fireEvent, screen, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resetSources, setSources } from '../../../state/sources' -import { install, mount, snap, source as settingsSource } from '../../../test/settingsHarness' +import { install, modelSource, mount, snap, source as settingsSource } from '../../../test/settingsHarness' import * as store from '../store' import { ROLES, roleProviders, rolesUsing } from './Roles' @@ -13,7 +13,7 @@ vi.mock('../../../state/toast', () => ({ show: () => {}, subscribe: () => () => beforeEach(() => { - setSources({ settings: settingsSource }) + setSources({ settings: settingsSource, model: modelSource }) }) afterEach(() => { @@ -25,10 +25,18 @@ afterEach(() => { const role = (id: string) => ROLES.find((r) => r.id === id)! const pill = (roleName: string): HTMLElement => screen.getByLabelText(`gui.settings.roles.change {"role":"${roleName}"}`) -const picker = (): HTMLElement => document.querySelector('.model-picker') as HTMLElement +/* One picker for the whole page since 2026-09-20: the composer's, at the body. */ +const picker = (): HTMLElement => document.querySelector('.mpick') as HTMLElement +/* The provider row's hit area is a button beside the name, not around it, so a + click on the name selects nothing. */ +const selectProvider = (name: string): void => { + const row = [...picker().querySelectorAll('.provs .row')] + .find((r) => r.querySelector('.nm')?.textContent?.includes(name))! + fireEvent.click(row.querySelector('.model-provider-action')!) +} const pick = async (roleName: string, model: string, providerName?: string): Promise => { await act(async () => { fireEvent.click(pill(roleName)) }) - if (providerName) await act(async () => { fireEvent.click(within(picker()).getByText(providerName)) }) + if (providerName) await act(async () => { selectProvider(providerName) }) await act(async () => { fireEvent.click(within(picker()).getByText(model)) }) } const sets = (calls: Array<[string, unknown]>) => calls.filter(([m]) => m === 'set').map(([, a]) => a) @@ -72,9 +80,11 @@ describe('model roles', () => { const { calls } = install() await mount('model') expect(roleProviders(role('image'), snap()).map((p) => p.id)).toEqual(['openrouter']) - await pick('gui.settings.roles.image', 'openai/gpt-4o') + /* The image slot lists image models: a text model on the same provider is + not offered for it any more. */ + await pick('gui.settings.roles.image', 'gemini-2.5-flash-image') expect(sets(calls)).toEqual([ - { key: 'tools.media.image', value: { model: 'openai/gpt-4o', quality: '' } }, + { key: 'tools.media.image', value: { model: 'google/gemini-2.5-flash-image', quality: '' } }, { key: 'tools.disabledTools', value: ['deep_research'] }, ]) }) @@ -112,7 +122,7 @@ describe('model roles', () => { const { calls } = install() await mount('model') await act(async () => { fireEvent.click(pill('gui.settings.roles.gate')) }) - const box = screen.getByPlaceholderText('gui.model.pick_search') as HTMLInputElement + const box = screen.getByPlaceholderText('gui.picker.search_ph') as HTMLInputElement await act(async () => { fireEvent.change(box, { target: { value: 'claude-haiku-4-5' } }) }) await act(async () => { fireEvent.click(screen.getByText('gui.model.pick_use {"id":"claude-haiku-4-5"}')) }) expect(calls).toEqual([ @@ -122,6 +132,44 @@ describe('model roles', () => { ]) }) + it('a slot with no provider offers the providers page and goes there', async () => { + /* A fresh install starts here: every slot empty, and the way out has to be + reachable. A sentence naming the page is not. */ + const data = snap() + data.providers = data.providers.map((p) => ({ ...p, on: false })) + install(data) + await mount('model') + const out = screen.getAllByText('gui.settings.roles.no_provider')[0]! + expect(out.tagName).toBe('BUTTON') + await act(async () => { fireEvent.click(out) }) + expect(store.get().tab).toBe('provider') + }) + + it('a media slot with OpenRouter off opens that row on the providers page', async () => { + const data = snap() + data.providers = data.providers.map((p) => (p.id === 'openrouter' ? { ...p, on: false } : p)) + install(data) + await mount('model') + await act(async () => { fireEvent.click(screen.getAllByText('gui.settings.roles.connect_openrouter')[0]!) }) + expect([store.get().tab, store.get().provider]).toEqual(['provider', 'openrouter']) + }) + + it('a typed id keeps the kind the chip stated, not the one its name implies', async () => { + /* "our-finetune" matches neither name pattern, so without the chip's answer + it is stored as text and disappears from the embedding slot it was just + typed into. */ + const { calls } = install() + await mount('model') + await act(async () => { fireEvent.click(pill('gui.settings.roles.embedding')) }) + const box = screen.getByPlaceholderText('gui.picker.search_ph') as HTMLInputElement + await act(async () => { fireEvent.change(box, { target: { value: 'our-finetune' } }) }) + await act(async () => { fireEvent.click(screen.getByText('gui.model.pick_use {"id":"our-finetune"}')) }) + expect(calls[0]).toEqual(['provider', { + op: 'add_model', slug: 'anthropic', model: 'our-finetune', + capabilities: ['embedding'], output_modalities: ['vector'], + }]) + }) + it('rolesUsing counts a role following the chat model through the chat provider', () => { const data = snap() expect(rolesUsing(data, 'anthropic').map((r) => r.id)).toEqual(['chat', 'title', 'gate']) diff --git a/ui-web/src/features/settings/providers/Roles.tsx b/ui-web/src/features/settings/providers/Roles.tsx index de5c25650..eaebce960 100644 --- a/ui-web/src/features/settings/providers/Roles.tsx +++ b/ui-web/src/features/settings/providers/Roles.tsx @@ -3,12 +3,13 @@ may serve it. The media tools' rows on the Tools page draw the same pill. */ import { useRef } from 'react' -import { ModelPicker } from '../../../components/ModelPicker' import { t } from '../../../i18n/t' +import { openPicker } from '../../model/source' +import { statedTags } from '../../model/types' import { Card, Row, Rov, Seg, Stepper } from '../Fields' import * as store from '../store' -import type { PickerProvider } from '../../../components/ModelPicker' +import type { Kind, Offer } from '../../model/types' import type { ProviderRow, SettingsSnapshot } from '../types' import type { JSX } from 'react' @@ -16,6 +17,17 @@ export type RoleId = | 'chat' | 'curator' | 'title' | 'memllm' | 'gate' | 'embedding' | 'rerank' | 'multimodal' | 'image' | 'speech' | 'video' +/* What each slot may be filled with. The one consumer of a model's kind: an + embedding slot listing a chat model is a pick that fails on the next call, + and the slot is the only place that knows which it wants. + Text for every slot that talks: `kind_of` files a model by what it WRITES, so + a model that also reads pictures is text, and the multimodal slot -- which + wants exactly that -- takes text too. */ +export const ROLE_KIND: Record = { + chat: 'text', curator: 'text', title: 'text', memllm: 'text', gate: 'text', multimodal: 'text', + embedding: 'embedding', rerank: 'reranker', image: 'image', speech: 'audio', video: 'video', +} + export interface Role { id: RoleId /* settings.set keys for the model and the provider. */ @@ -122,22 +134,14 @@ const providerName = (snap: SettingsSnapshot, id: string): string => { return p ? p.name : id } -/* A provider with nothing added yet offers the registry's own list: the - first-run wizard connects a vendor and picks a chat model in one step, - before anyone has visited the provider's page to add one, and an empty - column there is a dead end. Once something is added, the added list is - what the picker offers, as before. */ -function pickerProviders(rows: ProviderRow[]): PickerProvider[] { - return rows.map((p) => ({ - id: p.id, name: p.name, models: p.configured && p.configured.length ? p.configured : p.models, labels: p.labels, - })) -} - /* The write a pick makes, by role. The typed id is added to the provider first, so the role never names a model the provider does not list. */ -async function setRole(r: Role, model: string, provider: string, typed: boolean): Promise { +async function setRole(r: Role, model: string, provider: string, typed: boolean, kind?: Kind): Promise { const src = store.source() - if (typed) await src.provider('add_model', { slug: provider, model }) + /* The typed id joins the provider's list first, with what the person said it + is: a name the registry's patterns miss ("our-finetune") would otherwise be + stored as text and vanish from the very slot it was typed into. */ + if (typed) await src.provider('add_model', { slug: provider, model, ...statedTags(kind ?? 'text') }) if (r.id === 'chat') { if (await src.pickModel(model, provider)) store.set({ needsRestart: true }) return src.load() @@ -171,7 +175,12 @@ async function clearRole(r: Role): Promise { return undefined } -/* The pill that shows a role's model and opens the picker under it. */ +/* The pill that shows a role's model and opens the picker under it. + + The picker is the composer's: one component, opened with an offer that says + what this slot may take (its kind, the providers the role allows, the pair it + holds now) and what a pick means here (the role's own write, not a + conversation switch). */ export function RolePill({ role }: { role: Role }): JSX.Element { /* The picker hangs off this button, so it has to be reachable as an element and not only as markup. */ @@ -179,54 +188,65 @@ export function RolePill({ role }: { role: Role }): JSX.Element { const s = store.get() const val = roleValue(role, s.snap) const provs = roleProviders(role, s.snap) - const open = s.picker === role.id const inherit = !!role.keys + const chat = roleValue(ROLES[0]!, s.snap) + /* No provider this role may use is connected: there is nothing to open onto, + and the way out is the providers page rather than an empty popover. A + provider that is connected but has nothing of this kind added is NOT this + case -- the picker lists it, says the column is empty and offers a row to + type an id into. */ if (!provs.length && !val) { - return role.media - ? - : {t('gui.settings.roles.no_provider')} + /* The way out is the providers page, so say so with a button that goes + there. A sentence that names the page without taking the reader to it + leaves every slot on a fresh install a dead end -- which is the state a + fresh install starts in. */ + const label = role.media + ? 'gui.settings.roles.connect_openrouter' + : role.everos ? 'gui.settings.roles.no_key_provider' : 'gui.settings.roles.no_provider' + return ( + + ) } const dim = !val const clearable = !!val && role.id !== 'chat' const cls = ['settings-mpill', dim ? 'settings-dim' : '', clearable ? 'settings-clearable' : ''].filter(Boolean).join(' ') - const chat = roleValue(ROLES[0]!, s.snap) - const emptyNote = role.media - ? t('gui.settings.roles.connect_openrouter') - : role.everos ? t('gui.settings.roles.no_key_provider') : t('gui.settings.roles.no_provider') + const offer: Offer = { + kind: ROLE_KIND[role.id], + providers: provs.map((p) => p.id), + title: roleName(role), + current: val ?? (inherit && chat ? chat : undefined), + pick: async (model, provider, typed, kind) => { + await store.run(`role:${role.id}`, () => setRole(role, model, provider, typed, kind)) + }, + } return ( - <> - - - {clearable && ( - + + + {clearable && ( + )} - + ) } diff --git a/ui-web/src/features/settings/store.test.ts b/ui-web/src/features/settings/store.test.ts index 35a6bf1b4..e8a949575 100644 --- a/ui-web/src/features/settings/store.test.ts +++ b/ui-web/src/features/settings/store.test.ts @@ -15,6 +15,7 @@ afterEach(() => { store._resetForTests() settingsDialog._resetForTests() resetSources() + vi.restoreAllMocks() }) describe('settings store', () => { @@ -32,11 +33,27 @@ describe('settings store', () => { }) it('setTab closes every drawer of the section it leaves', () => { - store.set({ provider: 'openai', sheet: { slug: 'openai', q: '', sel: [], state: 'ready', items: [] }, skill: 's', toolOpen: 'exec', plugOpen: 'p', picker: 'chat', err: 'oops' }) + store.set({ provider: 'openai', sheet: { slug: 'openai', q: '', state: 'ready', items: [], kind: 'all', folded: {}, typed: null }, skill: 's', toolOpen: 'exec', plugOpen: 'p', err: 'oops' }) store.setTab('tools') const s = store.get() expect(s.tab).toBe('tools') - expect([s.provider, s.sheet, s.skill, s.toolOpen, s.plugOpen, s.picker, s.err]).toEqual([null, null, null, null, null, null, '']) + /* The model picker is no longer one of them: it is the composer's, owned by + the model store, and it closes on its own when the dialog does. */ + expect([s.provider, s.sheet, s.skill, s.toolOpen, s.plugOpen, s.err]).toEqual([null, null, null, null, null, '']) + }) + + it('both model doors open the providers page, which is where a key and a list are', async () => { + /* `openModels` answers "nothing is connected" and `openProviderModels` + answers "this one has no model added". Since the split, neither question + is answerable on the Model settings page: it holds the roles card and no + way to connect anything. */ + setSources({ settings: { load: async () => snapOf('m') } as unknown as SettingsSource }) + vi.spyOn(settingsDialog, 'open').mockImplementation(() => {}) + await store.openModels() + expect(settingsDialog.settingsTab.id).toBe('provider') + settingsDialog.settingsTab.id = 'general' + await store.openProviderModels('moonshot') + expect([settingsDialog.settingsTab.id, store.get().provider]).toEqual(['provider', 'moonshot']) }) it('run marks the key busy while the write runs and lands the snapshot it returns', async () => { diff --git a/ui-web/src/features/settings/store.ts b/ui-web/src/features/settings/store.ts index 8259d6448..c8374afb6 100644 --- a/ui-web/src/features/settings/store.ts +++ b/ui-web/src/features/settings/store.ts @@ -17,7 +17,9 @@ import * as settingsDialog from '../../state/settings' import { ds } from '../../state/sources' import { makeStore } from '../../state/store' import { show as toast } from '../../state/toast' +import { statedTags } from '../model/types' +import type { Kind } from '../model/types' import type { ArchivedSession, ModelCandidate, @@ -28,8 +30,12 @@ import type { UsageStats, } from './types' -export type SectionId = 'general' | 'usage' | 'model' | 'skills' | 'tools' | 'plugins' | 'archive' | 'about' -export const SECTIONS: SectionId[] = ['general', 'usage', 'model', 'skills', 'tools', 'plugins', 'archive', 'about'] +export type SectionId = 'general' | 'usage' | 'provider' | 'model' | 'skills' | 'tools' | 'plugins' | 'archive' | 'about' +export const SECTIONS: SectionId[] = ['general', 'usage', 'provider', 'model', 'skills', 'tools', 'plugins', 'archive', 'about'] + +/* The catalogue column's six. `direct` is the remainder: not a reseller, not an + OAuth sign-in, not something you run yourself. */ +export type ProvFilter = 'all' | 'on' | 'direct' | 'gateway' | 'oauth' | 'local' /* The vendor-list sheet under a provider's models card: what the vendor answered, what is ticked, and the typed filter. Kept here rather than in @@ -37,9 +43,15 @@ export const SECTIONS: SectionId[] = ['general', 'usage', 'model', 'skills', 'to export interface Sheet { slug: string q: string - sel: string[] state: 'loading' | 'ready' | 'failed' items: ModelCandidate[] + /* The kind tab in force, or every kind. */ + kind: 'all' | Kind + /* Vendor groups the reader has collapsed, by prefix. */ + folded: Record + /* What a typed id would be added as, once the reader has said; null means + the guess from its name still stands. */ + typed: Kind | null } /* A device flow in progress: the code the vendor's page asks for, and when @@ -72,16 +84,18 @@ export interface SettingsState { /* undefined = never answered (drawn as loading), null = the counter did not answer. */ usage: UsageStats | null | undefined - /* The provider whose detail is open, or null for the list. */ + /* The provider whose detail pane is drawn, or null for the page's own + default (the one serving the chat model). */ provider: string | null + /* The catalogue column's search term and filter. */ + provQ: string + provFilt: ProvFilter /* The slug picked in the add-provider block, or null when it is closed. */ provAdd: string | null sheet: Sheet | null hdrAdd: string | null ovlAdd: string | null chatCfg: boolean - /* The role whose model picker is open, or null. */ - picker: string | null oauth: Oauth | null skill: string | null detail: SkillDetail | null @@ -107,12 +121,13 @@ const initial = (): SettingsState => ({ range: { kind: '30', ...lastDays(30) }, usage: undefined, provider: null, + provQ: '', + provFilt: 'all', provAdd: null, sheet: null, hdrAdd: null, ovlAdd: null, chatCfg: false, - picker: null, oauth: null, skill: null, detail: null, @@ -221,13 +236,17 @@ export async function open(): Promise { await refresh() } +/* "Nothing is connected, go and connect something" and "this provider has no + model added, go and add one" both land on Model providers: since the split + that is where a key is entered and a model list is built, and the Model + settings page these used to open holds only the roles card. */ export async function openModels(): Promise { - settingsTab.id = 'model' + settingsTab.id = 'provider' await open() } export async function openProviderModels(slug: string): Promise { - settingsTab.id = 'model' + settingsTab.id = 'provider' set({ provider: slug }) await open() } @@ -236,8 +255,8 @@ export async function openProviderModels(slug: string): Promise { export function setTab(id: string): void { settingsTab.id = id set({ - tab: curTab(), err: '', provider: null, provAdd: null, sheet: null, hdrAdd: null, ovlAdd: null, - picker: null, skill: null, detail: null, toolOpen: null, plugOpen: null, + tab: curTab(), err: '', provider: null, provQ: '', provFilt: 'all', provAdd: null, sheet: null, hdrAdd: null, ovlAdd: null, + skill: null, detail: null, toolOpen: null, plugOpen: null, }) } @@ -304,7 +323,7 @@ export async function skillOpen(name: string): Promise { with no list endpoint answers a status other than ok, and the sheet then takes a typed id alone. */ export async function sheetOpen(slug: string): Promise { - set({ sheet: { slug, q: '', sel: [], state: 'loading', items: [] } }) + set({ sheet: { slug, q: '', state: 'loading', items: [], kind: 'all', folded: {}, typed: null } }) let items: ModelCandidate[] = [] let ok = false try { @@ -318,16 +337,27 @@ export async function sheetOpen(slug: string): Promise { if (sheet && sheet.slug === slug) set({ sheet: { ...sheet, state: ok ? 'ready' : 'failed', items } }) } -export function sheetPatch(patch: Partial): void { - const sheet = get().sheet - if (sheet) set({ sheet: { ...sheet, ...patch } }) +/* One row, one write. `add_model` states the kind for a typed id the + catalogues cannot describe; a listed row states nothing, because the reply + that listed it already carried one. + The "a role runs on this model" refusal belongs to the caller, not here: the + roles table lives in a component that reads this store, so reaching it from + this side closes a cycle -- and hiding that behind a dynamic import only + hides it from the gate that checks for one. The tag list beside the popover + refuses the same way, in the same place. */ +export async function sheetToggleModel(slug: string, id: string, listed: boolean, kind?: Kind): Promise { + const src = source() + if (listed) { + await run(`prov:${slug}`, () => src.provider('remove_model', { slug, model: id })) + return + } + const stated = kind && kind !== 'text' ? statedTags(kind) : {} + await run(`prov:${slug}`, () => src.provider('add_model', { slug, model: id, ...stated })) } -export function sheetToggle(id: string): void { +export function sheetPatch(patch: Partial): void { const sheet = get().sheet - if (!sheet) return - const sel = sheet.sel.includes(id) ? sheet.sel.filter((m) => m !== id) : [...sheet.sel, id] - set({ sheet: { ...sheet, sel } }) + if (sheet) set({ sheet: { ...sheet, ...patch } }) } /* Start a device flow and watch for it to land: the provider turns connected diff --git a/ui-web/src/features/settings/styles.css b/ui-web/src/features/settings/styles.css index 57e5c78ad..4117f5c57 100644 --- a/ui-web/src/features/settings/styles.css +++ b/ui-web/src/features/settings/styles.css @@ -254,15 +254,6 @@ footer {border-top:1px solid var(--line); margin:48px 0 0; padding:18px 0 70px; row it hangs off lives inside .spanels, whose overflow clips an absolutely positioned child, so the panel resolves against .smodal instead and the dialog is what bounds it. Only the size is CSS's to say. */ -.settings-ctl .model-picker {width:min(560px, calc(100vw - 32px)); height:400px} -.settings-ctl .model-picker-search {padding:10px 14px} -.settings-ctl .model-picker-search input {font-size:14px} -.settings-ctl .model-picker-body {grid-template-columns:170px 1fr} -.settings-ctl .model-picker-prov {font-size:13.5px; padding:7px 10px} -.settings-ctl .model-picker-model {font-size:13px; padding:7px 10px} -.settings-ppcard .model-picker-search {padding:10px 14px} -.settings-ppcard .model-picker-search input {font-size:14px} -.settings-ppcard .model-picker-body {grid-template-columns:190px 1fr} .settings-tag2.settings-pick {cursor:pointer; font-family:var(--mono)} .settings-tag2.settings-pick:hover {border-color:var(--amber); color:var(--text)} .settings-spin {display:inline-block; width:11px; height:11px; margin-right:7px; vertical-align:-1px; @@ -415,3 +406,77 @@ footer {border-top:1px solid var(--line); margin:48px 0 0; padding:18px 0 70px; .settings-xname .settings-xn {font-size:13.5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap} .settings-xrow.settings-open .settings-chev {transform:rotate(90deg); border-color:var(--amber); color:var(--gold); background:var(--amber-dp)} .settings-lnkico {margin-left:6px; vertical-align:middle} + +/* ---- Model providers: the catalogue in two columns ------------------------ + The prototype's `tp-*` family, prefixed. Left: every provider the wire + returns, searchable and filtered; right: the selected one's connection. + The shape is one page's for now -- the prototype shares it with channels and + memory, which are not built here, so it is copied when they are rather than + abstracted for pages that do not exist. */ +.settings-tp {display:grid; grid-template-columns:286px minmax(0,1fr); border:1px solid var(--line); border-radius:12px; overflow:hidden; min-height:420px; max-height:calc(100vh - 220px)} +.settings-tp-side {border-right:1px solid var(--line-soft); padding:13px; display:flex; flex-direction:column; gap:9px; min-height:0} +.settings-tp-find {display:flex; gap:8px; align-items:center} +.settings-tp-find input {flex:1; min-width:0; font:inherit; font-size:13px; padding:7px 11px; border:1px solid var(--line); border-radius:9px; background:var(--paper); color:var(--text); outline:none} +.settings-tp-find input:focus-visible {border-color:var(--amber)} +.settings-tp-find select {flex:none; font:inherit; font-size:12.5px; padding:7px 8px; border:1px solid var(--line); border-radius:9px; background:var(--paper); color:var(--muted); cursor:pointer; color-scheme:light dark} +.settings-tp-list {display:flex; flex-direction:column; gap:1px; flex:1; min-height:0; overflow-y:auto; margin:0 -5px} +.settings-tp-row {display:flex; align-items:center; border-radius:8px} +.settings-tp-row:hover {background:var(--surface)} +.settings-tp-row[aria-current="true"] {background:var(--raised)} +.settings-tp-hit {display:flex; align-items:center; gap:9px; width:100%; min-width:0; text-align:left; font:inherit; background:none; border:0; color:var(--text); padding:7px 9px; cursor:pointer} +.settings-tp-nm {flex:1; min-width:0; font-size:13.5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap} +.settings-tp-row[aria-current="true"] .settings-tp-nm {font-weight:600} +.settings-tp-def {flex:none; font-size:10.5px; color:var(--gold); border:1px solid var(--line); border-radius:99px; padding:0 6px} +.settings-tp-dot {width:7px; height:7px; border-radius:50%; background:var(--moss); flex:none} +.settings-tp-empty {padding:12px 9px; color:var(--faint); font-size:12.5px} +.settings-tp-main {padding:17px 20px 20px; min-width:0; min-height:0; overflow-y:auto} +.settings-tp-none {display:grid; place-items:center; padding:40px 20px; color:var(--faint); font-size:13px} +.settings-tp-head {display:flex; align-items:center; gap:10px} +.settings-tp-head .provider-icon {width:30px; height:30px; border-radius:8px} +.settings-tp-ttl {min-width:0} +.settings-tp-name {display:flex; align-items:center; gap:7px; font-family:var(--disp); font-size:17px; font-weight:600} +.settings-tp-state {font-size:12.5px; color:var(--muted); margin-top:4px} +.settings-tp-state.settings-tp-live {color:var(--moss)} +.settings-exlink {color:var(--gold); text-decoration:none; font-size:13px} +.settings-exlink:hover {text-decoration:underline} + +/* ---- Add model: a catalogue that hangs off its button --------------------- + The prototype's `apop` family, prefixed. Positioned in JS against the button + (AddModelPop.tsx) because its height follows the room, not the list. */ +/* At the body, so above the settings veil (--z-veil 40) like the picker. */ +.settings-apop {position:fixed; z-index:47; width:min(470px, calc(100vw - 32px)); display:flex; flex-direction:column; background:var(--paper); border:1px solid var(--line); border-radius:12px; box-shadow:var(--elev-up); overflow:hidden} +.settings-apsearch {display:flex; align-items:center; gap:9px; padding:9px 13px; border-bottom:1px solid var(--line-soft)} +.settings-apsearch svg {width:14px; height:14px; flex:none; fill:none; stroke:var(--faint); stroke-width:1.7; stroke-linecap:round} +.settings-apsearch input {flex:1; min-width:0; font:inherit; font-size:13px; border:0; background:none; color:var(--text); outline:none} +.settings-apkinds {display:flex; flex-wrap:wrap; gap:5px; padding:8px 10px 6px; border-bottom:1px solid var(--line-soft)} +.settings-mkind {display:inline-flex; align-items:center; gap:5px; font:inherit; font-size:12px; color:var(--muted); background:none; border:1px solid transparent; border-radius:7px; padding:3px 8px; cursor:pointer} +.settings-mkind:hover {background:var(--surface)} +.settings-mkind[aria-pressed="true"] {border-color:var(--line); background:var(--surface); color:var(--text)} +.settings-mkind .model-tag {width:12px; height:12px; color:inherit} +.settings-mkind i {font-style:normal; font-family:var(--mono); font-size:10px; color:var(--faint)} +.settings-apbody {flex:1; min-height:0; overflow-y:auto; padding:6px} +.settings-apwait {padding:22px 14px; color:var(--faint); font-size:13px; text-align:center} +.settings-mgroup {display:flex; align-items:center; gap:4px; padding:8px 6px 3px} +.settings-gt {flex:1; min-width:0; display:flex; align-items:center; gap:7px; font:inherit; background:none; border:0; color:var(--muted); padding:2px 4px; border-radius:6px; cursor:pointer; text-align:left} +.settings-gt:hover {background:var(--surface)} +.settings-gn {font-size:11px; font-weight:600; letter-spacing:.04em} +.settings-gc {font-family:var(--mono); font-size:10px; color:var(--faint)} +.settings-ga {flex:none; width:22px; height:22px; border:0; border-radius:6px; background:none; color:var(--faint); cursor:pointer; font-size:14px; line-height:1} +.settings-ga:hover:not([disabled]) {background:var(--raised); color:var(--text)} +.settings-ga[disabled] {opacity:.3; cursor:not-allowed} +.settings-apm {display:flex; align-items:center; gap:9px; width:100%; min-width:0; text-align:left; font:inherit; font-size:12.5px; background:none; border:0; color:var(--text); padding:6px 8px; border-radius:7px; cursor:pointer} +.settings-apm:hover {background:var(--surface)} +.settings-aptick {width:15px; height:15px; flex:none; border-radius:4px; border:1.5px solid var(--line); display:grid; place-items:center; font-size:10px; color:var(--ink)} +.settings-apm[aria-checked="true"] .settings-aptick {background:var(--amber); border-color:var(--amber); color:var(--ink-on-accent)} +.settings-apnm {flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:var(--mono); font-size:12px} +.settings-apm[aria-checked="true"] .settings-apnm {color:var(--muted)} +.settings-apsd {font-family:var(--sans); font-size:11.5px; color:var(--faint); flex:none} +.settings-apadd {color:var(--gold)} +.settings-apadd .settings-aptick {border-style:dashed; border-color:var(--gold); color:var(--gold)} +.settings-apadd .settings-apsd {color:var(--gold)} +.settings-apkind {display:inline-flex; align-items:center; gap:4px; flex:none; font:inherit; font-size:11px; color:var(--muted); border:1px solid var(--line); border-radius:6px; padding:1px 7px; background:var(--paper); cursor:pointer} +.settings-apkind:hover {border-color:var(--amber); color:var(--text)} +.settings-apkind .model-tag {width:11px; height:11px; color:inherit} +.settings-aphr {height:1px; background:var(--line-soft); margin:6px 4px} +.settings-apfoot {display:flex; align-items:center; gap:8px; padding:8px 12px; border-top:1px solid var(--line-soft)} +.settings-apnote {padding:7px 9px; margin:2px 2px 4px; border-radius:7px; background:var(--surface); color:var(--muted); font-size:12px} diff --git a/ui-web/src/features/settings/types.ts b/ui-web/src/features/settings/types.ts index 40675fb08..dfdbd45a2 100644 --- a/ui-web/src/features/settings/types.ts +++ b/ui-web/src/features/settings/types.ts @@ -36,6 +36,10 @@ export interface ProviderRow { on: boolean /* 'key' | 'oauth' | 'local' | 'endpoint' -- the registry's auth shape. */ kind?: string + /* Resells other vendors' models under vendor/model ids (the registry's + `is_gateway`). The catalogue's filter reads it; no client can derive it + from a slug. */ + gateway?: boolean needsBase?: boolean /* Whether the provider takes an API key at all. Absent from a source that predates the field, where every non-local provider took one. */ diff --git a/ui-web/src/lib/popover.ts b/ui-web/src/lib/popover.ts index ba3ae1b67..5ae2f168f 100644 --- a/ui-web/src/lib/popover.ts +++ b/ui-web/src/lib/popover.ts @@ -17,8 +17,9 @@ * * `place` is the two composer popovers' whole geometry, which was the same eight * lines in state/perm.ts and state/tier.ts down to the constants. The model - * picker keeps its own, because it hangs BELOW its anchor when there is no room - * above and clamps to a different margin. + * pickers keep their own: `.mpick` has two placements (above the composer card + * from the chip, below the row from a settings slot), and `anchorRow` below + * serves `components/ModelPicker`, whose last caller is the agents page. */ /* The card, not `.dock` around it: the dock is a full-width positioning strip diff --git a/ui-web/src/rpc/fixtures/model.ts b/ui-web/src/rpc/fixtures/model.ts index 8bf4b5753..344854ed6 100644 --- a/ui-web/src/rpc/fixtures/model.ts +++ b/ui-web/src/rpc/fixtures/model.ts @@ -9,7 +9,7 @@ */ import type { FixtureEnv, Fixtures } from '../fixtureTransport' -import type { ResultOf } from '../generated' +import type { ModelLabel, ResultOf } from '../generated' import type { Overrides } from '../overrideTransport' type Provider = ResultOf<'model.options'>['providers'][number] @@ -28,6 +28,12 @@ interface Entry { needs_api_base?: boolean /** Where the vendor hands out keys; the registry carries it for a few. */ key_url?: string + /** Resells other vendors' models; the catalogue page filters on it. */ + gateway?: boolean + /** The kind per model id, where it is not text. The live reply carries one + for every described model; offline, only the rows a page filters on need + to differ, and text is what a missing label means. */ + kinds?: Record } const PROVIDERS: Entry[] = [ @@ -48,6 +54,16 @@ const PROVIDERS: Entry[] = [ { slug: 'lm_studio', name: 'LM Studio', homepage: 'https://lmstudio.ai/', models: [], authenticated: false, auth_type: 'local', needs_api_base: true, default_api_base: 'http://localhost:1234/v1' }, + /* A gateway with one model of each kind a role slot filters on, so the + offline page can open every slot's picker and show the filter working. */ + { slug: 'openrouter', name: 'OpenRouter', homepage: 'https://openrouter.ai/', + key_url: 'https://openrouter.ai/keys', gateway: true, authenticated: true, auth_type: 'key', + models: ['anthropic/claude-sonnet-4-5', 'openai/text-embedding-3-small', 'BAAI/bge-reranker-v2-m3', 'google/gemini-2.5-flash-image'], + kinds: { + 'openai/text-embedding-3-small': 'embedding', + 'BAAI/bge-reranker-v2-m3': 'reranker', + 'google/gemini-2.5-flash-image': 'image', + } }, ] /* The first-run canvas's own list. Nine rows rather than seven, and every one @@ -85,6 +101,12 @@ const wire = (e: Entry, current: string): Provider => ({ needs_api_base: !!e.needs_api_base, warning: '', key_url: e.key_url ?? null, extra_headers: {}, + gateway: !!e.gateway, + /* One label per model, because the live reply carries a kind on every row it + describes and the surfaces file a model by it. */ + model_labels: Object.fromEntries( + e.models.map((m) => [m, { label: m.split('/').pop() as string, kind: e.kinds?.[m] ?? 'text' }]), + ), ...(e.default_api_base ? { default_api_base: e.default_api_base } : {}), }) diff --git a/ui-web/src/rpc/generated.ts b/ui-web/src/rpc/generated.ts index d17671eb4..478850fbb 100644 --- a/ui-web/src/rpc/generated.ts +++ b/ui-web/src/rpc/generated.ts @@ -821,6 +821,10 @@ export interface ModelOptionProvider { }; total_models: number; needs_api_base: boolean; + /** + * The registry's is_gateway: resells other vendors' models under vendor/model ids. The catalogue's gateway filter reads this; absent means false. + */ + gateway?: boolean; platforms?: { label: string; api_base: string; @@ -852,6 +856,10 @@ export interface ModelLabel { * What the model writes: text, image, video, audio, vector. */ output_modalities?: string[]; + /** + * The bucket a model list files this model under, from what it writes (registry_data.kind_of): a model that reads images is still text. A model with no label entry is text. + */ + kind: 'text' | 'image' | 'audio' | 'video' | 'embedding' | 'reranker'; /** * Tokens the model reads in one request, from the tables that also route rather than from the display registry -- the number shown has to be the number a request is sized with. Absent where no such table names the model. */ diff --git a/ui-web/src/styles/page.css b/ui-web/src/styles/page.css index 9f17af258..82d5a15c3 100644 --- a/ui-web/src/styles/page.css +++ b/ui-web/src/styles/page.css @@ -2305,6 +2305,8 @@ textarea:focus, textarea:focus-visible, min-width: 0; overflow: hidden; text-overflow: ellipsis; } /* capability popover above the composer */ +/* model picker: provider column + model column, fixed height so a provider + with 100+ models cannot grow the popover past the window */ /* model picker: provider column + model column, fixed height so a provider with 100+ models cannot grow the popover past the window */ /* The shared model picker's own rules (components/ModelPicker.tsx). @@ -2404,6 +2406,21 @@ textarea:focus, textarea:focus-visible, .mpick .models .row .nm.named { font-family: var(--sans); font-size: 12.5px; } .mpick .models .row .model-tags { margin-left: auto; } .mpick .empty { padding: 18px 12px; font-size: 12.5px; color: var(--faint); text-align: center; } +/* The slot's name, where a settings row opened the picker; absent for the + composer chip, whose own label already says what is being changed. */ +.mpick .model-slot { font-family: var(--disp); font-weight: 600; font-size: 13px; color: var(--text); } +/* The row for an id the provider does not list yet, and the chip saying what + the person is declaring it to be. */ +.mpick .model-typed { color: var(--amber); } +.mpick .model-typed .nm { font-family: var(--mono); } +.mpick .model-typed-hr { height: 1px; background: var(--line-soft); margin: 6px 4px; } +.mpick .model-kind { + display: inline-flex; align-items: center; gap: 4px; flex: none; cursor: pointer; + padding: 1px 6px; border: 1px solid var(--line); border-radius: 99px; + font-family: var(--sans); font-size: 10.5px; color: var(--muted); +} +.mpick .model-kind:hover { border-color: var(--amber); color: var(--text); } +.mpick .model-kind .model-tag { width: 11px; height: 11px; color: inherit; } .mpick .foot { border-top: 1px solid var(--line-soft); padding: 7px 12px; display: flex; justify-content: space-between; align-items: center; } .mpick .foot button { font-size: 11.5px; color: var(--amber); } diff --git a/ui-web/src/test/settingsHarness.ts b/ui-web/src/test/settingsHarness.ts index d26d06fe4..4c3056364 100644 --- a/ui-web/src/test/settingsHarness.ts +++ b/ui-web/src/test/settingsHarness.ts @@ -6,25 +6,73 @@ import { act, render } from '@testing-library/react' import { createElement } from 'react' import { vi } from 'vitest' +import { ModelApp } from '../features/model/ModelPicker' +import * as modelStore from '../features/model/store' import { SettingsApp } from '../features/settings/SettingsApp' import * as store from '../features/settings/store' import { setTranslator } from '../i18n/t' import * as settingsDialog from '../state/settings' +import type { ModelSource, Provider } from '../features/model/types' import type { ProviderRow, SettingsSnapshot, SettingsSource } from '../features/settings/types' +/* Every label carries the `kind` the wire carries, because the surfaces read + it: `model.options` runs `registry_data.kind_of` over each row, so a fixture + without it describes a reply the gateway cannot send -- and every slot would + see the whole list as text. The values here are what `kind_of` answers for + these ids (the embedding one through `inferred_tags`, from its name). */ export const providers = (): ProviderRow[] => [ { id: 'anthropic', name: 'Anthropic', models: ['claude-opus-4-5', 'claude-sonnet-4-5'], configured: ['claude-opus-4-5', 'claude-sonnet-4-5'], on: true, kind: 'key', acceptsKey: true, keyUrl: 'https://console.anthropic.com/settings/keys', - labels: { 'claude-opus-4-5': { label: 'Opus', description: 'the big one', context_window: 200000 } } }, - { id: 'openrouter', name: 'OpenRouter', models: ['openai/gpt-4o', 'anthropic/claude-sonnet-4-5', 'text-embedding-3-small'], - configured: ['openai/gpt-4o', 'anthropic/claude-sonnet-4-5', 'text-embedding-3-small'], on: true, kind: 'key', acceptsKey: true, + labels: { 'claude-opus-4-5': { label: 'Opus', description: 'the big one', context_window: 200000, kind: 'text' } } }, + { id: 'openrouter', name: 'OpenRouter', models: ['openai/gpt-4o', 'anthropic/claude-sonnet-4-5', 'text-embedding-3-small', 'google/gemini-2.5-flash-image'], + configured: ['openai/gpt-4o', 'anthropic/claude-sonnet-4-5', 'text-embedding-3-small', 'google/gemini-2.5-flash-image'], + on: true, kind: 'key', acceptsKey: true, gateway: true, + /* Only the two the wire would describe: `_model_labels` skips a model no + catalogue names and no tag reaches, and these two are reached by + `inferred_tags` and by a catalogue row. The label is the id, which is what + `describe` falls back to. */ + labels: { + 'text-embedding-3-small': { label: 'text-embedding-3-small', kind: 'embedding' }, + 'google/gemini-2.5-flash-image': { label: 'gemini-2.5-flash-image', kind: 'image' }, + }, apiBase: 'https://openrouter.ai/api/v1', defaultApiBase: 'https://openrouter.ai/api/v1', headers: { 'X-Title': '****set****' } }, { id: 'openai', name: 'OpenAI', models: [], configured: [], on: false, kind: 'key', acceptsKey: true, keyUrl: 'https://platform.openai.com/api-keys' }, { id: 'minimax_global', name: 'MiniMax Global', models: [], configured: [], on: false, kind: 'oauth', acceptsKey: false }, { id: 'ollama', name: 'Ollama', models: [], configured: [], on: false, kind: 'local', acceptsKey: true, needsBase: true }, ] +/* The same rows on the model domain's seam. The live page fills both from one + `model.options` reply, so a test that gave them different lists would be + testing a page that cannot exist -- and the roles card now opens the + composer's picker, which reads this one. */ +const asModelProvider = (p: ProviderRow): Provider => ({ + id: p.id, name: p.name, models: p.models, configured: p.configured, + on: p.on, kind: p.kind, labels: p.labels, gateway: p.gateway, + current: p.id === 'anthropic', +}) + +function modelSourceFor(rows: ProviderRow[], calls: Call[]): ModelSource { + return { + providers: () => rows.map(asModelProvider), + persist: async (model, provider, scope) => { calls.push(['persist', { model, provider, scope }]) }, + addModel: async (model, provider, kind) => { calls.push(['addModel', { model, provider, kind }]) }, + openSettings: () => { calls.push(['openSettings', null]) }, + } +} + +let liveModel: ModelSource | null = null + +/* The model domain's seam, the twin of `source` below. A file that mounts the + dialog installs both: the pages read the settings source, and the roles card + opens the model domain's picker. */ +export const modelSource: ModelSource = new Proxy({} as ModelSource, { + get: (_t, prop: string) => { + if (!liveModel) throw new Error('install() first') + return (liveModel as unknown as Record)[prop] + }, +}) + export function snap(over: Partial = {}): SettingsSnapshot { return { raw: { @@ -140,17 +188,25 @@ export function install(data: SettingsSnapshot = snap(), over: Partial {}) live = built document.body.innerHTML = SETTINGS_SHELL + liveModel = modelSourceFor(data.providers, calls) return { source, calls, data } } /** Forgets the source install() built, so the next file starts from nothing. */ export function _resetForTests(): void { live = null + liveModel = null + modelStore._resetForTests() } export async function mount(tab = 'general'): Promise> { settingsDialog.settingsTab.id = tab const view = render(createElement(SettingsApp), { container: document.getElementById('spanels')! }) + /* The picker is one node at the body, not a child of the dialog: main.tsx + mounts it there and a settings row opens it by a call. A harness that + rendered only the dialog would make every "open the picker" case silently + assert nothing. */ + render(createElement(ModelApp), { container: document.body.appendChild(document.createElement('div')) }) await act(async () => { await store.open() }) return view }