feat(ui): show change proposals as a card and on a Proposals page

A propose_change tool line now draws a card in the thread, for any kind
and any source: the kind, file and scope, the source, the time left and a
line diff against the file as it is now. The owner can Confirm, edit the
text then confirm, or Dismiss. A confirmed change shows a receipt with
Undo. Expired and stale proposals say nothing was saved and offer to ask
the assistant again; a failed write leaves the proposal waiting.

The inbox button counts the waiting changes and its panel links to a new
Proposals page that lists them with the same card.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 10:15:03 +00:00
1 parent a916f6bd42
commit c6f004436c
20 files changed
+1551 -14

No files matched your search

+122 -1
View File
@@ -454,7 +454,10 @@
"itemUnread": "{{title}}, ungelesen",
"arrived": "Neue Benachrichtigung: {{title}}",
"arrivedMany_one": "{{count}} neue Benachrichtigung",
"arrivedMany_other": "{{count}} neue Benachrichtigungen"
"arrivedMany_other": "{{count}} neue Benachrichtigungen",
"proposalsWaiting_one": "{{count}} Änderung wartet auf dich",
"proposalsWaiting_other": "{{count}} Änderungen warten auf dich",
"proposalsBody": "Ein Modell hat eine Änderung vorgeschlagen. Nichts wird gespeichert, bevor du antwortest."
},
"change": {
"loading": "Die Trigger-Änderung wird gelesen...",
@@ -583,5 +586,123 @@
"stopped_one": "{{count}} Schleife gestoppt.",
"stopped_other": "{{count}} Schleifen gestoppt."
}
},
"changes": {
"loading": "Der vorgeschlagene Änderung wird gelesen...",
"unread": "Die vorgeschlagene Änderung konnte nicht gelesen werden: {{message}}",
"removed": "Dieser Vorschlag existiert nicht mehr.",
"kind": {
"memory": "Gedächtnis",
"profile": "Profil",
"skill": "Skill",
"page": "Seite",
"dashboard": "Dashboard"
},
"scope": {
"user": "Alle deine Chats",
"agent": "Agent {{agent}}",
"agentAny": "Ein Agent",
"channel": "Diese Unterhaltung"
},
"help": "Nichts wird gespeichert, bevor du bestätigst.",
"row": {
"kind": "Art",
"target": "Datei",
"source": "Von",
"expires": "Läuft ab in"
},
"source": {
"chat": "Ein Chat",
"connector": "Connector {{name}}",
"connectorFrom": "Connector {{name}}, von {{sender}}"
},
"expiresHours": "{{hours}} Std. {{minutes}} Min.",
"expiresMinutes": "{{minutes}} Min.",
"diff": {
"label": "Änderungen an {{target}}",
"newFile": "Neue Datei",
"added_one": "{{count}} Zeile hinzugefügt",
"added_other": "{{count}} Zeilen hinzugefügt",
"removed_one": "{{count}} Zeile entfernt",
"removed_other": "{{count}} Zeilen entfernt",
"same": "Kein Unterschied zum aktuellen Text.",
"gap_one": "{{count}} unveränderte Zeile",
"gap_other": "{{count}} unveränderte Zeilen",
"more_one": "{{count}} weitere Zeile nicht angezeigt",
"more_other": "{{count}} weitere Zeilen nicht angezeigt",
"srAdded": "Hinzugefügt:",
"srRemoved": "Entfernt:"
},
"unreadFile": "Die aktuelle Datei konnte nicht gelesen werden, daher gilt der ganze Text als neu.",
"confirm": "Bestätigen",
"edit": "Bearbeiten und bestätigen",
"confirmEdited": "Mit meinem Text bestätigen",
"editLabel": "Text zum Speichern in {{target}}",
"failed": "Die Änderung konnte nicht gespeichert werden: {{message}}. Sie wartet weiter auf dich.",
"receipt": {
"applied": "Angewendet:",
"reverted": "Rückgängig gemacht:",
"dismissed": "Abgelehnt:",
"expired": "Abgelaufen:",
"stale": "Nicht angewendet:"
},
"why": {
"expired": "Sie hat mehr als einen Tag gewartet. Nichts wurde gespeichert.",
"stale": "Die Datei hat sich seit dem Vorschlag geändert, daher wurde nichts gespeichert."
},
"askAgain": "Erneut anfragen",
"askAgainText": "Bitte schlage diese Änderung noch einmal vor, die Datei hat sich inzwischen geändert: {{summary}} ({{target}})",
"announce": {
"applied": "Änderung angewendet.",
"dismissed": "Änderung abgelehnt.",
"undone": "Änderung rückgängig gemacht.",
"asked": "Der Assistent wurde gebeten, es erneut vorzuschlagen."
}
},
"proposalsPage": {
"title": "Vorschläge",
"supporting": "Änderungen, die Modelle vorgeschlagen haben und die auf deine Antwort warten.",
"emptyTitle": "Nichts wartet",
"emptyBody": "Wenn ein Modell eine Änderung an deinem Gedächtnis oder einem Agentenprofil vorschlägt, erscheint sie hier, bis du antwortest.",
"loadError": "Die Vorschläge konnten nicht geladen werden."
},
"memoryPage": {
"lede": "Die Notizen, die LocalAI in den Prompt deiner Chats legt. Ein Modell ändert sie erst, nachdem du einen Vorschlag bestätigt hast. Hier kannst du sie lesen, bearbeiten und löschen.",
"limits": "Grenzen: Ein Index hat höchstens {{lines}} Zeilen und {{index}}, ein Profil {{profile}}, und ein Zug enthält höchstens {{turn}} Notizen.",
"proposalsLink": "Wartende Änderungen ansehen",
"emptyTitle": "Noch keine Notizen",
"emptyBody": "Wenn du einen Assistenten bittest, sich etwas zu merken, und das bestätigst, erscheinen die Notizen hier.",
"loadError": "Deine Notizen konnten nicht geladen werden.",
"use": "{{size}} von {{max}} · {{lines}} von {{maxLines}} Zeilen",
"useBytes": "{{size}} von {{max}}",
"useLabel": "Größe des Index im Verhältnis zur Grenze",
"over": "Über der Grenze: das Ende wird nicht an das Modell gesendet.",
"index": "Index",
"group": {
"user": "Deine Notizen",
"agent": "Notizen von Agent {{key}}",
"channel": "Notizen der Unterhaltung {{key}}",
"profile": "Profil von Agent {{key}}"
},
"edit": "Bearbeiten",
"save": "Speichern",
"editLabel": "Text von {{name}}",
"editSizeIndex": "{{size}} von {{max}} · {{lines}} von {{maxLines}} Zeilen",
"editSize": "{{size}} von {{max}}",
"tooBig": "Zu lang: kürze den Text, um zu speichern.",
"saveFailed": "Speichern fehlgeschlagen: {{message}}",
"saved": "{{name}} gespeichert.",
"emptyFile": "(leer)",
"delete": "{{name}} löschen",
"deleteYes": "Löschen",
"confirmDelete": "{{name}} löschen?",
"deleted": "{{name}} in den Papierkorb verschoben.",
"clear": "Alle Notizen löschen",
"clearAsk_one": "{{count}} Datei löschen? Sie kommt in den Papierkorb und lässt sich unter Dateien wiederherstellen.",
"clearAsk_other": "{{count}} Dateien löschen? Sie kommen in den Papierkorb und lassen sich unter Dateien wiederherstellen.",
"clearYes_one": "{{count}} Datei löschen",
"clearYes_other": "{{count}} Dateien löschen",
"cleared_one": "{{count}} Datei in den Papierkorb verschoben.",
"cleared_other": "{{count}} Dateien in den Papierkorb verschoben."
}
}
+122 -1
View File
@@ -305,7 +305,10 @@
"itemUnread": "{{title}}, unread",
"arrived": "New notification: {{title}}",
"arrivedMany_one": "{{count}} new notification",
"arrivedMany_other": "{{count}} new notifications"
"arrivedMany_other": "{{count}} new notifications",
"proposalsWaiting_one": "{{count}} change waits for you",
"proposalsWaiting_other": "{{count}} changes wait for you",
"proposalsBody": "A model proposed a change. Nothing is saved until you answer."
},
"connectorsPage": {
"title": "Connectors",
@@ -918,5 +921,123 @@
"stopped_one": "Stopped {{count}} loop.",
"stopped_other": "Stopped {{count}} loops."
}
},
"changes": {
"loading": "Reading the proposed change...",
"unread": "The proposed change could not be read: {{message}}",
"removed": "This proposal is gone.",
"kind": {
"memory": "Memory",
"profile": "Profile",
"skill": "Skill",
"page": "Page",
"dashboard": "Dashboard"
},
"scope": {
"user": "All your chats",
"agent": "Agent {{agent}}",
"agentAny": "One agent",
"channel": "This conversation"
},
"help": "Nothing is saved until you confirm.",
"row": {
"kind": "Kind",
"target": "File",
"source": "From",
"expires": "Expires in"
},
"source": {
"chat": "A chat",
"connector": "Connector {{name}}",
"connectorFrom": "Connector {{name}}, from {{sender}}"
},
"expiresHours": "{{hours}} h {{minutes}} min",
"expiresMinutes": "{{minutes}} min",
"diff": {
"label": "Changes to {{target}}",
"newFile": "New file",
"added_one": "{{count}} line added",
"added_other": "{{count}} lines added",
"removed_one": "{{count}} line removed",
"removed_other": "{{count}} lines removed",
"same": "No difference from the current text.",
"gap_one": "{{count}} unchanged line",
"gap_other": "{{count}} unchanged lines",
"more_one": "{{count}} more line not shown",
"more_other": "{{count}} more lines not shown",
"srAdded": "Added:",
"srRemoved": "Removed:"
},
"unreadFile": "The current file could not be read, so all of the text counts as new.",
"confirm": "Confirm",
"edit": "Edit then confirm",
"confirmEdited": "Confirm with my text",
"editLabel": "Text to save in {{target}}",
"failed": "Could not save the change: {{message}}. It is still waiting for you.",
"receipt": {
"applied": "Applied:",
"reverted": "Undone:",
"dismissed": "Dismissed:",
"expired": "Expired:",
"stale": "Not applied:"
},
"why": {
"expired": "It waited more than a day. Nothing was saved.",
"stale": "The file changed after this was proposed, so nothing was saved."
},
"askAgain": "Ask again",
"askAgainText": "Please propose this change again, the file has changed since: {{summary}} ({{target}})",
"announce": {
"applied": "Change applied.",
"dismissed": "Change dismissed.",
"undone": "Change undone.",
"asked": "Asked the assistant to propose it again."
}
},
"proposalsPage": {
"title": "Proposals",
"supporting": "Changes that models asked for and that wait for your answer.",
"emptyTitle": "Nothing is waiting",
"emptyBody": "When a model proposes a change to your memory or an agent profile, it shows here until you answer.",
"loadError": "Could not load the proposals."
},
"memoryPage": {
"lede": "The notes that LocalAI puts in the prompt of your chats. A model changes them only after you confirm a proposal. Here you can read, edit and delete them.",
"limits": "Caps: an index has at most {{lines}} lines and {{index}}, a profile {{profile}}, and one turn carries at most {{turn}} of notes.",
"proposalsLink": "See the changes waiting for you",
"emptyTitle": "No notes yet",
"emptyBody": "When you ask an assistant to remember something and you confirm it, the notes appear here.",
"loadError": "Could not load your notes.",
"use": "{{size}} of {{max}} · {{lines}} of {{maxLines}} lines",
"useBytes": "{{size}} of {{max}}",
"useLabel": "Size of the index against its cap",
"over": "Over the cap: the end is not sent to the model.",
"index": "Index",
"group": {
"user": "Your notes",
"agent": "Notes of agent {{key}}",
"channel": "Notes of conversation {{key}}",
"profile": "Profile of agent {{key}}"
},
"edit": "Edit",
"save": "Save",
"editLabel": "Text of {{name}}",
"editSizeIndex": "{{size}} of {{max}} · {{lines}} of {{maxLines}} lines",
"editSize": "{{size}} of {{max}}",
"tooBig": "Too long: shorten it to save.",
"saveFailed": "Could not save: {{message}}",
"saved": "Saved {{name}}.",
"emptyFile": "(empty)",
"delete": "Delete {{name}}",
"deleteYes": "Delete",
"confirmDelete": "Delete {{name}}?",
"deleted": "Moved {{name}} to the Trash.",
"clear": "Clear all notes",
"clearAsk_one": "Delete {{count}} file? It moves to the Trash and you can restore it from Files.",
"clearAsk_other": "Delete {{count}} files? They move to the Trash and you can restore them from Files.",
"clearYes_one": "Delete {{count}} file",
"clearYes_other": "Delete {{count}} files",
"cleared_one": "Moved {{count}} file to the Trash.",
"cleared_other": "Moved {{count}} files to the Trash."
}
}
+122 -1
View File
@@ -454,7 +454,10 @@
"itemUnread": "{{title}}, sin leer",
"arrived": "Nueva notificación: {{title}}",
"arrivedMany_one": "{{count}} notificación nueva",
"arrivedMany_other": "{{count}} notificaciones nuevas"
"arrivedMany_other": "{{count}} notificaciones nuevas",
"proposalsWaiting_one": "{{count}} cambio te espera",
"proposalsWaiting_other": "{{count}} cambios te esperan",
"proposalsBody": "Un modelo propuso un cambio. No se guarda nada hasta que respondas."
},
"change": {
"loading": "Leyendo el cambio del disparador...",
@@ -583,5 +586,123 @@
"stopped_one": "Se detuvo {{count}} bucle.",
"stopped_other": "Se detuvieron {{count}} bucles."
}
},
"changes": {
"loading": "Leyendo el cambio propuesto...",
"unread": "No se pudo leer el cambio propuesto: {{message}}",
"removed": "Esta propuesta ya no existe.",
"kind": {
"memory": "Memoria",
"profile": "Perfil",
"skill": "Habilidad",
"page": "Página",
"dashboard": "Panel"
},
"scope": {
"user": "Todos tus chats",
"agent": "Agente {{agent}}",
"agentAny": "Un agente",
"channel": "Esta conversación"
},
"help": "No se guarda nada hasta que confirmes.",
"row": {
"kind": "Tipo",
"target": "Archivo",
"source": "De",
"expires": "Caduca en"
},
"source": {
"chat": "Un chat",
"connector": "Conector {{name}}",
"connectorFrom": "Conector {{name}}, de {{sender}}"
},
"expiresHours": "{{hours}} h {{minutes}} min",
"expiresMinutes": "{{minutes}} min",
"diff": {
"label": "Cambios en {{target}}",
"newFile": "Archivo nuevo",
"added_one": "{{count}} línea añadida",
"added_other": "{{count}} líneas añadidas",
"removed_one": "{{count}} línea eliminada",
"removed_other": "{{count}} líneas eliminadas",
"same": "Sin diferencias con el texto actual.",
"gap_one": "{{count}} línea sin cambios",
"gap_other": "{{count}} líneas sin cambios",
"more_one": "{{count}} línea más sin mostrar",
"more_other": "{{count}} líneas más sin mostrar",
"srAdded": "Añadida:",
"srRemoved": "Eliminada:"
},
"unreadFile": "No se pudo leer el archivo actual, así que todo el texto cuenta como nuevo.",
"confirm": "Confirmar",
"edit": "Editar y confirmar",
"confirmEdited": "Confirmar con mi texto",
"editLabel": "Texto que se guardará en {{target}}",
"failed": "No se pudo guardar el cambio: {{message}}. Sigue esperando tu respuesta.",
"receipt": {
"applied": "Aplicado:",
"reverted": "Deshecho:",
"dismissed": "Descartado:",
"expired": "Caducado:",
"stale": "No aplicado:"
},
"why": {
"expired": "Esperó más de un día. No se guardó nada.",
"stale": "El archivo cambió después de la propuesta, así que no se guardó nada."
},
"askAgain": "Pedir de nuevo",
"askAgainText": "Por favor, propón este cambio de nuevo; el archivo ha cambiado desde entonces: {{summary}} ({{target}})",
"announce": {
"applied": "Cambio aplicado.",
"dismissed": "Cambio descartado.",
"undone": "Cambio deshecho.",
"asked": "Se pidió al asistente que lo proponga de nuevo."
}
},
"proposalsPage": {
"title": "Propuestas",
"supporting": "Cambios que pidieron los modelos y que esperan tu respuesta.",
"emptyTitle": "No hay nada pendiente",
"emptyBody": "Cuando un modelo propone un cambio en tu memoria o en el perfil de un agente, aparece aquí hasta que respondas.",
"loadError": "No se pudieron cargar las propuestas."
},
"memoryPage": {
"lede": "Las notas que LocalAI añade al prompt de tus chats. Un modelo solo las cambia después de que confirmes una propuesta. Aquí puedes leerlas, editarlas y eliminarlas.",
"limits": "Límites: un índice tiene como máximo {{lines}} líneas y {{index}}, un perfil {{profile}}, y un turno lleva como máximo {{turn}} de notas.",
"proposalsLink": "Ver los cambios pendientes",
"emptyTitle": "Aún no hay notas",
"emptyBody": "Cuando pides a un asistente que recuerde algo y lo confirmas, las notas aparecen aquí.",
"loadError": "No se pudieron cargar tus notas.",
"use": "{{size}} de {{max}} · {{lines}} de {{maxLines}} líneas",
"useBytes": "{{size}} de {{max}}",
"useLabel": "Tamaño del índice frente a su límite",
"over": "Supera el límite: el final no se envía al modelo.",
"index": "Índice",
"group": {
"user": "Tus notas",
"agent": "Notas del agente {{key}}",
"channel": "Notas de la conversación {{key}}",
"profile": "Perfil del agente {{key}}"
},
"edit": "Editar",
"save": "Guardar",
"editLabel": "Texto de {{name}}",
"editSizeIndex": "{{size}} de {{max}} · {{lines}} de {{maxLines}} líneas",
"editSize": "{{size}} de {{max}}",
"tooBig": "Demasiado largo: acórtalo para guardar.",
"saveFailed": "No se pudo guardar: {{message}}",
"saved": "{{name}} guardado.",
"emptyFile": "(vacío)",
"delete": "Eliminar {{name}}",
"deleteYes": "Eliminar",
"confirmDelete": "¿Eliminar {{name}}?",
"deleted": "{{name}} movido a la Papelera.",
"clear": "Borrar todas las notas",
"clearAsk_one": "¿Eliminar {{count}} archivo? Va a la Papelera y puedes restaurarlo desde Archivos.",
"clearAsk_other": "¿Eliminar {{count}} archivos? Van a la Papelera y puedes restaurarlos desde Archivos.",
"clearYes_one": "Eliminar {{count}} archivo",
"clearYes_other": "Eliminar {{count}} archivos",
"cleared_one": "{{count}} archivo movido a la Papelera.",
"cleared_other": "{{count}} archivos movidos a la Papelera."
}
}
+122 -1
View File
@@ -454,7 +454,10 @@
"itemUnread": "{{title}}, non lue",
"arrived": "Nouvelle notification : {{title}}",
"arrivedMany_one": "{{count}} nouvelle notification",
"arrivedMany_other": "{{count}} nouvelles notifications"
"arrivedMany_other": "{{count}} nouvelles notifications",
"proposalsWaiting_one": "{{count}} modification vous attend",
"proposalsWaiting_other": "{{count}} modifications vous attendent",
"proposalsBody": "Un modèle a proposé une modification. Rien n'est enregistré avant votre réponse."
},
"change": {
"loading": "Lecture de la modification du déclencheur...",
@@ -583,5 +586,123 @@
"stopped_one": "{{count}} boucle arrêtée.",
"stopped_other": "{{count}} boucles arrêtées."
}
},
"changes": {
"loading": "Lecture de la modification proposée...",
"unread": "Impossible de lire la modification proposée : {{message}}",
"removed": "Cette proposition n'existe plus.",
"kind": {
"memory": "Mémoire",
"profile": "Profil",
"skill": "Skill",
"page": "Page",
"dashboard": "Tableau de bord"
},
"scope": {
"user": "Toutes vos conversations",
"agent": "Agent {{agent}}",
"agentAny": "Un agent",
"channel": "Cette conversation"
},
"help": "Rien n'est enregistré avant votre confirmation.",
"row": {
"kind": "Type",
"target": "Fichier",
"source": "De",
"expires": "Expire dans"
},
"source": {
"chat": "Une conversation",
"connector": "Connecteur {{name}}",
"connectorFrom": "Connecteur {{name}}, de {{sender}}"
},
"expiresHours": "{{hours}} h {{minutes}} min",
"expiresMinutes": "{{minutes}} min",
"diff": {
"label": "Modifications de {{target}}",
"newFile": "Nouveau fichier",
"added_one": "{{count}} ligne ajoutée",
"added_other": "{{count}} lignes ajoutées",
"removed_one": "{{count}} ligne supprimée",
"removed_other": "{{count}} lignes supprimées",
"same": "Aucune différence avec le texte actuel.",
"gap_one": "{{count}} ligne inchangée",
"gap_other": "{{count}} lignes inchangées",
"more_one": "{{count}} ligne de plus non affichée",
"more_other": "{{count}} lignes de plus non affichées",
"srAdded": "Ajoutée :",
"srRemoved": "Supprimée :"
},
"unreadFile": "Le fichier actuel n'a pas pu être lu : tout le texte compte comme nouveau.",
"confirm": "Confirmer",
"edit": "Modifier puis confirmer",
"confirmEdited": "Confirmer avec mon texte",
"editLabel": "Texte à enregistrer dans {{target}}",
"failed": "Impossible d'enregistrer la modification : {{message}}. Elle attend toujours votre réponse.",
"receipt": {
"applied": "Appliquée :",
"reverted": "Annulée :",
"dismissed": "Refusée :",
"expired": "Expirée :",
"stale": "Non appliquée :"
},
"why": {
"expired": "Elle a attendu plus d'un jour. Rien n'a été enregistré.",
"stale": "Le fichier a changé après la proposition : rien n'a été enregistré."
},
"askAgain": "Redemander",
"askAgainText": "Merci de proposer à nouveau cette modification, le fichier a changé entre-temps : {{summary}} ({{target}})",
"announce": {
"applied": "Modification appliquée.",
"dismissed": "Modification refusée.",
"undone": "Modification annulée.",
"asked": "L'assistant a été prié de la proposer à nouveau."
}
},
"proposalsPage": {
"title": "Propositions",
"supporting": "Modifications demandées par les modèles et en attente de votre réponse.",
"emptyTitle": "Rien en attente",
"emptyBody": "Quand un modèle propose une modification de votre mémoire ou du profil d'un agent, elle apparaît ici jusqu'à votre réponse.",
"loadError": "Impossible de charger les propositions."
},
"memoryPage": {
"lede": "Les notes que LocalAI ajoute au prompt de vos conversations. Un modèle ne les modifie qu'après votre confirmation d'une proposition. Ici, vous pouvez les lire, les modifier et les supprimer.",
"limits": "Limites : un index compte au plus {{lines}} lignes et {{index}}, un profil {{profile}}, et un tour transporte au plus {{turn}} de notes.",
"proposalsLink": "Voir les modifications en attente",
"emptyTitle": "Pas encore de notes",
"emptyBody": "Quand vous demandez à un assistant de retenir quelque chose et que vous confirmez, les notes apparaissent ici.",
"loadError": "Impossible de charger vos notes.",
"use": "{{size}} sur {{max}} · {{lines}} sur {{maxLines}} lignes",
"useBytes": "{{size}} sur {{max}}",
"useLabel": "Taille de l'index par rapport à sa limite",
"over": "Au-delà de la limite : la fin n'est pas envoyée au modèle.",
"index": "Index",
"group": {
"user": "Vos notes",
"agent": "Notes de l'agent {{key}}",
"channel": "Notes de la conversation {{key}}",
"profile": "Profil de l'agent {{key}}"
},
"edit": "Modifier",
"save": "Enregistrer",
"editLabel": "Texte de {{name}}",
"editSizeIndex": "{{size}} sur {{max}} · {{lines}} sur {{maxLines}} lignes",
"editSize": "{{size}} sur {{max}}",
"tooBig": "Trop long : raccourcissez pour enregistrer.",
"saveFailed": "Impossible d'enregistrer : {{message}}",
"saved": "{{name}} enregistré.",
"emptyFile": "(vide)",
"delete": "Supprimer {{name}}",
"deleteYes": "Supprimer",
"confirmDelete": "Supprimer {{name}} ?",
"deleted": "{{name}} déplacé dans la corbeille.",
"clear": "Effacer toutes les notes",
"clearAsk_one": "Supprimer {{count}} fichier ? Il va dans la corbeille et se restaure depuis Fichiers.",
"clearAsk_other": "Supprimer {{count}} fichiers ? Ils vont dans la corbeille et se restaurent depuis Fichiers.",
"clearYes_one": "Supprimer {{count}} fichier",
"clearYes_other": "Supprimer {{count}} fichiers",
"cleared_one": "{{count}} fichier déplacé dans la corbeille.",
"cleared_other": "{{count}} fichiers déplacés dans la corbeille."
}
}
+122 -1
View File
@@ -454,7 +454,10 @@
"itemUnread": "{{title}}, belum dibaca",
"arrived": "Notifikasi baru: {{title}}",
"arrivedMany_one": "{{count}} notifikasi baru",
"arrivedMany_other": "{{count}} notifikasi baru"
"arrivedMany_other": "{{count}} notifikasi baru",
"proposalsWaiting_one": "{{count}} perubahan menunggu Anda",
"proposalsWaiting_other": "{{count}} perubahan menunggu Anda",
"proposalsBody": "Model mengusulkan perubahan. Tidak ada yang disimpan sampai Anda menjawab."
},
"change": {
"loading": "Membaca perubahan pemicu...",
@@ -583,5 +586,123 @@
"stopped_one": "{{count}} perulangan dihentikan.",
"stopped_other": "{{count}} perulangan dihentikan."
}
},
"changes": {
"loading": "Membaca perubahan yang diusulkan...",
"unread": "Perubahan yang diusulkan tidak dapat dibaca: {{message}}",
"removed": "Usulan ini sudah tidak ada.",
"kind": {
"memory": "Memori",
"profile": "Profil",
"skill": "Skill",
"page": "Halaman",
"dashboard": "Dasbor"
},
"scope": {
"user": "Semua obrolan Anda",
"agent": "Agen {{agent}}",
"agentAny": "Satu agen",
"channel": "Percakapan ini"
},
"help": "Tidak ada yang disimpan sampai Anda mengonfirmasi.",
"row": {
"kind": "Jenis",
"target": "Berkas",
"source": "Dari",
"expires": "Kedaluwarsa dalam"
},
"source": {
"chat": "Obrolan",
"connector": "Konektor {{name}}",
"connectorFrom": "Konektor {{name}}, dari {{sender}}"
},
"expiresHours": "{{hours}} j {{minutes}} mnt",
"expiresMinutes": "{{minutes}} mnt",
"diff": {
"label": "Perubahan pada {{target}}",
"newFile": "Berkas baru",
"added_one": "{{count}} baris ditambahkan",
"added_other": "{{count}} baris ditambahkan",
"removed_one": "{{count}} baris dihapus",
"removed_other": "{{count}} baris dihapus",
"same": "Tidak ada perbedaan dari teks saat ini.",
"gap_one": "{{count}} baris tidak berubah",
"gap_other": "{{count}} baris tidak berubah",
"more_one": "{{count}} baris lagi tidak ditampilkan",
"more_other": "{{count}} baris lagi tidak ditampilkan",
"srAdded": "Ditambahkan:",
"srRemoved": "Dihapus:"
},
"unreadFile": "Berkas saat ini tidak dapat dibaca, jadi seluruh teks dianggap baru.",
"confirm": "Konfirmasi",
"edit": "Edit lalu konfirmasi",
"confirmEdited": "Konfirmasi dengan teks saya",
"editLabel": "Teks yang disimpan di {{target}}",
"failed": "Perubahan tidak dapat disimpan: {{message}}. Masih menunggu Anda.",
"receipt": {
"applied": "Diterapkan:",
"reverted": "Dibatalkan:",
"dismissed": "Ditolak:",
"expired": "Kedaluwarsa:",
"stale": "Tidak diterapkan:"
},
"why": {
"expired": "Menunggu lebih dari sehari. Tidak ada yang disimpan.",
"stale": "Berkas berubah setelah usulan dibuat, jadi tidak ada yang disimpan."
},
"askAgain": "Minta lagi",
"askAgainText": "Tolong usulkan perubahan ini lagi, berkas sudah berubah sejak itu: {{summary}} ({{target}})",
"announce": {
"applied": "Perubahan diterapkan.",
"dismissed": "Perubahan ditolak.",
"undone": "Perubahan dibatalkan.",
"asked": "Asisten diminta mengusulkannya lagi."
}
},
"proposalsPage": {
"title": "Usulan",
"supporting": "Perubahan yang diminta model dan menunggu jawaban Anda.",
"emptyTitle": "Tidak ada yang menunggu",
"emptyBody": "Saat model mengusulkan perubahan pada memori Anda atau profil agen, usulan itu tampil di sini sampai Anda menjawab.",
"loadError": "Usulan tidak dapat dimuat."
},
"memoryPage": {
"lede": "Catatan yang dimasukkan LocalAI ke prompt obrolan Anda. Model hanya mengubahnya setelah Anda mengonfirmasi usulan. Di sini Anda dapat membaca, mengedit, dan menghapusnya.",
"limits": "Batas: indeks maksimal {{lines}} baris dan {{index}}, profil {{profile}}, dan satu giliran membawa maksimal {{turn}} catatan.",
"proposalsLink": "Lihat perubahan yang menunggu",
"emptyTitle": "Belum ada catatan",
"emptyBody": "Saat Anda meminta asisten mengingat sesuatu dan mengonfirmasinya, catatan muncul di sini.",
"loadError": "Catatan Anda tidak dapat dimuat.",
"use": "{{size}} dari {{max}} · {{lines}} dari {{maxLines}} baris",
"useBytes": "{{size}} dari {{max}}",
"useLabel": "Ukuran indeks terhadap batasnya",
"over": "Melebihi batas: bagian akhir tidak dikirim ke model.",
"index": "Indeks",
"group": {
"user": "Catatan Anda",
"agent": "Catatan agen {{key}}",
"channel": "Catatan percakapan {{key}}",
"profile": "Profil agen {{key}}"
},
"edit": "Edit",
"save": "Simpan",
"editLabel": "Teks {{name}}",
"editSizeIndex": "{{size}} dari {{max}} · {{lines}} dari {{maxLines}} baris",
"editSize": "{{size}} dari {{max}}",
"tooBig": "Terlalu panjang: persingkat untuk menyimpan.",
"saveFailed": "Tidak dapat menyimpan: {{message}}",
"saved": "{{name}} disimpan.",
"emptyFile": "(kosong)",
"delete": "Hapus {{name}}",
"deleteYes": "Hapus",
"confirmDelete": "Hapus {{name}}?",
"deleted": "{{name}} dipindahkan ke Tempat Sampah.",
"clear": "Hapus semua catatan",
"clearAsk_one": "Hapus {{count}} berkas? Berkas dipindahkan ke Tempat Sampah dan dapat dipulihkan dari Berkas.",
"clearAsk_other": "Hapus {{count}} berkas? Berkas dipindahkan ke Tempat Sampah dan dapat dipulihkan dari Berkas.",
"clearYes_one": "Hapus {{count}} berkas",
"clearYes_other": "Hapus {{count}} berkas",
"cleared_one": "{{count}} berkas dipindahkan ke Tempat Sampah.",
"cleared_other": "{{count}} berkas dipindahkan ke Tempat Sampah."
}
}
+122 -1
View File
@@ -454,7 +454,10 @@
"itemUnread": "{{title}}, da leggere",
"arrived": "Nuova notifica: {{title}}",
"arrivedMany_one": "{{count}} nuova notifica",
"arrivedMany_other": "{{count}} nuove notifiche"
"arrivedMany_other": "{{count}} nuove notifiche",
"proposalsWaiting_one": "{{count}} modifica attende te",
"proposalsWaiting_other": "{{count}} modifiche attendono te",
"proposalsBody": "Un modello ha proposto una modifica. Non viene salvato nulla finché non rispondi."
},
"change": {
"loading": "Lettura della modifica del trigger...",
@@ -583,5 +586,123 @@
"stopped_one": "Fermato {{count}} ciclo.",
"stopped_other": "Fermati {{count}} cicli."
}
},
"changes": {
"loading": "Lettura della modifica proposta...",
"unread": "Impossibile leggere la modifica proposta: {{message}}",
"removed": "Questa proposta non esiste più.",
"kind": {
"memory": "Memoria",
"profile": "Profilo",
"skill": "Skill",
"page": "Pagina",
"dashboard": "Dashboard"
},
"scope": {
"user": "Tutte le tue chat",
"agent": "Agente {{agent}}",
"agentAny": "Un agente",
"channel": "Questa conversazione"
},
"help": "Non viene salvato nulla finché non confermi.",
"row": {
"kind": "Tipo",
"target": "File",
"source": "Da",
"expires": "Scade tra"
},
"source": {
"chat": "Una chat",
"connector": "Connettore {{name}}",
"connectorFrom": "Connettore {{name}}, da {{sender}}"
},
"expiresHours": "{{hours}} h {{minutes}} min",
"expiresMinutes": "{{minutes}} min",
"diff": {
"label": "Modifiche a {{target}}",
"newFile": "Nuovo file",
"added_one": "{{count}} riga aggiunta",
"added_other": "{{count}} righe aggiunte",
"removed_one": "{{count}} riga rimossa",
"removed_other": "{{count}} righe rimosse",
"same": "Nessuna differenza dal testo attuale.",
"gap_one": "{{count}} riga invariata",
"gap_other": "{{count}} righe invariate",
"more_one": "Altra {{count}} riga non mostrata",
"more_other": "Altre {{count}} righe non mostrate",
"srAdded": "Aggiunta:",
"srRemoved": "Rimossa:"
},
"unreadFile": "Non è stato possibile leggere il file attuale, quindi tutto il testo conta come nuovo.",
"confirm": "Conferma",
"edit": "Modifica e conferma",
"confirmEdited": "Conferma con il mio testo",
"editLabel": "Testo da salvare in {{target}}",
"failed": "Impossibile salvare la modifica: {{message}}. È ancora in attesa di te.",
"receipt": {
"applied": "Applicata:",
"reverted": "Annullata:",
"dismissed": "Rifiutata:",
"expired": "Scaduta:",
"stale": "Non applicata:"
},
"why": {
"expired": "Ha atteso più di un giorno. Non è stato salvato nulla.",
"stale": "Il file è cambiato dopo la proposta, quindi non è stato salvato nulla."
},
"askAgain": "Chiedi di nuovo",
"askAgainText": "Per favore proponi di nuovo questa modifica, il file è cambiato nel frattempo: {{summary}} ({{target}})",
"announce": {
"applied": "Modifica applicata.",
"dismissed": "Modifica rifiutata.",
"undone": "Modifica annullata.",
"asked": "Ho chiesto all'assistente di riproporla."
}
},
"proposalsPage": {
"title": "Proposte",
"supporting": "Modifiche richieste dai modelli che attendono la tua risposta.",
"emptyTitle": "Niente in attesa",
"emptyBody": "Quando un modello propone una modifica alla tua memoria o al profilo di un agente, appare qui finché non rispondi.",
"loadError": "Impossibile caricare le proposte."
},
"memoryPage": {
"lede": "Le note che LocalAI inserisce nel prompt delle tue chat. Un modello le modifica solo dopo che confermi una proposta. Qui puoi leggerle, modificarle ed eliminarle.",
"limits": "Limiti: un indice ha al massimo {{lines}} righe e {{index}}, un profilo {{profile}}, e un turno porta al massimo {{turn}} di note.",
"proposalsLink": "Vedi le modifiche in attesa",
"emptyTitle": "Ancora nessuna nota",
"emptyBody": "Quando chiedi a un assistente di ricordare qualcosa e confermi, le note appaiono qui.",
"loadError": "Impossibile caricare le tue note.",
"use": "{{size}} di {{max}} · {{lines}} di {{maxLines}} righe",
"useBytes": "{{size}} di {{max}}",
"useLabel": "Dimensione dell'indice rispetto al limite",
"over": "Oltre il limite: la parte finale non viene inviata al modello.",
"index": "Indice",
"group": {
"user": "Le tue note",
"agent": "Note dell'agente {{key}}",
"channel": "Note della conversazione {{key}}",
"profile": "Profilo dell'agente {{key}}"
},
"edit": "Modifica",
"save": "Salva",
"editLabel": "Testo di {{name}}",
"editSizeIndex": "{{size}} di {{max}} · {{lines}} di {{maxLines}} righe",
"editSize": "{{size}} di {{max}}",
"tooBig": "Troppo lungo: accorcialo per salvare.",
"saveFailed": "Impossibile salvare: {{message}}",
"saved": "{{name}} salvato.",
"emptyFile": "(vuoto)",
"delete": "Elimina {{name}}",
"deleteYes": "Elimina",
"confirmDelete": "Eliminare {{name}}?",
"deleted": "{{name}} spostato nel Cestino.",
"clear": "Cancella tutte le note",
"clearAsk_one": "Eliminare {{count}} file? Va nel Cestino e puoi ripristinarlo da File.",
"clearAsk_other": "Eliminare {{count}} file? Vanno nel Cestino e puoi ripristinarli da File.",
"clearYes_one": "Elimina {{count}} file",
"clearYes_other": "Elimina {{count}} file",
"cleared_one": "{{count}} file spostato nel Cestino.",
"cleared_other": "{{count}} file spostati nel Cestino."
}
}
+122 -1
View File
@@ -454,7 +454,10 @@
"itemUnread": "{{title}}, 읽지 않음",
"arrived": "새 알림: {{title}}",
"arrivedMany_one": "새 알림 {{count}}개",
"arrivedMany_other": "새 알림 {{count}}개"
"arrivedMany_other": "새 알림 {{count}}개",
"proposalsWaiting_one": "변경 {{count}}개가 기다리고 있습니다",
"proposalsWaiting_other": "변경 {{count}}개가 기다리고 있습니다",
"proposalsBody": "모델이 변경을 제안했습니다. 답하기 전에는 아무것도 저장되지 않습니다."
},
"change": {
"loading": "트리거 변경 사항을 읽는 중...",
@@ -583,5 +586,123 @@
"stopped_one": "반복 {{count}}개를 멈췄습니다.",
"stopped_other": "반복 {{count}}개를 멈췄습니다."
}
},
"changes": {
"loading": "제안된 변경을 읽는 중...",
"unread": "제안된 변경을 읽을 수 없습니다: {{message}}",
"removed": "이 제안은 더 이상 없습니다.",
"kind": {
"memory": "메모리",
"profile": "프로필",
"skill": "스킬",
"page": "페이지",
"dashboard": "대시보드"
},
"scope": {
"user": "내 모든 채팅",
"agent": "에이전트 {{agent}}",
"agentAny": "에이전트 하나",
"channel": "이 대화"
},
"help": "확인하기 전에는 아무것도 저장되지 않습니다.",
"row": {
"kind": "종류",
"target": "파일",
"source": "출처",
"expires": "만료까지"
},
"source": {
"chat": "채팅",
"connector": "커넥터 {{name}}",
"connectorFrom": "커넥터 {{name}}, 보낸 사람 {{sender}}"
},
"expiresHours": "{{hours}}시간 {{minutes}}분",
"expiresMinutes": "{{minutes}}분",
"diff": {
"label": "{{target}}의 변경 사항",
"newFile": "새 파일",
"added_one": "{{count}}줄 추가",
"added_other": "{{count}}줄 추가",
"removed_one": "{{count}}줄 삭제",
"removed_other": "{{count}}줄 삭제",
"same": "현재 텍스트와 다른 점이 없습니다.",
"gap_one": "변경 없는 {{count}}줄",
"gap_other": "변경 없는 {{count}}줄",
"more_one": "표시되지 않은 {{count}}줄 더",
"more_other": "표시되지 않은 {{count}}줄 더",
"srAdded": "추가됨:",
"srRemoved": "삭제됨:"
},
"unreadFile": "현재 파일을 읽을 수 없어 전체 텍스트를 새 내용으로 봅니다.",
"confirm": "확인",
"edit": "편집 후 확인",
"confirmEdited": "내 텍스트로 확인",
"editLabel": "{{target}}에 저장할 텍스트",
"failed": "변경을 저장하지 못했습니다: {{message}}. 아직 답을 기다리고 있습니다.",
"receipt": {
"applied": "적용됨:",
"reverted": "되돌림:",
"dismissed": "거절됨:",
"expired": "만료됨:",
"stale": "적용되지 않음:"
},
"why": {
"expired": "하루 넘게 기다렸습니다. 아무것도 저장되지 않았습니다.",
"stale": "제안 이후 파일이 바뀌어 아무것도 저장되지 않았습니다."
},
"askAgain": "다시 요청",
"askAgainText": "파일이 그 사이 바뀌었으니 이 변경을 다시 제안해 주세요: {{summary}} ({{target}})",
"announce": {
"applied": "변경을 적용했습니다.",
"dismissed": "변경을 거절했습니다.",
"undone": "변경을 되돌렸습니다.",
"asked": "어시스턴트에게 다시 제안하도록 요청했습니다."
}
},
"proposalsPage": {
"title": "제안",
"supporting": "모델이 요청했고 당신의 답을 기다리는 변경입니다.",
"emptyTitle": "대기 중인 항목이 없습니다",
"emptyBody": "모델이 메모리나 에이전트 프로필 변경을 제안하면 답할 때까지 여기에 표시됩니다.",
"loadError": "제안을 불러오지 못했습니다."
},
"memoryPage": {
"lede": "LocalAI가 채팅 프롬프트에 넣는 메모입니다. 모델은 당신이 제안을 확인한 뒤에만 이를 바꿉니다. 여기서 읽고, 편집하고, 삭제할 수 있습니다.",
"limits": "한도: 색인은 최대 {{lines}}줄과 {{index}}, 프로필은 {{profile}}, 한 턴에는 최대 {{turn}}의 메모가 들어갑니다.",
"proposalsLink": "대기 중인 변경 보기",
"emptyTitle": "아직 메모가 없습니다",
"emptyBody": "어시스턴트에게 무언가를 기억하도록 요청하고 확인하면 메모가 여기에 나타납니다.",
"loadError": "메모를 불러오지 못했습니다.",
"use": "{{size}} / {{max}} · {{lines}} / {{maxLines}}줄",
"useBytes": "{{size}} / {{max}}",
"useLabel": "색인 크기와 한도",
"over": "한도 초과: 끝부분은 모델에 전달되지 않습니다.",
"index": "색인",
"group": {
"user": "내 메모",
"agent": "에이전트 {{key}}의 메모",
"channel": "대화 {{key}}의 메모",
"profile": "에이전트 {{key}}의 프로필"
},
"edit": "편집",
"save": "저장",
"editLabel": "{{name}}의 텍스트",
"editSizeIndex": "{{size}} / {{max}} · {{lines}} / {{maxLines}}줄",
"editSize": "{{size}} / {{max}}",
"tooBig": "너무 깁니다. 저장하려면 줄여 주세요.",
"saveFailed": "저장하지 못했습니다: {{message}}",
"saved": "{{name}}을(를) 저장했습니다.",
"emptyFile": "(비어 있음)",
"delete": "{{name}} 삭제",
"deleteYes": "삭제",
"confirmDelete": "{{name}}을(를) 삭제할까요?",
"deleted": "{{name}}을(를) 휴지통으로 옮겼습니다.",
"clear": "모든 메모 지우기",
"clearAsk_one": "파일 {{count}}개를 삭제할까요? 휴지통으로 이동하며 파일에서 복원할 수 있습니다.",
"clearAsk_other": "파일 {{count}}개를 삭제할까요? 휴지통으로 이동하며 파일에서 복원할 수 있습니다.",
"clearYes_one": "파일 {{count}}개 삭제",
"clearYes_other": "파일 {{count}}개 삭제",
"cleared_one": "파일 {{count}}개를 휴지통으로 옮겼습니다.",
"cleared_other": "파일 {{count}}개를 휴지통으로 옮겼습니다."
}
}
@@ -454,7 +454,10 @@
"itemUnread": "{{title}}, não lida",
"arrived": "Nova notificação: {{title}}",
"arrivedMany_one": "{{count}} nova notificação",
"arrivedMany_other": "{{count}} novas notificações"
"arrivedMany_other": "{{count}} novas notificações",
"proposalsWaiting_one": "{{count}} alteração aguarda você",
"proposalsWaiting_other": "{{count}} alterações aguardam você",
"proposalsBody": "Um modelo propôs uma alteração. Nada é salvo até você responder."
},
"change": {
"loading": "Lendo a alteração do gatilho...",
@@ -583,5 +586,123 @@
"stopped_one": "{{count}} loop parado.",
"stopped_other": "{{count}} loops parados."
}
},
"changes": {
"loading": "Lendo a alteração proposta...",
"unread": "Não foi possível ler a alteração proposta: {{message}}",
"removed": "Esta proposta não existe mais.",
"kind": {
"memory": "Memória",
"profile": "Perfil",
"skill": "Skill",
"page": "Página",
"dashboard": "Painel"
},
"scope": {
"user": "Todos os seus chats",
"agent": "Agente {{agent}}",
"agentAny": "Um agente",
"channel": "Esta conversa"
},
"help": "Nada é salvo até você confirmar.",
"row": {
"kind": "Tipo",
"target": "Arquivo",
"source": "De",
"expires": "Expira em"
},
"source": {
"chat": "Um chat",
"connector": "Conector {{name}}",
"connectorFrom": "Conector {{name}}, de {{sender}}"
},
"expiresHours": "{{hours}} h {{minutes}} min",
"expiresMinutes": "{{minutes}} min",
"diff": {
"label": "Alterações em {{target}}",
"newFile": "Arquivo novo",
"added_one": "{{count}} linha adicionada",
"added_other": "{{count}} linhas adicionadas",
"removed_one": "{{count}} linha removida",
"removed_other": "{{count}} linhas removidas",
"same": "Nenhuma diferença do texto atual.",
"gap_one": "{{count}} linha sem alteração",
"gap_other": "{{count}} linhas sem alteração",
"more_one": "Mais {{count}} linha não exibida",
"more_other": "Mais {{count}} linhas não exibidas",
"srAdded": "Adicionada:",
"srRemoved": "Removida:"
},
"unreadFile": "Não foi possível ler o arquivo atual, então todo o texto conta como novo.",
"confirm": "Confirmar",
"edit": "Editar e confirmar",
"confirmEdited": "Confirmar com meu texto",
"editLabel": "Texto a salvar em {{target}}",
"failed": "Não foi possível salvar a alteração: {{message}}. Ela continua aguardando você.",
"receipt": {
"applied": "Aplicada:",
"reverted": "Desfeita:",
"dismissed": "Dispensada:",
"expired": "Expirada:",
"stale": "Não aplicada:"
},
"why": {
"expired": "Aguardou mais de um dia. Nada foi salvo.",
"stale": "O arquivo mudou depois da proposta, então nada foi salvo."
},
"askAgain": "Pedir de novo",
"askAgainText": "Por favor, proponha esta alteração de novo; o arquivo mudou desde então: {{summary}} ({{target}})",
"announce": {
"applied": "Alteração aplicada.",
"dismissed": "Alteração dispensada.",
"undone": "Alteração desfeita.",
"asked": "O assistente foi solicitado a propor de novo."
}
},
"proposalsPage": {
"title": "Propostas",
"supporting": "Alterações que os modelos pediram e que aguardam sua resposta.",
"emptyTitle": "Nada aguardando",
"emptyBody": "Quando um modelo propõe uma alteração na sua memória ou no perfil de um agente, ela aparece aqui até você responder.",
"loadError": "Não foi possível carregar as propostas."
},
"memoryPage": {
"lede": "As notas que o LocalAI coloca no prompt dos seus chats. Um modelo só as altera depois que você confirma uma proposta. Aqui você pode lê-las, editá-las e excluí-las.",
"limits": "Limites: um índice tem no máximo {{lines}} linhas e {{index}}, um perfil {{profile}}, e um turno leva no máximo {{turn}} de notas.",
"proposalsLink": "Ver as alterações pendentes",
"emptyTitle": "Ainda não há notas",
"emptyBody": "Quando você pede a um assistente que lembre de algo e confirma, as notas aparecem aqui.",
"loadError": "Não foi possível carregar suas notas.",
"use": "{{size}} de {{max}} · {{lines}} de {{maxLines}} linhas",
"useBytes": "{{size}} de {{max}}",
"useLabel": "Tamanho do índice em relação ao limite",
"over": "Acima do limite: o final não é enviado ao modelo.",
"index": "Índice",
"group": {
"user": "Suas notas",
"agent": "Notas do agente {{key}}",
"channel": "Notas da conversa {{key}}",
"profile": "Perfil do agente {{key}}"
},
"edit": "Editar",
"save": "Salvar",
"editLabel": "Texto de {{name}}",
"editSizeIndex": "{{size}} de {{max}} · {{lines}} de {{maxLines}} linhas",
"editSize": "{{size}} de {{max}}",
"tooBig": "Longo demais: encurte para salvar.",
"saveFailed": "Não foi possível salvar: {{message}}",
"saved": "{{name}} salvo.",
"emptyFile": "(vazio)",
"delete": "Excluir {{name}}",
"deleteYes": "Excluir",
"confirmDelete": "Excluir {{name}}?",
"deleted": "{{name}} movido para a Lixeira.",
"clear": "Limpar todas as notas",
"clearAsk_one": "Excluir {{count}} arquivo? Ele vai para a Lixeira e você pode restaurá-lo em Arquivos.",
"clearAsk_other": "Excluir {{count}} arquivos? Eles vão para a Lixeira e você pode restaurá-los em Arquivos.",
"clearYes_one": "Excluir {{count}} arquivo",
"clearYes_other": "Excluir {{count}} arquivos",
"cleared_one": "{{count}} arquivo movido para a Lixeira.",
"cleared_other": "{{count}} arquivos movidos para a Lixeira."
}
}
@@ -454,7 +454,10 @@
"itemUnread": "{{title}},未读",
"arrived": "新通知:{{title}}",
"arrivedMany_one": "{{count}} 条新通知",
"arrivedMany_other": "{{count}} 条新通知"
"arrivedMany_other": "{{count}} 条新通知",
"proposalsWaiting_one": "{{count}} 项更改等待你处理",
"proposalsWaiting_other": "{{count}} 项更改等待你处理",
"proposalsBody": "模型提议了一项更改。在你回应之前不会保存任何内容。"
},
"change": {
"loading": "正在读取触发器更改...",
@@ -583,5 +586,123 @@
"stopped_one": "已停止 {{count}} 个循环。",
"stopped_other": "已停止 {{count}} 个循环。"
}
},
"changes": {
"loading": "正在读取提议的更改...",
"unread": "无法读取提议的更改:{{message}}",
"removed": "此提议已不存在。",
"kind": {
"memory": "记忆",
"profile": "配置文件",
"skill": "技能",
"page": "页面",
"dashboard": "仪表板"
},
"scope": {
"user": "你的所有聊天",
"agent": "智能体 {{agent}}",
"agentAny": "一个智能体",
"channel": "此对话"
},
"help": "确认之前不会保存任何内容。",
"row": {
"kind": "类型",
"target": "文件",
"source": "来源",
"expires": "剩余时间"
},
"source": {
"chat": "聊天",
"connector": "连接器 {{name}}",
"connectorFrom": "连接器 {{name}},来自 {{sender}}"
},
"expiresHours": "{{hours}} 小时 {{minutes}} 分钟",
"expiresMinutes": "{{minutes}} 分钟",
"diff": {
"label": "对 {{target}} 的更改",
"newFile": "新文件",
"added_one": "新增 {{count}} 行",
"added_other": "新增 {{count}} 行",
"removed_one": "删除 {{count}} 行",
"removed_other": "删除 {{count}} 行",
"same": "与当前文本没有差异。",
"gap_one": "{{count}} 行未变",
"gap_other": "{{count}} 行未变",
"more_one": "另有 {{count}} 行未显示",
"more_other": "另有 {{count}} 行未显示",
"srAdded": "新增:",
"srRemoved": "删除:"
},
"unreadFile": "无法读取当前文件,因此全部文本都视为新内容。",
"confirm": "确认",
"edit": "编辑后确认",
"confirmEdited": "用我的文本确认",
"editLabel": "保存到 {{target}} 的文本",
"failed": "无法保存更改:{{message}}。它仍在等待你处理。",
"receipt": {
"applied": "已应用:",
"reverted": "已撤销:",
"dismissed": "已拒绝:",
"expired": "已过期:",
"stale": "未应用:"
},
"why": {
"expired": "已等待超过一天,未保存任何内容。",
"stale": "文件在提议之后发生了变化,因此未保存任何内容。"
},
"askAgain": "重新请求",
"askAgainText": "请重新提议这项更改,文件此后已变化:{{summary}}({{target}})",
"announce": {
"applied": "更改已应用。",
"dismissed": "更改已拒绝。",
"undone": "更改已撤销。",
"asked": "已请助手重新提议。"
}
},
"proposalsPage": {
"title": "提议",
"supporting": "模型提出、等待你回应的更改。",
"emptyTitle": "没有待处理的内容",
"emptyBody": "当模型提议更改你的记忆或智能体配置文件时,会显示在这里,直到你作出回应。",
"loadError": "无法加载提议。"
},
"memoryPage": {
"lede": "LocalAI 放入你聊天提示词中的笔记。只有在你确认提议后,模型才会修改它们。你可以在这里查看、编辑和删除。",
"limits": "上限:索引最多 {{lines}} 行和 {{index}},配置文件 {{profile}},每轮对话最多携带 {{turn}} 的笔记。",
"proposalsLink": "查看等待处理的更改",
"emptyTitle": "还没有笔记",
"emptyBody": "当你让助手记住某事并确认后,笔记会显示在这里。",
"loadError": "无法加载你的笔记。",
"use": "{{size}} / {{max}} · {{lines}} / {{maxLines}} 行",
"useBytes": "{{size}} / {{max}}",
"useLabel": "索引大小与上限",
"over": "超出上限:末尾部分不会发送给模型。",
"index": "索引",
"group": {
"user": "你的笔记",
"agent": "智能体 {{key}} 的笔记",
"channel": "对话 {{key}} 的笔记",
"profile": "智能体 {{key}} 的配置文件"
},
"edit": "编辑",
"save": "保存",
"editLabel": "{{name}} 的文本",
"editSizeIndex": "{{size}} / {{max}} · {{lines}} / {{maxLines}} 行",
"editSize": "{{size}} / {{max}}",
"tooBig": "太长:请缩短后再保存。",
"saveFailed": "无法保存:{{message}}",
"saved": "已保存 {{name}}。",
"emptyFile": "(空)",
"delete": "删除 {{name}}",
"deleteYes": "删除",
"confirmDelete": "删除 {{name}}?",
"deleted": "已将 {{name}} 移到回收站。",
"clear": "清除所有笔记",
"clearAsk_one": "删除 {{count}} 个文件?文件会移到回收站,可在“文件”中恢复。",
"clearAsk_other": "删除 {{count}} 个文件?文件会移到回收站,可在“文件”中恢复。",
"clearYes_one": "删除 {{count}} 个文件",
"clearYes_other": "删除 {{count}} 个文件",
"cleared_one": "已将 {{count}} 个文件移到回收站。",
"cleared_other": "已将 {{count}} 个文件移到回收站。"
}
}
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { usePendingChanges } from '../hooks/usePendingChanges'
import { disablePush, enablePush, pushState } from '../utils/chatPush'
import { relativeDate } from '../utils/fileBrowser'
import {
@@ -130,6 +131,7 @@ export default function ChatInbox({ inbox }) {
const [busy, setBusy] = useState(false)
const [fresh, setFresh] = useState(() => new Set())
const [announce, setAnnounce] = useState('')
const waiting = usePendingChanges()
const ref = useRef(null)
const btnRef = useRef(null)
const panelRef = useRef(null)
@@ -169,6 +171,7 @@ export default function ChatInbox({ inbox }) {
useEffect(() => {
if (!open) { setConfirm(''); setDone(''); setError(''); return }
inbox.refresh()
waiting.refresh()
panelRef.current?.focus({ preventScroll: true })
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per opening
}, [open])
@@ -239,8 +242,11 @@ export default function ChatInbox({ inbox }) {
}
}
const badge = badgeText(inbox.unread)
const buttonName = inbox.unread > 0 ? t('inbox.buttonUnread', { count: inbox.unread }) : t('inbox.title')
// The badge counts what needs a look: unread notifications and the changes
// that wait for an answer.
const attention = inbox.unread + waiting.count
const badge = badgeText(attention)
const buttonName = attention > 0 ? t('inbox.buttonUnread', { count: attention }) : t('inbox.title')
const filters = [
{ id: 'all', label: t('inbox.filterAll') },
{ id: 'unread', label: t('inbox.filterUnread') },
@@ -340,6 +346,19 @@ export default function ChatInbox({ inbox }) {
</Callout>
)}
<div className="nx-body">
{waiting.count > 0 && (
<button
type="button" className="nx-waiting" data-testid="inbox-proposals"
onClick={() => { setOpen(false); navigate('/app/chats/proposals') }}
>
<span className="nx-tile nx-tile--accent" aria-hidden="true"><Icon name="edit" /></span>
<span className="nx-waiting__text">
<span className="nx-item__title">{t('inbox.proposalsWaiting', { count: waiting.count })}</span>
<span className="nx-item__body">{t('inbox.proposalsBody')}</span>
</span>
<Icon name="arrow-right" className="nx-item__go" />
</button>
)}
{done && <p className="nx-note nx-note--ok" role="status" data-testid="inbox-done"><Icon name="check-circle" />{done}</p>}
{error && <p className="nx-note nx-note--error" role="alert"><Icon name="alert-circle" />{error}</p>}
{failedLoad && (
@@ -4,6 +4,8 @@ import { renderMarkdown, highlightAll } from '../../utils/markdown'
import { toolLine, toolOutput } from '../../utils/chatThread'
import { addonChange, toolRefusal } from '../../utils/addonChange'
import { CHANGE_CARD, triggerCards } from '../../utils/chatProposal'
import { changeProposalIds } from '../../utils/changeProposal'
import ChangeProposalCard from './ChangeProposalCard'
import { toolLabel, formatDuration, runTimes } from '../../utils/toolLabels'
import Icon from '../Icon'
import { openChatFile } from './chatActions'
@@ -357,9 +359,13 @@ function NetworkCards({ items }) {
// ends; a run with no message after it (ActivityRow) draws them with the fold.
export function ActivityProposals({ items, proposalTick = 0 }) {
const { t: tc } = useTranslation('chats')
return triggerCards(items).map(({ kind, id }) => (kind === CHANGE_CARD
? <TriggerChangeCard key={`tc-${id}`} id={id} t={tc} tick={proposalTick} />
: <ProposalCard key={`p-${id}`} id={id} t={tc} tick={proposalTick} />))
return [
...triggerCards(items).map(({ kind, id }) => (kind === CHANGE_CARD
? <TriggerChangeCard key={`tc-${id}`} id={id} t={tc} tick={proposalTick} />
: <ProposalCard key={`p-${id}`} id={id} t={tc} tick={proposalTick} />)),
// A change a model proposed to a memory note, a profile or another kind.
...changeProposalIds(items).map((id) => <ChangeProposalCard key={`cp-${id}`} id={id} t={tc} tick={proposalTick} />),
]
}
// A tool call that has an MCP App: the tool line, then the app in its frame.
@@ -0,0 +1,263 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useCallback, useContext, useEffect, useId, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { chatChangesApi, filesApi } from '../../utils/api'
import {
byteText, CHANGES_EVENT, changeProposalView, KNOWN_KINDS, KNOWN_SCOPES, scopeOfProposal, sourceOfProposal, targetPath, timeLeft,
} from '../../utils/changeProposal'
import { diffLines, diffStats, withContext } from '../../utils/lineDiff'
import Icon from '../Icon'
import { Button } from '../ui'
import { ChatSendContext } from './chatSendContext'
import './changeproposal.css'
// Past this many shown lines a diff stops and says how many more there are.
const MAX_SHOWN = 120
// The most of the current file the card reads to draw the diff.
const MAX_READ = 1 << 20
const announceChanged = () => {
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent(CHANGES_EVENT))
}
function kindName(p, t) {
return KNOWN_KINDS.includes(p.kind) ? t(`changes.kind.${p.kind}`) : String(p.kind || '')
}
function scopeName(p, t) {
const { scope, agent } = scopeOfProposal(p)
if (!KNOWN_SCOPES.includes(scope)) return String(scope)
if (scope === 'agent' && !agent) return t('changes.scope.agentAny')
return t(`changes.scope.${scope}`, { agent })
}
function sourceName(p, t) {
const s = sourceOfProposal(p)
if (s.kind === 'chat') return t('changes.source.chat')
if (s.sender) return t('changes.source.connectorFrom', { name: s.id || s.kind, sender: s.sender })
return t('changes.source.connector', { name: s.id || s.kind })
}
function expiryName(p, t) {
const left = timeLeft(p)
if (!left) return ''
return left.hours > 0 ? t('changes.expiresHours', { hours: left.hours, minutes: left.minutes }) : t('changes.expiresMinutes', { minutes: left.minutes })
}
// The lines a proposal adds and removes, with a few unchanged lines around
// them. Every row says what it is in words for a screen reader; the sign in
// the margin is for the eye. A file the card could not read is shown as all
// new text.
function Diff({ before, after, target, t }) {
const ops = useMemo(() => diffLines(before ?? '', after ?? ''), [before, after])
const { added, removed } = diffStats(ops)
const rows = useMemo(() => withContext(ops, 2), [ops])
const shown = rows.slice(0, MAX_SHOWN)
return (
<div className="cp-diff" role="group" aria-label={t('changes.diff.label', { target })} data-testid="change-diff">
<p className="cp-diff__stat" data-testid="change-diff-stat">
{before === '' ? <span>{t('changes.diff.newFile')} · </span> : null}
<span className="cp-diff__n cp-diff__n--add">{t('changes.diff.added', { count: added })}</span>
{' '}
<span className="cp-diff__n cp-diff__n--del">{t('changes.diff.removed', { count: removed })}</span>
</p>
{added + removed === 0 ? <p className="cx-help">{t('changes.diff.same')}</p> : (
<ul className="cp-diff__lines">
{shown.map((op, i) => (op.type === 'gap' ? (
<li key={i} className="cp-diff__gap" data-op="gap">{t('changes.diff.gap', { count: op.count })}</li>
) : (
<li key={i} className="cp-diff__line" data-op={op.type}>
<span className="cp-diff__mark" aria-hidden="true">{op.type === 'add' ? '+' : op.type === 'del' ? '−' : ''}</span>
{op.type !== 'same' && <span className="sr-only">{t(op.type === 'add' ? 'changes.diff.srAdded' : 'changes.diff.srRemoved')}</span>}
<code className="cp-diff__text">{op.text || ' '}</code>
</li>
)))}
{rows.length > shown.length && <li className="cp-diff__gap" data-op="gap">{t('changes.diff.more', { count: rows.length - shown.length })}</li>}
</ul>
)}
</div>
)
}
// A change that a model proposed to something that lasts (a memory note, an
// agent profile, and the kinds that come later), drawn for any kind and any
// source: a tool line whose card is of kind change_proposal (card.id is the
// proposal's id), or a row of the Proposals page. It reads the proposal from
// the server, so it says the same after a reload and on every frontend:
// waiting (Confirm, Edit then confirm, Dismiss), then a receipt: applied (with
// Undo), undone, dismissed, expired or stale. Nothing is written until the
// person confirms.
export default function ChangeProposalCard({ id, initial, t, tick = 0 }) {
const uid = useId()
const sendToChat = useContext(ChatSendContext)
const [view, setView] = useState(() => (initial ? changeProposalView(initial) : { kind: 'loading' }))
// The current text of the target: '' for a file that is not there, null when
// it could not be read.
const [current, setCurrent] = useState(undefined)
const [edit, setEdit] = useState(null)
const [busy, setBusy] = useState('')
const [error, setError] = useState('')
const [said, setSaid] = useState('')
const load = useCallback(async () => {
let v
try {
v = changeProposalView(await chatChangesApi.get(id))
} catch (e) {
v = changeProposalView(null, e)
}
setView(v)
if (v.kind === 'pending') {
try {
setCurrent(await filesApi.readText(targetPath(v.proposal), MAX_READ))
} catch (e) {
setCurrent(e?.status === 404 ? '' : null)
}
}
return v
}, [id])
useEffect(() => { load() }, [load, tick])
const act = async (what, call, say) => {
setBusy(what)
setError('')
let failure = null
try {
await call()
setEdit(null)
if (say) setSaid(say)
} catch (e) {
failure = e
setSaid('')
}
const v = await load()
// A 409 means the proposal moved on (stale, expired, answered elsewhere):
// the card then says so in its own words. Any other failure leaves the
// proposal waiting, with the reason; a write that failed is one of them.
if (failure && !(failure.status === 409 && v.kind !== 'pending' && v.kind !== 'applied')) {
const reason = failure.message.replace(/^studio: /, '').replace(/[.\s]+$/, '')
setError(failure.status === 409 ? reason : t('changes.failed', { message: reason }))
}
setBusy('')
announceChanged()
}
const status = <span className="sr-only" role="status" aria-live="polite">{said}</span>
const wrap = (node) => <>{node}{status}</>
const alert = error && <p className="cx-help cx-help--err" role="alert" data-testid="change-error">{error}</p>
const tid = 'change-proposal'
if (view.kind === 'loading') return wrap(<div className="cx-change" data-testid={tid} data-state="loading">{t('changes.loading')}</div>)
if (view.kind === 'error') {
return wrap(
<div className="cx-change" data-testid={tid} data-state="error">
{t('changes.unread', { message: view.message })}{' '}
<Button size="sm" variant="ghost" onClick={load}>{t('proposal.retry')}</Button>
</div>
)
}
if (view.kind === 'removed') return wrap(<div className="cx-change" data-testid={tid} data-state="removed">{t('changes.removed')}</div>)
const p = view.proposal
const target = p.target || ''
const meta = [
['kind', t('changes.row.kind'), `${kindName(p, t)} · ${scopeName(p, t)}`],
['target', t('changes.row.target'), <code key="c">{target}</code>],
['source', t('changes.row.source'), sourceName(p, t)],
]
if (view.kind === 'applied') {
return wrap(
<div className="cx-receipt" data-testid={tid} data-state="applied" data-kind={p.kind}>
<Icon name="check-circle" className="cx-receipt__icon" />
<div className="cx-receipt__main">
<p className="cx-receipt__line"><b>{t('changes.receipt.applied')}</b> <span data-testid="change-summary">{p.summary}</span></p>
<p className="cx-receipt__sub"><span>{kindName(p, t)} · {scopeName(p, t)}</span><code>{target}</code></p>
{alert}
</div>
<span className="cx-receipt__acts">
{view.canUndo && (
<Button size="sm" variant="ghost" icon="undo" busy={busy === 'undo'} disabled={!!busy}
onClick={() => act('undo', () => chatChangesApi.undo(p.id), t('changes.announce.undone'))}
>{t('proposal.receipt.undo')}</Button>
)}
</span>
</div>
)
}
if (view.kind !== 'pending') {
// Quiet receipts: reverted, dismissed, expired and stale. The last two
// offer to ask the assistant again, where there is a chat to ask.
const again = (view.kind === 'expired' || view.kind === 'stale') && sendToChat
return wrap(
<div className="cx-receipt cx-receipt--quiet" data-testid={tid} data-state={view.kind} data-kind={p.kind}>
<div className="cx-receipt__main">
<p className="cx-receipt__line"><b>{t(`changes.receipt.${view.kind}`)}</b> <span data-testid="change-summary">{p.summary}</span></p>
{(view.kind === 'expired' || view.kind === 'stale') && <p className="cx-help" data-testid="change-why">{t(`changes.why.${view.kind}`)}</p>}
{alert}
</div>
{again && (
<span className="cx-receipt__acts">
<Button size="sm" variant="secondary" busy={busy === 'again'} disabled={!!busy}
onClick={() => act('again', async () => { await sendToChat(t('changes.askAgainText', { summary: p.summary, target })) }, t('changes.announce.asked'))}
>{t('changes.askAgain')}</Button>
</span>
)}
</div>
)
}
// Waiting for an answer.
const expiry = expiryName(p, t)
return wrap(
<div className="cx-proposal cp-card" data-testid={tid} data-state="pending" data-kind={p.kind} aria-labelledby={`${uid}-h`}>
<div className="cx-proposal__head">
<b id={`${uid}-h`} data-testid="change-summary">{p.summary}</b>
</div>
<p className="cx-help">{t('changes.help')}</p>
<dl className="cx-plan" data-testid="change-rows">
{meta.map(([key, label, value]) => (
<div className="cx-plan__row" key={key} data-row={key}><dt>{label}</dt><dd>{value}</dd></div>
))}
{expiry && <div className="cx-plan__row" data-row="expires"><dt>{t('changes.row.expires')}</dt><dd>{expiry}</dd></div>}
</dl>
{current === undefined ? null : (edit === null
? <Diff before={current ?? ''} after={p.content || ''} target={target} t={t} />
: null)}
{current === null && <p className="cx-help">{t('changes.unreadFile')}</p>}
{edit !== null ? (
<form
className="cx-proposal__edit cp-edit"
onSubmit={(e) => { e.preventDefault(); act('confirm', () => chatChangesApi.confirm(p.id, edit), t('changes.announce.applied')) }}
onKeyDown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); setEdit(null) } }}
>
<label className="cx-field__label" htmlFor={`${uid}-text`}>{t('changes.editLabel', { target })}</label>
<textarea
id={`${uid}-text`} className="cx-in cp-edit__text" rows={Math.min(14, Math.max(5, edit.split('\n').length + 1))}
value={edit} spellCheck={false} autoFocus data-testid="change-edit-text"
aria-describedby={`${uid}-size`}
onChange={(e) => setEdit(e.target.value)}
/>
<p className="cx-help" id={`${uid}-size`}>{byteText(new TextEncoder().encode(edit).length)}</p>
<span className="cx-pending__acts">
<button type="submit" className="cx-btn cx-btn--primary" disabled={!!busy}>{t('changes.confirmEdited')}</button>
<button type="button" className="cx-btn" onClick={() => setEdit(null)}>{t('proposal.cancelEdit')}</button>
</span>
</form>
) : (
<span className="cx-pending__acts">
<button type="button" className="cx-btn cx-btn--primary" disabled={!!busy}
onClick={() => act('confirm', () => chatChangesApi.confirm(p.id), t('changes.announce.applied'))}
>{t('changes.confirm')}</button>
<button type="button" className="cx-btn" disabled={!!busy} onClick={() => setEdit(p.content || '')}>{t('changes.edit')}</button>
<button type="button" className="cx-btn" disabled={!!busy}
onClick={() => act('dismiss', () => chatChangesApi.dismiss(p.id), t('changes.announce.dismissed'))}
>{t('proposal.dismiss')}</button>
</span>
)}
{alert}
</div>
)
}
@@ -0,0 +1,34 @@
/* The card of a change a model proposed: a line diff of what would be written.
It sits in the frame of the proposal card (.cx-proposal in pages/chats.css)
and uses only design tokens. */
.cp-diff { margin: 0 0 12px; }
.cp-diff__stat { margin: 0 0 6px; font-size: var(--text-xs); color: var(--dk-muted); }
.cp-diff__n--add { color: var(--dk-ok); font-weight: 600; }
.cp-diff__n--del { color: var(--dk-error); font-weight: 600; }
.cp-diff__lines {
margin: 0;
padding: 0;
list-style: none;
max-height: 280px;
overflow: auto;
border-radius: var(--dk-radius-2);
box-shadow: inset 0 0 0 1px var(--dk-edge);
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.5;
}
.cp-diff__line { display: flex; gap: 8px; padding: 1px 10px 1px 6px; }
.cp-diff__line[data-op="add"] { background: var(--dk-ok-wash); }
.cp-diff__line[data-op="del"] { background: var(--dk-error-wash); }
.cp-diff__mark { flex: none; width: 1ch; text-align: center; color: var(--dk-muted); user-select: none; }
.cp-diff__line[data-op="add"] .cp-diff__mark { color: var(--dk-ok); }
.cp-diff__line[data-op="del"] .cp-diff__mark { color: var(--dk-error); }
.cp-diff__text { min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--dk-text); font: inherit; }
.cp-diff__line[data-op="same"] .cp-diff__text { color: var(--dk-muted); }
.cp-diff__gap { padding: 2px 10px; color: var(--dk-muted); font-style: italic; border-top: 1px dashed var(--dk-edge); border-bottom: 1px dashed var(--dk-edge); }
.cp-edit__text { width: 100%; min-height: 96px; font-family: var(--font-mono); font-size: var(--text-xs); resize: vertical; }
/* The Proposals page: one card for each change that waits. */
.pp-list { margin: 0; padding: 0; list-style: none; display: grid; gap: 12px; max-width: 720px; }
.pp-item .cx-proposal,
.pp-item .cx-receipt { margin-top: 0; }
@@ -0,0 +1,5 @@
import { createContext } from 'react'
// How a card in the thread sends a message to the open chat, for the cards
// that offer to ask the assistant again. null outside the chat page.
export const ChatSendContext = createContext(null)
@@ -152,6 +152,27 @@
.nx-item__main:focus { outline: none; }
.nx-item__main:focus-visible { box-shadow: inset 0 0 0 2px var(--focus-ring-color); }
.nx-waiting {
display: grid;
grid-template-columns: 32px minmax(0, 1fr) 16px;
align-items: center;
column-gap: var(--sp-3);
width: 100%;
padding: var(--sp-3) var(--sp-4);
border: 0;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--dk-accent-wash);
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.nx-waiting:focus { outline: none; }
.nx-waiting:focus-visible { box-shadow: inset 0 0 0 2px var(--focus-ring-color); }
.nx-waiting__text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.nx-waiting .nx-item__title { color: var(--color-text-primary); font-weight: var(--font-weight-medium, 600); }
.nx-waiting .nx-item__go { opacity: 1; }
.nx-tile {
display: grid;
place-items: center;
+46
View File
@@ -0,0 +1,46 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { chatChangesApi } from '../utils/api'
import { CHANGES_EVENT } from '../utils/changeProposal'
const POLL_MS = 60_000
// The change proposals that wait for the person: how many, for the badge of the
// inbox and the Proposals page. It reads again when the tab is shown, every
// minute, when any card answers one (CHANGES_EVENT) and on refresh(). A server
// that keeps no proposals (files off, chats off) answers 404 or 409: the count
// then stays zero and the hook stops asking.
export function usePendingChanges() {
const [items, setItems] = useState([])
const [status, setStatus] = useState('loading')
const [error, setError] = useState(null)
const offRef = useRef(false)
const refresh = useCallback(async () => {
if (offRef.current) return
try {
const list = await chatChangesApi.list('pending')
setItems(Array.isArray(list) ? list : [])
setStatus('ready')
setError(null)
} catch (e) {
if (e?.status === 404 || e?.status === 409) { offRef.current = true; setItems([]); setStatus('ready'); return }
setError(e)
setStatus('error')
}
}, [])
useEffect(() => {
refresh()
const timer = setInterval(refresh, POLL_MS)
const onShow = () => { if (document.visibilityState === 'visible') refresh() }
window.addEventListener(CHANGES_EVENT, refresh)
document.addEventListener('visibilitychange', onShow)
return () => {
clearInterval(timer)
window.removeEventListener(CHANGES_EVENT, refresh)
document.removeEventListener('visibilitychange', onShow)
}
}, [refresh])
return { items, count: items.length, status, error, refresh }
}
+3
View File
@@ -72,6 +72,7 @@ import ConfirmDialog from '../components/ConfirmDialog'
import ChatsMenu from '../components/ChatsMenu'
// eslint-disable-next-line no-unused-vars
import ChatInbox from '../components/ChatInbox'
import { ChatSendContext } from '../components/chat/chatSendContext'
import { useAuth } from '../context/AuthContext'
import { useOperations } from '../hooks/useOperations'
import { useLoadedModels } from '../hooks/useLoadedModels'
@@ -1060,6 +1061,7 @@ function ChatPage() {
{isEmpty && <EmptyHead name={activeId ? title : (pickedAgent?.name || '')} manage={manage} />}
<div className="cx-thread" data-testid="chat-thread" role="region" aria-label={tc('transcript')} hidden={isEmpty}>
<EgressCardsContext.Provider value={egressCards}>
<ChatSendContext.Provider value={sendLive}>
{rows.map((row, i) => {
if (row.kind === 'activity') return <ActivityRow key={row.key} id={`cx-act-${row.key}`} items={row.items} proposalTick={chat.ticks.proposals} />
if (row.kind === 'app') return <AppRow key={row.key} chatId={activeId} row={row} onSend={sendLive} />
@@ -1083,6 +1085,7 @@ function ChatPage() {
fileChipsFor(row),
]
})}
</ChatSendContext.Provider>
</EgressCardsContext.Provider>
{(chat.running || streaming.text || streaming.reasoning || streaming.tools.length > 0) && (
@@ -0,0 +1,37 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import ChangeProposalCard from '../components/chat/ChangeProposalCard'
import { EmptyState, LoadError, Page, PageHeader, SkeletonRows } from '../components/ui'
import { usePendingChanges } from '../hooks/usePendingChanges'
import './chats.css'
// The changes that models proposed and that wait for an answer, from every
// chat and every connector, each as the card the chat draws. A card that was
// answered stays until the list is read again, so the receipt can be read and
// Undo used.
export default function Proposals() {
const { t } = useTranslation('chats')
const pending = usePendingChanges()
const { status, error, refresh } = pending
// A card stays on the page after it is answered, with its receipt, until the
// page is left; the list the server gives only holds the ones that wait.
const seen = useRef(new Map())
for (const p of pending.items) if (!seen.current.has(p.id)) seen.current.set(p.id, p)
const items = Array.from(seen.current.values())
return (
<Page className="pp-page" data-testid="proposals-page">
<PageHeader title={t('proposalsPage.title')} supporting={t('proposalsPage.supporting')} />
{status === 'loading' && <SkeletonRows count={3} />}
{status === 'error' && <LoadError title={t('proposalsPage.loadError')} error={error} onRetry={refresh} testId="proposals-load-error" />}
{status === 'ready' && items.length === 0 && (
<EmptyState variant="no-results" icon="check-circle" title={t('proposalsPage.emptyTitle')} body={t('proposalsPage.emptyBody')} testId="proposals-empty" />
)}
{items.length > 0 && (
<ul className="pp-list" aria-label={t('proposalsPage.title')}>
{items.map((p) => <li key={p.id} className="pp-item"><ChangeProposalCard id={p.id} initial={p} t={t} /></li>)}
</ul>
)}
</Page>
)
}
+2
View File
@@ -102,6 +102,7 @@ const AgentTasks = page('agent-tasks', () => import('./pages/AgentTasks'))
const ChatsAdmin = page('chats-admin', () => import('./pages/ChatsAdmin'))
const ChatConnectors = page('chats', () => import('./pages/ChatConnectors'))
const ChatConversion = page('chats', () => import('./pages/ChatConversion'))
const Proposals = page('chats', () => import('./pages/Proposals'))
const Models = page('models', () => import('./pages/Models'))
const ManageRedirect = page('manage', () => import('./pages/ManageRedirect'))
// Referenced only from JSX below — same blind spot as Activity further down.
@@ -255,6 +256,7 @@ const appChildren = [
{ path: 'chats/triggers', element: <ChatsRedirect /> },
{ path: 'chats/connectors', element: <Feature feature="connectors"><ChatConnectors /></Feature> },
{ path: 'chats/conversion', element: <Feature feature="chats"><ChatConversion /></Feature> },
{ path: 'chats/proposals', element: <Feature feature="chats"><Proposals /></Feature> },
// One route for a new chat and an open one, so the page stays mounted when a
// chat starts or is left (a delete waiting for its Undo survives the move).
{ path: 'chats/all', element: <Feature feature="chats"><AllChats /></Feature> },
+12
View File
@@ -533,6 +533,18 @@ export const chatTriggersApi = {
dismissChange: (id) => fetchJSON(`/api/chats/triggers/changes/${enc2(id)}`, { method: 'DELETE' }),
}
// The changes a model proposed to your memory, profile and other files, and
// the notes the host puts in the prompt. confirm may carry the person's own
// text, which is written in place of the proposed one.
export const chatChangesApi = {
list: (status) => fetchJSON(`/api/chats/changes${q({ status })}`),
get: (id) => fetchJSON(`/api/chats/changes/${enc2(id)}`),
confirm: (id, content) => postJSON(`/api/chats/changes/${enc2(id)}/confirm`, content === undefined ? {} : { content }),
dismiss: (id) => postJSON(`/api/chats/changes/${enc2(id)}/dismiss`, {}),
undo: (id) => postJSON(`/api/chats/changes/${enc2(id)}/undo`, {}),
contextFiles: () => fetchJSON('/api/chats/context-files'),
}
// The inbox: what ended while the user was elsewhere.
export const chatNotificationsApi = {
list: (unread, limit = 50) => fetchJSON(`/api/chats/notifications${q({ unread: unread ? 1 : undefined, limit })}`),