diff --git a/Dockerfile.heroku b/Dockerfile.heroku index 53b1715f..a91a3642 100644 --- a/Dockerfile.heroku +++ b/Dockerfile.heroku @@ -1,3 +1,3 @@ # Community-maintained template, not tested in CI. # Thinker: https://github.com/patrickbrandao/thinker -FROM docker.io/tmsoftbrasil/thinker:0.31 +FROM docker.io/tmsoftbrasil/thinker:0.32 diff --git a/deploy/aws/TUTORIAL.md b/deploy/aws/TUTORIAL.md index 0cb6bc02..d9402123 100644 --- a/deploy/aws/TUTORIAL.md +++ b/deploy/aws/TUTORIAL.md @@ -39,7 +39,7 @@ The deploy script exposes the common settings as environment variables: STACK_NAME=thinker \ SERVICE_NAME=thinker \ AWS_REGION=us-east-1 \ -IMAGE_URL=docker.io/tmsoftbrasil/thinker:0.31 \ +IMAGE_URL=docker.io/tmsoftbrasil/thinker:0.32 \ DATABASE_INSTANCE_CLASS=db.t4g.micro \ DESIRED_COUNT=1 \ ./deploy/aws/deploy.sh diff --git a/deploy/aws/deploy.sh b/deploy/aws/deploy.sh index 1503df7a..b14e134e 100755 --- a/deploy/aws/deploy.sh +++ b/deploy/aws/deploy.sh @@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" STACK_NAME="${STACK_NAME:-thinker}" SERVICE_NAME="${SERVICE_NAME:-thinker}" -IMAGE_URL="${IMAGE_URL:-docker.io/tmsoftbrasil/thinker:0.31}" +IMAGE_URL="${IMAGE_URL:-docker.io/tmsoftbrasil/thinker:0.32}" DESIRED_COUNT="${DESIRED_COUNT:-1}" DATABASE_INSTANCE_CLASS="${DATABASE_INSTANCE_CLASS:-db.t4g.micro}" DATABASE_DELETION_PROTECTION="${DATABASE_DELETION_PROTECTION:-false}" diff --git a/deploy/aws/thinker.yaml b/deploy/aws/thinker.yaml index 1ca38644..64012ac4 100644 --- a/deploy/aws/thinker.yaml +++ b/deploy/aws/thinker.yaml @@ -14,7 +14,7 @@ Parameters: ConstraintDescription: Use up to 40 lowercase letters, numbers, and hyphens, starting with a letter. ImageUrl: Type: String - Default: docker.io/tmsoftbrasil/thinker:0.31 + Default: docker.io/tmsoftbrasil/thinker:0.32 Description: Thinker container image to deploy. ContainerPort: Type: Number diff --git a/deploy/coolify/TUTORIAL.md b/deploy/coolify/TUTORIAL.md index fc6fa4f1..cdfcfb87 100644 --- a/deploy/coolify/TUTORIAL.md +++ b/deploy/coolify/TUTORIAL.md @@ -38,7 +38,7 @@ curl -sSf https:///api/v1/health ## What Gets Provisioned -- Thinker Docker image `tmsoftbrasil/thinker:0.31`. +- Thinker Docker image `tmsoftbrasil/thinker:0.32`. - PostgreSQL 18 container. - Persistent Docker volume for PostgreSQL data. - Persistent Docker volume for compressed request recordings at `/data/request-recordings`. diff --git a/deploy/coolify/docker-compose.yml b/deploy/coolify/docker-compose.yml index d1ac2d93..4007642f 100644 --- a/deploy/coolify/docker-compose.yml +++ b/deploy/coolify/docker-compose.yml @@ -2,7 +2,7 @@ # Thinker: https://github.com/patrickbrandao/thinker services: thinker: - image: tmsoftbrasil/thinker:0.31 + image: tmsoftbrasil/thinker:0.32 restart: unless-stopped stop_grace_period: 30s environment: diff --git a/deploy/easypanel/TUTORIAL.md b/deploy/easypanel/TUTORIAL.md index 0ffebf63..ef6054c5 100644 --- a/deploy/easypanel/TUTORIAL.md +++ b/deploy/easypanel/TUTORIAL.md @@ -21,7 +21,7 @@ If Thinker is available in your Easypanel template catalog: 1. Open your Easypanel project. 2. Choose **Templates**. 3. Search for **Thinker**. -4. Keep the default image `tmsoftbrasil/thinker:0.31` unless you need to pin another version. +4. Keep the default image `tmsoftbrasil/thinker:0.32` unless you need to pin another version. 5. Deploy the template. The template provisions Thinker, PostgreSQL, a persistent request-recording volume, generated secrets, and a domain proxy on port `2099`. @@ -43,7 +43,7 @@ Create the Thinker app service: 1. Click **Create Service**. 2. Choose **App**. 3. Set source type to **Docker image**. -4. Use image `tmsoftbrasil/thinker:0.31`. +4. Use image `tmsoftbrasil/thinker:0.32`. 5. Set the proxy port to `2099`. 6. Add your domain and mark it as the primary domain. @@ -97,7 +97,7 @@ Before publishing a template, smoke test the full first-run flow: ## What Gets Provisioned -- Thinker Docker image `tmsoftbrasil/thinker:0.31`. +- Thinker Docker image `tmsoftbrasil/thinker:0.32`. - PostgreSQL service. - Generated or manually configured session and encryption secrets. - HTTPS domain proxy to Thinker port `2099`. diff --git a/deploy/easypanel/template/README.md b/deploy/easypanel/template/README.md index 40a472c7..b5e95231 100644 --- a/deploy/easypanel/template/README.md +++ b/deploy/easypanel/template/README.md @@ -15,7 +15,7 @@ templates/thinker/meta.yaml The template provisions: -- Thinker app service from `tmsoftbrasil/thinker:0.31`. +- Thinker app service from `tmsoftbrasil/thinker:0.32`. - PostgreSQL 18 service. - Persistent app volume for compressed request recordings. - Generated database password, Better Auth secret, and Thinker encryption key. diff --git a/deploy/easypanel/template/meta.yaml b/deploy/easypanel/template/meta.yaml index ff5c89b0..137a4fa6 100644 --- a/deploy/easypanel/template/meta.yaml +++ b/deploy/easypanel/template/meta.yaml @@ -37,7 +37,7 @@ schema: appServiceImage: type: string title: App Service Image - default: tmsoftbrasil/thinker:0.31 + default: tmsoftbrasil/thinker:0.32 databaseServiceName: type: string title: Database Service Name diff --git a/deploy/fly/TUTORIAL.md b/deploy/fly/TUTORIAL.md index 1d4c9537..e029d97d 100644 --- a/deploy/fly/TUTORIAL.md +++ b/deploy/fly/TUTORIAL.md @@ -34,7 +34,7 @@ Environment variables: - `FLY_ORG`: Fly organization. Defaults to `personal`. - `FLY_RECORDING_BUCKET_NAME`: private Tigris bucket name. Defaults to `-recordings`. Keep the same value on reruns; changing an attached bucket requires a manual recording migration. -The script uses [`fly.toml`](fly.toml) as a template and deploys `docker.io/tmsoftbrasil/thinker:0.31`. It creates a private Tigris bucket and lets Fly stage the bucket's `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` secrets on the app; Thinker uses those credentials with the bucket name, Tigris endpoint, and `auto` region from `fly.toml`. Re-running the script keeps that bucket and the existing `BETTER_AUTH_SECRET` and `THINKER_ENCRYPTION_KEY` values; rotate those secrets manually only if you intend to invalidate sessions and encrypted provider credentials. +The script uses [`fly.toml`](fly.toml) as a template and deploys `docker.io/tmsoftbrasil/thinker:0.32`. It creates a private Tigris bucket and lets Fly stage the bucket's `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` secrets on the app; Thinker uses those credentials with the bucket name, Tigris endpoint, and `auto` region from `fly.toml`. Re-running the script keeps that bucket and the existing `BETTER_AUTH_SECRET` and `THINKER_ENCRYPTION_KEY` values; rotate those secrets manually only if you intend to invalidate sessions and encrypted provider credentials. ## Manual Deploy @@ -112,7 +112,7 @@ fly logs --app ## What Gets Provisioned -- Fly app running `docker.io/tmsoftbrasil/thinker:0.31`. +- Fly app running `docker.io/tmsoftbrasil/thinker:0.32`. - Fly Postgres app. - Private Tigris S3-compatible bucket for compressed request recordings. - `DATABASE_URL` secret from `fly postgres attach`. diff --git a/deploy/fly/fly.toml b/deploy/fly/fly.toml index fdfe8e80..7c3a909f 100644 --- a/deploy/fly/fly.toml +++ b/deploy/fly/fly.toml @@ -12,7 +12,7 @@ kill_signal = "SIGINT" kill_timeout = "30s" [build] - image = "docker.io/tmsoftbrasil/thinker:0.31" + image = "docker.io/tmsoftbrasil/thinker:0.32" [env] PORT = "2099" diff --git a/deploy/gcp/deploystack.json b/deploy/gcp/deploystack.json index f2832da1..0b446637 100644 --- a/deploy/gcp/deploystack.json +++ b/deploy/gcp/deploystack.json @@ -26,7 +26,7 @@ "name": "image_url", "prompt": "Thinker image", "type": "string", - "default": "docker.io/tmsoftbrasil/thinker:0.31" + "default": "docker.io/tmsoftbrasil/thinker:0.32" }, { "name": "database_tier", diff --git a/deploy/gcp/variables.tf b/deploy/gcp/variables.tf index 767cece7..2c4cb87e 100644 --- a/deploy/gcp/variables.tf +++ b/deploy/gcp/variables.tf @@ -23,7 +23,7 @@ variable "service_name" { variable "image_url" { description = "Thinker container image to deploy." type = string - default = "docker.io/tmsoftbrasil/thinker:0.31" + default = "docker.io/tmsoftbrasil/thinker:0.32" } variable "database_tier" { diff --git a/deploy/koyeb/TUTORIAL.md b/deploy/koyeb/TUTORIAL.md index 8f6fff30..1f62b9ad 100644 --- a/deploy/koyeb/TUTORIAL.md +++ b/deploy/koyeb/TUTORIAL.md @@ -33,7 +33,7 @@ openssl rand -hex 32 Open the Koyeb deploy link: ```text -https://app.koyeb.com/deploy?type=docker&image=docker.io%2Ftmsoftbrasil%2Fthinker%3A0.31&name=thinker&service_type=web&ports=2099%3Bhttp%3B%2F&env%5BPORT%5D=2099&env%5BDATABASE_URL%5D=postgres%3A%2F%2FUSER%3APASSWORD%40HOST%2FDB%3Fsslmode%3Drequire&env%5BBETTER_AUTH_SECRET%5D=replace-with-openssl-rand-hex-32&env%5BTHINKER_ENCRYPTION_KEY%5D=replace-with-different-openssl-rand-hex-32&env%5BBETTER_AUTH_URL%5D=https%3A%2F%2F%7B%7B+KOYEB_PUBLIC_DOMAIN+%7D%7D&env%5BBIND_ADDRESS%5D=0.0.0.0&env%5BDB_POOL_MAX%5D=8&env%5BAUTH_DB_POOL_MAX%5D=4&env%5BTHINKER_RECORDING_STORAGE%5D=s3&env%5BTHINKER_RECORDING_S3_BUCKET%5D=replace-with-private-bucket&env%5BTHINKER_RECORDING_S3_ENDPOINT%5D=https%3A%2F%2Fs3.example.com&env%5BTHINKER_RECORDING_S3_REGION%5D=us-east-1&env%5BTHINKER_RECORDING_S3_ACCESS_KEY_ID%5D=replace-with-limited-access-key&env%5BTHINKER_RECORDING_S3_SECRET_ACCESS_KEY%5D=replace-with-secret-key&env%5BTHINKER_RECORDING_S3_FORCE_PATH_STYLE%5D=false +https://app.koyeb.com/deploy?type=docker&image=docker.io%2Ftmsoftbrasil%2Fthinker%3A0.32&name=thinker&service_type=web&ports=2099%3Bhttp%3B%2F&env%5BPORT%5D=2099&env%5BDATABASE_URL%5D=postgres%3A%2F%2FUSER%3APASSWORD%40HOST%2FDB%3Fsslmode%3Drequire&env%5BBETTER_AUTH_SECRET%5D=replace-with-openssl-rand-hex-32&env%5BTHINKER_ENCRYPTION_KEY%5D=replace-with-different-openssl-rand-hex-32&env%5BBETTER_AUTH_URL%5D=https%3A%2F%2F%7B%7B+KOYEB_PUBLIC_DOMAIN+%7D%7D&env%5BBIND_ADDRESS%5D=0.0.0.0&env%5BDB_POOL_MAX%5D=8&env%5BAUTH_DB_POOL_MAX%5D=4&env%5BTHINKER_RECORDING_STORAGE%5D=s3&env%5BTHINKER_RECORDING_S3_BUCKET%5D=replace-with-private-bucket&env%5BTHINKER_RECORDING_S3_ENDPOINT%5D=https%3A%2F%2Fs3.example.com&env%5BTHINKER_RECORDING_S3_REGION%5D=us-east-1&env%5BTHINKER_RECORDING_S3_ACCESS_KEY_ID%5D=replace-with-limited-access-key&env%5BTHINKER_RECORDING_S3_SECRET_ACCESS_KEY%5D=replace-with-secret-key&env%5BTHINKER_RECORDING_S3_FORCE_PATH_STYLE%5D=false ``` In the deploy form: @@ -45,7 +45,7 @@ In the deploy form: - Leave `BETTER_AUTH_URL` as `https://{{ KOYEB_PUBLIC_DOMAIN }}`. - Leave `PORT`, `BIND_ADDRESS`, `DB_POOL_MAX`, `AUTH_DB_POOL_MAX`, and `THINKER_RECORDING_STORAGE=s3` unchanged for a single-instance deploy. Set `THINKER_RECORDING_S3_FORCE_PATH_STYLE=true` only if your object-storage provider requires path-style URLs. -The button deploys `docker.io/tmsoftbrasil/thinker:0.31` and exposes port `2099` over HTTP. +The button deploys `docker.io/tmsoftbrasil/thinker:0.32` and exposes port `2099` over HTTP. ## Open Thinker diff --git a/design-system/registry/components.json b/design-system/registry/components.json index b46f45cd..1da4f8f9 100644 --- a/design-system/registry/components.json +++ b/design-system/registry/components.json @@ -660,7 +660,8 @@ "classes": [ "floating-window", "floating-window--fit", - "floating-window--wide" + "floating-window--wide", + "floating-window--xwide" ], "files": [ "shell.css" @@ -2164,7 +2165,7 @@ "prop": "display", "value": "inline-flex", "file": "shell.css", - "line": 1069 + "line": 1073 }, { "selector": ".app.is-collapsed .user-trigger > svg", @@ -2173,7 +2174,7 @@ "prop": "display", "value": "inline-flex", "file": "shell.css", - "line": 1069 + "line": 1073 }, { "selector": ".app.is-collapsed .user-trigger", @@ -2182,21 +2183,21 @@ "prop": "padding", "value": "6px 8px", "file": "shell.css", - "line": 1078 + "line": 1082 }, { "selector": ".liquid-metal::after", "prop": "inset", "value": "-5px", "file": "shell.css", - "line": 1244 + "line": 1248 }, { "selector": ".liquid-metal::after", "prop": "padding", "value": "5px", "file": "shell.css", - "line": 1245 + "line": 1249 } ], "ghostRules": [ @@ -2207,8 +2208,8 @@ "prop": "display", "value": "revert", "file": "shell.css", - "line": 1062, - "losesTo": "shell.css:1069" + "line": 1066, + "losesTo": "shell.css:1073" }, { "selector": ".app.is-collapsed .user-trigger > svg", @@ -2217,8 +2218,8 @@ "prop": "display", "value": "revert", "file": "shell.css", - "line": 1062, - "losesTo": "shell.css:1069" + "line": 1066, + "losesTo": "shell.css:1073" }, { "selector": ".app.is-collapsed .user-trigger", @@ -2227,24 +2228,24 @@ "prop": "padding", "value": "0 10px", "file": "shell.css", - "line": 1074, - "losesTo": "shell.css:1078" + "line": 1078, + "losesTo": "shell.css:1082" }, { "selector": ".liquid-metal::after", "prop": "inset", "value": "-2px", "file": "shell.css", - "line": 1200, - "losesTo": "shell.css:1244" + "line": 1204, + "losesTo": "shell.css:1248" }, { "selector": ".liquid-metal::after", "prop": "padding", "value": "2px", "file": "shell.css", - "line": 1202, - "losesTo": "shell.css:1245" + "line": 1206, + "losesTo": "shell.css:1249" } ], "componentSheets": { @@ -2261,7 +2262,7 @@ "tokens": 58, "darkOverrides": 50, "blocks": 225, - "classes": 243, + "classes": 244, "canonicalRules": 5, "ghostRules": 5 } diff --git a/design-system/registry/llms.txt b/design-system/registry/llms.txt index 90c6707b..506a17c3 100644 --- a/design-system/registry/llms.txt +++ b/design-system/registry/llms.txt @@ -72,7 +72,7 @@ ### sidebar --sidebar-width-collapsed: 60px -## Blocks (225 BEM blocks, 243 classes) +## Blocks (225 BEM blocks, 244 classes) actions (shell.css): actions agent-type-select (shell.css): agent-type-select__option alert-row (shell.css): alert-row @@ -124,7 +124,7 @@ edge-saving (canvas.css): edge-saving empty (base.css): empty eyebrow (base.css): eyebrow field (base.css, canvas.css, shell.css): field -floating-window (shell.css): floating-window floating-window--fit floating-window--wide +floating-window (shell.css): floating-window floating-window--fit floating-window--wide floating-window--xwide floating-window-body (shell.css): floating-window-body floating-window-foot (shell.css): floating-window-foot floating-window-head (shell.css): floating-window-head @@ -324,18 +324,18 @@ Tabs: src/components/ui.tsx (empty until primitives exist) ## Canonical rules — same selector+property declared twice with different values; the winner renders (5) -.app.is-collapsed .nav-action { display: inline-flex } ← shell.css:1069 [@media (max-width: 900px)] -.app.is-collapsed .user-trigger > svg { display: inline-flex } ← shell.css:1069 [@media (max-width: 900px)] -.app.is-collapsed .user-trigger { padding: 6px 8px } ← shell.css:1078 [@media (max-width: 900px)] -.liquid-metal::after { inset: -5px } ← shell.css:1244 -.liquid-metal::after { padding: 5px } ← shell.css:1245 +.app.is-collapsed .nav-action { display: inline-flex } ← shell.css:1073 [@media (max-width: 900px)] +.app.is-collapsed .user-trigger > svg { display: inline-flex } ← shell.css:1073 [@media (max-width: 900px)] +.app.is-collapsed .user-trigger { padding: 6px 8px } ← shell.css:1082 [@media (max-width: 900px)] +.liquid-metal::after { inset: -5px } ← shell.css:1248 +.liquid-metal::after { padding: 5px } ← shell.css:1249 ## Ghost rules — declared but always losing; edit the canonical rule instead, delete these in the refactor project (5) -.app.is-collapsed .nav-action { display: revert } at shell.css:1062 loses to shell.css:1069 -.app.is-collapsed .user-trigger > svg { display: revert } at shell.css:1062 loses to shell.css:1069 -.app.is-collapsed .user-trigger { padding: 0 10px } at shell.css:1074 loses to shell.css:1078 -.liquid-metal::after { inset: -2px } at shell.css:1200 loses to shell.css:1244 -.liquid-metal::after { padding: 2px } at shell.css:1202 loses to shell.css:1245 +.app.is-collapsed .nav-action { display: revert } at shell.css:1066 loses to shell.css:1073 +.app.is-collapsed .user-trigger > svg { display: revert } at shell.css:1066 loses to shell.css:1073 +.app.is-collapsed .user-trigger { padding: 0 10px } at shell.css:1078 loses to shell.css:1082 +.liquid-metal::after { inset: -2px } at shell.css:1204 loses to shell.css:1248 +.liquid-metal::after { padding: 2px } at shell.css:1206 loses to shell.css:1249 ## Component-loaded sheets — imported by components, not index.css; they load after the global styles (2) canvas.css ← pages/Topology.tsx diff --git a/docs/dev/guia.md b/docs/dev/guia.md index c2a0d942..0f46ec30 100644 --- a/docs/dev/guia.md +++ b/docs/dev/guia.md @@ -166,6 +166,7 @@ O dashboard é React 19 com react-router 7, Vite e Tailwind 4 ([ADR 0007](../adr - **Estilo:** tokens por papel em `src/styles/tokens.css` (fundos, fios, quatro níveis de texto, um acento azul, estados, portas e séries de gráfico, nos dois temas) e as classes de `base.css`/`shell.css`. Componentes usam os primitivos de `src/components/ui.tsx` e utilitários Tailwind ligados aos tokens (`bg-elev`, `text-muted`, `border-line`...); nada de hex nem de `dark:` para cor. - **Fluxo (React Flow):** `src/components/flow/` traz o palco (`FlowCanvas`), os nós no estilo do n8n (`source`: ponto de entrada com o texto dentro e o lado esquerdo mais arredondado; `node`: ícone dentro de um quadrado e o nome embaixo), as arestas (curvas ligando bolinha a bolinha, com seta só na entrada IP → harness, de tamanho fixo em pixels; pulsos quando há tráfego, tracejadas para fallback, vermelhas quando metade ou mais falhou; espessura pelo tráfego do último minuto, da base na ligação mais calma até 4x na mais movimentada) e o layout em colunas (`columnLayout`: uma coluna por tipo, a 396 px uma da outra, ordenada pelos pais para evitar cruzamentos; posições salvas têm prioridade; o arraste encaixa em meia grade, `DRAG_SNAP` = 12 px). Nos controles de zoom, o quarto botão alterna o arraste no espaço vazio entre mover o palco (`hand`, padrão) e selecionar vários nós com uma caixa para arrastá-los juntos (`square-dashed-mouse-pointer`; o botão do meio e o direito continuam movendo o palco); a seleção sobrevive à recarga dos dados. A página `/topology` usa o palco e carrega num chunk próprio. - **Topologia** (`pages/Topology.tsx` e `pages/topology-adapter.ts`): `GET /api/v1/topology?range=m|h|d` (1 a 120 min, 1 a 24 h ou 1 a 60 dias, `topologyMinutes`; fora disso vale 24h), `range=interval&from=…&to=…` (instantes ISO; conta as tentativas que começaram ou terminaram entre eles) ou `range=live`, que troca a janela pelas tentativas `pending` e as encerradas há até 3 s (`timestamp + duration_ms`; pendentes com mais de 1 h ficam de fora) e faz `recent` contar as que ainda aguardam resposta; os IPs vêm da tabela `requests`, gravada `pending` na entrada (`console/topology.service.ts`) devolve, só da organização ativa, o caminho da requisição: IPs dos clientes → harnesses → conexões de provedor (uma por conta: chave, assinatura ou servidor local; abaixo do ícone, o ícone do tipo no menu lateral) → perfis de proxy de saída (nó "Proxy Switch", ícone `waypoints`) → servidores de proxy (ícone `hand-platter`, nome e `host:porta`) → saída. A saída tem três nós redondos, por serem o fim da cadeia: "Internet" (`globe`), a internet à frente dos proxies; "Local Gateway" (`globe-lock`), o acesso direto da própria instância, usado por conexões sem proxy e pelo item Direct da cadeia de um perfil; e "Rede local", para servidores locais. Depois da saída vem a coluna **Destino** (`destinations`, nós `dest:`, ícone `map-pin`): um nó por hostname (com a porta quando não é a padrão, `urlHost`), compartilhado pelas contas que usam o mesmo host. O host vem da URL base da conta (`tenant_providers.base_url`), senão do `base_url` do custom provider ou servidor local, senão do endpoint embutido do provedor com a região (`defaultAccountBaseUrl`). Conexão sem proxy liga a sua saída ao destino; com perfil, as tentativas `direct` saem pelo Local Gateway e o resto pela Internet. O harness liga nas conexões que servem a rota principal e os fallbacks dos seus modelos virtuais habilitados (`keyLabel` fixa a conexão; sem ele, todas as ativas do provedor e tipo de autenticação); o resto (auto, tiers, especificidade) só aparece com tráfego na janela, salvo num harness sem tráfego, que mostra as rotas auto configuradas para nunca ficar sem ligação, atribuído pela `tenant_provider_id` de `agent_messages`. Cada aresta harness → conexão traz os modelos (`via`: modelo virtual ou `auto` → modelo final). A saída da conexão segue o `ProxyEgressService`: atribuição própria, senão o padrão do provedor (`:`, servidores locais pelo tipo), senão global; um perfil com itens vira o nó do perfil, e global com `THINKER_HTTP_PROXY` vira o nó "Global" (servidores locais nunca usam o global). O perfil liga em cada servidor de proxy da sua cadeia (um nó por servidor, compartilhado entre perfis), cada servidor liga na Internet, e o perfil liga no Local Gateway quando tem o item Direct; o nó Global liga direto na Internet. O tráfego de cada linha vem de onde cada tentativa saiu (`agent_messages.egress`: `direct` conta para o Local Gateway, `proxy` para o servidor em `proxy_id`); tentativas sem esse registro caem no primeiro servidor habilitado da cadeia. Hops que falharam (`egress_hops` com resultado diferente de `ok` e `skipped_disabled`) contam como erro no servidor e na ligação perfil → servidor, nunca na ligação servidor → Internet. Os IPs vêm de `caller_attribution.clientIp` (gravado pelo proxy via `common/utils/client-ip.ts`, com `TRUSTED_PROXIES`); IPv6 aparece expandido (sem compressão, com zeros à esquerda) em duas linhas de quatro grupos, em fonte menor para caber na largura do IPv4, e IPv4 mapeado aparece como IPv4. Os nós são arrastáveis: `PUT /api/v1/topology/positions` grava as posições por workspace na tabela `topology_positions` (papel `member`), o `GET` as devolve em `positions`, e `DELETE /api/v1/topology/positions` ("Reorganizar", botão `broom-sparkles` nos controles do palco, com confirmação num modal) volta ao layout em colunas. Cada nó e ligação traz também `recent`, as requisições do último minuto (fixo, qualquer que seja o período), que definem a espessura da linha; a página recarrega a cada 30 s para a espessura acompanhar. A borda dos nós fica azul com tráfego, vermelha com qualquer erro no período e neutra (tracejada) sem tráfego. Os nós de IP têm largura fixa (a do IPv4 mais longo, mais 10% e 5 px), com o ícone alinhado ao topo da primeira linha. Ligações sem tráfego na janela ficam cinza, tracejadas e semitransparentes ("Sem uso"). Métricas ficam no hover (nós e arestas) e na contagem sobre a aresta. A página recarrega a cada nova requisição (SSE), tem, alinhado à esquerda, o select "Filtro" (Recente: todo o diagrama no período; Tráfego: só nós e ligações com tráfego; Tempo real: todo o diagrama, com só as linhas de requisições em andamento acesas e a contagem das que aguardam resposta, consultando a cada 1 s e sem o slider; Intervalo: dois campos de data e hora com segundos, que no primeiro clique vêm do primeiro segundo do dia até agora; `thinker.topologyMode`), escolhe o período com um select de unidade (minutos, horas, dias) e um slider com 25% da largura da barra, guarda o período no navegador (`thinker.topologyRange`), na barra compacta acima do palco (sem texto de ajuda), e abre o objeto com clique duplo. +- **Cadeias de rotas no Roteamento** (`RouteChainCard.tsx`, `ModelChainManager.tsx`): os cards do `auto` (tier `default`, `pages/AutoChainCard.tsx`) e dos modelos virtuais são só leitura (fluxo de cima para baixo, conta e preço resumido). Toda edição fica na janela flutuante "Gerenciar": à esquerda o fluxo (principal e até 5 fallbacks, reordenável, com os parâmetros por modelo), à direita o catálogo agrupado por conta, com filtro de conta e busca por expressão regular. O arrasto usa eventos de ponteiro (`route-drag.ts`), não o drag-and-drop do HTML5. Cada salvamento grava o principal e depois os fallbacks; a janela ignora os recarregamentos intermediários. O `available-models` devolve `key_labels` (as contas que listam o modelo, por prioridade); escolher um modelo de um provedor com várias contas fixa a rota naquela conta. - **Demonstração da topologia:** `scripts/dev/topology-demo.sh` cria, num banco **local** de dev (padrão: contêiner `thinker-dev-prodcopy`), o workspace "Demo Topologia" com todas as combinações (clientes IPv4, IPv6 e IPv4 mapeado; harnesses com e sem tráfego ou rotas; modelos virtuais com fallback; rotas auto; contas por chave, assinatura, provedor custom e servidores locais; perfis só com proxies, com proxies e Direct, só Direct e vazio; atribuição por conta, herdada do padrão do provedor e sobrescrita para direto) e gera requisições, tentativas, logs e estatísticas de proxy relativos ao horário atual. `--live` continua gerando tráfego a cada 10 s; `--remove` apaga o workspace. O backend guarda em cache os workspaces de cada usuário, então reinicie-o depois de criar o workspace para ele aparecer. ### Traduções do dashboard (#42) diff --git a/docs/releases/0.32.0.md b/docs/releases/0.32.0.md new file mode 100644 index 00000000..313837ea --- /dev/null +++ b/docs/releases/0.32.0.md @@ -0,0 +1,9 @@ +# Thinker 0.32.0 + +```bash +docker pull tmsoftbrasil/thinker:0.32.0 +``` + +## Mudanças + +- feat(dashboard): manage route chains in a floating window with per-account model catalog diff --git a/package-lock.json b/package-lock.json index 4c93d630..a5355c68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14768,7 +14768,7 @@ } }, "packages/thinker": { - "version": "0.31.9" + "version": "0.32.0" } } } diff --git a/packages/backend/src/model-discovery/model-discovery.service.ts b/packages/backend/src/model-discovery/model-discovery.service.ts index d32d17cb..457f8601 100644 --- a/packages/backend/src/model-discovery/model-discovery.service.ts +++ b/packages/backend/src/model-discovery/model-discovery.service.ts @@ -616,7 +616,9 @@ export class ModelDiscoveryService { const models: DiscoveredModel[] = []; const seen = new Map(); - for (const p of providers) { + // Priority order, so each model's account list starts with the primary key. + const byPriority = [...providers].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)); + for (const p of byPriority) { if (p.provider.startsWith('custom:')) continue; const rawCached = p.cached_models; if (!Array.isArray(rawCached)) continue; @@ -634,9 +636,18 @@ export class ModelDiscoveryService { // providers can expose the same native model name, and the picker must // keep each provider-specific route selectable. const dedupeKey = `${providerId}::${effectiveAuthType}::${m.id}`; - if (!seen.has(dedupeKey)) { + const existing = seen.get(dedupeKey); + if (existing === undefined) { seen.set(dedupeKey, models.length); - models.push({ ...m, provider: p.provider, authType: effectiveAuthType }); + models.push({ + ...m, + provider: p.provider, + authType: effectiveAuthType, + keyLabels: [p.label], + }); + } else { + const labels = models[existing]!.keyLabels; + if (labels && !labels.includes(p.label)) labels.push(p.label); } } } diff --git a/packages/backend/src/model-discovery/model-fetcher.ts b/packages/backend/src/model-discovery/model-fetcher.ts index a62ec27d..a0e27eed 100644 --- a/packages/backend/src/model-discovery/model-fetcher.ts +++ b/packages/backend/src/model-discovery/model-fetcher.ts @@ -80,6 +80,8 @@ export interface DiscoveredModel { supportedEndpoints?: readonly string[]; qualityScore: number; authType?: AuthType; + /** Labels of the connections (accounts) whose cached list holds this model, by priority. */ + keyLabels?: string[]; /** Custom providers only: the display name the user gave the provider. */ providerName?: string; /** diff --git a/packages/backend/src/routing/model.controller.ts b/packages/backend/src/routing/model.controller.ts index 7c27015c..3099364e 100644 --- a/packages/backend/src/routing/model.controller.ts +++ b/packages/backend/src/routing/model.controller.ts @@ -161,6 +161,7 @@ export class ModelController { model_name: m.id, provider: m.provider, auth_type: authType, + ...(m.keyLabels ? { key_labels: m.keyLabels } : {}), input_price_per_token: m.inputPricePerToken, output_price_per_token: m.outputPricePerToken, context_window: m.contextWindow, diff --git a/packages/frontend/src/components/ModelChainManager.tsx b/packages/frontend/src/components/ModelChainManager.tsx new file mode 100644 index 00000000..a5845612 --- /dev/null +++ b/packages/frontend/src/components/ModelChainManager.tsx @@ -0,0 +1,432 @@ +import { useEffect, useRef, useState } from 'react'; +import { ArrowDown, ChevronDown, ChevronUp, GripVertical, Route, X } from 'lucide-react'; +import type { + AuthType, + AvailableModel, + CustomProviderData, + ModelCapability, + ModelRoute, + RequestParamDefaults, + RoutingProvider, +} from '../services/api.js'; +import { PROVIDERS } from '../services/providers.js'; +import { customProviderColor } from '../services/formatters.js'; +import { providerIcon, customProviderLogo } from './ProviderIcon.js'; +import { authBadgeFor } from './AuthBadge.js'; +import ModelPickerModal from './ModelPickerModal.js'; +import ModelParamsAffordance from './ModelParamsAffordance.jsx'; +import { AuthIcon, routeAccountLabel } from './RouteAccount.js'; +import { beginRouteDrag, registerRouteDropZone, type RouteDragPayload } from './route-drag.js'; +import { toast } from '../services/toast-store.js'; +import { t } from '../i18n/index.js'; +import { FloatingWindow, cx } from './ui.js'; + +/** Primary + 5 fallbacks. */ +const MAX_ROUTES = 6; + +export interface ModelChainManagerProps { + agentName: string; + chainId: string; + chainName: string; + /** Current chain: primary first, then the fallbacks. */ + routes: ModelRoute[]; + models: AvailableModel[]; + customProviders: CustomProviderData[]; + connectedProviders: RoutingProvider[]; + requiredCapability?: ModelCapability; + /** Persists the whole chain; an empty list clears it. */ + onSave: (routes: ModelRoute[]) => Promise; + onClose: () => void; + /** Model params scope of the chain (`header:`, `virtual:`, tier). */ + paramsScope?: string; + /** Per-route request params; each flow row gets the params affordance when set. */ + getModelParams?: ( + scope: string, + provider: string, + authType: AuthType, + model: string, + ) => RequestParamDefaults | null; + setModelParams?: ( + scope: string, + provider: string, + authType: AuthType, + model: string, + params: RequestParamDefaults | null, + ) => Promise; +} + +/** Comparable form of a chain: what the server stores for each route. */ +const chainKey = (routes: ModelRoute[]): string => + routes + .map((r) => `${r.provider.toLowerCase()}|${r.authType}|${r.model}|${r.keyLabel ?? ''}`) + .join('\n'); + +const sameRoute = (a: ModelRoute, b: ModelRoute): boolean => + a.model === b.model && + a.provider.toLowerCase() === b.provider.toLowerCase() && + a.authType === b.authType && + (a.keyLabel ?? '') === (b.keyLabel ?? ''); + +/** + * Non-modal window to build a route chain: the flow (primary on top, then the + * fallbacks) on the left, the model catalog on the right. Models are dragged + * from the catalog into any gap of the flow; flow rows are dragged to reorder. + * Every change is saved at once. + */ +export default function ModelChainManager(props: ModelChainManagerProps) { + const [routes, setRoutes] = useState(props.routes); + const [dropIndex, setDropIndex] = useState(null); + const listRef = useRef(null); + const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle'); + + /** + * Chain this window saved and is waiting to see back from the server. One + * save is two requests (primary, then fallbacks) and the page reloads after + * each, so the props pass through a half-saved chain; following them made + * the rows flicker. While a chain is pending, reloads that differ from it + * are ignored; the first one that matches ends the wait. + */ + const pendingRef = useRef<{ key: string; settled: boolean } | null>(null); + const latestPropsRef = useRef(props.routes); + latestPropsRef.current = props.routes; + const settleTimer = useRef | null>(null); + useEffect( + () => () => { + if (settleTimer.current) clearTimeout(settleTimer.current); + }, + [], + ); + + // Follow the saved chain when the page reloads it. + const propsKey = chainKey(props.routes); + useEffect(() => { + const pending = pendingRef.current; + if (pending) { + if (pending.key !== propsKey) return; + if (pending.settled) pendingRef.current = null; + return; + } + setRoutes(props.routes); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [propsKey]); + + const commit = async (next: ModelRoute[]) => { + const previous = routes; + const key = chainKey(next); + pendingRef.current = { key, settled: false }; + if (settleTimer.current) clearTimeout(settleTimer.current); + setRoutes(next); + setStatus('saving'); + try { + await props.onSave(next); + if (pendingRef.current?.key !== key) return; // a newer change took over + setStatus('saved'); + if (chainKey(latestPropsRef.current) === key) { + pendingRef.current = null; + return; + } + pendingRef.current = { key, settled: true }; + // If the server stores the chain differently, stop waiting and show its version. + settleTimer.current = setTimeout(() => { + if (pendingRef.current?.key !== key) return; + pendingRef.current = null; + setRoutes(latestPropsRef.current); + }, 4000); + } catch { + if (pendingRef.current?.key !== key) return; + pendingRef.current = null; + // The API error is already on screen; show the saved chain again. + setRoutes(previous); + setStatus('failed'); + } + }; + + const insertRoute = (route: ModelRoute, index: number) => { + if (routes.some((r) => sameRoute(r, route))) { + toast.error(t('routing.manager.duplicate')); + return; + } + if (routes.length >= MAX_ROUTES) { + toast.error(t('routing.manager.full')); + return; + } + const next = [...routes]; + next.splice(Math.min(index, next.length), 0, route); + void commit(next); + }; + + const moveRoute = (from: number, to: number) => { + // `to` is a gap index (0..length); removing `from` shifts later gaps. + const target = to > from ? to - 1 : to; + if (target === from) return; + const next = [...routes]; + const [item] = next.splice(from, 1); + next.splice(target, 0, item!); + void commit(next); + }; + + const removeRoute = (index: number) => { + void commit(routes.filter((_, i) => i !== index)); + }; + + /** + * Gap the pointer points at: the number of flow items whose middle is above + * it. The whole flow column is one drop target, so dropping on an item (its + * upper or lower half) works as well as dropping on a gap. + */ + const gapAt = (clientY: number): number => { + const items = listRef.current?.querySelectorAll('[data-flow-item]') ?? []; + let index = 0; + for (const el of items) { + const rect = el.getBoundingClientRect(); + if (clientY > rect.top + rect.height / 2) index += 1; + } + return index; + }; + + const handleDrop = (payload: RouteDragPayload, clientY: number) => { + const index = gapAt(clientY); + setDropIndex(null); + if (payload.kind === 'flow') moveRoute(payload.index, index); + else insertRoute(payload.route, index); + }; + + // The zone is registered once; the ref always points at this render's handlers. + const dropRef = useRef(handleDrop); + dropRef.current = handleDrop; + useEffect(() => { + const el = listRef.current; + if (!el) return; + return registerRouteDropZone({ + el, + onHover: (clientY) => setDropIndex(clientY === null ? null : gapAt(clientY)), + onDrop: (payload, clientY) => dropRef.current(payload, clientY), + }); + }, []); + + const modelInfo = (r: ModelRoute): AvailableModel | undefined => + props.models.find( + (m) => m.model_name === r.model && m.provider.toLowerCase() === r.provider.toLowerCase(), + ) ?? props.models.find((m) => m.model_name === r.model); + + const providerName = (provId: string): string => { + if (provId.startsWith('custom:')) { + return props.customProviders.find((c) => `custom:${c.id}` === provId)?.name ?? provId; + } + return PROVIDERS.find((p) => p.id === provId)?.name ?? provId; + }; + + const authText = (a: AuthType): string => + a === 'subscription' + ? t('models.picker.subscription') + : a === 'local' + ? t('models.picker.local') + : t('models.picker.usageBased'); + + const accountText = (r: ModelRoute): string => routeAccountLabel(r, props.connectedProviders); + + const renderIcon = (r: ModelRoute) => { + const provId = r.provider.toLowerCase(); + if (provId.startsWith('custom:')) { + const name = providerName(provId); + return ( + customProviderLogo(name, 24) ?? ( + + {name.charAt(0).toUpperCase()} + + ) + ); + } + return ( + + {providerIcon(provId, 24)} + {authBadgeFor(r.authType, 10)} + + ); + }; + + /** A gap of the flow: an arrow at rest, an accent line where a drop lands. Fixed height, so + * highlighting it never shifts the items under the pointer. */ + const renderGap = (index: number, arrow: boolean) => ( +
+ {dropIndex === index ? ( + + ) : arrow ? ( +
+ ); + + const flow = ( +
+
+

{t('routing.manager.flow')}

+

{t('routing.manager.flowHint')}

+
+
+ {routes.length === 0 ? ( +
+ {t('routing.manager.dropEmpty')} +
+ ) : ( + <> + {renderGap(0, false)} + {routes.map((r, i) => { + const info = modelInfo(r); + const label = info?.display_name ?? r.model; + const provId = r.provider.toLowerCase(); + return ( +
+
{ + // The row's own buttons (move, remove) keep their clicks. + if ((e.target as HTMLElement).closest('button')) return; + beginRouteDrag(e, { kind: 'flow', index: i }, label); + }} + > +
+ {renderGap(i + 1, i < routes.length - 1)} +
+ ); + })} + + )} +
+
+ ); + + return ( + + ); +} diff --git a/packages/frontend/src/components/ModelPickerModal.tsx b/packages/frontend/src/components/ModelPickerModal.tsx index e9bfc3af..f1a7af5f 100644 --- a/packages/frontend/src/components/ModelPickerModal.tsx +++ b/packages/frontend/src/components/ModelPickerModal.tsx @@ -23,6 +23,7 @@ import ModelCapabilityBadges, { ModelModalityBadges, } from './ModelCapabilityBadges.js'; import VirtualList from './VirtualList.js'; +import { beginRouteDrag } from './route-drag.js'; import { Button, Modal, Tabs, cx, type TabItem } from './ui.js'; const PICKER_NARROW_QUERY = '(max-width: 640px)'; @@ -31,10 +32,20 @@ const PICKER_MODEL_ROW_HEIGHT = 40; /** Stacked label + four cells + gaps + padding in the ≤640px layout. */ const PICKER_MODEL_ROW_HEIGHT_NARROW = 160; -type ModalModel = { value: string; label: string; pricing: AvailableModel; authType: AuthType }; +type ModalModel = { + value: string; + label: string; + pricing: AvailableModel; + authType: AuthType; + /** Account the row was listed by; '' when the connection has no label. */ + keyLabel: string; + /** More than one account of this provider and billing: the route pins `keyLabel`. */ + multiKey: boolean; +}; type PickerItem = - | { kind: 'header'; provId: string; name: string; authType: AuthType } + | { kind: 'header'; provId: string; name: string; authType: AuthType; keyLabel: string } | { kind: 'model'; provId: string; name: string; model: ModalModel }; + /** Picker tab: one auth category, or every connected provider at once. */ type PickerTab = AuthType | 'all'; @@ -52,8 +63,18 @@ interface Props { tiers: TierAssignment[]; customProviders?: CustomProviderData[]; connectedProviders?: RoutingProvider[]; - onSelect: (tierId: string, modelName: string, providerId: string, authType?: AuthType) => void; + onSelect: ( + tierId: string, + modelName: string, + providerId: string, + authType?: AuthType, + keyLabel?: string, + ) => void; onClose: () => void; + /** Render only the picker body, without the modal (e.g. inside a floating window). */ + embedded?: boolean; + /** Rows can be dragged out to a registered route drop zone (see `route-drag.ts`). */ + draggableRows?: boolean; onConnectProviders?: () => void; onProviderRefreshed?: () => void | Promise; requiredCapability?: ModelCapability; @@ -85,6 +106,23 @@ function labelForModel( return name; } +/** + * Search text as a case-insensitive regular expression (`^gpt-5`, `flash|pro`). + * Text that is not a valid expression yet (e.g. `gpt(` mid-typing) falls back + * to a plain substring match. Null when the search is empty. + */ +export function searchMatcher(raw: string): ((text: string) => boolean) | null { + const q = raw.trim(); + if (!q) return null; + try { + const re = new RegExp(q, 'i'); + return (text) => re.test(text); + } catch { + const needle = q.toLowerCase(); + return (text) => text.toLowerCase().includes(needle); + } +} + const isFreeModel = (m: AvailableModel): boolean => m.input_price_per_token != null && m.output_price_per_token != null && @@ -147,6 +185,8 @@ export default function ModelPickerModal(props: Props) { return 'api_key'; }); const [search, setSearch] = useState(''); + /** Connection id of the account filter; '' = every account. */ + const [accountId, setAccountId] = useState(''); const [showFreeOnly, setShowFreeOnly] = useState(false); /** Required capabilities: only show models that have ALL of these. */ const [requiredCapabilities, setRequiredCapabilities] = useState>(() => @@ -236,8 +276,42 @@ export default function ModelPickerModal(props: Props) { } }; + /** Display name of a provider id (built-in or custom). */ + const providerName = (provId: string): string => { + if (provId.startsWith('custom:')) { + const cp = (props.customProviders ?? []).find((c) => `custom:${c.id}` === provId); + return cp?.name ?? provId; + } + return PROVIDERS.find((p) => p.id === provId)?.name ?? provId; + }; + const authText = (a: AuthType): string => + a === 'subscription' + ? t('models.picker.subscription') + : a === 'local' + ? t('models.picker.local') + : t('models.picker.usageBased'); + + /** Usable connections, as account filter options (scoped to the active tab). */ + const accountOptions = useMemo(() => { + return connected + .filter((c) => isUsable(c) && (activeTab === 'all' || c.auth_type === activeTab)) + .map((c) => { + const provId = resolveProviderId(c.provider) ?? c.provider.toLowerCase(); + return { + id: c.id, + provId, + authType: c.auth_type, + label: c.label, + text: `${providerName(provId)} · ${authText(c.auth_type)} · ${c.label}`, + }; + }) + .sort((a, b) => a.text.localeCompare(b.text)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.connectedProviders, props.customProviders, activeTab]); + const selectedAccount = accountOptions.find((o) => o.id === accountId) ?? null; + const groupedModels = useMemo(() => { - const q = search.toLowerCase().trim(); + const match = searchMatcher(search); const labels = new Map(); for (const prov of PROVIDERS) { for (const m of prov.models) labels.set(m.value, m.label); @@ -272,10 +346,30 @@ export default function ModelPickerModal(props: Props) { return conn?.auth_type ?? 'api_key'; }; - type Group = { provId: string; name: string; authType: AuthType; models: ModalModel[] }; + type Group = { + provId: string; + name: string; + authType: AuthType; + keyLabel: string; + order: number; + models: ModalModel[]; + }; const groupMap = new Map(); const freeOnly = tab === 'api_key' && showFreeOnly; + /** `auth\0provider` → active account labels, by priority. */ + const accountLabels = new Map(); + for (const c of [...connectedList].sort((a, b) => a.priority - b.priority)) { + if (!c.is_active) continue; + for (const id of new Set([c.provider.toLowerCase(), resolveProviderId(c.provider)])) { + if (!id) continue; + const key = `${c.auth_type}\0${id}`; + const list = accountLabels.get(key) ?? []; + if (!list.includes(c.label)) list.push(c.label); + accountLabels.set(key, list); + } + } + for (const m of props.models) { // Filter by the model's own auth_type to prevent subscription models // from leaking into the API Keys tab and vice versa (e.g. when the @@ -312,20 +406,47 @@ export default function ModelPickerModal(props: Props) { if (!provId) continue; const authType = authFor(m, provId); if (allowedProviders && !allowedProviders.has(`${authType}\0${provId}`)) continue; - const groupKey = `${authType}\0${provId}`; - if (!groupMap.has(groupKey)) { - const isCustom = provId.startsWith('custom:'); - const provDef = PROVIDERS.find((p) => p.id === provId); - const name = isCustom - ? (m.provider_display_name ?? cpNames.get(provId) ?? m.provider) - : (provDef?.name ?? m.provider); - groupMap.set(groupKey, { provId, name, authType, models: [] }); - } + if ( + selectedAccount && + (selectedAccount.authType !== authType || selectedAccount.provId !== provId) + ) + continue; + const providerAccounts = accountLabels.get(`${authType}\0${provId}`) ?? []; + // One row per account that lists the model (the backend's key_labels), + // so two accounts of the same provider never blur into one group. + const modelAccounts = + m.key_labels && m.key_labels.length > 0 + ? m.key_labels.filter( + (l) => providerAccounts.length === 0 || providerAccounts.includes(l), + ) + : providerAccounts; + const accounts = modelAccounts.length > 0 ? modelAccounts : ['']; + const multiKey = providerAccounts.length > 1; const label = m.display_name && m.display_name !== m.model_name ? m.display_name : labelForModel(m.model_name, labels, providerIds); - groupMap.get(groupKey)!.models.push({ value: m.model_name, label, pricing: m, authType }); + for (const keyLabel of accounts) { + if (selectedAccount && selectedAccount.label !== keyLabel) continue; + const groupKey = `${authType}\0${provId}\0${keyLabel}`; + if (!groupMap.has(groupKey)) { + const isCustom = provId.startsWith('custom:'); + const provDef = PROVIDERS.find((p) => p.id === provId); + const name = isCustom + ? (m.provider_display_name ?? cpNames.get(provId) ?? m.provider) + : (provDef?.name ?? m.provider); + const order = providerAccounts.indexOf(keyLabel); + groupMap.set(groupKey, { provId, name, authType, keyLabel, order, models: [] }); + } + groupMap.get(groupKey)!.models.push({ + value: m.model_name, + label, + pricing: m, + authType, + keyLabel, + multiKey, + }); + } } const groups: Group[] = []; @@ -335,21 +456,23 @@ export default function ModelPickerModal(props: Props) { if (b.value === 'openrouter/free') return 1; return a.label.localeCompare(b.label); }); - if (q) { - const nameMatch = group.name.toLowerCase().includes(q); + if (match) { + const nameMatch = match(group.name) || (group.keyLabel !== '' && match(group.keyLabel)); const filtered = nameMatch ? group.models - : group.models.filter( - (m) => m.label.toLowerCase().includes(q) || m.value.toLowerCase().includes(q), - ); + : group.models.filter((m) => match(m.label) || match(m.value)); if (filtered.length > 0) groups.push({ ...group, models: filtered }); } else if (group.models.length > 0) { groups.push(group); } } - groups.sort((a, b) => a.name.localeCompare(b.name) || a.authType.localeCompare(b.authType)); + groups.sort( + (a, b) => + a.name.localeCompare(b.name) || a.authType.localeCompare(b.authType) || a.order - b.order, + ); return groups; }, [ + selectedAccount, search, activeTab, showFreeOnly, @@ -368,6 +491,7 @@ export default function ModelPickerModal(props: Props) { provId: group.provId, name: group.name, authType: group.authType, + keyLabel: group.keyLabel, }); for (const model of group.models) { items.push({ kind: 'model', provId: group.provId, name: group.name, model }); @@ -383,7 +507,7 @@ export default function ModelPickerModal(props: Props) { ? PICKER_MODEL_ROW_HEIGHT_NARROW : PICKER_MODEL_ROW_HEIGHT; - const pickerResetKey = `${activeTab}\0${search}\0${showFreeOnly}\0${[...requiredCapabilities].sort().join(',')}`; + const pickerResetKey = `${activeTab}\0${accountId}\0${search}\0${showFreeOnly}\0${[...requiredCapabilities].sort().join(',')}`; /** Returns the role of a model in the current tier: "Primary", "Fallback 1", etc. or null */ const modelRole = ( @@ -539,7 +663,32 @@ export default function ModelPickerModal(props: Props) { )} disabled={disabled} title={tooltip} - onClick={() => props.onSelect(props.tierId, model.value, provId, model.authType)} + onPointerDown={ + props.draggableRows && !disabled + ? (e) => + beginRouteDrag( + e, + { + kind: 'route', + route: { + model: model.value, + provider: provId, + authType: model.authType, + ...(model.multiKey && model.keyLabel ? { keyLabel: model.keyLabel } : {}), + }, + }, + model.keyLabel ? `${model.label} · ${model.keyLabel}` : model.label, + ) + : undefined + } + onClick={() => { + // The account goes along only when it picks one of several keys. + if (model.multiKey && model.keyLabel) { + props.onSelect(props.tierId, model.value, provId, model.authType, model.keyLabel); + } else { + props.onSelect(props.tierId, model.value, provId, model.authType); + } + }} > @@ -603,6 +752,179 @@ export default function ModelPickerModal(props: Props) { ); }; + const body = ( +
+ {showTabs ? ( + { + setActiveTab(key); + if (key !== 'api_key') setShowFreeOnly(false); + }} + /> + ) : null} + + {showSearch || accountOptions.length > 1 ? ( +
+
+
+ +
+ ) : null} + +
+
+ {isPaid ? ( + + ) : null} +
+
+ {availableCapabilities.map((cap) => ( + + ))} +
+
+ + {groupedModels.length > 0 ? ( + + ) : null} + + {pickerItems.length > 0 ? ( + + {(item) => + item.kind === 'header' ? ( +
+ + {renderGroupIcon(item.provId, item.name)} + + {item.name} + {isAll ? ( + + {authText(item.authType)} + + ) : null} + {item.keyLabel ? ( + + + ) : null} + {props.agentName && !item.provId.startsWith('custom:') ? ( + + ) : null} +
+ ) : ( + renderModelRow(item.provId, item.model) + ) + } +
+ ) : ( +
+
+

{emptyMessage}

+ {!search.trim() && !showFreeOnly && props.onConnectProviders ? ( + + ) : null} +
+
+ )} +
+ ); + + if (props.embedded) return body; return ( } > -
- {showTabs ? ( - { - setActiveTab(key); - if (key !== 'api_key') setShowFreeOnly(false); - }} - /> - ) : null} - - {showSearch ? ( -
-
- ) : null} - -
-
- {isPaid ? ( - - ) : null} -
-
- {availableCapabilities.map((cap) => ( - - ))} -
-
- - {groupedModels.length > 0 ? ( - - ) : null} - - {pickerItems.length > 0 ? ( - - {(item) => - item.kind === 'header' ? ( -
- - {renderGroupIcon(item.provId, item.name)} - - {item.name} - {isAll ? ( - - {item.authType === 'subscription' - ? t('models.picker.subscription') - : item.authType === 'local' - ? t('models.picker.local') - : t('models.picker.usageBased')} - - ) : null} - {props.agentName && !item.provId.startsWith('custom:') ? ( - - ) : null} -
- ) : ( - renderModelRow(item.provId, item.model) - ) - } -
- ) : ( -
-
-

{emptyMessage}

- {!search.trim() && !showFreeOnly && props.onConnectProviders ? ( - - ) : null} -
-
- )} -
+ {body}
); } diff --git a/packages/frontend/src/components/RouteAccount.tsx b/packages/frontend/src/components/RouteAccount.tsx new file mode 100644 index 00000000..17349a47 --- /dev/null +++ b/packages/frontend/src/components/RouteAccount.tsx @@ -0,0 +1,22 @@ +import { KeyRound, Server, User } from 'lucide-react'; +import type { AuthType, ModelRoute, RoutingProvider } from '../services/api.js'; +import { t } from '../i18n/index.js'; + +/** Billing type icon, as on the model picker tabs. */ +export function AuthIcon({ authType }: { authType: AuthType }) { + const Icon = authType === 'subscription' ? User : authType === 'local' ? Server : KeyRound; + return
- {currentModel ? null : ( - - )} + {props.details} -
- {currentModel ? ( -
setPickerMode('primary')} - > -
-
- {renderOverrideIcon()} - - {modelLabel || currentModel} - -
-
- {primaryKeys.length > 1 ? ( - - ) : null} - {props.getModelParams && - props.setModelParams && - providerId && - effectiveAuth && - effectiveAuth !== 'local' ? ( - - ) : null} - -
-
-
- - - {effectiveAuth === 'subscription' - ? (formatPerRequestCost(modelInfo?.cost_per_request) ?? - t('routing.includedInSubscription')) - : priceLabel} - - {skippedBadge} - {routeNotInCatalog(props.chain.override_route, props.models) ? ( - - ) : null} - -
-
- ) : null} -
- - {currentModel ? ( -
- props.onFallbacksUpdate(updated, updatedRoutes)} - onAddFallback={() => setPickerMode('fallback')} - persistFallbacks={(_agent, _id, models, routes) => - props.persistFallbacks(models, routes) - } - getModelParams={props.getModelParams} - setModelParams={props.setModelParams} - modelParamsScope={props.paramsScope} - responseMode={props.chain.response_mode ?? 'buffered'} - persistClearFallbacks={() => props.persistClearFallbacks()} - /> + {routes.length > 0 ? ( +
+ {routes.map(renderRoute)}
) : null} - {pickerMode !== null ? ( - setPickerMode(null)} - onSelect={handlePickerSelect} - /> - ) : null} - - {pendingKeyPick ? ( - setPendingKeyPick(null)} + requiredCapability={isStreamMode ? 'stream' : undefined} + onSave={saveChain} + onClose={() => setManagerOpen(false)} + paramsScope={props.paramsScope} + getModelParams={props.getModelParams} + setModelParams={props.setModelParams} /> ) : null}
diff --git a/packages/frontend/src/components/VirtualModelCard.tsx b/packages/frontend/src/components/VirtualModelCard.tsx index 82e66ff5..193ff5b7 100644 --- a/packages/frontend/src/components/VirtualModelCard.tsx +++ b/packages/frontend/src/components/VirtualModelCard.tsx @@ -8,6 +8,7 @@ import type { RoutingProvider, } from '../services/api.js'; import { + clearVirtualModelOverride, clearVirtualModelFallbacks, setVirtualModelFallbacks, type VirtualModel, @@ -77,6 +78,7 @@ export default function VirtualModelCard(props: VirtualModelCardProps) { setVirtualModelFallbacks(props.agentName, vm.id, models, routes) } persistClearFallbacks={() => clearVirtualModelFallbacks(props.agentName, vm.id)} + persistClearChain={() => clearVirtualModelOverride(props.agentName, vm.id)} paramsScope={modelParamsScopeForVirtualModel(vm.id)} getModelParams={props.getModelParams} setModelParams={props.setModelParams} diff --git a/packages/frontend/src/components/route-drag.ts b/packages/frontend/src/components/route-drag.ts new file mode 100644 index 00000000..ddaa4469 --- /dev/null +++ b/packages/frontend/src/components/route-drag.ts @@ -0,0 +1,136 @@ +import type { PointerEvent as ReactPointerEvent } from 'react'; +import type { AuthType } from '../services/api.js'; + +/** + * Pointer-based drag for route chains (model manager). Native HTML5 + * drag-and-drop proved unreliable here (the drop never landed even though the + * target highlighted), so a drag is tracked with plain pointer events: press, + * move past a small threshold, release over the registered drop zone. + */ + +/** A catalog model being added, or a flow row being moved. */ +export type RouteDragPayload = + | { + kind: 'route'; + route: { model: string; provider: string; authType: AuthType; keyLabel?: string }; + } + | { kind: 'flow'; index: number }; + +export interface RouteDropZone { + el: HTMLElement; + /** Pointer Y while it is over the zone, or null when it leaves / the drag ends. */ + onHover: (clientY: number | null, payload: RouteDragPayload) => void; + onDrop: (payload: RouteDragPayload, clientY: number) => void; +} + +const THRESHOLD_PX = 5; +let zone: RouteDropZone | null = null; + +/** Registers the drop zone; returns the unregister function. */ +export function registerRouteDropZone(z: RouteDropZone): () => void { + zone = z; + return () => { + if (zone === z) zone = null; + }; +} + +const insideZone = (x: number, y: number): boolean => { + if (!zone) return false; + const r = zone.el.getBoundingClientRect(); + return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; +}; + +/** + * Call from `onPointerDown`. A press that never moves past the threshold stays + * a plain click; once it does, a label follows the pointer and the click that + * the release would fire is swallowed. + */ +export function beginRouteDrag( + event: ReactPointerEvent, + payload: RouteDragPayload, + label: string, +): void { + if (event.button !== 0) return; + const startX = event.clientX; + const startY = event.clientY; + let ghost: HTMLDivElement | null = null; + let hovering = false; + + const place = (x: number, y: number) => { + if (ghost) ghost.style.transform = `translate(${x + 12}px, ${y + 12}px)`; + }; + + const start = () => { + ghost = document.createElement('div'); + ghost.className = 'route-drag-ghost badge is-accent'; + ghost.textContent = label; + Object.assign(ghost.style, { + position: 'fixed', + left: '0', + top: '0', + zIndex: '1000', + pointerEvents: 'none', + maxWidth: '280px', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + boxShadow: '0 4px 14px rgb(0 0 0 / 0.25)', + }); + document.body.appendChild(ghost); + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'grabbing'; + }; + + const onMove = (e: PointerEvent) => { + if (!ghost) { + if (Math.hypot(e.clientX - startX, e.clientY - startY) < THRESHOLD_PX) return; + start(); + } + place(e.clientX, e.clientY); + const over = insideZone(e.clientX, e.clientY); + if (over) zone?.onHover(e.clientY, payload); + else if (hovering) zone?.onHover(null, payload); + hovering = over; + }; + + const cleanup = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onCancel); + window.removeEventListener('keydown', onKey); + if (ghost) { + ghost.remove(); + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + } + zone?.onHover(null, payload); + }; + + const swallowNextClick = () => { + const stop = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }; + window.addEventListener('click', stop, { capture: true, once: true }); + // A release outside any clickable element fires no click: drop the guard. + setTimeout(() => window.removeEventListener('click', stop, { capture: true }), 0); + }; + + const onUp = (e: PointerEvent) => { + const dragged = ghost !== null; + const target = dragged && insideZone(e.clientX, e.clientY) ? zone : null; + cleanup(); + if (!dragged) return; + swallowNextClick(); + target?.onDrop(payload, e.clientY); + }; + const onCancel = () => cleanup(); + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') cleanup(); + }; + + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onCancel); + window.addEventListener('keydown', onKey); +} diff --git a/packages/frontend/src/components/ui.tsx b/packages/frontend/src/components/ui.tsx index 194efeb0..770172a5 100644 --- a/packages/frontend/src/components/ui.tsx +++ b/packages/frontend/src/components/ui.tsx @@ -493,7 +493,8 @@ export function FloatingWindow({ return; } const onKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') onClose(); + // A modal opened from the window takes Escape for itself. + if (event.key === 'Escape' && !document.querySelector('.overlay')) onClose(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); diff --git a/packages/frontend/src/i18n/locales/en/models.ts b/packages/frontend/src/i18n/locales/en/models.ts index f43bc475..c3e092d3 100644 --- a/packages/frontend/src/i18n/locales/en/models.ts +++ b/packages/frontend/src/i18n/locales/en/models.ts @@ -122,6 +122,11 @@ export default { included: 'Included in subscription', priceInOut: '{{ input }} in · {{ output }} out', unavailable: '{{ capability }} unavailable', + allAccounts: 'All accounts', + searchRegexPlaceholder: 'Search models, providers or accounts (regex)...', + searchRegexHint: 'Accepts regular expressions, case-insensitive. E.g. ^gpt-5, flash|pro, free$', + accountFilter: 'Filter by account', + account: 'Account: {{ label }}', }, vm: { nameTaken: 'Another virtual model on this harness already uses this name', diff --git a/packages/frontend/src/i18n/locales/en/routing.ts b/packages/frontend/src/i18n/locales/en/routing.ts index 95bbc7b1..5d3b05ce 100644 --- a/packages/frontend/src/i18n/locales/en/routing.ts +++ b/packages/frontend/src/i18n/locales/en/routing.ts @@ -13,6 +13,27 @@ export default { defaultSubtitle: 'Pick one model and up to 5 fallbacks as your default routing.', reset: 'Reset', addModel: '+ Add model', + manageModel: 'Manage', + cardPrice: '{{ input }} in, {{ output }} out', + cardSubscription: 'Subscription', + manager: { + title: 'Manage model: {{ name }}', + flow: 'Route flow', + flowHint: 'Requests try each model from top to bottom.', + catalog: 'Available models', + catalogHint: 'Drag a model to the flow, or click it to add it at the end.', + dropEmpty: 'Drag a model here to set the primary', + dropHere: 'Drop here', + remove: 'Remove {{ name }}', + moveUp: 'Move up', + moveDown: 'Move down', + full: 'The flow already has a primary and 5 fallbacks', + duplicate: 'This model and account are already in the flow', + autoKey: 'any account (by priority)', + saving: 'Saving…', + saved: 'Saved', + saveFailed: 'Could not save the flow', + }, change: 'Change', changeModelFor: 'Change model for {{ name }}', auto: 'auto', diff --git a/packages/frontend/src/i18n/locales/pt-BR/models.ts b/packages/frontend/src/i18n/locales/pt-BR/models.ts index 4fb3fea8..be6d1e95 100644 --- a/packages/frontend/src/i18n/locales/pt-BR/models.ts +++ b/packages/frontend/src/i18n/locales/pt-BR/models.ts @@ -126,6 +126,12 @@ const ptBR: Catalog = { included: 'Incluído na assinatura', priceInOut: '{{ input }} entrada · {{ output }} saída', unavailable: '{{ capability }} indisponível', + allAccounts: 'Todas as contas', + searchRegexPlaceholder: 'Buscar modelos, provedores ou contas (regex)...', + searchRegexHint: + 'Aceita expressões regulares, sem diferenciar maiúsculas. Ex.: ^gpt-5, flash|pro, free$', + accountFilter: 'Filtrar por conta', + account: 'Conta: {{ label }}', }, vm: { nameTaken: 'Outro modelo virtual deste harness já usa este nome', diff --git a/packages/frontend/src/i18n/locales/pt-BR/routing.ts b/packages/frontend/src/i18n/locales/pt-BR/routing.ts index e34482db..ca18d35e 100644 --- a/packages/frontend/src/i18n/locales/pt-BR/routing.ts +++ b/packages/frontend/src/i18n/locales/pt-BR/routing.ts @@ -15,6 +15,27 @@ const ptBR: Catalog = { defaultSubtitle: 'Escolha um modelo e até 5 fallbacks como seu roteamento padrão.', reset: 'Redefinir', addModel: '+ Adicionar modelo', + manageModel: 'Gerenciar', + cardPrice: '{{ input }} in, {{ output }} out', + cardSubscription: 'Assinatura', + manager: { + title: 'Gerenciar modelo: {{ name }}', + flow: 'Fluxo de rotas', + flowHint: 'As requisições tentam cada modelo de cima para baixo.', + catalog: 'Modelos disponíveis', + catalogHint: 'Arraste um modelo para o fluxo, ou clique nele para adicioná-lo no fim.', + dropEmpty: 'Arraste um modelo para cá para definir o principal', + dropHere: 'Solte aqui', + remove: 'Remover {{ name }}', + moveUp: 'Subir', + moveDown: 'Descer', + full: 'O fluxo já tem o principal e 5 fallbacks', + duplicate: 'Este modelo com esta conta já está no fluxo', + autoKey: 'qualquer conta (por prioridade)', + saving: 'Salvando…', + saved: 'Salvo', + saveFailed: 'Não foi possível salvar o fluxo', + }, change: 'Trocar', changeModelFor: 'Trocar o modelo de {{ name }}', auto: 'auto', diff --git a/packages/frontend/src/pages/AutoChainCard.tsx b/packages/frontend/src/pages/AutoChainCard.tsx new file mode 100644 index 00000000..fbdad799 --- /dev/null +++ b/packages/frontend/src/pages/AutoChainCard.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { modelParamsScopeForTier } from 'thinker-shared'; +import { + clearFallbacks, + overrideTier, + resetTier, + setFallbacks, + type AuthType, + type AvailableModel, + type CustomProviderData, + type ModelRoute, + type RequestParamDefaults, + type RoutingProvider, + type TierAssignment, +} from '../services/api.js'; +import RouteChainCard from '../components/RouteChainCard.js'; +import VirtualModelTitle from '../components/VirtualModelTitle.js'; +import VirtualModelSnippetModal from '../components/VirtualModelSnippetModal.js'; + +const TIER = 'default'; + +export interface AutoChainCardProps { + agentName: string; + tier: TierAssignment | undefined; + models: AvailableModel[]; + customProviders: CustomProviderData[]; + connectedProviders: RoutingProvider[]; + /** Refetches the tiers after a save. */ + reloadTiers: () => void | Promise; + getModelParams?: ( + scope: string, + provider: string, + authType: AuthType, + model: string, + ) => RequestParamDefaults | null; + setModelParams?: ( + scope: string, + provider: string, + authType: AuthType, + model: string, + params: RequestParamDefaults | null, + ) => Promise; +} + +/** + * The `auto` model (the agent's default tier) as a read-only route chain, like + * the virtual models: the chain is edited only in the "Manage model" window. + */ +export default function AutoChainCard(props: AutoChainCardProps) { + const [showExample, setShowExample] = useState(false); + const tier = props.tier; + + return ( + <> + setShowExample(true)} />} + onOverride={async (model, provider, authType, keyLabel) => { + try { + await overrideTier(props.agentName, TIER, model, provider, authType, keyLabel); + } finally { + void props.reloadTiers(); + } + }} + onFallbacksUpdate={() => void props.reloadTiers()} + persistFallbacks={(models: string[], routes?: ModelRoute[]) => + setFallbacks(props.agentName, TIER, models, routes) + } + persistClearFallbacks={() => clearFallbacks(props.agentName, TIER)} + persistClearChain={async () => { + // A tier reset clears only the primary; drop the fallbacks first. + await clearFallbacks(props.agentName, TIER); + await resetTier(props.agentName, TIER); + }} + paramsScope={modelParamsScopeForTier(TIER)} + getModelParams={props.getModelParams} + setModelParams={props.setModelParams} + /> + {showExample ? ( + setShowExample(false)} + /> + ) : null} + + ); +} diff --git a/packages/frontend/src/pages/Routing.tsx b/packages/frontend/src/pages/Routing.tsx index 77ae98a5..9b1c846d 100644 --- a/packages/frontend/src/pages/Routing.tsx +++ b/packages/frontend/src/pages/Routing.tsx @@ -19,7 +19,7 @@ import { Button, cx } from '../components/ui.js'; import RoutingDefaultTierSection from './RoutingDefaultTierSection.js'; import RoutingSpecificitySection from './RoutingSpecificitySection.js'; import RoutingVirtualModelsSection from './RoutingVirtualModelsSection.js'; -import RoutingTierCard from './RoutingTierCard.js'; +import AutoChainCard from './AutoChainCard.js'; import { RoutingLoadingSkeleton, ActiveProviderIcons } from './RoutingPanels.js'; import { useRoutingActions } from './RoutingActions.js'; import { @@ -47,7 +47,7 @@ import { type ResponseMode, } from '../services/api.js'; import { parseCustomProviderParams, parseProviderDeepLink } from '../services/routing-params.js'; -import { DEFAULT_STAGE, STAGES } from '../services/providers.js'; +import { STAGES } from '../services/providers.js'; import NoConnectionsPrompt from '../components/NoConnectionsPrompt.jsx'; const PREFILL_PARAMS = ['provider', 'name', 'baseUrl', 'apiKey', 'models']; @@ -491,26 +491,13 @@ export default function Routing() { // of virtual models. const renderUnified = () => virtualModels( - setDropdownTier(tierId)} - onOverride={handleOverride} - onPinKey={actions.handlePinKey} - onReset={actions.handleReset} - onFallbackUpdate={actions.handleFallbackUpdate} - onAddFallback={(tierId) => setFallbackPickerTier(tierId)} - getFallbacksFor={actions.getFallbacksFor} connectedProviders={enabledConnectedProviders} + reloadTiers={() => tiers.reload()} getModelParams={getModelParamsFor} setModelParams={setModelParamsFor} />, diff --git a/packages/frontend/src/pages/RoutingVirtualModelsSection.tsx b/packages/frontend/src/pages/RoutingVirtualModelsSection.tsx index 98256e6d..ce262b90 100644 --- a/packages/frontend/src/pages/RoutingVirtualModelsSection.tsx +++ b/packages/frontend/src/pages/RoutingVirtualModelsSection.tsx @@ -115,8 +115,11 @@ export default function RoutingVirtualModelsSection(props: RoutingVirtualModelsS await setVirtualModelOverride(props.agentName, vm.id, model, provider, authType, keyLabel), ); reload(); - } catch { - // fetchMutate already showed the backend message. + } catch (err) { + // fetchMutate already showed the backend message; callers that save a + // whole chain (the model manager) need the failure to roll back. + reload(); + throw err; } }; @@ -184,7 +187,7 @@ export default function RoutingVirtualModelsSection(props: RoutingVirtualModelsS {props.leading || items.length > 0 ? (
{props.leading ?
{props.leading}
: null} diff --git a/packages/frontend/src/services/api/routing.ts b/packages/frontend/src/services/api/routing.ts index 737a7657..7700178f 100644 --- a/packages/frontend/src/services/api/routing.ts +++ b/packages/frontend/src/services/api/routing.ts @@ -442,6 +442,8 @@ export interface AvailableModel { model_name: string; provider: string; auth_type?: AuthType; + /** Labels of the accounts (connections) that list this model, primary key first. */ + key_labels?: string[]; input_price_per_token: number | null; output_price_per_token: number | null; /** Per-request USD cost for per-request subscriptions (e.g. OpenCode Go). */ diff --git a/packages/frontend/src/styles/shell.css b/packages/frontend/src/styles/shell.css index ebda83f9..d7cb26ac 100644 --- a/packages/frontend/src/styles/shell.css +++ b/packages/frontend/src/styles/shell.css @@ -604,6 +604,10 @@ width: min(840px, calc(100vw - 32px)); height: min(760px, calc(100vh - 32px)); } + .floating-window--xwide { + width: min(1180px, calc(100vw - 32px)); + height: min(800px, calc(100vh - 32px)); + } /* Height follows the content, up to the viewport. */ .floating-window--fit { height: auto; diff --git a/packages/frontend/tests/components/RouteChainCard.test.tsx b/packages/frontend/tests/components/RouteChainCard.test.tsx index d44f608b..04132974 100644 --- a/packages/frontend/tests/components/RouteChainCard.test.tsx +++ b/packages/frontend/tests/components/RouteChainCard.test.tsx @@ -1,10 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, act } from '@testing-library/react'; -const mockToast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn() })); -vi.mock('../../src/services/toast-store', () => ({ toast: mockToast })); -vi.mock('../../src/services/toast-store.js', () => ({ toast: mockToast })); - vi.mock('../../src/components/ProviderIcon.js', () => ({ providerIcon: (id: string) => , customProviderLogo: (name: string) => @@ -14,42 +10,18 @@ vi.mock('../../src/components/AuthBadge.js', () => ({ authBadgeFor: (auth: string | null) => (auth ? : null), })); -let fallbackProps: Record = {}; -vi.mock('../../src/components/FallbackList.js', () => ({ - default: (props: Record) => { - fallbackProps = props; - return
; - }, -})); - -let pickerProps: Record | null = null; -vi.mock('../../src/components/ModelPickerModal.js', () => ({ - default: (props: Record) => { - pickerProps = props; - return
; - }, -})); - -let keyModalProps: Record | null = null; -vi.mock('../../src/components/KeyPickerModal.js', () => ({ - default: (props: Record) => { - keyModalProps = props; - return
; - }, -})); - -let keyChipProps: Record | null = null; -vi.mock('../../src/components/RouteKeyChip.js', () => ({ +let managerProps: Record | null = null; +vi.mock('../../src/components/ModelChainManager.js', () => ({ default: (props: Record) => { - keyChipProps = props; - return ; + managerProps = props; + return
; }, })); -let paramsProps: Record | null = null; +const paramsProps: Record[] = []; vi.mock('../../src/components/ModelParamsAffordance.jsx', () => ({ default: (props: Record) => { - paramsProps = props; + paramsProps.push(props); return ; }, })); @@ -58,7 +30,7 @@ import RouteChainCard, { type RouteChain, type RouteChainCardProps, } from '../../src/components/RouteChainCard'; -import type { AvailableModel, RoutingProvider } from '../../src/services/api'; +import type { AvailableModel, ModelRoute, RoutingProvider } from '../../src/services/api'; import { resetLocaleForTests } from '../../src/i18n'; const model = (over: Partial = {}): AvailableModel => ({ @@ -117,24 +89,26 @@ function setup(over: Partial = {}) { return { ...utils, props }; } +const routeNames = (container: HTMLElement) => + [...container.querySelectorAll('.routing-card__main')].map((el) => el.textContent); + +const openManager = () => fireEvent.click(screen.getByRole('button', { name: 'Manage' })); + beforeEach(() => { vi.clearAllMocks(); - fallbackProps = {}; - pickerProps = null; - keyModalProps = null; - keyChipProps = null; - paramsProps = null; + managerProps = null; + paramsProps.length = 0; }); -describe('RouteChainCard primary chip', () => { - it('shows the model display name, provider icon, auth badge and per-million price', () => { +afterEach(() => resetLocaleForTests('en')); + +describe('RouteChainCard flow', () => { + it('lists the primary and the fallbacks top to bottom', () => { const { container } = setup(); - expect(container.querySelector('.routing-card__main')!.textContent).toBe('GPT 5.5'); + expect(routeNames(container)).toEqual(['GPT 5.5', 'claude-a', 'gemini-b']); expect(screen.getByTestId('provider-icon-openai')).toBeTruthy(); - expect(screen.getByTestId('auth-api_key')).toBeTruthy(); - expect(container.querySelector('.routing-card__chip-price')!.textContent).toBe( - '$2.00 in · $10.00 out per 1M', - ); + expect(screen.getByTestId('provider-icon-anthropic')).toBeTruthy(); + expect(screen.getAllByTestId('auth-api_key')).toHaveLength(3); }); it('shows the chain name and applies cardClass and enabled classList entries only', () => { @@ -146,450 +120,187 @@ describe('RouteChainCard primary chip', () => { expect(root.className).not.toContain('is-off'); }); - it('falls back to the raw model id when the catalog does not list it', () => { - const { container } = setup({ models: [] }); - expect(container.querySelector('.routing-card__main')!.textContent).toBe('gpt-5.5'); - expect(container.querySelector('.routing-card__chip-price')!.textContent).toBe(''); - }); - - it('resolves a dated catalog entry by prefix', () => { - const { container } = setup({ + it('falls back to the raw model id, and resolves a dated catalog entry by prefix', () => { + const { container, unmount } = setup({ models: [] }); + expect(routeNames(container)[0]).toBe('gpt-5.5'); + unmount(); + const dated = setup({ models: [model({ model_name: 'gpt-5.5-2026-01-01', display_name: 'GPT dated' })], }); - expect(container.querySelector('.routing-card__main')!.textContent).toBe('GPT dated'); + expect(routeNames(dated.container)[0]).toBe('GPT dated'); }); - it('shows the subscription price per request, or "included" when there is none', () => { + it('shows the per-million price, the subscription price per request or "included"', () => { + const { container, unmount } = setup({ chain: chain({ fallback_routes: null }) }); + expect(container.querySelector('.routing-card__chip-price')!.textContent).toBe( + '$2.00 in, $10.00 out', + ); + unmount(); const sub = chain({ override_route: { provider: 'openai', authType: 'subscription', model: 'gpt-5.5' }, + fallback_routes: null, }); - const { container, unmount } = setup({ - chain: sub, - models: [model({ cost_per_request: 0.04 })], - }); - expect(container.querySelector('.routing-card__chip-price')!.textContent).toContain('0.04'); - unmount(); - const second = setup({ chain: sub, models: [model({ cost_per_request: null })] }); - expect(second.container.querySelector('.routing-card__chip-price')!.textContent).toBe( - 'Included in subscription', + const perRequest = setup({ chain: sub, models: [model({ cost_per_request: 0.04 })] }); + expect(perRequest.container.querySelector('.routing-card__chip-price')!.textContent).toContain( + '0.04', ); - }); - - it('infers the auth type from connected providers: subscription beats api_key beats local', () => { - const noAuth = chain({ override_route: { provider: 'openai', model: 'gpt-5.5' } as never }); - const providers = [ - key({ id: 'a', auth_type: 'local' }), - key({ id: 'b', auth_type: 'api_key' }), - key({ id: 'c', auth_type: 'subscription' }), - ]; - const { unmount } = setup({ chain: noAuth, connectedProviders: providers }); - expect(screen.getByTestId('auth-subscription')).toBeTruthy(); - unmount(); - const b = setup({ chain: noAuth, connectedProviders: providers.slice(0, 2) }); - expect(b.container.querySelector('[data-testid="auth-api_key"]')).not.toBeNull(); - b.unmount(); - const c = setup({ chain: noAuth, connectedProviders: providers.slice(0, 1) }); - expect(c.container.querySelector('[data-testid="auth-local"]')).not.toBeNull(); - }); - - it('renders a custom provider as a coloured initial, or its logo', () => { - const custom = chain({ - override_route: { provider: 'custom:cp1', authType: 'api_key', model: 'm' }, - }); - const { container, unmount } = setup({ - chain: custom, - models: [], - customProviders: [{ id: 'cp1', name: 'acme' } as never], - }); - expect(container.querySelector('.routing-card__override-icon')!.textContent).toBe('A'); - unmount(); - const logo = setup({ - chain: custom, - models: [], - customProviders: [{ id: 'cp1', name: 'Logo Co' } as never], - }); - expect(logo.getByTestId('custom-logo')).toBeTruthy(); - }); - - it('uses "C" when a custom provider is no longer listed', () => { - const { container } = setup({ - chain: chain({ - override_route: { provider: 'custom:gone', authType: 'api_key', model: 'm' }, - }), - models: [], - }); - expect(container.querySelector('.routing-card__override-icon')!.textContent).toBe('C'); - }); -}); - -describe('RouteChainCard empty chain', () => { - it('offers only "+ Add model" and no chip or fallback list', () => { - const { container } = setup({ chain: chain({ override_route: null, fallback_routes: null }) }); - expect(container.querySelector('.routing-card__model-chip')).toBeNull(); - expect(screen.queryByTestId('fallback-list')).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: '+ Add model' })); - expect(screen.getByRole('dialog', { name: 'model picker' })).toBeTruthy(); - expect(pickerProps!.tierId).toBe('tier-1'); - expect(pickerProps!.requiredCapability).toBeUndefined(); - }); - - it('hides "+ Add model" once a primary exists', () => { - setup(); - expect(screen.queryByRole('button', { name: '+ Add model' })).toBeNull(); - }); -}); - -describe('RouteChainCard stream mode', () => { - const streamChain = chain({ response_mode: 'stream' }); - - it('badges and dims a primary that cannot stream, and the picker requires the capability', () => { - const { container } = setup({ chain: streamChain, models: [model({ capabilities: [] })] }); - expect(container.querySelector('.routing-card__skipped-badge')!.textContent).toBe( - 'Skipped in Stream', + perRequest.unmount(); + const included = setup({ chain: sub, models: [model({ cost_per_request: null })] }); + expect(included.container.querySelector('.routing-card__chip-price')!.textContent).toBe( + 'Subscription', ); - expect(container.querySelector('.routing-card__model-chip--skipped')).not.toBeNull(); - fireEvent.click(container.querySelector('.routing-card__model-chip')!); - expect(pickerProps!.requiredCapability).toBe('stream'); }); - it('shows no badge when the primary supports stream', () => { + it('names the account of each route: its pin, the only key, or any by priority', () => { const { container } = setup({ - chain: streamChain, - models: [model({ capabilities: ['stream'] as never })], - }); - expect(container.querySelector('.routing-card__skipped-badge')).toBeNull(); - }); - - it('never badges in buffered mode', () => { - const { container } = setup({ chain: chain({ response_mode: 'buffered' }) }); - expect(container.querySelector('.routing-card__skipped-badge')).toBeNull(); - }); -}); - -describe('RouteChainCard fallbacks', () => { - it('hands FallbackList the fallback models in chain order', () => { - setup(); - expect(fallbackProps.fallbacks).toEqual(['claude-a', 'gemini-b']); - expect(fallbackProps.tier).toBe('tier-1'); - expect(fallbackProps.modelParamsScope).toBe('header:tier-1'); - expect(fallbackProps.responseMode).toBe('buffered'); - }); - - it('treats null fallback routes as an empty list', () => { - setup({ chain: chain({ fallback_routes: null }) }); - expect(fallbackProps.fallbacks).toEqual([]); - expect(fallbackProps.fallbackRoutes).toBeNull(); - }); - - it('forwards updates and persistence of the list unchanged', async () => { - const { props } = setup(); - fallbackProps.onUpdate(['x'], [{ provider: 'p', authType: 'api_key', model: 'x' }]); - expect(props.onFallbacksUpdate).toHaveBeenCalledWith( - ['x'], - [{ provider: 'p', authType: 'api_key', model: 'x' }], - ); - await fallbackProps.persistFallbacks('agent', 'id', ['x'], []); - expect(props.persistFallbacks).toHaveBeenCalledWith(['x'], []); - await fallbackProps.persistClearFallbacks(); - expect(props.persistClearFallbacks).toHaveBeenCalled(); - }); - - it('appends a picked fallback to the end of the chain and persists the ordered routes', async () => { - const { props } = setup(); - act(() => fallbackProps.onAddFallback()); - expect(pickerProps).not.toBeNull(); - await act(async () => { - await pickerProps!.onSelect('tier-1', 'gpt-mini', 'openai', 'api_key'); - }); - const expectedRoutes = [ - { provider: 'anthropic', authType: 'api_key', model: 'claude-a' }, - { provider: 'google', authType: 'api_key', model: 'gemini-b' }, - { provider: 'openai', authType: 'api_key', model: 'gpt-mini' }, - ]; - expect(props.persistFallbacks).toHaveBeenCalledWith( - ['claude-a', 'gemini-b', 'gpt-mini'], - expectedRoutes, - ); - expect(props.onFallbacksUpdate).toHaveBeenCalledWith( - ['claude-a', 'gemini-b', 'gpt-mini'], - expectedRoutes, - ); - expect(mockToast.success).toHaveBeenCalledWith('Fallback added'); - expect(screen.queryByRole('dialog', { name: 'model picker' })).toBeNull(); - }); - - it('defaults the new fallback auth type to api_key', async () => { - const { props } = setup({ chain: chain({ fallback_routes: [] }) }); - act(() => fallbackProps.onAddFallback()); - await act(async () => { - await pickerProps!.onSelect('tier-1', 'gpt-mini', 'openai'); - }); - expect(props.persistFallbacks).toHaveBeenCalledWith( - ['gpt-mini'], - [{ provider: 'openai', authType: 'api_key', model: 'gpt-mini' }], - ); - }); - - it('reports a failed save without updating the list', async () => { - const { props } = setup({ persistFallbacks: vi.fn().mockRejectedValue(new Error('x')) }); - act(() => fallbackProps.onAddFallback()); - await act(async () => { - await pickerProps!.onSelect('tier-1', 'gpt-mini', 'openai', 'api_key'); - }); - expect(mockToast.error).toHaveBeenCalledWith('Failed to add fallback'); - expect(props.onFallbacksUpdate).not.toHaveBeenCalled(); - expect(mockToast.success).not.toHaveBeenCalled(); - }); - - it('auto-pins the only remaining key for a fallback of a multi-key provider', async () => { - const keys = [ - key({ id: 'a', label: 'Personal', priority: 0 }), - key({ id: 'b', label: 'Work', priority: 1 }), - ]; - // The primary already uses model gpt-5.5 with the first key; picking gpt-5.5 as fallback leaves Work. - const { props } = setup({ - connectedProviders: keys, - chain: chain({ - override_route: { - provider: 'openai', - authType: 'api_key', - model: 'gpt-5.5', - keyLabel: 'Personal', - }, - fallback_routes: [], - }), - }); - act(() => fallbackProps.onAddFallback()); - await act(async () => { - await pickerProps!.onSelect('tier-1', 'gpt-5.5', 'openai', 'api_key'); - }); - expect(props.persistFallbacks).toHaveBeenCalledWith( - ['gpt-5.5'], - [{ provider: 'openai', authType: 'api_key', model: 'gpt-5.5', keyLabel: 'Work' }], - ); - expect(screen.queryByRole('dialog', { name: 'key picker' })).toBeNull(); - }); - - it('ignores a fallback pick when every key is already used for that model', async () => { - const keys = [ - key({ id: 'a', label: 'Personal', priority: 0 }), - key({ id: 'b', label: 'Work', priority: 1 }), - ]; - const { props } = setup({ - connectedProviders: keys, chain: chain({ override_route: { provider: 'openai', authType: 'api_key', model: 'gpt-5.5', - keyLabel: 'Personal', + keyLabel: 'Work', }, fallback_routes: [ - { provider: 'openai', authType: 'api_key', model: 'gpt-5.5', keyLabel: 'Work' }, + { provider: 'openai', authType: 'api_key', model: 'gpt-x' }, + { provider: 'anthropic', authType: 'api_key', model: 'claude-a' }, ], }), + connectedProviders: [ + key({ id: 'a', label: 'Personal' }), + key({ id: 'b', label: 'Work', priority: 1 }), + key({ id: 'c', provider: 'anthropic', label: 'Solo' }), + ], }); - act(() => fallbackProps.onAddFallback()); - await act(async () => { - await pickerProps!.onSelect('tier-1', 'gpt-5.5', 'openai', 'api_key'); - }); - expect(props.persistFallbacks).not.toHaveBeenCalled(); - expect(screen.queryByRole('dialog', { name: 'key picker' })).toBeNull(); - }); -}); - -describe('RouteChainCard picking the primary', () => { - it('opens the picker from the chip and from the pencil, without double handling', () => { - const { container } = setup(); - fireEvent.click(container.querySelector('.routing-card__model-chip')!); - expect(screen.getByRole('dialog', { name: 'model picker' })).toBeTruthy(); - act(() => pickerProps!.onClose()); - expect(screen.queryByRole('dialog', { name: 'model picker' })).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: 'Change model for My chain' })); - expect(screen.getByRole('dialog', { name: 'model picker' })).toBeTruthy(); - }); - - it('overrides directly when the provider has a single key', async () => { - const { props } = setup(); - fireEvent.click(screen.getByRole('button', { name: 'Change model for My chain' })); - await act(async () => { - await pickerProps!.onSelect('tier-1', 'gpt-new', 'openai', 'api_key'); - }); - expect(props.onOverride).toHaveBeenCalledWith('gpt-new', 'openai', 'api_key'); - expect(screen.queryByRole('dialog', { name: 'model picker' })).toBeNull(); + const accounts = [...container.querySelectorAll('.routing-card__account')].map( + (el) => el.textContent, + ); + expect(accounts).toEqual(['Work', 'any account (by priority)', 'Solo']); }); - it('asks which key to pin when a multi-key provider is picked, then overrides with the label', async () => { - const keys = [ - key({ id: 'a', label: 'Personal', priority: 0 }), - key({ id: 'b', label: 'Work', priority: 1 }), - ]; - const { props } = setup({ connectedProviders: keys }); - fireEvent.click(screen.getByRole('button', { name: 'Change model for My chain' })); - await act(async () => { - await pickerProps!.onSelect('tier-1', 'gpt-new', 'openai', 'api_key'); - }); - expect(screen.getByRole('dialog', { name: 'key picker' })).toBeTruthy(); - expect(keyModalProps!.keys.map((k: RoutingProvider) => k.label)).toEqual(['Personal', 'Work']); - expect(keyModalProps!.modelName).toBe('gpt-new'); - expect(props.onOverride).not.toHaveBeenCalled(); - act(() => keyModalProps!.onPick('Work')); - expect(props.onOverride).toHaveBeenCalledWith('gpt-new', 'openai', 'api_key', 'Work'); - expect(screen.queryByRole('dialog', { name: 'key picker' })).toBeNull(); + it('renders nothing below the header for an empty chain', () => { + const { container } = setup({ chain: chain({ override_route: null, fallback_routes: null }) }); + expect(container.querySelector('.routing-card__route')).toBeNull(); + expect(screen.getByRole('button', { name: 'Manage' })).toBeTruthy(); }); - it('leaves the pin on Auto when the key picker answers null', async () => { - const keys = [ - key({ id: 'a', label: 'Personal' }), - key({ id: 'b', label: 'Work', priority: 1 }), - ]; - const { props } = setup({ connectedProviders: keys }); - fireEvent.click(screen.getByRole('button', { name: 'Change model for My chain' })); - await act(async () => { - await pickerProps!.onSelect('tier-1', 'gpt-new', 'openai', 'api_key'); + it('badges and dims a route that cannot stream while the chain is in stream mode', () => { + const { container } = setup({ + chain: chain({ response_mode: 'stream', fallback_routes: null }), + models: [model({ capabilities: ['tools'] })], }); - act(() => keyModalProps!.onPick(null)); - expect(props.onOverride).toHaveBeenCalledWith('gpt-new', 'openai', 'api_key'); + expect(screen.getByText('Skipped in Stream')).toBeTruthy(); + expect(container.querySelector('.routing-card__route--skipped')).toBeTruthy(); }); - it('can dismiss the key picker without changing anything', async () => { - const keys = [ - key({ id: 'a', label: 'Personal' }), - key({ id: 'b', label: 'Work', priority: 1 }), - ]; - const { props } = setup({ connectedProviders: keys }); - fireEvent.click(screen.getByRole('button', { name: 'Change model for My chain' })); - await act(async () => { - await pickerProps!.onSelect('tier-1', 'gpt-new', 'openai', 'api_key'); - }); - act(() => keyModalProps!.onClose()); - expect(screen.queryByRole('dialog', { name: 'key picker' })).toBeNull(); - expect(props.onOverride).not.toHaveBeenCalled(); - }); - - it('names a custom provider in the key picker', async () => { - const keys = [ - key({ id: 'a', provider: 'custom:cp1', label: 'One' }), - key({ id: 'b', provider: 'custom:cp1', label: 'Two', priority: 1 }), - ]; + it('shows no stream badge when the route streams, or in buffered mode', () => { setup({ - connectedProviders: keys, - customProviders: [{ id: 'cp1', name: 'Acme Gateway' } as never], - }); - fireEvent.click(screen.getByRole('button', { name: 'Change model for My chain' })); - await act(async () => { - await pickerProps!.onSelect('tier-1', 'm', 'custom:cp1', 'api_key'); + chain: chain({ response_mode: 'stream', fallback_routes: null }), + models: [model({ capabilities: ['stream'] })], }); - expect(keyModalProps!.providerName).toBe('Acme Gateway'); + expect(screen.queryByText('Skipped in Stream')).toBeNull(); }); -}); -describe('RouteChainCard key chip and params', () => { - it('shows the key chip only for a multi-key primary, and re-pins through onOverride', async () => { - const keys = [ - key({ id: 'a', label: 'Personal' }), - key({ id: 'b', label: 'Work', priority: 1 }), - ]; - const { props, unmount } = setup({ connectedProviders: keys }); - expect(screen.getByTestId('key-chip')).toBeTruthy(); - await act(async () => { - await keyChipProps!.onPick('Work'); - }); - expect(props.onOverride).toHaveBeenCalledWith('gpt-5.5', 'openai', 'api_key', 'Work'); - await act(async () => { - await keyChipProps!.onPick(null); - }); - expect(props.onOverride).toHaveBeenLastCalledWith('gpt-5.5', 'openai', 'api_key', undefined); - unmount(); - setup({ connectedProviders: [key()] }); - expect(screen.queryByTestId('key-chip')).toBeNull(); + it('offers no in-card editing: no change, remove or key controls', () => { + setup(); + const buttons = screen + .getAllByRole('button') + .map((b) => b.getAttribute('aria-label') ?? b.textContent); + expect(buttons).toEqual(['Options for My chain', 'Manage']); }); - it('shows the params affordance only when both getter and setter are given and auth is not local', () => { - const getModelParams = vi.fn(); + it('keeps the params out of the card and hands them to the manager', () => { + const getModelParams = vi.fn(() => null); const setModelParams = vi.fn(); - const { unmount } = setup({ getModelParams, setModelParams }); - expect(screen.getByTestId('params-affordance')).toBeTruthy(); - expect(paramsProps).toMatchObject({ - provider: 'openai', - authType: 'api_key', - model: 'gpt-5.5', - scope: 'header:tier-1', - agentName: 'demo', - }); - unmount(); - - const onlyGetter = setup({ getModelParams }); - expect(onlyGetter.queryByTestId('params-affordance')).toBeNull(); - onlyGetter.unmount(); - - const local = setup({ - getModelParams, - setModelParams, - chain: chain({ override_route: { provider: 'ollama', authType: 'local', model: 'llama' } }), - models: [], - }); - expect(local.queryByTestId('params-affordance')).toBeNull(); + setup({ getModelParams, setModelParams }); + expect(screen.queryByTestId('params-affordance')).toBeNull(); + openManager(); + expect(managerProps!.paramsScope).toBe('header:tier-1'); + expect(managerProps!.getModelParams).toBe(getModelParams); + expect(managerProps!.setModelParams).toBe(setModelParams); }); -}); -describe('RouteChainCard options menu', () => { - it('toggles the menu from the gear button and closes via the provided callback', () => { + it('hides the options menu when the wrapper gives none, and toggles it otherwise', () => { + const { unmount } = setup({ renderMenu: undefined }); + expect(screen.queryByRole('button', { name: 'Options for My chain' })).toBeNull(); + unmount(); setup(); - const gear = screen.getByRole('button', { name: 'Options for My chain' }); - expect(gear.getAttribute('aria-expanded')).toBe('false'); - expect(screen.queryByText('menu-item')).toBeNull(); - fireEvent.click(gear); - expect(gear.getAttribute('aria-expanded')).toBe('true'); + fireEvent.click(screen.getByRole('button', { name: 'Options for My chain' })); fireEvent.click(screen.getByText('menu-item')); expect(screen.queryByText('menu-item')).toBeNull(); - fireEvent.click(gear); - fireEvent.click(gear); - expect(screen.queryByText('menu-item')).toBeNull(); }); +}); - it('closes the menu on an outside click and on Escape', () => { - setup(); - const gear = screen.getByRole('button', { name: 'Options for My chain' }); - fireEvent.click(gear); - fireEvent.pointerDown(document.body); - expect(screen.queryByText('menu-item')).toBeNull(); - fireEvent.click(gear); - fireEvent.keyDown(document, { key: 'Escape' }); - expect(screen.queryByText('menu-item')).toBeNull(); +describe('RouteChainCard manager', () => { + it('opens the manager with the chain in order and the stream requirement', () => { + setup({ chain: chain({ response_mode: 'stream' }) }); + openManager(); + expect(screen.getByRole('dialog', { name: 'model manager' })).toBeTruthy(); + expect(managerProps!.chainId).toBe('tier-1'); + expect(managerProps!.routes.map((r: ModelRoute) => r.model)).toEqual([ + 'gpt-5.5', + 'claude-a', + 'gemini-b', + ]); + expect(managerProps!.requiredCapability).toBe('stream'); + act(() => managerProps!.onClose()); + expect(screen.queryByRole('dialog', { name: 'model manager' })).toBeNull(); + }); + + it('saves a chain as the primary, then the fallbacks', async () => { + const { props } = setup(); + openManager(); + const next: ModelRoute[] = [ + { provider: 'anthropic', authType: 'api_key', model: 'claude-a', keyLabel: 'Work' }, + { provider: 'openai', authType: 'api_key', model: 'gpt-5.5' }, + ]; + await act(() => managerProps!.onSave(next)); + expect(props.onOverride).toHaveBeenCalledWith('claude-a', 'anthropic', 'api_key', 'Work'); + expect(props.persistFallbacks).toHaveBeenCalledWith(['gpt-5.5'], [next[1]]); + expect(props.onFallbacksUpdate).toHaveBeenCalledWith(['gpt-5.5'], [next[1]]); }); - it('renders the title slots: leading, extra, and a replacement for the plain name', () => { - setup({ - titleLeading: drag, - titleExtra: extra, - titleContent: custom title, - details:

detail row

, - }); - expect(screen.getByText('drag')).toBeTruthy(); - expect(screen.getByText('extra')).toBeTruthy(); - expect(screen.getByText('custom title')).toBeTruthy(); - expect(screen.queryByText('My chain')).toBeNull(); - expect(screen.getByText('detail row')).toBeTruthy(); + it('clears the fallbacks when the chain keeps only a primary', async () => { + const { props } = setup(); + openManager(); + await act(() => + managerProps!.onSave([{ provider: 'openai', authType: 'api_key', model: 'gpt-5.5' }]), + ); + expect(props.persistClearFallbacks).toHaveBeenCalled(); + expect(props.persistFallbacks).not.toHaveBeenCalled(); + expect(props.onFallbacksUpdate).toHaveBeenCalledWith([], []); }); -}); -describe('RouteChainCard in pt-BR', () => { - it('translates the add button and the fallback toast', async () => { - resetLocaleForTests('pt-BR'); - const { props } = setup({ chain: chain({ override_route: null, fallback_routes: null }) }); - expect(screen.getByRole('button', { name: '+ Adicionar modelo' })).toBeTruthy(); - expect(screen.queryByRole('button', { name: '+ Add model' })).toBeNull(); + it('clears the whole chain when emptied, and refuses without a clear handler', async () => { + const persistClearChain = vi.fn().mockResolvedValue(undefined); + const { props, unmount } = setup({ persistClearChain }); + openManager(); + await act(() => managerProps!.onSave([])); + expect(persistClearChain).toHaveBeenCalled(); expect(props.onOverride).not.toHaveBeenCalled(); + unmount(); + setup(); + openManager(); + await expect(managerProps!.onSave([])).rejects.toThrow(); + }); + + it('fails the save, without touching the fallbacks, when the primary is rejected', async () => { + const { props } = setup({ onOverride: vi.fn().mockRejectedValue(new Error('no')) }); + openManager(); + await expect( + managerProps!.onSave([ + { provider: 'openai', authType: 'api_key', model: 'gpt-5.5' }, + { provider: 'anthropic', authType: 'api_key', model: 'claude-a' }, + ]), + ).rejects.toThrow('no'); + expect(props.persistFallbacks).not.toHaveBeenCalled(); }); +}); - it('toasts the translated confirmation after adding a fallback', async () => { +describe('RouteChainCard in pt-BR', () => { + it('translates the manage button', () => { resetLocaleForTests('pt-BR'); setup(); - act(() => fallbackProps.onAddFallback()); - await act(async () => { - await pickerProps!.onSelect('tier-1', 'gpt-mini', 'openai', 'api_key'); - }); - expect(mockToast.success).toHaveBeenCalledWith('Fallback adicionado'); + expect(screen.getByRole('button', { name: 'Gerenciar' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Manage' })).toBeNull(); }); }); diff --git a/packages/frontend/tests/components/VirtualModelCard.test.tsx b/packages/frontend/tests/components/VirtualModelCard.test.tsx index 512ba9f5..9297f03b 100644 --- a/packages/frontend/tests/components/VirtualModelCard.test.tsx +++ b/packages/frontend/tests/components/VirtualModelCard.test.tsx @@ -16,11 +16,11 @@ vi.mock('../../src/components/ProviderIcon.js', () => ({ customProviderLogo: () => null, })); -let fallbackProps: Record = {}; -vi.mock('../../src/components/FallbackList.js', () => ({ +let managerProps: Record | null = null; +vi.mock('../../src/components/ModelChainManager.js', () => ({ default: (props: Record) => { - fallbackProps = props; - return
; + managerProps = props; + return
; }, })); @@ -71,7 +71,7 @@ function renderCard(overrides: Partial = {}, extra: Record { beforeEach(() => { vi.clearAllMocks(); - fallbackProps = {}; + managerProps = null; }); it('titles the card with the bare name and shows the primary and the fallbacks', () => { @@ -80,9 +80,7 @@ describe('VirtualModelCard', () => { expect(screen.getByText('deepseek/flash')).toBeTruthy(); expect(screen.queryByText('model: "deepseek/flash"')).toBeNull(); expect(screen.getByText('deepseek-chat')).toBeTruthy(); - expect(screen.getByTestId('fallback-list')).toBeTruthy(); - expect(fallbackProps.fallbacks).toEqual(['gpt-5.5']); - expect(fallbackProps.modelParamsScope).toBe('virtual:vm-1'); + expect(screen.getByText('gpt-5.5')).toBeTruthy(); }); it('keeps the switches out of the card', () => { @@ -131,17 +129,16 @@ describe('VirtualModelCard', () => { it('persists the chain through the virtual model API', async () => { renderCard(); - const persist = fallbackProps.persistFallbacks as ( - a: string, - t: string, - m: string[], - r?: unknown, - ) => Promise; - await persist('x', 'y', ['m'], undefined); - expect(mockSetFallbacks).toHaveBeenCalledWith('demo', 'vm-1', ['m'], undefined); - await (fallbackProps.persistClearFallbacks as () => Promise)(); + fireEvent.click(screen.getByRole('button', { name: 'Manage' })); + const save = managerProps!.onSave as (routes: unknown[]) => Promise; + const fallback = { provider: 'openai', authType: 'api_key', model: 'm' }; + await save([{ provider: 'deepseek', authType: 'api_key', model: 'deepseek-chat' }, fallback]); + expect(mockSetFallbacks).toHaveBeenCalledWith('demo', 'vm-1', ['m'], [fallback]); + await save([{ provider: 'deepseek', authType: 'api_key', model: 'deepseek-chat' }]); expect(mockClearFallbacks).toHaveBeenCalledWith('demo', 'vm-1'); - // No Reset on virtual model cards: models are removed one by one. + await save([]); + expect(mockClearOverride).toHaveBeenCalledWith('demo', 'vm-1'); + // No Reset on virtual model cards: the chain is edited in the manager. expect(screen.queryByText('Reset')).toBeNull(); }); }); diff --git a/packages/frontend/tests/components/model-picker-search.test.ts b/packages/frontend/tests/components/model-picker-search.test.ts new file mode 100644 index 00000000..1ff98ab1 --- /dev/null +++ b/packages/frontend/tests/components/model-picker-search.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { searchMatcher } from '../../src/components/ModelPickerModal'; + +describe('searchMatcher', () => { + it('returns null for an empty search', () => { + expect(searchMatcher(' ')).toBeNull(); + }); + + it('matches regular expressions, case-insensitive', () => { + const m = searchMatcher('^gpt-5|flash$')!; + expect(m('GPT-5.1')).toBe(true); + expect(m('DeepSeek V4 Flash')).toBe(true); + expect(m('my-gpt-5')).toBe(false); + }); + + it('falls back to a substring match when the expression is invalid', () => { + const m = searchMatcher('gpt(')!; + expect(m('model gpt(beta)')).toBe(true); + expect(m('gpt-5')).toBe(false); + }); +}); diff --git a/packages/frontend/tests/i18n/routing.pt-BR.test.tsx b/packages/frontend/tests/i18n/routing.pt-BR.test.tsx index 8a92b62a..a37b67b6 100644 --- a/packages/frontend/tests/i18n/routing.pt-BR.test.tsx +++ b/packages/frontend/tests/i18n/routing.pt-BR.test.tsx @@ -85,7 +85,7 @@ describe('Routing in pt-BR', () => { expect(screen.getByText('Modo de resposta: Buffered')).toBeDefined(); expect(screen.getByText('Criar modelo virtual')).toBeDefined(); expect(screen.queryByText('Modelo virtual:')).toBeNull(); - expect(screen.getByText('+ Adicionar modelo')).toBeDefined(); + expect(screen.getByText('Gerenciar')).toBeDefined(); expect(screen.getByText('1 conexão')).toBeDefined(); expect(screen.queryByText('Criar nível personalizado')).toBeNull(); expect(screen.queryByText('Instruções de configuração')).toBeNull(); diff --git a/packages/frontend/tests/pages/Routing.test.tsx b/packages/frontend/tests/pages/Routing.test.tsx index 6eb9b4ec..11ff2bb9 100644 --- a/packages/frontend/tests/pages/Routing.test.tsx +++ b/packages/frontend/tests/pages/Routing.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, fireEvent, waitFor, screen } from '@testing-library/react'; +import { render, fireEvent, waitFor, screen, act } from '@testing-library/react'; import { MemoryRouter, useLocation as useRouterLocation } from 'react-router'; import type { ReactNode } from 'react'; @@ -440,21 +440,13 @@ vi.mock('../../src/pages/RoutingSpecificitySection.js', () => ({ ), })); -// The unified clean-agent view renders RoutingTierCard directly -// (the legacy tabbed path delegates to the section components instead, so these -// are only exercised here). Mock them to surface their callbacks as buttons. -vi.mock('../../src/pages/RoutingTierCard.js', () => ({ +// The unified clean-agent view renders the `auto` chain card directly (the +// legacy tabbed path delegates to the section components instead). +let autoCardProps: Record | null = null; +vi.mock('../../src/pages/AutoChainCard.js', () => ({ default: (props: Record) => { - return ( -
- -
- ); + autoCardProps = props; + return
; }, })); @@ -1779,23 +1771,28 @@ describe('Routing page', () => { it('renders the auto card in the virtual models grid, without tabs or footer', async () => { const { container } = renderRouting(); await waitFor(() => { - expect(screen.getByTestId('tier-card')).toBeDefined(); + expect(screen.getByTestId('auto-card')).toBeDefined(); }); expect(screen.queryByTestId('routing-tabs')).toBeNull(); expect( - container.querySelector('.virtual-model-list [data-testid="tier-card"]'), + container.querySelector('.virtual-model-list [data-testid="auto-card"]'), ).not.toBeNull(); expect(screen.queryByText('Reset all to auto')).toBeNull(); expect(screen.queryByText('How routing works')).toBeNull(); expect(screen.queryByText('Setup instructions')).toBeNull(); }); - it('opens the dropdown picker from the auto card', async () => { + it('hands the auto card a tier reload', async () => { renderRouting(); - fireEvent.click(await screen.findByTestId('tier-card-dropdown')); await waitFor(() => { - expect(lastModalsProps?.dropdownTier).toBe('default'); + expect(autoCardProps).not.toBeNull(); + }); + expect(autoCardProps!.agentName).toBeTruthy(); + const calls = mockGetTierAssignments.mock.calls.length; + await act(async () => { + await autoCardProps!.reloadTiers(); }); + expect(mockGetTierAssignments.mock.calls.length).toBeGreaterThan(calls); }); }); }); diff --git a/packages/thinker/package.json b/packages/thinker/package.json index 3a355bf1..4d022cdf 100644 --- a/packages/thinker/package.json +++ b/packages/thinker/package.json @@ -1,6 +1,6 @@ { "name": "thinker", - "version": "0.31.9", + "version": "0.32.0", "private": true, "description": "Canonical release version for the Thinker Docker image. This package holds no code — the real backend and frontend live in ../backend and ../frontend. See README.md for details." }