mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(ui): show chats that no person started apart from the person's chats
Chats that a trigger, a connector, a job or a page started no longer fill the sidebar, Jump back in or the default All chats list. All chats gets a second tab, From add-ons and automations, that groups them by origin kind and by origin, using the name of the binding or the trigger (a kind this release does not know falls under a generic group). The All chats entry in the sidebar shows how many of them wait for the person. Ctrl+K still finds every chat and marks these with a badge, and a transcript of such a chat says what started it, with a link to its page. The browser remembers the tab. The lists ask the server for origin=person or origin=automated, so a burst of bot chats cannot push a person's chats off a page. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
39 files changed
+1270
-56
No files matched your search
@@ -102,7 +102,17 @@
|
||||
"untitled": "Neuer Chat",
|
||||
"origin": {
|
||||
"trigger": "Von einem Auslöser gestartet",
|
||||
"connector": "Über einen Connector"
|
||||
"connector": "Über einen Connector",
|
||||
"job": "Von einem Job gestartet",
|
||||
"page": "Von einer Seite gestartet",
|
||||
"other": "Von etwas anderem gestartet"
|
||||
},
|
||||
"originBadge": {
|
||||
"trigger": "Trigger",
|
||||
"connector": "Connector",
|
||||
"job": "Job",
|
||||
"page": "Seite",
|
||||
"other": "Automatisch"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
@@ -386,7 +396,38 @@
|
||||
"loadFailed": "Deine Chats konnten nicht geladen werden.",
|
||||
"emptyTitle": "Noch keine Chats",
|
||||
"emptyBody": "Ein Chat beginnt auf der Startseite: Nachricht eingeben und senden. Danach stehen deine Chats hier.",
|
||||
"start": "Chat beginnen"
|
||||
"start": "Chat beginnen",
|
||||
"tabsLabel": "Chat-Listen",
|
||||
"tab": {
|
||||
"mine": "Meine Chats",
|
||||
"automated": "Von Add-ons und Automatisierungen"
|
||||
},
|
||||
"automated": {
|
||||
"intro": "Unterhaltungen, die nicht du gestartet hast, sondern etwa ein Connector, ein Trigger oder ein Job. Sie erscheinen nicht in deinen Chat-Listen.",
|
||||
"empty": "Bisher hat nichts von selbst einen Chat gestartet.",
|
||||
"kind": {
|
||||
"trigger": "Trigger",
|
||||
"connector": "Connectors",
|
||||
"job": "Jobs",
|
||||
"page": "Seiten",
|
||||
"other": "Sonstige"
|
||||
},
|
||||
"badge": {
|
||||
"trigger": "Trigger",
|
||||
"connector": "Connector",
|
||||
"job": "Job",
|
||||
"page": "Seite",
|
||||
"other": "Automatisch"
|
||||
},
|
||||
"chats": "Chats: {{count}}",
|
||||
"lastActive": "Zuletzt aktiv {{when}}",
|
||||
"running": "Läuft",
|
||||
"waiting": "Wartet auf dich",
|
||||
"waitingCount": "Chats, die auf dich warten: {{count}}",
|
||||
"open": "{{title}} öffnen",
|
||||
"showAll": "Alle {{count}} anzeigen",
|
||||
"showLess": "Weniger anzeigen"
|
||||
}
|
||||
},
|
||||
"foot": {
|
||||
"state": {
|
||||
@@ -600,5 +641,16 @@
|
||||
"missing": "Der Ordner dieses Agenten existiert nicht mehr. Wähle im Agenten einen anderen Ordner.",
|
||||
"startError": "Der Ordner dieses Agenten fehlt, daher kann der Chat nicht starten. Wähle einen anderen Ordner für den Agenten.",
|
||||
"editAgent": "Agent bearbeiten"
|
||||
},
|
||||
"origin": {
|
||||
"banner": "Diese Unterhaltung wurde von {{title}} gestartet.",
|
||||
"open": "{{title}} öffnen",
|
||||
"kindFallback": {
|
||||
"trigger": "einem Trigger",
|
||||
"connector": "einem Connector",
|
||||
"job": "einem Job",
|
||||
"page": "einer Seite",
|
||||
"other": "etwas anderem"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,5 +117,7 @@
|
||||
"recentChats": "Letzte Chats",
|
||||
"newChat": "Neuer Chat",
|
||||
"untitledChat": "Chat ohne Titel",
|
||||
"chatRunning": "Läuft"
|
||||
"chatRunning": "Läuft",
|
||||
"automatedWaiting": "Automatische Chats, die auf dich warten: {{count}}",
|
||||
"automatedRunning": "Automatische Chats, die laufen: {{count}}"
|
||||
}
|
||||
@@ -174,10 +174,20 @@
|
||||
"untitled": "New chat",
|
||||
"origin": {
|
||||
"trigger": "Started by a trigger",
|
||||
"connector": "From a connector"
|
||||
"connector": "From a connector",
|
||||
"job": "Started by a job",
|
||||
"page": "Started by a page",
|
||||
"other": "Started by something else"
|
||||
},
|
||||
"exportJson": "Export as JSON",
|
||||
"importChat": "Import a chat"
|
||||
"importChat": "Import a chat",
|
||||
"originBadge": {
|
||||
"trigger": "Trigger",
|
||||
"connector": "Connector",
|
||||
"job": "Job",
|
||||
"page": "Page",
|
||||
"other": "Automated"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"you": "You",
|
||||
@@ -541,7 +551,38 @@
|
||||
"loadFailed": "Could not load your chats.",
|
||||
"emptyTitle": "No chats yet",
|
||||
"emptyBody": "A chat starts on Home: type a message and send it. Your chats are listed here after that.",
|
||||
"start": "Start a chat"
|
||||
"start": "Start a chat",
|
||||
"tabsLabel": "Chat lists",
|
||||
"tab": {
|
||||
"mine": "My chats",
|
||||
"automated": "From add-ons and automations"
|
||||
},
|
||||
"automated": {
|
||||
"intro": "Conversations that something other than you started, such as a connector, a trigger or a job. They stay out of your chat lists.",
|
||||
"empty": "Nothing has started a chat by itself yet.",
|
||||
"kind": {
|
||||
"trigger": "Triggers",
|
||||
"connector": "Connectors",
|
||||
"job": "Jobs",
|
||||
"page": "Pages",
|
||||
"other": "Other"
|
||||
},
|
||||
"badge": {
|
||||
"trigger": "Trigger",
|
||||
"connector": "Connector",
|
||||
"job": "Job",
|
||||
"page": "Page",
|
||||
"other": "Automated"
|
||||
},
|
||||
"chats": "Chats: {{count}}",
|
||||
"lastActive": "Last active {{when}}",
|
||||
"running": "Running",
|
||||
"waiting": "Waiting for you",
|
||||
"waitingCount": "Chats waiting for you: {{count}}",
|
||||
"open": "Open {{title}}",
|
||||
"showAll": "Show all {{count}}",
|
||||
"showLess": "Show fewer"
|
||||
}
|
||||
},
|
||||
"foot": {
|
||||
"state": {
|
||||
@@ -747,5 +788,16 @@
|
||||
"missing": "The folder of this agent is gone. Choose another folder in the agent.",
|
||||
"startError": "This agent's folder is missing, so the chat cannot start. Choose another folder for the agent.",
|
||||
"editAgent": "Edit agent"
|
||||
},
|
||||
"origin": {
|
||||
"banner": "This conversation was started by {{title}}.",
|
||||
"open": "Open {{title}}",
|
||||
"kindFallback": {
|
||||
"trigger": "a trigger",
|
||||
"connector": "a connector",
|
||||
"job": "a job",
|
||||
"page": "a page",
|
||||
"other": "something else"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,5 +116,7 @@
|
||||
"recentChats": "Recent chats",
|
||||
"newChat": "New chat",
|
||||
"untitledChat": "Untitled chat",
|
||||
"chatRunning": "Working"
|
||||
"chatRunning": "Working",
|
||||
"automatedWaiting": "Automated chats waiting for you: {{count}}",
|
||||
"automatedRunning": "Automated chats running: {{count}}"
|
||||
}
|
||||
@@ -102,7 +102,17 @@
|
||||
"untitled": "Nuevo chat",
|
||||
"origin": {
|
||||
"trigger": "Iniciado por un disparador",
|
||||
"connector": "Desde un conector"
|
||||
"connector": "Desde un conector",
|
||||
"job": "Iniciado por un trabajo",
|
||||
"page": "Iniciado por una página",
|
||||
"other": "Iniciado por otra cosa"
|
||||
},
|
||||
"originBadge": {
|
||||
"trigger": "Disparador",
|
||||
"connector": "Conector",
|
||||
"job": "Trabajo",
|
||||
"page": "Página",
|
||||
"other": "Automático"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
@@ -386,7 +396,38 @@
|
||||
"loadFailed": "No se pudieron cargar tus chats.",
|
||||
"emptyTitle": "Aún no hay chats",
|
||||
"emptyBody": "Un chat empieza en Inicio: escribe un mensaje y envíalo. Después, tus chats aparecen aquí.",
|
||||
"start": "Empezar un chat"
|
||||
"start": "Empezar un chat",
|
||||
"tabsLabel": "Listas de chats",
|
||||
"tab": {
|
||||
"mine": "Mis chats",
|
||||
"automated": "De complementos y automatizaciones"
|
||||
},
|
||||
"automated": {
|
||||
"intro": "Conversaciones que inició algo distinto de ti, como un conector, un disparador o un trabajo. No aparecen en tus listas de chats.",
|
||||
"empty": "Nada ha iniciado un chat por sí solo todavía.",
|
||||
"kind": {
|
||||
"trigger": "Disparadores",
|
||||
"connector": "Conectores",
|
||||
"job": "Trabajos",
|
||||
"page": "Páginas",
|
||||
"other": "Otros"
|
||||
},
|
||||
"badge": {
|
||||
"trigger": "Disparador",
|
||||
"connector": "Conector",
|
||||
"job": "Trabajo",
|
||||
"page": "Página",
|
||||
"other": "Automático"
|
||||
},
|
||||
"chats": "Chats: {{count}}",
|
||||
"lastActive": "Última actividad {{when}}",
|
||||
"running": "En curso",
|
||||
"waiting": "Esperando por ti",
|
||||
"waitingCount": "Chats que te esperan: {{count}}",
|
||||
"open": "Abrir {{title}}",
|
||||
"showAll": "Mostrar los {{count}}",
|
||||
"showLess": "Mostrar menos"
|
||||
}
|
||||
},
|
||||
"foot": {
|
||||
"state": {
|
||||
@@ -600,5 +641,16 @@
|
||||
"missing": "La carpeta de este agente ya no existe. Elige otra carpeta en el agente.",
|
||||
"startError": "Falta la carpeta de este agente, así que el chat no puede empezar. Elige otra carpeta para el agente.",
|
||||
"editAgent": "Editar agente"
|
||||
},
|
||||
"origin": {
|
||||
"banner": "Esta conversación la inició {{title}}.",
|
||||
"open": "Abrir {{title}}",
|
||||
"kindFallback": {
|
||||
"trigger": "un disparador",
|
||||
"connector": "un conector",
|
||||
"job": "un trabajo",
|
||||
"page": "una página",
|
||||
"other": "otra cosa"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,5 +117,7 @@
|
||||
"recentChats": "Chats recientes",
|
||||
"newChat": "Nuevo chat",
|
||||
"untitledChat": "Chat sin título",
|
||||
"chatRunning": "En curso"
|
||||
"chatRunning": "En curso",
|
||||
"automatedWaiting": "Chats automáticos que te esperan: {{count}}",
|
||||
"automatedRunning": "Chats automáticos en curso: {{count}}"
|
||||
}
|
||||
@@ -100,7 +100,17 @@
|
||||
"untitled": "Nouveau chat",
|
||||
"origin": {
|
||||
"trigger": "Lancé par un déclencheur",
|
||||
"connector": "Depuis un connecteur"
|
||||
"connector": "Depuis un connecteur",
|
||||
"job": "Démarré par une tâche",
|
||||
"page": "Démarré par une page",
|
||||
"other": "Démarré par autre chose"
|
||||
},
|
||||
"originBadge": {
|
||||
"trigger": "Déclencheur",
|
||||
"connector": "Connecteur",
|
||||
"job": "Tâche",
|
||||
"page": "Page",
|
||||
"other": "Automatique"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
@@ -384,7 +394,38 @@
|
||||
"loadFailed": "Impossible de charger vos conversations.",
|
||||
"emptyTitle": "Aucune conversation",
|
||||
"emptyBody": "Une conversation commence sur l'accueil : écrivez un message et envoyez-le. Vos conversations s'affichent ensuite ici.",
|
||||
"start": "Commencer une conversation"
|
||||
"start": "Commencer une conversation",
|
||||
"tabsLabel": "Listes de chats",
|
||||
"tab": {
|
||||
"mine": "Mes chats",
|
||||
"automated": "Des modules et automatisations"
|
||||
},
|
||||
"automated": {
|
||||
"intro": "Des conversations démarrées par autre chose que vous, comme un connecteur, un déclencheur ou une tâche. Elles restent hors de vos listes de chats.",
|
||||
"empty": "Rien n’a encore démarré de chat tout seul.",
|
||||
"kind": {
|
||||
"trigger": "Déclencheurs",
|
||||
"connector": "Connecteurs",
|
||||
"job": "Tâches",
|
||||
"page": "Pages",
|
||||
"other": "Autres"
|
||||
},
|
||||
"badge": {
|
||||
"trigger": "Déclencheur",
|
||||
"connector": "Connecteur",
|
||||
"job": "Tâche",
|
||||
"page": "Page",
|
||||
"other": "Automatique"
|
||||
},
|
||||
"chats": "Chats : {{count}}",
|
||||
"lastActive": "Dernière activité {{when}}",
|
||||
"running": "En cours",
|
||||
"waiting": "Vous attend",
|
||||
"waitingCount": "Chats qui vous attendent : {{count}}",
|
||||
"open": "Ouvrir {{title}}",
|
||||
"showAll": "Tout afficher ({{count}})",
|
||||
"showLess": "Afficher moins"
|
||||
}
|
||||
},
|
||||
"foot": {
|
||||
"state": {
|
||||
@@ -598,5 +639,16 @@
|
||||
"missing": "Le dossier de cet agent n'existe plus. Choisissez un autre dossier dans l'agent.",
|
||||
"startError": "Le dossier de cet agent est manquant, le chat ne peut donc pas démarrer. Choisissez un autre dossier pour l'agent.",
|
||||
"editAgent": "Modifier l'agent"
|
||||
},
|
||||
"origin": {
|
||||
"banner": "Cette conversation a été démarrée par {{title}}.",
|
||||
"open": "Ouvrir {{title}}",
|
||||
"kindFallback": {
|
||||
"trigger": "un déclencheur",
|
||||
"connector": "un connecteur",
|
||||
"job": "une tâche",
|
||||
"page": "une page",
|
||||
"other": "autre chose"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,5 +120,7 @@
|
||||
"recentChats": "Conversations récentes",
|
||||
"newChat": "Nouvelle conversation",
|
||||
"untitledChat": "Conversation sans titre",
|
||||
"chatRunning": "En cours"
|
||||
"chatRunning": "En cours",
|
||||
"automatedWaiting": "Chats automatiques qui vous attendent : {{count}}",
|
||||
"automatedRunning": "Chats automatiques en cours : {{count}}"
|
||||
}
|
||||
@@ -102,7 +102,17 @@
|
||||
"untitled": "Obrolan baru",
|
||||
"origin": {
|
||||
"trigger": "Dimulai oleh pemicu",
|
||||
"connector": "Dari konektor"
|
||||
"connector": "Dari konektor",
|
||||
"job": "Dimulai oleh job",
|
||||
"page": "Dimulai oleh halaman",
|
||||
"other": "Dimulai oleh hal lain"
|
||||
},
|
||||
"originBadge": {
|
||||
"trigger": "Pemicu",
|
||||
"connector": "Konektor",
|
||||
"job": "Job",
|
||||
"page": "Halaman",
|
||||
"other": "Otomatis"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
@@ -386,7 +396,38 @@
|
||||
"loadFailed": "Tidak dapat memuat obrolan Anda.",
|
||||
"emptyTitle": "Belum ada obrolan",
|
||||
"emptyBody": "Obrolan dimulai di Beranda: ketik pesan lalu kirim. Setelah itu obrolan Anda tercantum di sini.",
|
||||
"start": "Mulai obrolan"
|
||||
"start": "Mulai obrolan",
|
||||
"tabsLabel": "Daftar obrolan",
|
||||
"tab": {
|
||||
"mine": "Obrolan saya",
|
||||
"automated": "Dari add-on dan otomatisasi"
|
||||
},
|
||||
"automated": {
|
||||
"intro": "Percakapan yang dimulai oleh sesuatu selain Anda, seperti konektor, pemicu, atau job. Percakapan ini tidak muncul di daftar obrolan Anda.",
|
||||
"empty": "Belum ada yang memulai obrolan sendiri.",
|
||||
"kind": {
|
||||
"trigger": "Pemicu",
|
||||
"connector": "Konektor",
|
||||
"job": "Job",
|
||||
"page": "Halaman",
|
||||
"other": "Lainnya"
|
||||
},
|
||||
"badge": {
|
||||
"trigger": "Pemicu",
|
||||
"connector": "Konektor",
|
||||
"job": "Job",
|
||||
"page": "Halaman",
|
||||
"other": "Otomatis"
|
||||
},
|
||||
"chats": "Obrolan: {{count}}",
|
||||
"lastActive": "Terakhir aktif {{when}}",
|
||||
"running": "Berjalan",
|
||||
"waiting": "Menunggu Anda",
|
||||
"waitingCount": "Obrolan yang menunggu Anda: {{count}}",
|
||||
"open": "Buka {{title}}",
|
||||
"showAll": "Tampilkan semua ({{count}})",
|
||||
"showLess": "Tampilkan lebih sedikit"
|
||||
}
|
||||
},
|
||||
"foot": {
|
||||
"state": {
|
||||
@@ -600,5 +641,16 @@
|
||||
"missing": "Folder agen ini sudah tidak ada. Pilih folder lain di agen.",
|
||||
"startError": "Folder agen ini hilang, jadi chat tidak bisa dimulai. Pilih folder lain untuk agen.",
|
||||
"editAgent": "Edit agen"
|
||||
},
|
||||
"origin": {
|
||||
"banner": "Percakapan ini dimulai oleh {{title}}.",
|
||||
"open": "Buka {{title}}",
|
||||
"kindFallback": {
|
||||
"trigger": "sebuah pemicu",
|
||||
"connector": "sebuah konektor",
|
||||
"job": "sebuah job",
|
||||
"page": "sebuah halaman",
|
||||
"other": "hal lain"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,5 +117,7 @@
|
||||
"recentChats": "Obrolan terbaru",
|
||||
"newChat": "Obrolan baru",
|
||||
"untitledChat": "Obrolan tanpa judul",
|
||||
"chatRunning": "Berjalan"
|
||||
"chatRunning": "Berjalan",
|
||||
"automatedWaiting": "Obrolan otomatis yang menunggu Anda: {{count}}",
|
||||
"automatedRunning": "Obrolan otomatis yang berjalan: {{count}}"
|
||||
}
|
||||
@@ -102,7 +102,17 @@
|
||||
"untitled": "Nuova chat",
|
||||
"origin": {
|
||||
"trigger": "Avviata da un trigger",
|
||||
"connector": "Da un connettore"
|
||||
"connector": "Da un connettore",
|
||||
"job": "Avviato da un job",
|
||||
"page": "Avviato da una pagina",
|
||||
"other": "Avviato da qualcos’altro"
|
||||
},
|
||||
"originBadge": {
|
||||
"trigger": "Trigger",
|
||||
"connector": "Connettore",
|
||||
"job": "Job",
|
||||
"page": "Pagina",
|
||||
"other": "Automatico"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
@@ -386,7 +396,38 @@
|
||||
"loadFailed": "Impossibile caricare le tue chat.",
|
||||
"emptyTitle": "Ancora nessuna chat",
|
||||
"emptyBody": "Una chat inizia dalla Home: scrivi un messaggio e invialo. Poi le tue chat sono elencate qui.",
|
||||
"start": "Inizia una chat"
|
||||
"start": "Inizia una chat",
|
||||
"tabsLabel": "Elenchi delle chat",
|
||||
"tab": {
|
||||
"mine": "Le mie chat",
|
||||
"automated": "Da componenti aggiuntivi e automazioni"
|
||||
},
|
||||
"automated": {
|
||||
"intro": "Conversazioni avviate da qualcosa che non sei tu, come un connettore, un trigger o un job. Restano fuori dai tuoi elenchi di chat.",
|
||||
"empty": "Nulla ha ancora avviato una chat da solo.",
|
||||
"kind": {
|
||||
"trigger": "Trigger",
|
||||
"connector": "Connettori",
|
||||
"job": "Job",
|
||||
"page": "Pagine",
|
||||
"other": "Altro"
|
||||
},
|
||||
"badge": {
|
||||
"trigger": "Trigger",
|
||||
"connector": "Connettore",
|
||||
"job": "Job",
|
||||
"page": "Pagina",
|
||||
"other": "Automatico"
|
||||
},
|
||||
"chats": "Chat: {{count}}",
|
||||
"lastActive": "Ultima attività {{when}}",
|
||||
"running": "In corso",
|
||||
"waiting": "Ti aspetta",
|
||||
"waitingCount": "Chat che ti aspettano: {{count}}",
|
||||
"open": "Apri {{title}}",
|
||||
"showAll": "Mostra tutte ({{count}})",
|
||||
"showLess": "Mostra meno"
|
||||
}
|
||||
},
|
||||
"foot": {
|
||||
"state": {
|
||||
@@ -600,5 +641,16 @@
|
||||
"missing": "La cartella di questo agente non esiste più. Scegli un'altra cartella nell'agente.",
|
||||
"startError": "La cartella di questo agente manca, quindi la chat non può partire. Scegli un'altra cartella per l'agente.",
|
||||
"editAgent": "Modifica agente"
|
||||
},
|
||||
"origin": {
|
||||
"banner": "Questa conversazione è stata avviata da {{title}}.",
|
||||
"open": "Apri {{title}}",
|
||||
"kindFallback": {
|
||||
"trigger": "un trigger",
|
||||
"connector": "un connettore",
|
||||
"job": "un job",
|
||||
"page": "una pagina",
|
||||
"other": "qualcos’altro"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,5 +117,7 @@
|
||||
"recentChats": "Chat recenti",
|
||||
"newChat": "Nuova chat",
|
||||
"untitledChat": "Chat senza titolo",
|
||||
"chatRunning": "In corso"
|
||||
"chatRunning": "In corso",
|
||||
"automatedWaiting": "Chat automatiche che ti aspettano: {{count}}",
|
||||
"automatedRunning": "Chat automatiche in corso: {{count}}"
|
||||
}
|
||||
@@ -102,7 +102,17 @@
|
||||
"untitled": "새 채팅",
|
||||
"origin": {
|
||||
"trigger": "트리거로 시작됨",
|
||||
"connector": "커넥터에서"
|
||||
"connector": "커넥터에서",
|
||||
"job": "작업이 시작함",
|
||||
"page": "페이지가 시작함",
|
||||
"other": "다른 것이 시작함"
|
||||
},
|
||||
"originBadge": {
|
||||
"trigger": "트리거",
|
||||
"connector": "커넥터",
|
||||
"job": "작업",
|
||||
"page": "페이지",
|
||||
"other": "자동"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
@@ -386,7 +396,38 @@
|
||||
"loadFailed": "채팅을 불러오지 못했습니다.",
|
||||
"emptyTitle": "아직 채팅이 없습니다",
|
||||
"emptyBody": "채팅은 홈에서 시작합니다. 메시지를 입력하고 보내세요. 그 후 채팅이 여기에 표시됩니다.",
|
||||
"start": "채팅 시작"
|
||||
"start": "채팅 시작",
|
||||
"tabsLabel": "채팅 목록",
|
||||
"tab": {
|
||||
"mine": "내 채팅",
|
||||
"automated": "애드온 및 자동화"
|
||||
},
|
||||
"automated": {
|
||||
"intro": "커넥터, 트리거, 작업처럼 사용자가 아닌 것이 시작한 대화입니다. 내 채팅 목록에는 표시되지 않습니다.",
|
||||
"empty": "아직 스스로 채팅을 시작한 것이 없습니다.",
|
||||
"kind": {
|
||||
"trigger": "트리거",
|
||||
"connector": "커넥터",
|
||||
"job": "작업",
|
||||
"page": "페이지",
|
||||
"other": "기타"
|
||||
},
|
||||
"badge": {
|
||||
"trigger": "트리거",
|
||||
"connector": "커넥터",
|
||||
"job": "작업",
|
||||
"page": "페이지",
|
||||
"other": "자동"
|
||||
},
|
||||
"chats": "채팅: {{count}}",
|
||||
"lastActive": "마지막 활동 {{when}}",
|
||||
"running": "실행 중",
|
||||
"waiting": "확인 대기 중",
|
||||
"waitingCount": "확인을 기다리는 채팅: {{count}}",
|
||||
"open": "{{title}} 열기",
|
||||
"showAll": "{{count}}개 모두 보기",
|
||||
"showLess": "간단히 보기"
|
||||
}
|
||||
},
|
||||
"foot": {
|
||||
"state": {
|
||||
@@ -600,5 +641,16 @@
|
||||
"missing": "이 에이전트의 폴더가 더 이상 없습니다. 에이전트에서 다른 폴더를 선택하세요.",
|
||||
"startError": "이 에이전트의 폴더가 없어 채팅을 시작할 수 없습니다. 에이전트에 다른 폴더를 선택하세요.",
|
||||
"editAgent": "에이전트 편집"
|
||||
},
|
||||
"origin": {
|
||||
"banner": "이 대화는 {{title}}에서 시작되었습니다.",
|
||||
"open": "{{title}} 열기",
|
||||
"kindFallback": {
|
||||
"trigger": "트리거",
|
||||
"connector": "커넥터",
|
||||
"job": "작업",
|
||||
"page": "페이지",
|
||||
"other": "다른 요소"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,5 +117,7 @@
|
||||
"recentChats": "최근 채팅",
|
||||
"newChat": "새 채팅",
|
||||
"untitledChat": "제목 없는 채팅",
|
||||
"chatRunning": "진행 중"
|
||||
"chatRunning": "진행 중",
|
||||
"automatedWaiting": "확인을 기다리는 자동 채팅: {{count}}",
|
||||
"automatedRunning": "실행 중인 자동 채팅: {{count}}"
|
||||
}
|
||||
@@ -105,7 +105,17 @@
|
||||
"untitled": "Novo chat",
|
||||
"origin": {
|
||||
"trigger": "Iniciado por um gatilho",
|
||||
"connector": "De um conector"
|
||||
"connector": "De um conector",
|
||||
"job": "Iniciado por um job",
|
||||
"page": "Iniciado por uma página",
|
||||
"other": "Iniciado por outra coisa"
|
||||
},
|
||||
"originBadge": {
|
||||
"trigger": "Gatilho",
|
||||
"connector": "Conector",
|
||||
"job": "Job",
|
||||
"page": "Página",
|
||||
"other": "Automático"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
@@ -389,7 +399,38 @@
|
||||
"loadFailed": "Não foi possível carregar seus chats.",
|
||||
"emptyTitle": "Nenhum chat ainda",
|
||||
"emptyBody": "Um chat começa no Início: digite uma mensagem e envie. Depois disso, seus chats aparecem aqui.",
|
||||
"start": "Começar um chat"
|
||||
"start": "Começar um chat",
|
||||
"tabsLabel": "Listas de chats",
|
||||
"tab": {
|
||||
"mine": "Meus chats",
|
||||
"automated": "De complementos e automações"
|
||||
},
|
||||
"automated": {
|
||||
"intro": "Conversas iniciadas por algo que não é você, como um conector, um gatilho ou um job. Elas ficam fora das suas listas de chats.",
|
||||
"empty": "Nada iniciou um chat sozinho até agora.",
|
||||
"kind": {
|
||||
"trigger": "Gatilhos",
|
||||
"connector": "Conectores",
|
||||
"job": "Jobs",
|
||||
"page": "Páginas",
|
||||
"other": "Outros"
|
||||
},
|
||||
"badge": {
|
||||
"trigger": "Gatilho",
|
||||
"connector": "Conector",
|
||||
"job": "Job",
|
||||
"page": "Página",
|
||||
"other": "Automático"
|
||||
},
|
||||
"chats": "Chats: {{count}}",
|
||||
"lastActive": "Última atividade {{when}}",
|
||||
"running": "Em execução",
|
||||
"waiting": "Aguardando você",
|
||||
"waitingCount": "Chats aguardando você: {{count}}",
|
||||
"open": "Abrir {{title}}",
|
||||
"showAll": "Mostrar todos ({{count}})",
|
||||
"showLess": "Mostrar menos"
|
||||
}
|
||||
},
|
||||
"foot": {
|
||||
"state": {
|
||||
@@ -603,5 +644,16 @@
|
||||
"missing": "A pasta deste agente não existe mais. Escolha outra pasta no agente.",
|
||||
"startError": "A pasta deste agente está ausente, então o chat não pode começar. Escolha outra pasta para o agente.",
|
||||
"editAgent": "Editar agente"
|
||||
},
|
||||
"origin": {
|
||||
"banner": "Esta conversa foi iniciada por {{title}}.",
|
||||
"open": "Abrir {{title}}",
|
||||
"kindFallback": {
|
||||
"trigger": "um gatilho",
|
||||
"connector": "um conector",
|
||||
"job": "um job",
|
||||
"page": "uma página",
|
||||
"other": "outra coisa"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,5 +117,7 @@
|
||||
"recentChats": "Chats recentes",
|
||||
"newChat": "Novo chat",
|
||||
"untitledChat": "Chat sem título",
|
||||
"chatRunning": "Em andamento"
|
||||
"chatRunning": "Em andamento",
|
||||
"automatedWaiting": "Chats automáticos aguardando você: {{count}}",
|
||||
"automatedRunning": "Chats automáticos em execução: {{count}}"
|
||||
}
|
||||
@@ -102,7 +102,17 @@
|
||||
"untitled": "新对话",
|
||||
"origin": {
|
||||
"trigger": "由触发器启动",
|
||||
"connector": "来自连接器"
|
||||
"connector": "来自连接器",
|
||||
"job": "由作业发起",
|
||||
"page": "由页面发起",
|
||||
"other": "由其他来源发起"
|
||||
},
|
||||
"originBadge": {
|
||||
"trigger": "触发器",
|
||||
"connector": "连接器",
|
||||
"job": "作业",
|
||||
"page": "页面",
|
||||
"other": "自动"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
@@ -386,7 +396,38 @@
|
||||
"loadFailed": "无法加载你的对话。",
|
||||
"emptyTitle": "还没有对话",
|
||||
"emptyBody": "对话从首页开始:输入消息并发送。之后你的对话会列在这里。",
|
||||
"start": "开始对话"
|
||||
"start": "开始对话",
|
||||
"tabsLabel": "聊天列表",
|
||||
"tab": {
|
||||
"mine": "我的聊天",
|
||||
"automated": "来自附加组件和自动化"
|
||||
},
|
||||
"automated": {
|
||||
"intro": "不是你发起的对话,例如由连接器、触发器或作业发起。它们不会出现在你的聊天列表中。",
|
||||
"empty": "还没有任何内容自行发起聊天。",
|
||||
"kind": {
|
||||
"trigger": "触发器",
|
||||
"connector": "连接器",
|
||||
"job": "作业",
|
||||
"page": "页面",
|
||||
"other": "其他"
|
||||
},
|
||||
"badge": {
|
||||
"trigger": "触发器",
|
||||
"connector": "连接器",
|
||||
"job": "作业",
|
||||
"page": "页面",
|
||||
"other": "自动"
|
||||
},
|
||||
"chats": "聊天:{{count}}",
|
||||
"lastActive": "最近活动 {{when}}",
|
||||
"running": "运行中",
|
||||
"waiting": "等待你处理",
|
||||
"waitingCount": "等待你处理的聊天:{{count}}",
|
||||
"open": "打开{{title}}",
|
||||
"showAll": "显示全部 {{count}} 个",
|
||||
"showLess": "收起"
|
||||
}
|
||||
},
|
||||
"foot": {
|
||||
"state": {
|
||||
@@ -600,5 +641,16 @@
|
||||
"missing": "此智能体的文件夹已不存在。请在智能体中选择其他文件夹。",
|
||||
"startError": "此智能体的文件夹缺失,因此无法开始对话。请为智能体选择其他文件夹。",
|
||||
"editAgent": "编辑智能体"
|
||||
},
|
||||
"origin": {
|
||||
"banner": "此对话由{{title}}发起。",
|
||||
"open": "打开{{title}}",
|
||||
"kindFallback": {
|
||||
"trigger": "触发器",
|
||||
"connector": "连接器",
|
||||
"job": "作业",
|
||||
"page": "页面",
|
||||
"other": "其他来源"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,5 +117,7 @@
|
||||
"recentChats": "最近对话",
|
||||
"newChat": "新对话",
|
||||
"untitledChat": "未命名对话",
|
||||
"chatRunning": "进行中"
|
||||
"chatRunning": "进行中",
|
||||
"automatedWaiting": "等待你处理的自动聊天:{{count}}",
|
||||
"automatedRunning": "运行中的自动聊天:{{count}}"
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Icon from './Icon'
|
||||
import { StatusChip } from './ui'
|
||||
import { chatStatus, groupByOrigin, knownKind } from '../utils/originChats'
|
||||
|
||||
// How many chats a group lists before "Show all".
|
||||
const SHOWN = 5
|
||||
|
||||
// The chats that something other than a person started, for the second tab of
|
||||
// All chats. One block per origin kind (triggers, connectors, jobs, pages and
|
||||
// whatever a later release adds), and inside it one group per origin: the
|
||||
// binding of a connector, a trigger, a job, a page. A group says how many
|
||||
// chats it has, when it was last active, whether a turn runs and whether a
|
||||
// chat waits for the person, and links to the page of its origin.
|
||||
export default function AutomatedChats({ chats, ctx, needsYou, onOpen, onDelete }) {
|
||||
const { t, i18n } = useTranslation('chat')
|
||||
const [open, setOpen] = useState({})
|
||||
const kinds = useMemo(() => groupByOrigin(chats, ctx, needsYou), [chats, ctx, needsYou])
|
||||
const fmt = useMemo(() => new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }), [i18n.language])
|
||||
|
||||
if (kinds.length === 0) {
|
||||
return <div className="ac-empty" data-testid="automated-empty">{t('allChats.automated.empty')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ac" data-testid="automated-chats">
|
||||
<p className="ac-intro">{t('allChats.automated.intro')}</p>
|
||||
{kinds.map((k) => {
|
||||
const kind = knownKind(k.kind)
|
||||
return (
|
||||
<section key={k.kind} className="ac-kind" data-testid={`automated-kind-${k.kind}`} aria-labelledby={`ac-kind-${k.kind}`}>
|
||||
<h2 id={`ac-kind-${k.kind}`} className="ac-kind__title">
|
||||
{t(`allChats.automated.kind.${kind}`)}
|
||||
<span className="ac-kind__count">{k.count}</span>
|
||||
</h2>
|
||||
{k.groups.map((g) => {
|
||||
const expanded = Boolean(open[g.key])
|
||||
const shown = expanded ? g.chats : g.chats.slice(0, SHOWN)
|
||||
const title = g.title || t(`allChats.automated.badge.${kind}`)
|
||||
return (
|
||||
<div key={g.key} className="ac-group" data-testid="automated-group">
|
||||
<div className="ac-group__head">
|
||||
<div className="ac-group__main">
|
||||
<span className="ac-badge" data-testid="automated-origin-badge" title={t(`menu.origin.${kind}`)}>{t(`allChats.automated.badge.${kind}`)}</span>
|
||||
<h3 className="ac-group__title" data-testid="automated-group-title">{title}</h3>
|
||||
</div>
|
||||
<div className="ac-group__meta">
|
||||
<span data-testid="automated-count">{t('allChats.automated.chats', { count: g.count })}</span>
|
||||
<span>{t('allChats.automated.lastActive', { when: fmt.format(g.lastActive) })}</span>
|
||||
{g.running > 0 && <StatusChip status="starting" label={t('allChats.automated.running')} testId="automated-running" />}
|
||||
{g.waiting > 0 && <StatusChip tone="warn" icon="warning" label={t('allChats.automated.waiting')} testId="automated-waiting" />}
|
||||
{g.href && <Link className="ac-link" to={g.href} data-testid="automated-origin-link">{t('allChats.automated.open', { title })}</Link>}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="ac-list">
|
||||
{shown.map((c) => {
|
||||
const status = chatStatus(c, needsYou)
|
||||
return (
|
||||
<li key={c.id} className="ac-row" data-testid="automated-chat" data-status={status}>
|
||||
<button type="button" className="ac-row__open" onClick={() => onOpen(c.id)}>
|
||||
<span className="ac-row__name">{c.name || t('menu.untitled')}</span>
|
||||
<span className="ac-row__time">{fmt.format(c.updatedAt || c.createdAt || 0)}</span>
|
||||
</button>
|
||||
{status === 'running' && <Icon name="spinner" spin className="ac-row__busy" title={t('allChats.automated.running')} />}
|
||||
{status === 'waiting' && <Icon name="warning" className="ac-row__wait" title={t('allChats.automated.waiting')} />}
|
||||
<button
|
||||
type="button"
|
||||
className="cx-icobtn cx-icobtn--danger"
|
||||
onClick={() => onDelete(c)}
|
||||
title={t('menu.deleteChat')}
|
||||
aria-label={t('menu.deleteChat')}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{g.chats.length > SHOWN && (
|
||||
<button type="button" className="cx-link ac-more" onClick={() => setOpen((o) => ({ ...o, [g.key]: !expanded }))}>
|
||||
{expanded ? t('allChats.automated.showLess') : t('allChats.automated.showAll', { count: g.chats.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { groupConversations } from '../utils/homeConversations'
|
||||
import Icon from './Icon'
|
||||
import { knownKind } from '../utils/originChats'
|
||||
|
||||
// The chats come from the server list (see menuChats in utils/chatThread.js):
|
||||
// a name, the model and the times. The names are matched here at once; with
|
||||
@@ -14,7 +15,9 @@ function matches(chat, query) {
|
||||
const SEARCH_DELAY_MS = 250
|
||||
|
||||
// The chat history: a search box, one block per day, the model that answered
|
||||
// and the time, with rename, copy, export and delete on each row. It reads
|
||||
// and the time, with rename, copy, export and delete on each row. A chat that
|
||||
// no person started (a trigger, a connector, a job or a page) carries a badge
|
||||
// with the name of what started it (originTitle) or the name of its kind. It reads
|
||||
// like the Home "Jump back in" list. Two frames show it: the Ctrl or Cmd K
|
||||
// dialog of a transcript (ChatsMenu) and the All chats page.
|
||||
//
|
||||
@@ -32,6 +35,7 @@ export default function ChatsList({
|
||||
onExport,
|
||||
onCopyChat,
|
||||
onSearch,
|
||||
originTitle,
|
||||
onEscape,
|
||||
autoFocus = false,
|
||||
idPrefix = 'chats',
|
||||
@@ -188,7 +192,8 @@ export default function ChatsList({
|
||||
const idx = n++
|
||||
const selected = idx === activeIdx
|
||||
const editing = editingId === chat.id
|
||||
const preview = snippets[chat.id] || (chat.origin ? t(`menu.origin.${chat.origin}`, chat.origin) : '')
|
||||
const preview = snippets[chat.id] || ''
|
||||
const originKind = chat.origin ? (knownKind(chat.origin)) : ''
|
||||
return (
|
||||
<div
|
||||
key={chat.id}
|
||||
@@ -227,6 +232,11 @@ export default function ChatsList({
|
||||
{chat.name || t('menu.untitled')}
|
||||
</span>
|
||||
)}
|
||||
{originKind && (
|
||||
<span className="cx-row-item__origin" data-testid="chats-origin-badge" title={t(`menu.origin.${originKind}`)}>
|
||||
{originTitle?.(chat) || t(`menu.originBadge.${originKind}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{preview && <div className="cx-row-item__preview">{preview}</div>}
|
||||
<span className="cx-row-item__model">{chat.model || t('menu.noModel')}</span>
|
||||
|
||||
@@ -22,6 +22,7 @@ const ChatsMenu = forwardRef(function ChatsMenu({
|
||||
onExport,
|
||||
onCopyChat,
|
||||
onSearch,
|
||||
originTitle,
|
||||
}, ref) {
|
||||
const { t } = useTranslation('chat')
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -109,6 +110,7 @@ const ChatsMenu = forwardRef(function ChatsMenu({
|
||||
onExport={onExport}
|
||||
onCopyChat={onCopyChat}
|
||||
onSearch={onSearch}
|
||||
originTitle={originTitle}
|
||||
onEscape={close}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Icon from './Icon'
|
||||
import { useRecentChats } from '../hooks/useRecentChats'
|
||||
import { useAutomatedChats, useRecentChats } from '../hooks/useRecentChats'
|
||||
import { attention } from '../utils/originChats'
|
||||
import { allChatsDestination } from '../utils/navigationModel'
|
||||
import { preloadRoute } from '../router'
|
||||
|
||||
@@ -21,7 +22,14 @@ export default function SidebarChats({ onClose, collapsed }) {
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const chats = useRecentChats(true, RECENT_CHATS, pathname)
|
||||
// The chats that no person started are not listed here (All chats has them
|
||||
// on a tab of their own); the entry only says when one needs the person.
|
||||
const automated = useAutomatedChats(true, pathname)
|
||||
const { waiting, running } = attention(automated.chats, automated.needsYou)
|
||||
const allLabel = t(allChatsDestination.labelKey)
|
||||
const badge = waiting > 0
|
||||
? t('automatedWaiting', { count: waiting })
|
||||
: running > 0 ? t('automatedRunning', { count: running }) : ''
|
||||
const preload = () => preloadRoute('/app/chats')
|
||||
|
||||
return (
|
||||
@@ -64,6 +72,8 @@ export default function SidebarChats({ onClose, collapsed }) {
|
||||
>
|
||||
<Icon name={allChatsDestination.icon} className="nav-icon" aria-hidden="true" />
|
||||
<span className="nav-label">{allLabel}</span>
|
||||
{waiting > 0 && <span className="nav-chat-all__badge" data-testid="sidebar-automated-badge" title={badge} aria-label={badge}>{waiting > 9 ? '9+' : waiting}</span>}
|
||||
{waiting === 0 && running > 0 && <span className="nav-chat-all__dot" data-testid="sidebar-automated-running" title={badge} role="img" aria-label={badge} />}
|
||||
</NavLink>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -99,6 +99,36 @@
|
||||
.sidebar-chats__new:hover { background: var(--dk-hover); color: var(--dk-text); }
|
||||
.sidebar-chats__new:focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 1px; }
|
||||
.nav-chat .nav-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-chat-all__badge {
|
||||
flex: none;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
margin-left: auto;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--dk-accent);
|
||||
color: var(--dk-on-accent);
|
||||
font-size: var(--fs-meta);
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.nav-chat-all__dot {
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-left: auto;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--dk-accent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.sidebar.collapsed .nav-chat-all { position: relative; }
|
||||
.sidebar.collapsed .nav-chat-all__badge,
|
||||
.sidebar.collapsed .nav-chat-all__dot { position: absolute; top: 2px; right: 2px; margin: 0; }
|
||||
@media (max-width: 639px) {
|
||||
.sidebar.collapsed .nav-chat-all__badge,
|
||||
.sidebar.collapsed .nav-chat-all__dot { position: static; margin-left: auto; }
|
||||
}
|
||||
.nav-chat__busy { flex: none; margin-left: auto; color: var(--color-text-muted); }
|
||||
|
||||
.sidebar.collapsed .sidebar-chats__head { display: none; }
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { chatConnectorsApi, chatTriggersApi } from '../utils/api'
|
||||
|
||||
const EMPTY = { connectors: [], connectorKinds: [], triggers: [] }
|
||||
|
||||
// The lists that name what started a chat: the connector bindings (and the
|
||||
// kinds the add-ons serve) and the triggers. Each read is best effort: a
|
||||
// server with connectors or triggers off answers 409, and the chats of that
|
||||
// origin are then named by their kind. Nothing is read until `enabled`.
|
||||
export function useOriginContext(enabled) {
|
||||
const [ctx, setCtx] = useState(EMPTY)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined
|
||||
let live = true
|
||||
Promise.all([
|
||||
chatConnectorsApi.list().catch(() => null),
|
||||
chatConnectorsApi.kinds().catch(() => null),
|
||||
chatTriggersApi.list().catch(() => null),
|
||||
]).then(([connectors, kinds, triggers]) => {
|
||||
if (!live) return
|
||||
setCtx({
|
||||
connectors: connectors?.connectors || [],
|
||||
connectorKinds: kinds?.kinds || [],
|
||||
triggers: triggers?.triggers || [],
|
||||
})
|
||||
})
|
||||
return () => { live = false }
|
||||
}, [enabled])
|
||||
|
||||
return ctx
|
||||
}
|
||||
+42
-6
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { chatsApi } from '../utils/api'
|
||||
import { chatNotificationsApi, chatsApi } from '../utils/api'
|
||||
import { menuChats } from '../utils/chatThread'
|
||||
import { needsYouIds } from '../utils/originChats'
|
||||
import { pendingChatDeletes } from './useChatDelete'
|
||||
|
||||
const EVENT = 'localai:chats-list'
|
||||
@@ -17,8 +18,10 @@ export function newestChats(chats, limit) {
|
||||
return [...(chats || [])].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)).slice(0, limit)
|
||||
}
|
||||
|
||||
// The newest chats of the signed-in user, for the sidebar. It reads
|
||||
// GET /api/chats?limit=N&kind=chat when it starts and whenever `key` changes
|
||||
// The newest chats of the signed-in user, for the sidebar. Only the chats a
|
||||
// person started: the ones a trigger, a connector or a page started have their
|
||||
// own area (All chats, second tab). It reads
|
||||
// GET /api/chats?limit=N&kind=chat&origin=person when it starts and whenever `key` changes
|
||||
// (the sidebar passes the route), and takes the announcements in between.
|
||||
// A failed read keeps what is shown: the list is a shortcut, not a record.
|
||||
export function useRecentChats(enabled, limit, key) {
|
||||
@@ -27,18 +30,51 @@ export function useRecentChats(enabled, limit, key) {
|
||||
useEffect(() => {
|
||||
if (!enabled) { setChats([]); return undefined }
|
||||
let live = true
|
||||
chatsApi.list(limit, 'chat')
|
||||
.then((r) => { if (live) setChats(newestChats(menuChats((r?.conversations || []).filter(c => c.kind !== 'subagent' && !pendingChatDeletes.has(c.id))), limit)) })
|
||||
chatsApi.list(limit, 'chat', 'person')
|
||||
.then((r) => { if (live) setChats(newestChats(menuChats((r?.conversations || []).filter(c => c.kind !== 'subagent' && !pendingChatDeletes.has(c.id))).filter(c => !c.origin), limit)) })
|
||||
.catch(() => {})
|
||||
return () => { live = false }
|
||||
}, [enabled, limit, key])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined
|
||||
const onList = (e) => setChats(newestChats(e.detail, limit))
|
||||
const onList = (e) => setChats(newestChats((e.detail || []).filter(c => !c.origin), limit))
|
||||
window.addEventListener(EVENT, onList)
|
||||
return () => window.removeEventListener(EVENT, onList)
|
||||
}, [enabled, limit])
|
||||
|
||||
return chats
|
||||
}
|
||||
|
||||
// The chats that a trigger, a connector, a job or a page started, and what
|
||||
// needs the person among them, for the badge on the sidebar's All chats entry.
|
||||
// It reads GET /api/chats?kind=chat&origin=automated (and the unread inbox
|
||||
// items, which say which chats ask for the person) when it starts and whenever
|
||||
// `key` changes (the sidebar passes the route) and when the tab gets focus
|
||||
// back. A failed read keeps what is shown.
|
||||
export function useAutomatedChats(enabled, key) {
|
||||
const [state, setState] = useState({ chats: [], needsYou: new Set() })
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined
|
||||
let live = true
|
||||
const load = () => {
|
||||
Promise.all([
|
||||
chatsApi.list(200, 'chat', 'automated'),
|
||||
chatNotificationsApi.list(true, 200).catch(() => null),
|
||||
]).then(([r, inbox]) => {
|
||||
if (!live) return
|
||||
setState({
|
||||
chats: menuChats((r?.conversations || []).filter(c => c.kind !== 'subagent' && c.origin)),
|
||||
needsYou: needsYouIds(inbox?.notifications),
|
||||
})
|
||||
}).catch(() => {})
|
||||
}
|
||||
load()
|
||||
const onVisible = () => { if (document.visibilityState === 'visible') load() }
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => { live = false; document.removeEventListener('visibilitychange', onVisible) }
|
||||
}, [enabled, key])
|
||||
|
||||
return state
|
||||
}
|
||||
+13
-2
@@ -111,8 +111,19 @@ export function useServerChat({ seed = null } = {}) {
|
||||
// Sub-agent runs belong to their parent; the list shows chats only. The
|
||||
// server leaves the runs out before it applies the limit (200 is its
|
||||
// maximum); the filter stays for a server that does not know kind.
|
||||
const data = await chatsApi.list(200, 'chat')
|
||||
setChats((data.conversations || []).filter(c => c.kind !== 'subagent'))
|
||||
//
|
||||
// The chats a person started and the others are read apart, each with its
|
||||
// own limit, so a burst of chats from a connector or a trigger cannot push
|
||||
// the person's chats out of the page. A failed read of the others leaves
|
||||
// them out: the list is a shortcut, the person's chats are the record.
|
||||
const [data, others] = await Promise.all([
|
||||
chatsApi.list(200, 'chat', 'person'),
|
||||
chatsApi.list(200, 'chat', 'automated').catch(() => null),
|
||||
])
|
||||
const seen = new Set()
|
||||
const all = [...(data.conversations || []), ...(others?.conversations || [])]
|
||||
.filter(c => c.kind !== 'subagent' && !seen.has(c.id) && seen.add(c.id))
|
||||
setChats(all)
|
||||
setPersistent(data.persistent !== false)
|
||||
setFiles({ on: data.files !== false, reason: data.files_reason || '' })
|
||||
setImageModel(data.image_model || '')
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { chatsApi } from '../utils/api'
|
||||
import { chatNotificationsApi, chatsApi } from '../utils/api'
|
||||
import { chatFromExport, chatMarkdown, downloadFrom, menuChats } from '../utils/chatThread'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
import { CAP_CHAT } from '../utils/capabilities'
|
||||
import { useModels } from '../hooks/useModels'
|
||||
import { CHAT_UNDO_MS, useChatDelete } from '../hooks/useChatDelete'
|
||||
import { announceChats } from '../hooks/useRecentChats'
|
||||
import { useOriginContext } from '../hooks/useOriginContext'
|
||||
import { attention, needsYouIds, readTab, writeTab } from '../utils/originChats'
|
||||
import AutomatedChats from '../components/AutomatedChats'
|
||||
import ChatsList from '../components/ChatsList'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import HomeUndoToast from '../components/home/HomeUndoToast'
|
||||
@@ -20,24 +23,41 @@ import './chat.css'
|
||||
// transcript (ChatsList): search over titles and message text, rename, copy,
|
||||
// export, delete with Undo and Clear all, plus Import. A row opens its
|
||||
// transcript; New chat opens Home, where a chat starts.
|
||||
//
|
||||
// Two tabs. "My chats" lists the chats a person started. "From add-ons and
|
||||
// automations" lists the others (a connector, a trigger, a job, a page
|
||||
// started them), grouped by what started them, so a bot's conversations do not
|
||||
// sit among the person's own. The browser remembers the tab.
|
||||
export default function AllChats() {
|
||||
const navigate = useNavigate()
|
||||
const { addToast } = useOutletContext()
|
||||
const { t } = useTranslation('chat')
|
||||
const { t: tc } = useTranslation('chats')
|
||||
const { models: chatModels } = useModels(CAP_CHAT)
|
||||
const [state, setState] = useState({ status: 'loading', chats: [], error: null })
|
||||
const [state, setState] = useState({ status: 'loading', chats: [], needsYou: new Set(), error: null })
|
||||
const [tab, setTabState] = useState(() => readTab())
|
||||
const setTab = (id) => { setTabState(id); writeTab(id) }
|
||||
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||
const importInputRef = useRef(null)
|
||||
|
||||
const load = useCallback(async (keep = false) => {
|
||||
if (!keep) setState(s => ({ ...s, status: 'loading', error: null }))
|
||||
try {
|
||||
const data = await chatsApi.list(200, 'chat')
|
||||
setState({ status: 'ready', chats: (data.conversations || []).filter(c => c.kind !== 'subagent'), error: null })
|
||||
// Each side has its own limit, so a burst of chats from a connector cannot
|
||||
// push a person's chats off the page. The inbox says which of the others
|
||||
// wait for the person; a failed read of it or of the others is not an error.
|
||||
const [mine, others, inbox] = await Promise.all([
|
||||
chatsApi.list(200, 'chat', 'person'),
|
||||
chatsApi.list(200, 'chat', 'automated').catch(() => null),
|
||||
chatNotificationsApi.list(true, 200).catch(() => null),
|
||||
])
|
||||
const seen = new Set()
|
||||
const chats = [...(mine.conversations || []), ...(others?.conversations || [])]
|
||||
.filter(c => c.kind !== 'subagent' && !seen.has(c.id) && seen.add(c.id))
|
||||
setState({ status: 'ready', chats, needsYou: needsYouIds(inbox?.notifications), error: null })
|
||||
} catch (error) {
|
||||
// A failed refresh keeps the list on screen; a failed first load says so.
|
||||
setState(s => (keep && s.status === 'ready' ? s : { status: 'error', chats: [], error }))
|
||||
setState(s => (keep && s.status === 'ready' ? s : { status: 'error', chats: [], needsYou: new Set(), error }))
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
@@ -48,10 +68,16 @@ export default function AllChats() {
|
||||
onFailed: (e) => addToast(e.message || tc('errors.delete'), 'error'),
|
||||
})
|
||||
|
||||
const visibleChats = useMemo(
|
||||
const allVisible = useMemo(
|
||||
() => menuChats(state.chats).filter(c => c.id !== deletes.pending?.id),
|
||||
[state.chats, deletes.pending],
|
||||
)
|
||||
const visibleChats = useMemo(() => allVisible.filter(c => !c.origin), [allVisible])
|
||||
const automatedChats = useMemo(() => allVisible.filter(c => c.origin), [allVisible])
|
||||
const originCtx = useOriginContext(automatedChats.length > 0)
|
||||
const { waiting } = attention(automatedChats, state.needsYou)
|
||||
// The tabs appear once there is a second list to show.
|
||||
const showTabs = automatedChats.length > 0 || tab === 'automated'
|
||||
// The sidebar's recent chats follow this list.
|
||||
useEffect(() => { if (state.status === 'ready') announceChats(visibleChats) }, [visibleChats, state.status])
|
||||
|
||||
@@ -118,11 +144,53 @@ export default function AllChats() {
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
{state.status === 'ready' && showTabs && (
|
||||
<div className="dk-tabs ac-tabs" role="tablist" aria-label={t('allChats.tabsLabel')} data-testid="all-chats-tabs">
|
||||
{['mine', 'automated'].map(id => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`all-chats-tab-${id}`}
|
||||
aria-controls={`all-chats-panel-${id}`}
|
||||
aria-selected={tab === id}
|
||||
tabIndex={tab === id ? 0 : -1}
|
||||
className="dk-tab"
|
||||
data-testid={`all-chats-tab-${id}`}
|
||||
onClick={() => setTab(id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
|
||||
e.preventDefault()
|
||||
const next = id === 'mine' ? 'automated' : 'mine'
|
||||
setTab(next)
|
||||
document.getElementById(`all-chats-tab-${next}`)?.focus()
|
||||
}}
|
||||
>
|
||||
{t(`allChats.tab.${id}`)}
|
||||
<span className="ac-tab__count" data-testid={`all-chats-count-${id}`}>{id === 'mine' ? visibleChats.length : automatedChats.length}</span>
|
||||
{id === 'automated' && waiting > 0 && (
|
||||
<span className="ac-tab__wait" data-testid="all-chats-waiting" title={t('allChats.automated.waitingCount', { count: waiting })} aria-label={t('allChats.automated.waitingCount', { count: waiting })}>{waiting}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'loading' && <SkeletonRows count={6} />}
|
||||
{state.status === 'error' && (
|
||||
<LoadError title={t('allChats.loadFailed')} error={state.error} onRetry={() => load()} nothingChanged={false} />
|
||||
)}
|
||||
{state.status === 'ready' && visibleChats.length === 0 && !deletes.pending && (
|
||||
{state.status === 'ready' && tab === 'automated' && (
|
||||
<div role={showTabs ? 'tabpanel' : undefined} id="all-chats-panel-automated" aria-labelledby="all-chats-tab-automated">
|
||||
<AutomatedChats
|
||||
chats={automatedChats}
|
||||
ctx={originCtx}
|
||||
needsYou={state.needsYou}
|
||||
onOpen={(id) => navigate(`/app/chats/${encodeURIComponent(id)}`)}
|
||||
onDelete={(c) => deletes.request({ id: c.id, name: c.name || tc('list.untitled') })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'ready' && tab === 'mine' && visibleChats.length === 0 && !deletes.pending && (
|
||||
<EmptyState
|
||||
testId="all-chats-empty"
|
||||
title={t('allChats.emptyTitle')}
|
||||
@@ -130,8 +198,8 @@ export default function AllChats() {
|
||||
actions={<Button variant="primary" icon="chat" onClick={() => navigate('/app')}>{t('allChats.start')}</Button>}
|
||||
/>
|
||||
)}
|
||||
{state.status === 'ready' && (visibleChats.length > 0 || deletes.pending) && (
|
||||
<div className="cx-menu cx-menu--inline" data-testid="all-chats-list">
|
||||
{state.status === 'ready' && tab === 'mine' && (visibleChats.length > 0 || deletes.pending) && (
|
||||
<div className="cx-menu cx-menu--inline" role={showTabs ? 'tabpanel' : undefined} id="all-chats-panel-mine" aria-labelledby={showTabs ? 'all-chats-tab-mine' : undefined} data-testid="all-chats-list">
|
||||
<ChatsList
|
||||
chats={visibleChats}
|
||||
idPrefix="all-chats"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { useParams, useOutletContext, useNavigate, useLocation, useSearchParams } from 'react-router-dom'
|
||||
import { useParams, useOutletContext, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { fromState } from '../utils/editorNav'
|
||||
import { useServerChat } from '../hooks/useServerChat'
|
||||
@@ -8,6 +8,8 @@ import { useComposerArrival } from '../hooks/useComposerTransition'
|
||||
import { useChatEgress } from '../hooks/useChatEgress'
|
||||
import { cardsByTool } from '../utils/chatEgress'
|
||||
import { useInbox } from '../hooks/useInbox'
|
||||
import { useOriginContext } from '../hooks/useOriginContext'
|
||||
import { isAutomated, knownKind, resolveOrigin } from '../utils/originChats'
|
||||
import { highlightAll, enhanceCodeBlocks } from '../utils/markdown'
|
||||
import {
|
||||
threadRows, streamingParts, lastUserText, chatMarkdown, menuChats, downloadFrom, chatFromExport,
|
||||
@@ -699,8 +701,14 @@ function ChatPage() {
|
||||
() => menuChats(chat.chats).filter(c => c.id !== pendingDelete?.id),
|
||||
[chat.chats, pendingDelete],
|
||||
)
|
||||
// The sidebar's recent chats follow this list.
|
||||
useEffect(() => { announceChats(visibleChats) }, [visibleChats])
|
||||
// The sidebar's recent chats follow this list, without the chats that no
|
||||
// person started: they have their own area (All chats, second tab).
|
||||
useEffect(() => { announceChats(visibleChats.filter(c => !c.origin)) }, [visibleChats])
|
||||
// What started a chat is named by the connector and trigger lists. They are
|
||||
// read only when this page shows a chat that something else started.
|
||||
const hasAutomated = visibleChats.some(c => c.origin) || Boolean(activeChat?.origin)
|
||||
const originCtx = useOriginContext(hasAutomated)
|
||||
const originTitle = (c) => resolveOrigin({ origin: c.origin, originId: c.originId }, originCtx).title
|
||||
|
||||
const promptDeleteAll = () => setConfirmDialog({
|
||||
title: t('deleteAllDialog.title'),
|
||||
@@ -709,7 +717,8 @@ function ChatPage() {
|
||||
danger: true,
|
||||
onConfirm: async () => {
|
||||
setConfirmDialog(null)
|
||||
for (const c of visibleChats) {
|
||||
// Clear all removes the person's own chats. The ones that something else started stay.
|
||||
for (const c of visibleChats.filter(x => !x.origin)) {
|
||||
try { await chat.remove(c.id); drafts.clear(c.id); pane.forget(c.id) } catch (e) { addToast(e.message || tc('errors.delete'), 'error'); break }
|
||||
}
|
||||
navigate('/app')
|
||||
@@ -1028,6 +1037,7 @@ function ChatPage() {
|
||||
onExport={(c) => exportChat(c.id)}
|
||||
onCopyChat={(c) => copyChat(c.id)}
|
||||
onSearch={(text) => chatsApi.search(text, 50)}
|
||||
originTitle={originTitle}
|
||||
/>
|
||||
)}
|
||||
manageMode={manage}
|
||||
@@ -1056,6 +1066,18 @@ function ChatPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeId && isAutomated(activeChat) && (() => {
|
||||
const o = resolveOrigin({ origin: activeChat.origin, originId: activeChat.origin_id }, originCtx)
|
||||
const kind = knownKind(o.kind)
|
||||
const name = o.title || t(`origin.kindFallback.${kind}`)
|
||||
return (
|
||||
<p className="cx-notice cx-origin-banner" role="note" data-testid="chat-origin-banner">
|
||||
<span>{t('origin.banner', { title: name })}</span>
|
||||
{o.href && <Link className="cx-link" to={o.href} data-testid="chat-origin-link">{t('origin.open', { title: name })}</Link>}
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
|
||||
<div className="cx-stage">
|
||||
<div className="cx-body" ref={messagesRef}>
|
||||
{isEmpty && <EmptyHead name={activeId ? title : (pickedAgent?.name || '')} manage={manage} />}
|
||||
|
||||
@@ -328,7 +328,7 @@ export default function Home() {
|
||||
// chats leaves the strip empty.
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
chatsApi.list(1, 'chat').then(r => {
|
||||
chatsApi.list(1, 'chat', 'person').then(r => {
|
||||
if (!live) return
|
||||
setConversations(resumeItems(r.conversations))
|
||||
setChatServer({ files: r.files !== false, filesReason: r.files_reason || '', persistent: r.persistent !== false, imageModel: r.image_model || '' })
|
||||
|
||||
@@ -1843,3 +1843,105 @@ html[data-handoff='fade']::view-transition-new(root) {
|
||||
animation: cxFade var(--dur-slow) var(--ease-out) both;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chats that a person did not start: the badge in a list row, the banner over
|
||||
a transcript, the tabs and the groups of All chats. */
|
||||
.cx-row-item__origin {
|
||||
flex: none;
|
||||
max-width: 14em;
|
||||
overflow: hidden;
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--dk-accent-wash);
|
||||
color: var(--dk-accent-text);
|
||||
font-size: var(--fs-meta);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 639px) { .cx-row-item__origin { max-width: 6.5em; } }
|
||||
.cx-origin-banner { margin: 8px 16px 0; justify-content: flex-start; flex-wrap: wrap; }
|
||||
.cx-origin-banner .cx-link { margin-left: auto; }
|
||||
|
||||
.ac-tabs { margin-bottom: 16px; }
|
||||
.ac-tabs .dk-tab { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.ac-tab__count {
|
||||
color: var(--dk-muted);
|
||||
font-size: var(--fs-meta);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ac-tab__wait {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--dk-warn-wash);
|
||||
color: var(--dk-warn-text);
|
||||
font-size: var(--fs-meta);
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.ac-intro, .ac-empty { margin: 0 0 16px; color: var(--dk-muted); font-size: var(--fs-small); }
|
||||
.ac-empty { padding: 28px 16px; text-align: center; }
|
||||
.ac-kind { margin-bottom: 28px; }
|
||||
.ac-kind__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 10px;
|
||||
color: var(--dk-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-meta);
|
||||
font-weight: 600;
|
||||
letter-spacing: .09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ac-kind__count { font-variant-numeric: tabular-nums; }
|
||||
.ac-group {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--dk-radius-2);
|
||||
box-shadow: inset 0 0 0 1px var(--dk-edge);
|
||||
}
|
||||
.ac-group__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 16px; }
|
||||
.ac-group__main { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.ac-group__title { margin: 0; min-width: 0; overflow: hidden; font-size: var(--fs-body); font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ac-group__meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; color: var(--dk-muted); font-size: var(--fs-small); }
|
||||
.ac-badge {
|
||||
flex: none;
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--dk-accent-wash);
|
||||
color: var(--dk-accent-text);
|
||||
font-size: var(--fs-meta);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ac-link { color: var(--dk-accent-text); font-weight: 600; }
|
||||
.ac-list { margin: 8px 0 0; padding: 0; list-style: none; }
|
||||
.ac-row { display: flex; align-items: center; gap: 8px; border-radius: var(--dk-radius-2); }
|
||||
.ac-row:hover { background: var(--dk-hover); }
|
||||
.ac-row__open {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 8px 6px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ac-row__name { min-width: 0; overflow: hidden; font-size: var(--fs-small); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ac-row__time { flex: none; color: var(--dk-muted); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; }
|
||||
.ac-row__busy { color: var(--dk-muted); }
|
||||
.ac-row__wait { color: var(--dk-warn-text); }
|
||||
.ac-more { margin-top: 6px; }
|
||||
@media (max-width: 639px) {
|
||||
.ac-group { padding: 10px; }
|
||||
.ac-row__open { flex-direction: column; align-items: flex-start; gap: 2px; }
|
||||
}
|
||||
Vendored
+1
-1
@@ -134,7 +134,7 @@ export const backendsApi = {
|
||||
// Chats API: conversations the server keeps. A message sent while a turn runs
|
||||
// waits and reaches the model, with any others, at its next request.
|
||||
export const chatsApi = {
|
||||
list: (limit, kind) => fetchJSON(chatsListPath(API_CONFIG.endpoints.chats, limit, kind)),
|
||||
list: (limit, kind, origin) => fetchJSON(chatsListPath(API_CONFIG.endpoints.chats, limit, kind, origin)),
|
||||
// With an agent, its model (when it has one) replaces model. With assistant,
|
||||
// the chat gets the LocalAI management tools (admin only, setting on).
|
||||
// switches are the drafted switches of a new chat (useChatSwitches): the
|
||||
|
||||
+2
@@ -163,6 +163,8 @@ export function menuChats(conversations) {
|
||||
model: c.model || '',
|
||||
running: Boolean(c.running),
|
||||
origin: c.origin || '',
|
||||
originId: c.origin_id || '',
|
||||
lastTurn: c.last_turn || '',
|
||||
agentId: c.agent_id || '',
|
||||
assistant: Boolean(c.assistant),
|
||||
createdAt: Date.parse(c.created) || 0,
|
||||
|
||||
+6
-1
@@ -1,10 +1,15 @@
|
||||
// The address of the chat list. With kind "chat" the server leaves sub-agent
|
||||
// runs out and counts only chats against the limit, so a burst of newer runs
|
||||
// cannot push a person's chats off the page.
|
||||
export function chatsListPath(base, limit, kind) {
|
||||
//
|
||||
// With an origin the server returns only the chats of that origin and counts
|
||||
// the limit against them: person (a person started it), automated (anything
|
||||
// else) or one origin kind such as trigger or connector.
|
||||
export function chatsListPath(base, limit, kind, origin) {
|
||||
const sp = new URLSearchParams()
|
||||
if (limit) sp.set('limit', String(limit))
|
||||
if (kind) sp.set('kind', kind)
|
||||
if (origin) sp.set('origin', origin)
|
||||
const s = sp.toString()
|
||||
return s ? `${base}?${s}` : base
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@ const DAY_MS = 24 * 60 * 60 * 1000
|
||||
// newest first. A sub-agent run belongs to its parent and is left out.
|
||||
export function resumeItems(conversations) {
|
||||
return (conversations || [])
|
||||
.filter(c => c && c.id && c.kind !== 'subagent')
|
||||
.filter(c => c && c.id && c.kind !== 'subagent' && !c.origin)
|
||||
.map(c => ({
|
||||
id: c.id,
|
||||
title: (c.title || '').trim() || 'New chat',
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import { addonPageAddress } from '../addons/addresses.js'
|
||||
|
||||
// Chats that a person did not start: a trigger, a connector binding, an agent
|
||||
// job or a page sent the first message. They stay out of the lists of the
|
||||
// person's own chats and have an area of their own. No React, so a node test
|
||||
// drives these helpers.
|
||||
//
|
||||
// Nothing here knows a platform. A chat has an origin kind (trigger,
|
||||
// connector, job, page, or a kind a later release adds) and the id of the
|
||||
// thing that started it. The title of that thing comes from the list the
|
||||
// kind belongs to; a kind that has no list falls back to the id, and a kind
|
||||
// this file never heard of falls under a generic group.
|
||||
|
||||
// The order of the groups. A kind that is not here comes after, by name.
|
||||
const KIND_ORDER = ['trigger', 'connector', 'job', 'page']
|
||||
|
||||
// The origin kinds that have words of their own; any other falls under "other".
|
||||
const KNOWN = ['trigger', 'connector', 'job', 'page']
|
||||
export const knownKind = (kind) => (KNOWN.includes(kind) ? kind : 'other')
|
||||
|
||||
export const isAutomated = (chat) => Boolean(chat?.origin)
|
||||
|
||||
// The chat summary, split: the chats a person started, then the others.
|
||||
export function splitByOrigin(chats) {
|
||||
const person = []
|
||||
const automated = []
|
||||
for (const c of chats || []) (isAutomated(c) ? automated : person).push(c)
|
||||
return { person, automated }
|
||||
}
|
||||
|
||||
// Where the thing that started a chat has its page, or '' when it has none.
|
||||
function originHref(kind, id) {
|
||||
if (kind === 'trigger') return id ? `/app/triggers/${encodeURIComponent(id)}` : '/app/triggers'
|
||||
if (kind === 'connector') return '/app/chats/connectors'
|
||||
if (kind === 'job') return '/app/triggers'
|
||||
if (kind === 'page') return id ? `/app/pages/${encodeURIComponent(id)}` : '/app/pages'
|
||||
return ''
|
||||
}
|
||||
|
||||
// The name a list gives the origin, or '' when the list does not know it.
|
||||
// ctx holds the lists the page read: connectors (bindings), connectorKinds
|
||||
// and triggers. A connector without a name of its own takes the title of its
|
||||
// kind.
|
||||
function originName(kind, id, ctx) {
|
||||
if (kind === 'connector') {
|
||||
const b = (ctx?.connectors || []).find((x) => x.id === id)
|
||||
if (!b) return ''
|
||||
if (b.name) return b.name
|
||||
return (ctx?.connectorKinds || []).find((k) => k.kind === b.kind)?.title || b.kind || ''
|
||||
}
|
||||
if (kind === 'trigger') return (ctx?.triggers || []).find((x) => x.id === id)?.name || ''
|
||||
// A job or a page has no list of its own here: its id is its name.
|
||||
if (kind === 'job' || kind === 'page') return id || ''
|
||||
return ''
|
||||
}
|
||||
|
||||
// What started a chat: { kind, id, key, title, href }. title is '' when
|
||||
// nothing names it; the view then shows the label of the kind.
|
||||
export function resolveOrigin(chat, ctx) {
|
||||
const kind = chat?.origin || ''
|
||||
const id = chat?.originId || ''
|
||||
let href = originHref(kind, id)
|
||||
if (kind === 'connector') {
|
||||
// The add-on that serves the binding's kind has a page of its own for it.
|
||||
const b = (ctx?.connectors || []).find((x) => x.id === id)
|
||||
const k = b && (ctx?.connectorKinds || []).find((x) => x.kind === b.kind && (!b.addon || x.addon === b.addon))
|
||||
if (k?.page) href = addonPageAddress({ addon: k.addon, page: k.page })
|
||||
}
|
||||
return { kind, id, key: `${kind}/${id}`, title: originName(kind, id, ctx), href }
|
||||
}
|
||||
|
||||
// The status of a chat of this kind: running while a turn runs, waiting when
|
||||
// it needs the person (its turn paused at the step limit, or an unread
|
||||
// notification asks for them; needsYou is the Set of chat ids with one), idle
|
||||
// otherwise.
|
||||
export function chatStatus(chat, needsYou) {
|
||||
if (chat?.lastTurn === 'paused' || needsYou?.has(chat?.id)) return 'waiting'
|
||||
if (chat?.running) return 'running'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
// The ids of the chats that unread inbox items ask the person to look at: a
|
||||
// run that failed, or something that needs them. An item links to its chat.
|
||||
const CHAT_LINK = /^\/app\/chats\/([^/?#]+)/
|
||||
export function needsYouIds(notifications) {
|
||||
const ids = new Set()
|
||||
for (const n of notifications || []) {
|
||||
if (n.read || (n.kind !== 'needs_attention' && n.kind !== 'run_failed')) continue
|
||||
const m = CHAT_LINK.exec(n.link || '')
|
||||
if (m) ids.add(decodeURIComponent(m[1]))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// How many of these chats need the person, and whether any turn runs.
|
||||
export function attention(chats, needsYou) {
|
||||
let waiting = 0
|
||||
let running = 0
|
||||
for (const c of chats || []) {
|
||||
if (!isAutomated(c)) continue
|
||||
const s = chatStatus(c, needsYou)
|
||||
if (s === 'waiting') waiting++
|
||||
else if (s === 'running') running++
|
||||
}
|
||||
return { waiting, running }
|
||||
}
|
||||
|
||||
// The chats that were not started by a person, in groups by origin kind and,
|
||||
// inside a kind, by origin (one trigger, one connector binding...). Kinds
|
||||
// keep a fixed order, origins and chats go newest first. Every group has the
|
||||
// count, the time of its latest chat and how many chats run or wait.
|
||||
export function groupByOrigin(chats, ctx, needsYou) {
|
||||
const kinds = new Map()
|
||||
for (const c of chats || []) {
|
||||
if (!isAutomated(c)) continue
|
||||
const o = resolveOrigin(c, ctx)
|
||||
if (!kinds.has(o.kind)) kinds.set(o.kind, new Map())
|
||||
const origins = kinds.get(o.kind)
|
||||
if (!origins.has(o.key)) origins.set(o.key, { ...o, chats: [], count: 0, lastActive: 0, running: 0, waiting: 0 })
|
||||
const g = origins.get(o.key)
|
||||
g.chats.push(c)
|
||||
g.count++
|
||||
g.lastActive = Math.max(g.lastActive, c.updatedAt || 0)
|
||||
const s = chatStatus(c, needsYou)
|
||||
if (s === 'running') g.running++
|
||||
if (s === 'waiting') g.waiting++
|
||||
}
|
||||
const rank = (k) => { const i = KIND_ORDER.indexOf(k); return i < 0 ? KIND_ORDER.length : i }
|
||||
return [...kinds.entries()]
|
||||
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
|
||||
.map(([kind, origins]) => {
|
||||
const groups = [...origins.values()].sort((a, b) => b.lastActive - a.lastActive)
|
||||
for (const g of groups) g.chats.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
||||
return {
|
||||
kind,
|
||||
count: groups.reduce((n, g) => n + g.count, 0),
|
||||
waiting: groups.reduce((n, g) => n + g.waiting, 0),
|
||||
running: groups.reduce((n, g) => n + g.running, 0),
|
||||
groups,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The tab of All chats the browser remembers.
|
||||
export const TAB_KEY = 'localai_all_chats_tab'
|
||||
export const TABS = ['mine', 'automated']
|
||||
|
||||
export function readTab(storage) {
|
||||
try {
|
||||
const v = (storage || window.localStorage).getItem(TAB_KEY)
|
||||
return TABS.includes(v) ? v : 'mine'
|
||||
} catch {
|
||||
return 'mine'
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTab(tab, storage) {
|
||||
try { (storage || window.localStorage).setItem(TAB_KEY, tab) } catch { /* private mode: the tab is not remembered */ }
|
||||
}
|
||||
@@ -8,6 +8,16 @@ test('chats list: a request for chats asks the server to leave sub-agent runs ou
|
||||
assert.equal(chatsListPath('/api/chats', 200, 'chat'), '/api/chats?limit=200&kind=chat')
|
||||
assert.equal(chatsListPath('/api/chats'), '/api/chats')
|
||||
assert.equal(chatsListPath('/api/chats', 5), '/api/chats?limit=5')
|
||||
assert.equal(chatsListPath('/api/chats', 8, 'chat', 'person'), '/api/chats?limit=8&kind=chat&origin=person')
|
||||
assert.equal(chatsListPath('/api/chats', 200, 'chat', 'automated'), '/api/chats?limit=200&kind=chat&origin=automated')
|
||||
})
|
||||
|
||||
test('chats list: resume rows leave out chats that a trigger or a connector started', () => {
|
||||
const rows = resumeItems([
|
||||
{ id: 'bot', origin: 'connector', origin_id: 'b1', updated: '2026-10-27T10:00:00Z' },
|
||||
{ id: 'me', title: 'Mine', origin: '', updated: '2026-10-27T09:00:00Z' },
|
||||
])
|
||||
assert.deepEqual(rows.map(r => r.id), ['me'])
|
||||
})
|
||||
|
||||
test('chats list: resume rows skip a sub-agent run that is the newest record', () => {
|
||||
|
||||
@@ -80,4 +80,7 @@ test('chat thread: server chats in the menu shape', () => {
|
||||
assert.equal(c.name, 'T')
|
||||
assert.equal(c.running, true)
|
||||
assert.equal(c.updatedAt, Date.parse('2026-01-02T00:00:00Z'))
|
||||
assert.deepEqual([c.origin, c.originId, c.lastTurn], ['', '', ''])
|
||||
const [b] = menuChats([{ id: 'b', origin: 'connector', origin_id: 'b1', last_turn: 'paused' }])
|
||||
assert.deepEqual([b.origin, b.originId, b.lastTurn], ['connector', 'b1', 'paused'])
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
attention, chatStatus, groupByOrigin, needsYouIds, readTab, resolveOrigin, splitByOrigin, writeTab,
|
||||
} from '../../src/utils/originChats.js'
|
||||
|
||||
const chat = (id, origin, originId, updatedAt, extra = {}) => ({ id, origin, originId, updatedAt, running: false, lastTurn: 'done', ...extra })
|
||||
const ctx = {
|
||||
connectors: [{ id: 'b1', name: 'Team bot', kind: 'chatapp' }, { id: 'b2', name: '', kind: 'chatapp' }],
|
||||
connectorKinds: [{ kind: 'chatapp', title: 'Chat App' }],
|
||||
triggers: [{ id: 't1', name: 'Morning digest' }],
|
||||
}
|
||||
|
||||
test('origin chats: the person and the others are split by origin', () => {
|
||||
const { person, automated } = splitByOrigin([chat('a', '', '', 1), chat('b', 'connector', 'b1', 2), chat('c', 'someday', 'x', 3)])
|
||||
assert.deepEqual(person.map(c => c.id), ['a'])
|
||||
assert.deepEqual(automated.map(c => c.id), ['b', 'c'])
|
||||
})
|
||||
|
||||
test('origin chats: a connector takes the name of its binding, then the title of its kind', () => {
|
||||
assert.equal(resolveOrigin(chat('a', 'connector', 'b1', 1), ctx).title, 'Team bot')
|
||||
assert.equal(resolveOrigin(chat('a', 'connector', 'b2', 1), ctx).title, 'Chat App')
|
||||
assert.equal(resolveOrigin(chat('a', 'connector', 'gone', 1), ctx).title, '')
|
||||
assert.equal(resolveOrigin(chat('a', 'connector', 'b1', 1), ctx).href, '/app/chats/connectors')
|
||||
})
|
||||
|
||||
test('origin chats: a connector links to the page of the add-on that serves its kind', () => {
|
||||
const withPage = { ...ctx, connectors: [{ id: 'b1', name: 'Team bot', kind: 'chatapp', addon: 'chatapp' }], connectorKinds: [{ addon: 'chatapp', kind: 'chatapp', title: 'Chat App', page: 'bot' }] }
|
||||
assert.equal(resolveOrigin(chat('a', 'connector', 'b1', 1), withPage).href, '/app/addons/chatapp/bot')
|
||||
})
|
||||
|
||||
test('origin chats: a trigger, a job and a page are named by their list or their id', () => {
|
||||
assert.equal(resolveOrigin(chat('a', 'trigger', 't1', 1), ctx).title, 'Morning digest')
|
||||
assert.equal(resolveOrigin(chat('a', 'trigger', 't1', 1), ctx).href, '/app/triggers/t1')
|
||||
assert.equal(resolveOrigin(chat('a', 'job', 'nightly', 1), ctx).title, 'nightly')
|
||||
assert.equal(resolveOrigin(chat('a', 'page', 'notes', 1), ctx).href, '/app/pages/notes')
|
||||
})
|
||||
|
||||
test('origin chats: an origin this release does not know gets no title and no link', () => {
|
||||
const o = resolveOrigin(chat('a', 'future', 'f1', 1), ctx)
|
||||
assert.deepEqual([o.kind, o.title, o.href], ['future', '', ''])
|
||||
})
|
||||
|
||||
test('origin chats: groups go by kind, then by origin, newest first, with counts', () => {
|
||||
const chats = [
|
||||
chat('a', 'connector', 'b1', 10), chat('b', 'connector', 'b1', 30), chat('c', 'connector', 'b2', 20),
|
||||
chat('d', 'trigger', 't1', 5), chat('e', 'future', 'f1', 99), chat('p', '', '', 100),
|
||||
]
|
||||
const kinds = groupByOrigin(chats, ctx, new Set())
|
||||
assert.deepEqual(kinds.map(k => k.kind), ['trigger', 'connector', 'future'])
|
||||
const conn = kinds[1]
|
||||
assert.equal(conn.count, 3)
|
||||
assert.deepEqual(conn.groups.map(g => g.id), ['b1', 'b2'])
|
||||
assert.equal(conn.groups[0].count, 2)
|
||||
assert.equal(conn.groups[0].lastActive, 30)
|
||||
assert.deepEqual(conn.groups[0].chats.map(c => c.id), ['b', 'a'])
|
||||
assert.ok(!JSON.stringify(kinds).includes('"p"'), 'the chat of a person is not grouped')
|
||||
})
|
||||
|
||||
test('origin chats: a running turn and a chat that needs the person are counted apart', () => {
|
||||
const chats = [
|
||||
chat('r', 'connector', 'b1', 3, { running: true }),
|
||||
chat('w', 'connector', 'b1', 2, { lastTurn: 'paused' }),
|
||||
chat('n', 'trigger', 't1', 1),
|
||||
chat('i', 'trigger', 't1', 0),
|
||||
]
|
||||
const needs = new Set(['n'])
|
||||
assert.equal(chatStatus(chats[0], needs), 'running')
|
||||
assert.equal(chatStatus(chats[1], needs), 'waiting')
|
||||
assert.equal(chatStatus(chats[2], needs), 'waiting')
|
||||
assert.equal(chatStatus(chats[3], needs), 'idle')
|
||||
assert.deepEqual(attention(chats, needs), { waiting: 2, running: 1 })
|
||||
const conn = groupByOrigin(chats, ctx, needs).find(k => k.kind === 'connector')
|
||||
assert.deepEqual([conn.running, conn.waiting], [1, 1])
|
||||
})
|
||||
|
||||
test('origin chats: only unread items that ask for the person count, and they link to a chat', () => {
|
||||
const ids = needsYouIds([
|
||||
{ kind: 'needs_attention', read: false, link: '/app/chats/c1' },
|
||||
{ kind: 'run_failed', read: false, link: '/app/chats/c%202?x=1' },
|
||||
{ kind: 'run_ended', read: false, link: '/app/chats/c3' },
|
||||
{ kind: 'needs_attention', read: true, link: '/app/chats/c4' },
|
||||
{ kind: 'needs_attention', read: false, link: '/app/triggers' },
|
||||
])
|
||||
assert.deepEqual([...ids].sort(), ['c 2', 'c1'])
|
||||
})
|
||||
|
||||
test('origin chats: the tab is remembered, and a storage that throws does not break it', () => {
|
||||
const mem = new Map()
|
||||
const storage = { getItem: (k) => mem.get(k) ?? null, setItem: (k, v) => mem.set(k, v) }
|
||||
assert.equal(readTab(storage), 'mine')
|
||||
writeTab('automated', storage)
|
||||
assert.equal(readTab(storage), 'automated')
|
||||
mem.set('localai_all_chats_tab', 'nonsense')
|
||||
assert.equal(readTab(storage), 'mine')
|
||||
const broken = { getItem() { throw new Error('denied') }, setItem() { throw new Error('denied') } }
|
||||
assert.equal(readTab(broken), 'mine')
|
||||
assert.doesNotThrow(() => writeTab('automated', broken))
|
||||
})
|
||||
@@ -174,6 +174,22 @@ The answers have the shapes of `GET /api/chats/{id}/tools` and `GET
|
||||
/api/chats/{id}/addons`. A model you may not use is `403`, a model the server
|
||||
does not have is `400`, and another user's agent is `404`.
|
||||
|
||||
### List the chats of one origin
|
||||
|
||||
`GET /api/chats` returns every chat unless you send `origin`. With
|
||||
`origin=person` it returns only the chats a person started, with
|
||||
`origin=automated` only the chats that something else started, and with an
|
||||
origin kind (`trigger`, `connector`, `job`, `page`) only the chats of that kind.
|
||||
Each chat carries `origin` (empty for a person) and `origin_id` (the trigger,
|
||||
binding, job or page that started it). With `origin` set, `limit` counts the
|
||||
chats kept and sub-agent runs are left out, so a burst of chats from a bot
|
||||
cannot push your own chats out of the first page.
|
||||
|
||||
```bash
|
||||
curl -s 'localhost:8080/api/chats?kind=chat&origin=person&limit=8'
|
||||
curl -s 'localhost:8080/api/chats?kind=chat&origin=automated&limit=200'
|
||||
```
|
||||
|
||||
## Keep typing while the model works
|
||||
|
||||
Send another message while a turn runs and it waits. The response says so:
|
||||
@@ -1804,6 +1820,22 @@ and opens again from any browser.
|
||||
Markdown or delete one, clear them all, or import a JSON export. A deleted
|
||||
chat can be brought back for a few seconds with **Undo**; then the server
|
||||
deletes it.
|
||||
|
||||
The page lists the chats you started. Chats that something else started (a
|
||||
connector, a trigger, an agent job or a page) have a second tab, **From
|
||||
add-ons and automations**, which appears once there is one. It groups them by
|
||||
what started them: first the kind (triggers, connectors, jobs, pages, any
|
||||
other), then the origin itself, such as one connector binding or one
|
||||
trigger, with the origin's name, how many chats it has, when it was last
|
||||
active, a **Running** mark while a turn runs and **Waiting for you** when a
|
||||
chat needs you (its turn paused at the step limit, or an unread inbox item
|
||||
asks for you). A link opens the origin's page. These chats do not appear in
|
||||
the sidebar, in **Jump back in** or in the **My chats** tab; the **All
|
||||
chats** entry in the sidebar shows a number when some of them wait for you.
|
||||
`Ctrl+K` in a transcript still lists and finds every chat, with a badge on
|
||||
the ones you did not start. Opening one shows "This conversation was started
|
||||
by ..." over the transcript, with a link to the origin. The browser
|
||||
remembers which tab you used last.
|
||||
- **Old addresses.** `/app/chats` with no chat id opens Home. A `?model=` in
|
||||
the address selects that model on Home, so `/app/chats?model=<id>` and the
|
||||
older `/app/chat/<model>` still start a chat on that model.
|
||||
|
||||
Reference in new issue
Block a user