feat(ui): describe every switch of the chat Tools menu

The Tools menu built its rows inside the composer, drew only some of
them with a description, and showed a group it had no words for ("changes",
the memory and profile edits) by its raw ID.

The rows now come from useToolGroups, and every row has the same three
parts: a title, a description of what the model can do with the switch on
(and, once it is off, what it loses), and a state line (who serves the
tools, whether commands are isolated, why a switch cannot be used). The
words come from the server, which declares a title and a description for
each group and each add-on; a translation by group ID wins when the
locale has one, so a group added later needs no change here. An add-on
row shows the title its manifest declares instead of its ID.

SwitchMenu is split into the chip and SwitchPanel, and SkillsMenu into
the chip and SkillsPanel, so another page can draw the same lists inline.
New strings are in every locale.

Assisted-by: Claude:claude-sonnet-5-5 eslint playwright
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 14:30:07 +00:00
1 parent 15740acbb9
commit f34e27e913
18 files changed
+476 -114

No files matched your search

+22
View File
@@ -773,6 +773,28 @@ test.describe('Chat tools menu: every group of built-in tools', () => {
await shot(page, 'groups-all')
})
test('every group has a title and a description, also one the interface has no words for, and an add-on shows its declared title', async ({ page }) => {
const state = await stubGroups(page)
state.builtins.push({ group: 'changes', tools: ['propose_change'], off: false, usable: true, provider: 'builtin', providers: {},
title: 'Memory and profile changes', description: 'The model can propose an edit; nothing changes until you confirm.', when_off: 'The model cannot propose edits.' })
state.builtins.push({ group: 'newgroup', tools: ['x_tool'], off: false, usable: true, provider: 'builtin', providers: {},
title: 'A new group', description: 'The model can use a tool the interface has no words for yet.' })
await page.route('**/api/chats/c1/addons', route => route.fulfill({ json: {
addons: [{ id: 'sandbox', tools: 2, off: false, title: 'Isolated runner', description: 'Runs code in a box.' }], commands: 'available', commands_provider: 'sandbox' } }))
await open(page)
// The ninth built-in group reads as a title, not as its id.
await expect(sw(page, 'Memory and profile changes')).toBeVisible()
await expect(sw(page, 'A new group')).toBeVisible()
await expect(sw(page, 'Isolated runner')).toBeVisible()
await expect(page.getByTestId('chat-tools-menu')).toContainText('Runs code in a box.')
const rows = page.getByTestId('chat-tools-item')
const n = await rows.count()
expect(n).toBe(10)
for (let i = 0; i < n; i++) await expect(rows.nth(i).getByTestId('chat-tools-blurb')).not.toBeEmpty()
await sw(page, 'Web').click()
await expect(row(page, 'Web')).toContainText('The model cannot look anything up online.')
})
test('switching a group off and on sends the list of groups that are off', async ({ page }) => {
const state = await stubGroups(page)
await open(page)
@@ -318,7 +318,8 @@
"files": "Deine Dateien",
"media": "Bilder und Audio",
"collections": "Sammlungen",
"subagents": "Sub-Agenten"
"subagents": "Sub-Agenten",
"changes": "Änderungen an Gedächtnis und Profil"
},
"builtinBy": "{{tools}} · eingebaut",
"builtinByAddon": "{{tools}} · bedient von {{provider}}",
@@ -330,7 +331,34 @@
"shell": {
"machine": "Dieser Rechner",
"isolated": "Befehle laufen isoliert"
}
},
"describe": {
"shell": "Das Modell kann Shell-Befehle und Skripte ausführen. Sie laufen in einem Add-on, das sie isoliert, wenn eines installiert ist, sonst direkt auf diesem Rechner.",
"files": "Das Modell kann deine Dateien auflisten, lesen, schreiben, bearbeiten, verschieben, löschen und durchsuchen und Dokumente lesen. Ein Chat eines Agenten, der in einem Ordner arbeitet, erreicht nur diesen Ordner.",
"web": "Das Modell kann im Internet suchen und eine öffentliche Seite über ihre Adresse lesen. Adressen in deinem Netzwerk werden abgelehnt, außer ein Administrator erlaubt sie.",
"media": "Das Modell kann ein Bild beschreiben oder eine Aufnahme aus deinen Dateien mit einem Hilfsmodell transkribieren.",
"collections": "Das Modell kann deine Sammlungen auflisten und darin nach den Passagen suchen, die zu einer Frage passen.",
"subagents": "Das Modell kann einen Sub-Agenten starten, der mit denselben Schaltern wie dieser Chat an einer Aufgabe arbeitet und Bericht erstattet.",
"saved_agents": "Das Modell kann deine gespeicherten Agenten auflisten und einen als Sub-Agenten starten, mit dessen eigenen Anweisungen und Modell.",
"triggers": "Das Modell kann Trigger vorschlagen, auflisten, ändern und löschen: Aufgaben, die von selbst zu einer festen Zeit oder beim Eintreffen einer Datei in einem Ordner laufen. Ein neuer Trigger oder eine Änderung wartet auf einer Karte im Chat, bis du sie bestätigst; Pausieren oder Fortsetzen gilt sofort und lässt sich rückgängig machen.",
"changes": "Das Modell kann eine Änderung an deinen Gedächtnisnotizen oder an den festen Regeln eines Agenten vorschlagen und eine bestätigte Änderung rückgängig machen. Nichts ändert sich, bis du den Vorschlag auf einer Karte im Chat bestätigst."
},
"whenOff": {
"shell": "Das Modell kann Dateien schreiben, aber nichts ausführen.",
"files": "Das Modell kann deine Dateien weder sehen noch ändern.",
"web": "Das Modell kann nichts online nachschlagen.",
"media": "Das Modell kann Bilder oder Aufnahmen nicht selbst lesen.",
"collections": "Das Modell kann deine Sammlungen nicht lesen.",
"subagents": "Das Modell arbeitet allein.",
"saved_agents": "Das Modell kann deine gespeicherten Agenten weder sehen noch starten.",
"triggers": "Das Modell kann keine Trigger erstellen oder verwalten. Ein Lauf, den ein Trigger oder ein Connector gestartet hat, hat sie nie.",
"changes": "Das Modell kann keine Änderungen an deinem Gedächtnis oder an den Regeln eines Agenten vorschlagen."
},
"addonDescribe_one": "Gibt dem Modell {{count}} Werkzeug dieses Add-ons.",
"addonDescribe_other": "Gibt dem Modell {{count}} Werkzeuge dieses Add-ons.",
"serverDescribe": "Ein MCP-Server des Modells: Seine Werkzeuge werden dem Modell angeboten.",
"connectorDescribe": "Einer deiner MCP-Connectors: Seine Werkzeuge werden dem Modell angeboten.",
"gone": "Hier nicht mehr verfügbar. Schalte es aus, um es zu entfernen."
},
"vision": {
"describedBy": "Dieses Modell kann keine Bilder ansehen. Wähle ein anderes Modell, sonst beschreibt {{model}} das Bild.",
@@ -373,7 +373,8 @@
"files": "Your files",
"media": "Images and audio",
"collections": "Collections",
"subagents": "Sub-agents"
"subagents": "Sub-agents",
"changes": "Memory and profile changes"
},
"builtinBy": "{{tools}} · built-in",
"builtinByAddon": "{{tools}} · served by {{provider}}",
@@ -431,7 +432,34 @@
"shell": {
"machine": "This machine",
"isolated": "Commands run isolated"
}
},
"describe": {
"shell": "The model can run shell commands and scripts. They run in an add-on that isolates them when one is installed, otherwise directly on this machine.",
"files": "The model can list, read, write, edit, move, delete and search your files, and read documents. A chat of an agent that works in one folder reaches only that folder.",
"web": "The model can search the web and read a public page by its address. Addresses inside your network are refused unless an administrator allows them.",
"media": "The model can describe an image or transcribe a recording from your files with a helper model.",
"collections": "The model can list your collections and search them for the passages that match a question.",
"subagents": "The model can start a sub-agent that works on one task with the same switches as this chat and reports back.",
"saved_agents": "The model can list your saved agents and start one as a sub-agent, with that agent's own instructions and model.",
"triggers": "The model can propose, list, change and delete triggers: tasks that run by themselves at a set time or when a file arrives in a folder. A new trigger or a change waits on a card in the chat until you confirm it; pausing or resuming a trigger applies at once and you can undo it.",
"changes": "The model can propose an edit to your memory notes or to an agent's standing rules, and undo an edit you confirmed. Nothing changes until you confirm the proposal on a card in the chat."
},
"whenOff": {
"shell": "The model can write files but cannot run anything.",
"files": "The model cannot see or change your files.",
"web": "The model cannot look anything up online.",
"media": "The model cannot read images or recordings itself.",
"collections": "The model cannot read your collections.",
"subagents": "The model works alone.",
"saved_agents": "The model cannot see or start your saved agents.",
"triggers": "The model cannot create or manage triggers. A run that a trigger or a connector started never has them.",
"changes": "The model cannot propose edits to your memory or to an agent's rules."
},
"addonDescribe_one": "Gives the model {{count}} tool from this add-on.",
"addonDescribe_other": "Gives the model {{count}} tools from this add-on.",
"serverDescribe": "An MCP server of the model: its tools are offered to the model.",
"connectorDescribe": "One of your MCP connectors: its tools are offered to the model.",
"gone": "Not available here any more. Switch it off to remove it."
},
"mcpPage": {
"title": "MCP connectors",
@@ -318,7 +318,8 @@
"files": "Tus archivos",
"media": "Imágenes y audio",
"collections": "Colecciones",
"subagents": "Subagentes"
"subagents": "Subagentes",
"changes": "Cambios de memoria y perfil"
},
"builtinBy": "{{tools}} · integradas",
"builtinByAddon": "{{tools}} · servidas por {{provider}}",
@@ -330,7 +331,34 @@
"shell": {
"machine": "Esta máquina",
"isolated": "Los comandos se ejecutan aislados"
}
},
"describe": {
"shell": "El modelo puede ejecutar comandos de shell y scripts. Se ejecutan en un add-on que los aísla, si hay uno instalado; si no, directamente en esta máquina.",
"files": "El modelo puede listar, leer, escribir, editar, mover, borrar y buscar tus archivos, y leer documentos. Un chat de un agente que trabaja en una sola carpeta solo llega a esa carpeta.",
"web": "El modelo puede buscar en la web y leer una página pública por su dirección. Las direcciones dentro de tu red se rechazan salvo que un administrador las permita.",
"media": "El modelo puede describir una imagen o transcribir una grabación de tus archivos con un modelo auxiliar.",
"collections": "El modelo puede listar tus colecciones y buscar en ellas los pasajes que responden a una pregunta.",
"subagents": "El modelo puede iniciar un subagente que trabaja en una tarea con los mismos interruptores que este chat y luego informa.",
"saved_agents": "El modelo puede listar tus agentes guardados e iniciar uno como subagente, con las instrucciones y el modelo de ese agente.",
"triggers": "El modelo puede proponer, listar, cambiar y borrar disparadores: tareas que se ejecutan solas a una hora fijada o cuando llega un archivo a una carpeta. Un disparador nuevo o un cambio espera en una tarjeta del chat hasta que lo confirmes; pausar o reanudar un disparador se aplica al instante y puedes deshacerlo.",
"changes": "El modelo puede proponer un cambio en tus notas de memoria o en las reglas fijas de un agente, y deshacer un cambio que confirmaste. Nada cambia hasta que confirmes la propuesta en una tarjeta del chat."
},
"whenOff": {
"shell": "El modelo puede escribir archivos pero no puede ejecutar nada.",
"files": "El modelo no puede ver ni cambiar tus archivos.",
"web": "El modelo no puede consultar nada en línea.",
"media": "El modelo no puede leer imágenes ni grabaciones por sí mismo.",
"collections": "El modelo no puede leer tus colecciones.",
"subagents": "El modelo trabaja solo.",
"saved_agents": "El modelo no puede ver ni iniciar tus agentes guardados.",
"triggers": "El modelo no puede crear ni gestionar disparadores. Una ejecución iniciada por un disparador o un conector nunca los tiene.",
"changes": "El modelo no puede proponer cambios en tu memoria ni en las reglas de un agente."
},
"addonDescribe_one": "Da al modelo {{count}} herramienta de este add-on.",
"addonDescribe_other": "Da al modelo {{count}} herramientas de este add-on.",
"serverDescribe": "Un servidor MCP del modelo: sus herramientas se ofrecen al modelo.",
"connectorDescribe": "Uno de tus conectores MCP: sus herramientas se ofrecen al modelo.",
"gone": "Ya no está disponible aquí. Apágalo para quitarlo."
},
"vision": {
"describedBy": "Este modelo no puede ver imágenes. Elige otro modelo o la imagen la describirá {{model}}.",
@@ -318,7 +318,8 @@
"files": "Vos fichiers",
"media": "Images et audio",
"collections": "Collections",
"subagents": "Sous-agents"
"subagents": "Sous-agents",
"changes": "Modifications de la mémoire et du profil"
},
"builtinBy": "{{tools}} · intégrés",
"builtinByAddon": "{{tools}} · fournis par {{provider}}",
@@ -330,7 +331,34 @@
"shell": {
"machine": "Cette machine",
"isolated": "Les commandes s’exécutent isolées"
}
},
"describe": {
"shell": "Le modèle peut exécuter des commandes shell et des scripts. Ils tournent dans un add-on qui les isole s'il y en a un d'installé, sinon directement sur cette machine.",
"files": "Le modèle peut lister, lire, écrire, modifier, déplacer, supprimer et chercher vos fichiers, et lire des documents. Un chat d'un agent qui travaille dans un seul dossier n'atteint que ce dossier.",
"web": "Le modèle peut chercher sur le web et lire une page publique à partir de son adresse. Les adresses de votre réseau sont refusées, sauf si un administrateur les autorise.",
"media": "Le modèle peut décrire une image ou transcrire un enregistrement de vos fichiers avec un modèle auxiliaire.",
"collections": "Le modèle peut lister vos collections et y chercher les passages qui répondent à une question.",
"subagents": "Le modèle peut lancer un sous-agent qui travaille sur une tâche avec les mêmes interrupteurs que ce chat, puis fait son rapport.",
"saved_agents": "Le modèle peut lister vos agents enregistrés et en lancer un comme sous-agent, avec les instructions et le modèle de cet agent.",
"triggers": "Le modèle peut proposer, lister, modifier et supprimer des déclencheurs : des tâches qui s'exécutent d'elles-mêmes à une heure fixée ou quand un fichier arrive dans un dossier. Un nouveau déclencheur ou une modification attend sur une carte du chat jusqu'à votre confirmation ; mettre en pause ou reprendre s'applique tout de suite et vous pouvez l'annuler.",
"changes": "Le modèle peut proposer une modification de vos notes de mémoire ou des règles permanentes d'un agent, et annuler une modification que vous avez confirmée. Rien ne change tant que vous ne confirmez pas la proposition sur une carte du chat."
},
"whenOff": {
"shell": "Le modèle peut écrire des fichiers mais ne peut rien exécuter.",
"files": "Le modèle ne peut ni voir ni modifier vos fichiers.",
"web": "Le modèle ne peut rien chercher en ligne.",
"media": "Le modèle ne peut pas lire lui-même les images ou les enregistrements.",
"collections": "Le modèle ne peut pas lire vos collections.",
"subagents": "Le modèle travaille seul.",
"saved_agents": "Le modèle ne peut ni voir ni lancer vos agents enregistrés.",
"triggers": "Le modèle ne peut ni créer ni gérer de déclencheurs. Une exécution lancée par un déclencheur ou un connecteur n'en a jamais.",
"changes": "Le modèle ne peut pas proposer de modifications de votre mémoire ou des règles d'un agent."
},
"addonDescribe_one": "Donne au modèle {{count}} outil de cet add-on.",
"addonDescribe_other": "Donne au modèle {{count}} outils de cet add-on.",
"serverDescribe": "Un serveur MCP du modèle : ses outils sont proposés au modèle.",
"connectorDescribe": "L'un de vos connecteurs MCP : ses outils sont proposés au modèle.",
"gone": "Plus disponible ici. Désactivez-le pour le retirer."
},
"vision": {
"describedBy": "Ce modèle ne peut pas regarder les images. Choisissez un autre modèle, sinon l'image sera décrite par {{model}}.",
@@ -318,7 +318,8 @@
"files": "File Anda",
"media": "Gambar dan audio",
"collections": "Koleksi",
"subagents": "Sub-agen"
"subagents": "Sub-agen",
"changes": "Perubahan memori dan profil"
},
"builtinBy": "{{tools}} · bawaan",
"builtinByAddon": "{{tools}} · dilayani oleh {{provider}}",
@@ -330,7 +331,34 @@
"shell": {
"machine": "Mesin ini",
"isolated": "Perintah berjalan terisolasi"
}
},
"describe": {
"shell": "Model dapat menjalankan perintah shell dan skrip. Perintah berjalan di add-on yang mengisolasinya jika ada yang terpasang, jika tidak langsung di mesin ini.",
"files": "Model dapat membuat daftar, membaca, menulis, mengedit, memindahkan, menghapus, dan mencari berkas Anda, serta membaca dokumen. Chat agen yang bekerja di satu folder hanya menjangkau folder itu.",
"web": "Model dapat mencari di web dan membaca halaman publik dari alamatnya. Alamat di dalam jaringan Anda ditolak kecuali administrator mengizinkannya.",
"media": "Model dapat mendeskripsikan gambar atau menyalin rekaman dari berkas Anda menjadi teks dengan model pembantu.",
"collections": "Model dapat membuat daftar koleksi Anda dan mencari bagian yang cocok dengan sebuah pertanyaan.",
"subagents": "Model dapat memulai sub-agen yang mengerjakan satu tugas dengan sakelar yang sama seperti chat ini, lalu melapor.",
"saved_agents": "Model dapat membuat daftar agen tersimpan Anda dan memulai salah satunya sebagai sub-agen, dengan instruksi dan model agen itu.",
"triggers": "Model dapat mengusulkan, membuat daftar, mengubah, dan menghapus pemicu: tugas yang berjalan sendiri pada waktu tertentu atau saat berkas masuk ke folder. Pemicu baru atau perubahan menunggu di kartu dalam chat sampai Anda mengonfirmasi; menjeda atau melanjutkan pemicu berlaku langsung dan bisa dibatalkan.",
"changes": "Model dapat mengusulkan perubahan pada catatan memori Anda atau aturan tetap sebuah agen, dan membatalkan perubahan yang sudah Anda konfirmasi. Tidak ada yang berubah sampai Anda mengonfirmasi usulan di kartu dalam chat."
},
"whenOff": {
"shell": "Model dapat menulis berkas tetapi tidak dapat menjalankan apa pun.",
"files": "Model tidak dapat melihat atau mengubah berkas Anda.",
"web": "Model tidak dapat mencari apa pun secara daring.",
"media": "Model tidak dapat membaca gambar atau rekaman sendiri.",
"collections": "Model tidak dapat membaca koleksi Anda.",
"subagents": "Model bekerja sendiri.",
"saved_agents": "Model tidak dapat melihat atau memulai agen tersimpan Anda.",
"triggers": "Model tidak dapat membuat atau mengelola pemicu. Proses yang dimulai oleh pemicu atau konektor tidak pernah memilikinya.",
"changes": "Model tidak dapat mengusulkan perubahan pada memori Anda atau aturan agen."
},
"addonDescribe_one": "Memberi model {{count}} alat dari add-on ini.",
"addonDescribe_other": "Memberi model {{count}} alat dari add-on ini.",
"serverDescribe": "Server MCP milik model: alatnya ditawarkan kepada model.",
"connectorDescribe": "Salah satu konektor MCP Anda: alatnya ditawarkan kepada model.",
"gone": "Tidak tersedia lagi di sini. Matikan untuk menghapusnya."
},
"vision": {
"describedBy": "Model ini tidak dapat melihat gambar. Pilih model lain, atau gambar akan dijelaskan oleh {{model}}.",
@@ -318,7 +318,8 @@
"files": "I tuoi file",
"media": "Immagini e audio",
"collections": "Raccolte",
"subagents": "Sotto-agenti"
"subagents": "Sotto-agenti",
"changes": "Modifiche a memoria e profilo"
},
"builtinBy": "{{tools}} · integrati",
"builtinByAddon": "{{tools}} · servito da {{provider}}",
@@ -330,7 +331,34 @@
"shell": {
"machine": "Questa macchina",
"isolated": "I comandi girano isolati"
}
},
"describe": {
"shell": "Il modello può eseguire comandi di shell e script. Girano in un add-on che li isola, se ce n'è uno installato, altrimenti direttamente su questa macchina.",
"files": "Il modello può elencare, leggere, scrivere, modificare, spostare, eliminare e cercare i tuoi file, e leggere documenti. Una chat di un agente che lavora in una sola cartella raggiunge solo quella cartella.",
"web": "Il modello può cercare sul web e leggere una pagina pubblica dal suo indirizzo. Gli indirizzi dentro la tua rete sono rifiutati, a meno che un amministratore li permetta.",
"media": "Il modello può descrivere un'immagine o trascrivere una registrazione dai tuoi file con un modello di supporto.",
"collections": "Il modello può elencare le tue raccolte e cercarvi i passaggi che rispondono a una domanda.",
"subagents": "Il modello può avviare un sotto-agente che lavora su un compito con gli stessi interruttori di questa chat e poi riferisce.",
"saved_agents": "Il modello può elencare i tuoi agenti salvati e avviarne uno come sotto-agente, con le istruzioni e il modello di quell'agente.",
"triggers": "Il modello può proporre, elencare, modificare ed eliminare trigger: attività che partono da sole a un'ora stabilita o quando arriva un file in una cartella. Un nuovo trigger o una modifica aspetta su una scheda nella chat finché non la confermi; mettere in pausa o riprendere un trigger vale subito e puoi annullarlo.",
"changes": "Il modello può proporre una modifica alle tue note di memoria o alle regole fisse di un agente, e annullare una modifica che hai confermato. Nulla cambia finché non confermi la proposta su una scheda nella chat."
},
"whenOff": {
"shell": "Il modello può scrivere file ma non può eseguire nulla.",
"files": "Il modello non può vedere né cambiare i tuoi file.",
"web": "Il modello non può cercare nulla online.",
"media": "Il modello non può leggere da solo immagini o registrazioni.",
"collections": "Il modello non può leggere le tue raccolte.",
"subagents": "Il modello lavora da solo.",
"saved_agents": "Il modello non può vedere né avviare i tuoi agenti salvati.",
"triggers": "Il modello non può creare né gestire trigger. Una esecuzione avviata da un trigger o da un connettore non li ha mai.",
"changes": "Il modello non può proporre modifiche alla tua memoria o alle regole di un agente."
},
"addonDescribe_one": "Dà al modello {{count}} strumento di questo add-on.",
"addonDescribe_other": "Dà al modello {{count}} strumenti di questo add-on.",
"serverDescribe": "Un server MCP del modello: i suoi strumenti sono offerti al modello.",
"connectorDescribe": "Uno dei tuoi connettori MCP: i suoi strumenti sono offerti al modello.",
"gone": "Non più disponibile qui. Spegnilo per rimuoverlo."
},
"vision": {
"describedBy": "Questo modello non può guardare le immagini. Scegli un altro modello, oppure l'immagine sarà descritta da {{model}}.",
@@ -318,7 +318,8 @@
"files": "내 파일",
"media": "이미지와 오디오",
"collections": "컬렉션",
"subagents": "하위 에이전트"
"subagents": "하위 에이전트",
"changes": "메모리 및 프로필 변경"
},
"builtinBy": "{{tools}} · 기본 제공",
"builtinByAddon": "{{tools}} · {{provider}}이(가) 제공",
@@ -330,7 +331,34 @@
"shell": {
"machine": "이 컴퓨터",
"isolated": "명령이 격리되어 실행됩니다"
}
},
"describe": {
"shell": "모델이 셸 명령과 스크립트를 실행할 수 있습니다. 격리하는 애드온이 설치되어 있으면 그 안에서, 없으면 이 기기에서 바로 실행됩니다.",
"files": "모델이 내 파일을 나열, 읽기, 쓰기, 편집, 이동, 삭제, 검색하고 문서를 읽을 수 있습니다. 한 폴더에서만 일하는 에이전트의 채팅은 그 폴더에만 접근합니다.",
"web": "모델이 웹을 검색하고 주소로 공개 페이지를 읽을 수 있습니다. 내 네트워크 안의 주소는 관리자가 허용하지 않으면 거부됩니다.",
"media": "모델이 보조 모델로 내 파일의 이미지를 설명하거나 녹음을 받아 적을 수 있습니다.",
"collections": "모델이 내 컬렉션을 나열하고 질문에 맞는 구절을 검색할 수 있습니다.",
"subagents": "모델이 이 채팅과 같은 스위치로 한 가지 작업을 하는 하위 에이전트를 시작하고 결과를 보고받을 수 있습니다.",
"saved_agents": "모델이 저장된 에이전트를 나열하고, 그 에이전트의 지침과 모델로 하위 에이전트로 시작할 수 있습니다.",
"triggers": "모델이 트리거를 제안, 나열, 변경, 삭제할 수 있습니다. 트리거는 정해진 시간이나 폴더에 파일이 도착할 때 스스로 실행되는 작업입니다. 새 트리거나 변경은 채팅의 카드에서 확인할 때까지 대기하며, 일시중지와 재개는 즉시 적용되고 되돌릴 수 있습니다.",
"changes": "모델이 메모리 노트나 에이전트의 고정 규칙에 대한 변경을 제안하고, 확인한 변경을 되돌릴 수 있습니다. 채팅의 카드에서 제안을 확인하기 전에는 아무것도 바뀌지 않습니다."
},
"whenOff": {
"shell": "모델이 파일은 쓸 수 있지만 아무것도 실행할 수 없습니다.",
"files": "모델이 내 파일을 보거나 바꿀 수 없습니다.",
"web": "모델이 온라인에서 아무것도 찾아볼 수 없습니다.",
"media": "모델이 이미지나 녹음을 직접 읽을 수 없습니다.",
"collections": "모델이 내 컬렉션을 읽을 수 없습니다.",
"subagents": "모델이 혼자 일합니다.",
"saved_agents": "모델이 저장된 에이전트를 보거나 시작할 수 없습니다.",
"triggers": "모델이 트리거를 만들거나 관리할 수 없습니다. 트리거나 커넥터가 시작한 실행에는 트리거 도구가 없습니다.",
"changes": "모델이 메모리나 에이전트 규칙의 변경을 제안할 수 없습니다."
},
"addonDescribe_one": "이 애드온의 도구 {{count}}개를 모델에 제공합니다.",
"addonDescribe_other": "이 애드온의 도구 {{count}}개를 모델에 제공합니다.",
"serverDescribe": "모델의 MCP 서버입니다. 이 서버의 도구가 모델에 제공됩니다.",
"connectorDescribe": "내 MCP 커넥터 중 하나입니다. 이 커넥터의 도구가 모델에 제공됩니다.",
"gone": "여기서는 더 이상 사용할 수 없습니다. 끄면 제거됩니다."
},
"vision": {
"describedBy": "이 모델은 이미지를 볼 수 없습니다. 다른 모델을 선택하지 않으면 {{model}}이(가) 이미지를 설명합니다.",
@@ -318,7 +318,8 @@
"files": "Seus arquivos",
"media": "Imagens e áudio",
"collections": "Coleções",
"subagents": "Subagentes"
"subagents": "Subagentes",
"changes": "Alterações de memória e perfil"
},
"builtinBy": "{{tools}} · integradas",
"builtinByAddon": "{{tools}} · servidas por {{provider}}",
@@ -330,7 +331,34 @@
"shell": {
"machine": "Esta máquina",
"isolated": "Os comandos rodam isolados"
}
},
"describe": {
"shell": "O modelo pode executar comandos de shell e scripts. Eles rodam em um add-on que os isola, se houver um instalado; senão, direto nesta máquina.",
"files": "O modelo pode listar, ler, escrever, editar, mover, apagar e buscar seus arquivos, e ler documentos. Um chat de um agente que trabalha em uma só pasta alcança apenas essa pasta.",
"web": "O modelo pode pesquisar na web e ler uma página pública pelo endereço. Endereços dentro da sua rede são recusados, a menos que um administrador permita.",
"media": "O modelo pode descrever uma imagem ou transcrever uma gravação dos seus arquivos com um modelo auxiliar.",
"collections": "O modelo pode listar suas coleções e buscar nelas os trechos que respondem a uma pergunta.",
"subagents": "O modelo pode iniciar um subagente que trabalha em uma tarefa com os mesmos interruptores deste chat e depois informa o resultado.",
"saved_agents": "O modelo pode listar seus agentes salvos e iniciar um como subagente, com as instruções e o modelo desse agente.",
"triggers": "O modelo pode propor, listar, alterar e apagar gatilhos: tarefas que rodam sozinhas em um horário definido ou quando um arquivo chega a uma pasta. Um gatilho novo ou uma alteração espera em um cartão no chat até você confirmar; pausar ou retomar um gatilho vale na hora e você pode desfazer.",
"changes": "O modelo pode propor uma alteração nas suas notas de memória ou nas regras fixas de um agente, e desfazer uma alteração que você confirmou. Nada muda até você confirmar a proposta em um cartão no chat."
},
"whenOff": {
"shell": "O modelo pode escrever arquivos, mas não pode executar nada.",
"files": "O modelo não pode ver nem alterar seus arquivos.",
"web": "O modelo não pode consultar nada online.",
"media": "O modelo não pode ler imagens ou gravações por conta própria.",
"collections": "O modelo não pode ler suas coleções.",
"subagents": "O modelo trabalha sozinho.",
"saved_agents": "O modelo não pode ver nem iniciar seus agentes salvos.",
"triggers": "O modelo não pode criar nem gerenciar gatilhos. Uma execução iniciada por um gatilho ou um conector nunca os tem.",
"changes": "O modelo não pode propor alterações na sua memória ou nas regras de um agente."
},
"addonDescribe_one": "Dá ao modelo {{count}} ferramenta deste add-on.",
"addonDescribe_other": "Dá ao modelo {{count}} ferramentas deste add-on.",
"serverDescribe": "Um servidor MCP do modelo: suas ferramentas são oferecidas ao modelo.",
"connectorDescribe": "Um dos seus conectores MCP: suas ferramentas são oferecidas ao modelo.",
"gone": "Não está mais disponível aqui. Desligue para removê-lo."
},
"vision": {
"describedBy": "Este modelo não consegue ver imagens. Escolha outro modelo, ou a imagem será descrita por {{model}}.",
@@ -318,7 +318,8 @@
"files": "你的文件",
"media": "图片和音频",
"collections": "知识库",
"subagents": "子代理"
"subagents": "子代理",
"changes": "记忆和配置文件的修改"
},
"builtinBy": "{{tools}} · 内置",
"builtinByAddon": "{{tools}} · 由 {{provider}} 提供",
@@ -330,7 +331,34 @@
"shell": {
"machine": "本机",
"isolated": "命令在隔离环境中运行"
}
},
"describe": {
"shell": "模型可以运行 shell 命令和脚本。若已安装能隔离命令的附加组件,命令在其中运行;否则直接在这台机器上运行。",
"files": "模型可以列出、读取、写入、编辑、移动、删除和搜索你的文件,并读取文档。只在一个文件夹中工作的智能体,其聊天只能访问该文件夹。",
"web": "模型可以搜索网页,并通过地址读取公开页面。你网络内部的地址会被拒绝,除非管理员允许。",
"media": "模型可以用辅助模型描述你文件中的图片,或转写其中的录音。",
"collections": "模型可以列出你的集合,并在其中搜索与问题匹配的段落。",
"subagents": "模型可以启动一个子智能体,用与本聊天相同的开关处理一项任务并汇报结果。",
"saved_agents": "模型可以列出你保存的智能体,并将其中一个作为子智能体启动,使用该智能体自己的指令和模型。",
"triggers": "模型可以提议、列出、修改和删除触发器:在设定时间或有文件到达文件夹时自动运行的任务。新触发器或修改会在聊天的卡片上等待你确认;暂停或恢复触发器立即生效,且可以撤销。",
"changes": "模型可以提议修改你的记忆笔记或智能体的固定规则,并撤销你已确认的修改。在你于聊天的卡片上确认提议之前,不会有任何改变。"
},
"whenOff": {
"shell": "模型可以写文件,但不能运行任何东西。",
"files": "模型看不到也无法更改你的文件。",
"web": "模型无法在线查询任何内容。",
"media": "模型无法自行读取图片或录音。",
"collections": "模型无法读取你的集合。",
"subagents": "模型独自工作。",
"saved_agents": "模型看不到也无法启动你保存的智能体。",
"triggers": "模型无法创建或管理触发器。由触发器或连接器启动的运行永远没有这些工具。",
"changes": "模型无法提议修改你的记忆或智能体的规则。"
},
"addonDescribe_one": "向模型提供此附加组件的 {{count}} 个工具。",
"addonDescribe_other": "向模型提供此附加组件的 {{count}} 个工具。",
"serverDescribe": "模型的一个 MCP 服务器:其工具会提供给模型。",
"connectorDescribe": "你的 MCP 连接器之一:其工具会提供给模型。",
"gone": "此处已不可用。关闭它即可移除。"
},
"vision": {
"describedBy": "此模型无法查看图片。请选择其他模型,否则图片将由 {{model}} 描述。",
@@ -4,12 +4,11 @@ import { useTranslation } from 'react-i18next'
import { useAuth } from '../../context/AuthContext'
import { CAP_CHAT } from '../../utils/capabilities'
import { seesImages } from '../../utils/chatVision'
import { connectorRef } from '../../utils/chatMCP'
import { networkMode } from '../../utils/chatEgress'
import { unknownTextKey } from '../../utils/chatCommands'
import { gbLabel, gbNumber } from '../../utils/modelLedger'
import { useModelFit } from '../../hooks/useModelFit'
import { useCommandsNotice } from '../../hooks/useCommandsNotice'
import { SAVED_AGENTS, SUBAGENTS, useToolGroups } from '../../hooks/useToolGroups'
import { fillStyle, hostMemory, memoryFigure } from '../home/memory'
import HomeComposer from '../home/HomeComposer'
import ModelPicker from '../ModelPicker'
@@ -28,20 +27,6 @@ import { availableCommands, commandGroups, COMMAND_GROUPS, filterArgs, usableSki
import { CHAT_SLASH_ACTIONS } from './chatActions'
import { SLASH_ACTIONS } from '../home/homeActions'
// The opt-in group of built-in tools that lets a chat call the saved agents.
const SAVED_AGENTS = 'saved_agents'
// The group of bash, whoever serves it, and the group of spawn_agent.
const SHELL = 'shell'
const SUBAGENTS = 'subagents'
// The order of the built-in groups in the Tools menu: what a chat does with
// the person's machine and files first. A group the server adds later goes
// last, in the server's order.
const BUILTIN_ORDER = [SHELL, 'files', 'web', 'media', 'collections', SUBAGENTS, SAVED_AGENTS, 'triggers']
const builtinRank = (g) => {
const i = BUILTIN_ORDER.indexOf(g)
return i < 0 ? BUILTIN_ORDER.length : i
}
// /model and /models take a model name: the menu suggests the installed chat
// models as the person types.
function withModelArgs(commands) {
@@ -96,38 +81,6 @@ export default function ChatComposer({
const onPickerOpen = useCallback(() => setFitOpenCount(n => n + 1), [])
const commands = useCommandsNotice(switches.commands, isAdmin, [user, chatId, effectiveModel, agentId])
// Who serves a group of built-in tools in this chat: the built-in itself, or
// an add-on that shadows a tool of the group (the server resolves it).
const builtinNote = (b) => {
const by = b.providers || {}
const shadowed = Object.entries(by).filter(([, p]) => p && p !== 'builtin')
if (shadowed.length === 0) return {}
const provider = (p) => (p === 'builtin' ? tc('tools.servedByBuiltin') : p)
const mixed = b.provider === 'mixed'
const note = mixed
? Object.entries(by).map(([tool, p]) => tc('tools.servedByTool', { tool, provider: provider(p) })).join(' · ')
: tc('tools.servedBy', { provider: b.provider })
// One provider fits a chip next to the name; one per tool is a line under it.
return { [mixed ? 'note' : 'chip']: note, title: tc('tools.builtinByAddon', { tools: (b.tools || []).join(', '), provider: mixed ? note : b.provider }) }
}
// Who runs the commands of the shell group, as a short chip, and on the
// line under it whether they are isolated (a warning when they are not). When nothing can run
// them, the row says why in the notice's words.
const shellNote = (b) => {
if (b.usable === false) return commands.info ? { reason: tc(`tools.commands.${commands.info.textKey}`, { reason: commands.info.reason }) } : {}
const by = (b.providers || {}).bash || b.provider
const selected = switches.commands
const chip = by === 'builtin' ? tc('tools.shell.machine') : by ? tc('tools.servedBy', { provider: by }) : undefined
// Group discovery is not evidence of the selected provider's isolation.
// A chat that is not created yet has no status to read: say nothing of isolation.
if (selected.state === 'draft' && by) return { chip }
if (!by || selected.provider !== by) return { chip, note: tc('tools.commands.unknown') }
if (['host', 'host-off', 'host-unavailable', 'provider-host'].includes(selected.state)) return { chip, note: tc('tools.commands.providerHost'), warn: true }
if (selected.state === 'available') return { chip, note: tc('tools.shell.isolated') }
return { chip, note: tc(`tools.commands.${unknownTextKey(selected.code)}`) }
}
// What a row of the model list says: whether it is loaded, what it can do
// and, where the server can estimate it, how it fits this machine.
const describeModel = (name, warm) => {
@@ -175,30 +128,7 @@ export default function ChatComposer({
// The tools and skills of the chat, each with its switch. A new chat gets
// the draft's when it starts; an agent's chat lists only what the agent grants.
const toolGroups = [
{
// The tools LocalAI serves itself, such as web search: one switch per group.
key: 'builtins', title: tc('tools.builtins'), icon: 'globe', onSet: switches.setBuiltinsOn,
rows: [...switches.builtins].sort((a, b) => builtinRank(a.group) - builtinRank(b.group)).map((b) => ({
key: `b-${b.group}`, id: b.group, optIn: !!b.opt_in, label: tc(`tools.builtin.${b.group}`, { defaultValue: b.group }),
title: tc('tools.builtinBy', { tools: (b.tools || []).join(', ') }),
on: !b.off, usable: b.usable, reason: b.reason, onToggle: (on) => switches.toggleBuiltin(b.group, on),
...(b.group === SHELL ? shellNote(b) : builtinNote(b)),
})),
},
{
key: 'addons', title: tc('tools.addons'), icon: 'puzzle', onSet: switches.setAddonsOn,
rows: switches.addons.map((a) => ({ key: `a-${a.id}`, id: a.id, label: a.id, meta: t('tools.toolCount', { count: a.tools }), on: !a.off, onToggle: (on) => switches.toggleAddon(a.id, on) })),
},
{
key: 'servers', title: tc('tools.servers'), icon: 'server', onSet: switches.setMCPOn,
rows: switches.servers.map((s) => ({ key: `s-${s.name}`, id: s.name, label: s.name, on: !s.off, usable: s.usable, reason: s.reason, onToggle: (on) => switches.toggleMCP(s.name, on) })),
},
{
key: 'connectors', title: tc('tools.connectors'), icon: 'plug', onSet: switches.setMCPOn,
rows: switches.connectors.map((c) => ({ key: `c-${c.id}`, id: connectorRef(c.id), label: c.name, on: !c.off, usable: c.usable, reason: c.reason, onToggle: (on) => switches.toggleMCP(connectorRef(c.id), on) })),
},
]
const toolGroups = useToolGroups(switches, { commandsInfo: commands.info })
const network = egress && networkMode(egress) !== 'hidden'
// A chat whose agent has a folder reaches only that folder with its file tools.
const scopeLine = agentFolderLine(pickedAgent, t)
@@ -24,11 +24,24 @@ import { SEARCH_MIN, fallbackNotice, filterSkills, skillCounts, skillKey, skillS
// onSet(ids: Set, on): switches those skills with one request
// error: the text of a refused change, which the hook has already reverted
export default function SkillsMenu({ skills, replaced = [], onSet, error, testId = 'chat-skills' }) {
const { t } = useTranslation('chat')
if (skills.length === 0) return null
const { on } = skillCounts(skills)
return (
<SwitchPopover icon="book" label={t('skills.label')} title={t('skills.title')} count={on} testId={testId}>
<SkillsPanel skills={skills} replaced={replaced} onSet={onSet} error={error} testId={testId} />
</SwitchPopover>
)
}
// The body of the skills list, without the chip: the header, the search, one
// row per skill and the status line. The agent form draws it inline.
// `later` is the note under the header (what a skill added later does).
export function SkillsPanel({ skills, replaced = [], onSet, error, testId = 'chat-skills', later, manage = true }) {
const { t } = useTranslation('chat')
const [query, setQuery] = useState('')
const noteId = useId()
if (skills.length === 0) return null
const { on, total } = skillCounts(skills)
const searchable = total >= SEARCH_MIN
const q = searchable ? query : ''
@@ -46,7 +59,7 @@ export default function SkillsMenu({ skills, replaced = [], onSet, error, testId
const shownReplaced = filtering ? filterSkills(replaced, q) : replaced
return (
<SwitchPopover icon="book" label={t('skills.label')} title={t('skills.title')} count={on} testId={testId}>
<>
<SwitchHead
title={t('skills.heading')}
summary={t('skills.count', { on, total })}
@@ -77,7 +90,7 @@ export default function SkillsMenu({ skills, replaced = [], onSet, error, testId
/>
</div>
)}
<p className="swl-head__note" id={noteId}>{t('skills.later')}</p>
<p className="swl-head__note" id={noteId}>{later || t('skills.later')}</p>
{fallback && (
<p className="swl-head__note swl-head__note--warn" data-testid={`${testId}-fallback`}>
{t('skills.fallback', { count: fallback.skills.length, skills: fallback.skills.join(', '), name: fallback.addons.join(', ') })}
@@ -98,9 +111,9 @@ export default function SkillsMenu({ skills, replaced = [], onSet, error, testId
{shown.length === 0 && <p className="swl-empty">{t('skills.noMatch', { query: query.trim() })}</p>}
</div>
<SwitchStatus error={error} failed={t('skills.failed')} testId={`${testId}-error`} reserve>
<Button size="sm" variant="ghost" iconEnd="arrow-right" to="/app/skills" data-testid={`${testId}-manage`}>{t('skills.manage')}</Button>
{manage && <Button size="sm" variant="ghost" iconEnd="arrow-right" to="/app/skills" data-testid={`${testId}-manage`}>{t('skills.manage')}</Button>}
</SwitchStatus>
</SwitchPopover>
</>
)
}
@@ -113,7 +113,10 @@ export function SwitchGroup({ title, icon, count, actions, testId, children }) {
// has a disabled switch and says why; the reason describes the switch.
//
// name the visible name; it names the switch
// desc a line of text; on the name's line unless `stacked`
// blurb what the switch does, in a sentence, under the name; every
// switch of the Tools list has one
// desc a line of text (the state: who serves it, why it is off);
// on the name's line unless `stacked`
// tone warn draws the line as a warning
// chip a short label next to the name ("Served by add-on searx")
// meta a muted text before the switch ("4 tools")
@@ -121,8 +124,8 @@ export function SwitchGroup({ title, icon, count, actions, testId, children }) {
// title the tooltip of the row; else the name and the text
// data data-* attributes of the row
export function SwitchRow({
name, desc, tone, chip, chipTestId, meta, metaTestId, metaKind, reason, title, checked, disabled = false, onChange,
stacked = false, testId, descTestId, data = {},
name, blurb, desc, tone, chip, chipTestId, meta, metaTestId, metaKind, reason, title, checked, disabled = false, onChange,
stacked = false, testId, descTestId, blurbTestId, data = {},
}) {
const nameId = useId()
const descId = useId()
@@ -132,7 +135,7 @@ export function SwitchRow({
const attrs = Object.fromEntries(Object.entries(data).map(([k, v]) => [`data-${k}`, v]))
return (
<div
className={`swl-row${stacked ? ' swl-row--stacked' : ''}`}
className={`swl-row${stacked || blurb ? ' swl-row--stacked' : ''}`}
data-on={checked ? 'true' : 'false'}
data-disabled={disabled ? 'true' : undefined}
title={title}
@@ -144,6 +147,7 @@ export function SwitchRow({
<span className="swl-row__name" id={nameId} title={title ? name : undefined}>{name}</span>
{chip && <StatusChip tone="idle" icon="puzzle" className="swl-row__chip" testId={chipTestId}>{chip}</StatusChip>}
</span>
{blurb && <span className="swl-row__blurb" data-testid={blurbTestId}>{blurb}</span>}
{line && (
<span className={`swl-row__desc${reason ? ' swl-row__desc--reason' : ''}${tone === 'warn' ? ' swl-row__desc--warn' : ''}`} id={descId} data-testid={reason ? undefined : descTestId}>
{reason && <Icon name="info" className="swl-row__why" />}
@@ -13,20 +13,36 @@ import { bulkIds, bulkable, rowOn, switchCounts } from '../../utils/chatSwitchRo
// to switch and no notice.
//
// groups: [{ key, title, icon, onSet(ids, on), rows: [{ key, id, label, title,
// on, usable, reason, note, warn, chip, meta, optIn, onToggle }] }]
// note is a line under the name (warn draws it as a warning); chip is a short
// on, usable, reason, description, whenOff, note, warn, chip, meta, optIn,
// onToggle }] }]
// description says what the model can do with the switch on, whenOff what it
// loses once the switch is off; both show under the name of every row. note is a line under the name (warn draws it as a warning); chip is a short
// label next to it (who serves the tool); meta is a muted text on the right.
// notice is a block over the groups, for something that holds for the whole
// chat (the chat cannot run commands, its network); the menu shows for it even
// with no rows.
export default function SwitchMenu({ icon, label, title, heading, note, notice, groups, error, footer, testId }) {
const { t } = useTranslation('chat')
const noteId = useId()
const rows = groups.filter((g) => g.rows.length > 0).flatMap((g) => g.rows)
if (rows.length === 0 && !notice) return null
const { on } = switchCounts(rows)
return (
<SwitchPopover icon={icon} label={label} title={title} count={on} describedBy={note ? noteId : undefined} testId={testId} menuClassName="swl-menu--wide">
<SwitchPanel heading={heading} note={note} noteId={noteId} notice={notice} groups={groups} error={error} footer={footer} testId={testId} />
</SwitchPopover>
)
}
// The body of the Tools list, without the chip: the header with the count and
// the bulk actions, the groups with their rows, and the status line. The
// Tools menu of a chat puts it in a popover; the agent form draws it inline,
// so both show the same switches the same way.
export function SwitchPanel({ heading, note, noteId, notice, groups, error, footer, testId }) {
const { t } = useTranslation('chat')
const [busy, setBusy] = useState(false)
const shown = groups.filter((g) => g.rows.length > 0)
const rows = shown.flatMap((g) => g.rows)
if (rows.length === 0 && !notice) return null
const { on, total } = switchCounts(rows)
const titled = shown.length > 1
@@ -61,7 +77,7 @@ export default function SwitchMenu({ icon, label, title, heading, note, notice,
)
return (
<SwitchPopover icon={icon} label={label} title={title} count={on} describedBy={note ? noteId : undefined} testId={testId} menuClassName="swl-menu--wide">
<>
<SwitchHead
title={heading}
summary={rows.length > 0 ? t('tools.count', { on, total }) : null}
@@ -90,7 +106,7 @@ export default function SwitchMenu({ icon, label, title, heading, note, notice,
})}
</div>
<SwitchStatus error={error} failed={t('tools.failed')} testId={`${testId}-error`}>{footer}</SwitchStatus>
</SwitchPopover>
</>
)
}
@@ -99,9 +115,14 @@ function ToolRow({ row: r, testId }) {
const usable = r.usable !== false
const reason = !usable ? (r.reason || t('tools.unavailable')) : ''
const desc = r.note || (r.optIn && usable ? t('tools.optIn') : '')
const on = rowOn(r)
// What the switch does, and, once it is off, what the model loses.
const blurb = [r.description, !on && usable ? r.whenOff : ''].filter(Boolean).join(' ')
return (
<SwitchRow
name={r.label}
blurb={blurb}
blurbTestId={blurb ? `${testId}-blurb` : undefined}
desc={desc}
tone={r.warn ? 'warn' : undefined}
chip={r.chip}
@@ -134,6 +134,14 @@
font-size: var(--fs-meta);
color: var(--color-text-muted);
}
.swl-row__blurb {
align-self: stretch;
font-size: var(--fs-meta);
line-height: 1.4;
color: var(--color-text-secondary);
white-space: normal;
overflow-wrap: anywhere;
}
.swl-row__desc--reason { color: var(--color-text-secondary); }
.swl-row__desc--warn { color: var(--color-warning); }
.swl-row__why { margin-right: var(--sp-1); vertical-align: -0.125em; color: var(--color-text-muted); }
+113
View File
@@ -0,0 +1,113 @@
import { useTranslation } from 'react-i18next'
import { connectorRef } from '../utils/chatMCP'
import { unknownTextKey } from '../utils/chatCommands'
// The groups of the Tools list, built from the lists of the switches
// (useChatSwitches for a chat, useAgentTools for a saved agent). The Tools
// menu of the composer and the Tools fold of the agent form both draw these
// groups with SwitchMenu/SwitchPanel, so a switch has the same name, the same
// description and the same state line on both.
//
// Every row has the same three parts:
// label what the switch is (the title)
// description what the model can do when the switch is on, and, once it is
// off, what it loses (whenOff)
// note / chip the state: who serves the tools, whether commands are
// isolated, why the switch cannot be used now
// The words come from the server (the title and description each built-in
// group and each add-on declares); a translation by group ID, when the locale
// has one, wins.
// The order of the built-in groups in the list: what a chat does with the
// person's machine and files first. A group the server adds later goes last,
// in the server's order.
export const SAVED_AGENTS = 'saved_agents'
export const SHELL = 'shell'
export const SUBAGENTS = 'subagents'
const BUILTIN_ORDER = [SHELL, 'files', 'web', 'media', 'collections', SUBAGENTS, SAVED_AGENTS, 'triggers', 'changes']
export const builtinRank = (g) => {
const i = BUILTIN_ORDER.indexOf(g)
return i < 0 ? BUILTIN_ORDER.length : i
}
// switches: { builtins, addons, servers, connectors, commands, set*On, toggle* }
// options.commandState: draw the state of the commands (who runs them, whether
// they are isolated) on the shell row. The agent form has no chat to ask.
export function useToolGroups(switches, { commandsInfo = null, commandState = true } = {}) {
const { t } = useTranslation('chat')
const { t: tc } = useTranslation('chats')
// Who serves a group of built-in tools: the built-in itself, or an add-on
// that shadows a tool of the group (the server resolves it).
const builtinNote = (b) => {
const by = b.providers || {}
const shadowed = Object.entries(by).filter(([, p]) => p && p !== 'builtin')
if (shadowed.length === 0) return {}
const provider = (p) => (p === 'builtin' ? tc('tools.servedByBuiltin') : p)
const mixed = b.provider === 'mixed'
const note = mixed
? Object.entries(by).map(([tool, p]) => tc('tools.servedByTool', { tool, provider: provider(p) })).join(' · ')
: tc('tools.servedBy', { provider: b.provider })
// One provider fits a chip next to the name; one per tool is a line under it.
return { [mixed ? 'note' : 'chip']: note, title: tc('tools.builtinByAddon', { tools: (b.tools || []).join(', '), provider: mixed ? note : b.provider }) }
}
// Who runs the commands of the shell group, as a short chip, and on the
// line under it whether they are isolated (a warning when they are not). When nothing can run
// them, the row says why in the notice's words.
const shellNote = (b) => {
if (b.usable === false) return commandsInfo ? { reason: tc(`tools.commands.${commandsInfo.textKey}`, { reason: commandsInfo.reason }) } : {}
const by = (b.providers || {}).bash || b.provider
const selected = switches.commands || {}
const chip = by === 'builtin' ? tc('tools.shell.machine') : by ? tc('tools.servedBy', { provider: by }) : undefined
if (!commandState) return { chip }
// Group discovery is not evidence of the selected provider's isolation.
if (!by || selected.provider !== by) return { chip, note: tc('tools.commands.unknown') }
if (['host', 'host-off', 'host-unavailable', 'provider-host'].includes(selected.state)) return { chip, note: tc('tools.commands.providerHost'), warn: true }
if (selected.state === 'available') return { chip, note: tc('tools.shell.isolated') }
return { chip, note: tc(`tools.commands.${unknownTextKey(selected.code)}`) }
}
const builtinRow = (b) => ({
key: `b-${b.group}`, id: b.group, optIn: !!b.opt_in,
label: tc(`tools.builtin.${b.group}`, { defaultValue: b.title || b.group }),
description: tc(`tools.describe.${b.group}`, { defaultValue: b.description || '' }),
whenOff: tc(`tools.whenOff.${b.group}`, { defaultValue: b.when_off || '' }),
title: tc('tools.builtinBy', { tools: (b.tools || []).join(', ') }),
on: !b.off, usable: b.usable, reason: b.reason, onToggle: (on) => switches.toggleBuiltin(b.group, on),
...(b.group === SHELL ? shellNote(b) : builtinNote(b)),
})
const groups = [
{
// The tools LocalAI serves itself, such as web search: one switch per group.
key: 'builtins', title: tc('tools.builtins'), icon: 'globe', onSet: switches.setBuiltinsOn,
rows: [...switches.builtins].sort((a, b) => builtinRank(a.group) - builtinRank(b.group)).map(builtinRow),
},
{
// An add-on shows the title and the description its manifest declares.
key: 'addons', title: tc('tools.addons'), icon: 'puzzle', onSet: switches.setAddonsOn,
rows: switches.addons.map((a) => ({
key: `a-${a.id}`, id: a.id, label: a.title || a.id,
description: a.gone ? '' : (a.description || tc('tools.addonDescribe', { count: a.tools || 0 })),
note: a.gone ? tc('tools.gone') : '',
meta: a.gone ? undefined : t('tools.toolCount', { count: a.tools }), on: !a.off, onToggle: (on) => switches.toggleAddon(a.id, on),
})),
},
{
key: 'servers', title: tc('tools.servers'), icon: 'server', onSet: switches.setMCPOn,
rows: switches.servers.map((s) => ({
key: `s-${s.name}`, id: s.name, label: s.name, description: tc('tools.serverDescribe'),
on: !s.off, usable: s.usable, reason: s.reason, onToggle: (on) => switches.toggleMCP(s.name, on),
})),
},
{
key: 'connectors', title: tc('tools.connectors'), icon: 'plug', onSet: switches.setMCPOn,
rows: switches.connectors.map((c) => ({
key: `c-${c.id}`, id: connectorRef(c.id), label: c.name, description: tc('tools.connectorDescribe'),
note: c.gone ? tc('tools.gone') : (c.hint || ''), on: !c.off, usable: c.usable, reason: c.reason, onToggle: (on) => switches.toggleMCP(connectorRef(c.id), on),
})),
},
]
return groups
}
@@ -267,18 +267,16 @@ for (const provider of ['copper-runner', 'violet-executor']) test(`typed safety:
...imports, chatAddonsApi:{list:() => new Promise(resolve => requests.push(resolve))},
chatToolsApi:{list:async () => ({})}, chatSkillsApi:{list:async () => ({})},
})
const composer = hookHarness('../../src/components/chat/ChatComposer.jsx', 'ChatComposer', {
useTranslation:() => ({t:key => key}), useAuth:() => ({isAdmin:true,user:{id:'alice'}}),
useModelFit:() => ({}), useCommandsNotice:() => ({info:null}), unknownTextKey: imports.unknownTextKey,
// Only the original code needs these; keeping them permits a genuine RED run.
SANDBOX_ADDON:'sandbox', useSandboxStatus:() => null,
}, source => source.replace('export default function', 'function').split(' // What a row of the model list')[0] + 'return shellNote\n}')
// The rows of the Tools list come from useToolGroups, which the Tools menu
// of a chat and the agent form both use.
const toolGroups = hookHarness('../../src/hooks/useToolGroups.js', 'useToolGroups', {
useTranslation:() => ({t:key => key}), connectorRef: imports.connectorRef, unknownTextKey: imports.unknownTextKey,
}, source => source.replace(/export const/g, 'const'))
const group = {group:'shell',providers:{bash:provider},provider,usable:true}
const render = (actor = 'alice:1', override) => {
const switches = switchesHook.render('same-chat', false, null, actor)
const switches = { ...switchesHook.render('same-chat', false, null, actor), builtins: [group], toggleBuiltin() {} }
if (override) switches.commands = override
const shellNote = composer.render({composer:{drafts:{},switches,models:[]}})
return shellNote(group)
return toolGroups.render(switches, {}).find(g => g.key === 'builtins').rows[0]
}
const unknown = note => {
assert.equal(note.note, 'tools.commands.unknown')
@@ -21,6 +21,7 @@ const scope = {
hostMemory: () => null, agentFolderLine: () => '', CAP_CHAT: 'chat',
CHAT_SLASH_ACTIONS: [], SLASH_ACTIONS: [],
HomeComposer: props => { captured = props; return React.createElement('textarea', { defaultValue: props.message }) },
useToolGroups: () => [], SAVED_AGENTS: 'saved_agents', SUBAGENTS: 'subagents',
CommandsChip: leaf, CommandsNotice: leaf, ToolFallbacks: leaf, ChatToolsControls: leaf,
}
const compiled = ts.transpileModule(source.replace(/^import .*\n/gm, '').replace('export default function', 'function'), {