From b34e2e6ab773b886f9506a55a795d4d6b99064c7 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 3 Nov 2026 09:04:08 +0100 Subject: [PATCH 01/22] feat(ai-components): agent conversations, reply steps, approvals and sign-in AIConversation lays an agent conversation out by turn. parseMessageParts reads a reply's ai_reasoning and ai_tool_call attachments, shown by AIReasoning and AIToolCall, with AIToolApproval for a tool that asks first and AIToolAuthorization for a connector sign-in, run in a popup by createPopupAuthorizer and finished by completeConnectorSignIn. Also AISuggestions, AIReplyActions, AIMessageComposer.ChatOptions, default attachment previews and a className merge on the composer. Mirrors StreamChatAI on iOS and stream-chat-android-ai-compose. --- src/i18n/de.json | 36 +- src/i18n/en.json | 36 +- src/i18n/es.json | 36 +- src/i18n/fr.json | 36 +- src/i18n/hi.json | 36 +- src/i18n/it.json | 36 +- src/i18n/ja.json | 36 +- src/i18n/ko.json | 36 +- src/i18n/nl.json | 36 +- src/i18n/pt.json | 36 +- src/i18n/ru.json | 36 +- src/i18n/tr.json | 36 +- .../AIMessageComposer/AIMessageComposer.tsx | 12 +- .../AIMessageComposer/AttachmentPreview.tsx | 66 ++- .../AIMessageComposer/ChatOptions.tsx | 248 +++++++++ .../AIComponents/AIMessageComposer/index.ts | 8 + .../Conversation/AIConversation.tsx | 526 ++++++++++++++++++ .../AIComponents/Conversation/entries.ts | 136 +++++ .../Conversation/fromStreamMessage.ts | 63 +++ .../AIComponents/Conversation/index.ts | 4 + .../AIComponents/Conversation/types.ts | 45 ++ .../Message/AIAssistantMessage.tsx | 64 +++ .../Message/AIMessageAttachment.tsx | 138 +++++ .../AIComponents/Message/AIReplyActions.tsx | 151 +++++ .../Message/AITypingIndicator.tsx | 27 + .../AIComponents/Message/AIUserMessage.tsx | 57 ++ src/plugins/AIComponents/Message/index.ts | 5 + .../MessageParts/AIMessageParts.tsx | 79 +++ .../MessageParts/AIToolApproval.tsx | 158 ++++++ .../MessageParts/AIToolAuthorization.tsx | 248 +++++++++ .../AIComponents/MessageParts/AIToolCall.tsx | 96 ++++ .../AIComponents/MessageParts/index.ts | 6 + .../MessageParts/parseMessageParts.ts | 215 +++++++ .../AIComponents/MessageParts/types.ts | 160 ++++++ src/plugins/AIComponents/README.md | 153 ++++- .../AIComponents/Reasoning/AIReasoning.tsx | 356 ++++++++++++ src/plugins/AIComponents/Reasoning/index.ts | 1 + .../AIComponents/Reasoning/thinkingClock.ts | 107 ++++ .../Suggestions/AISuggestions.tsx | 43 ++ .../ToolCalls/AIClientIdentity.ts | 50 ++ .../AIComponents/ToolCalls/AIToolApprover.tsx | 65 +++ src/plugins/AIComponents/ToolCalls/index.ts | 3 + .../AIComponents/ToolCalls/popupAuthorizer.ts | 234 ++++++++ .../__tests__/AIConversation.test.tsx | 327 +++++++++++ .../__tests__/AIMessageComposer.test.tsx | 8 + .../__tests__/AIMessagePartViews.test.tsx | 466 ++++++++++++++++ .../__tests__/AttachmentPreview.test.tsx | 28 +- .../__tests__/messageParts.test.ts | 246 ++++++++ .../__tests__/popupAuthorizer.test.ts | 282 ++++++++++ .../__tests__/renderWithEnglish.tsx | 23 + .../AIComponents/__tests__/ssr.test.tsx | 51 ++ src/plugins/AIComponents/icons.tsx | 77 +++ src/plugins/AIComponents/index.ts | 6 + src/plugins/AIComponents/styling/_tokens.scss | 56 ++ .../AIComponents/styling/ai-conversation.scss | 433 ++++++++++++++ .../styling/ai-message-composer.scss | 6 + .../styling/ai-message-parts.scss | 385 +++++++++++++ src/plugins/AIComponents/styling/index.scss | 2 + src/plugins/AIComponents/utils.ts | 48 ++ 59 files changed, 6374 insertions(+), 26 deletions(-) create mode 100644 src/plugins/AIComponents/AIMessageComposer/ChatOptions.tsx create mode 100644 src/plugins/AIComponents/Conversation/AIConversation.tsx create mode 100644 src/plugins/AIComponents/Conversation/entries.ts create mode 100644 src/plugins/AIComponents/Conversation/fromStreamMessage.ts create mode 100644 src/plugins/AIComponents/Conversation/index.ts create mode 100644 src/plugins/AIComponents/Conversation/types.ts create mode 100644 src/plugins/AIComponents/Message/AIAssistantMessage.tsx create mode 100644 src/plugins/AIComponents/Message/AIMessageAttachment.tsx create mode 100644 src/plugins/AIComponents/Message/AIReplyActions.tsx create mode 100644 src/plugins/AIComponents/Message/AITypingIndicator.tsx create mode 100644 src/plugins/AIComponents/Message/AIUserMessage.tsx create mode 100644 src/plugins/AIComponents/Message/index.ts create mode 100644 src/plugins/AIComponents/MessageParts/AIMessageParts.tsx create mode 100644 src/plugins/AIComponents/MessageParts/AIToolApproval.tsx create mode 100644 src/plugins/AIComponents/MessageParts/AIToolAuthorization.tsx create mode 100644 src/plugins/AIComponents/MessageParts/AIToolCall.tsx create mode 100644 src/plugins/AIComponents/MessageParts/index.ts create mode 100644 src/plugins/AIComponents/MessageParts/parseMessageParts.ts create mode 100644 src/plugins/AIComponents/MessageParts/types.ts create mode 100644 src/plugins/AIComponents/Reasoning/AIReasoning.tsx create mode 100644 src/plugins/AIComponents/Reasoning/index.ts create mode 100644 src/plugins/AIComponents/Reasoning/thinkingClock.ts create mode 100644 src/plugins/AIComponents/Suggestions/AISuggestions.tsx create mode 100644 src/plugins/AIComponents/ToolCalls/AIClientIdentity.ts create mode 100644 src/plugins/AIComponents/ToolCalls/AIToolApprover.tsx create mode 100644 src/plugins/AIComponents/ToolCalls/index.ts create mode 100644 src/plugins/AIComponents/ToolCalls/popupAuthorizer.ts create mode 100644 src/plugins/AIComponents/__tests__/AIConversation.test.tsx create mode 100644 src/plugins/AIComponents/__tests__/AIMessagePartViews.test.tsx create mode 100644 src/plugins/AIComponents/__tests__/messageParts.test.ts create mode 100644 src/plugins/AIComponents/__tests__/popupAuthorizer.test.ts create mode 100644 src/plugins/AIComponents/__tests__/renderWithEnglish.tsx create mode 100644 src/plugins/AIComponents/icons.tsx create mode 100644 src/plugins/AIComponents/styling/ai-conversation.scss create mode 100644 src/plugins/AIComponents/styling/ai-message-parts.scss create mode 100644 src/plugins/AIComponents/utils.ts diff --git a/src/i18n/de.json b/src/i18n/de.json index 449bc36c8..812805392 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -42,6 +42,7 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} hat erstellt: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} hat abgestimmt: {{pollOptionText}}", "📍Shared location": "📍Geteilter Standort", + "A step this app version can't show": "Ein Schritt, den diese App-Version nicht anzeigen kann", "Actions": "Actions", "Add": "Hinzufügen", "Add {{ count }} members_one": "{{ count }} Mitglied hinzufügen", @@ -54,6 +55,7 @@ "Add reaction": "Reaktion hinzufügen", "Admin": "Administrator", "All results loaded": "Alle Ergebnisse geladen", + "Allow": "Erlauben", "Allow access to camera": "Zugriff auf Kamera erlauben", "Allow access to microphone": "Zugriff auf Mikrofon erlauben", "Allow comments": "Kommentare erlauben", @@ -93,6 +95,7 @@ "aria/Audio position {{ progress }} percent": "Audioposition {{ progress }} Prozent", "aria/Back to attachments": "Zurück zu Anhängen", "aria/Back to parent menu button": "Zurück zum übergeordneten Menü Schaltfläche", + "aria/Bad response": "Schlechte Antwort", "aria/Block User": "Benutzer blockieren", "aria/Bookmark Message": "Nachricht für später speichern", "aria/Cancel recording": "Aufnahme abbrechen", @@ -101,6 +104,7 @@ "aria/Channel Actions": "Kanalaktionen", "aria/Channel details": "Kanaldetails", "aria/Channel list": "Kanalliste", + "aria/Chat options": "Chat-Optionen", "aria/Chat view controls": "Chat-Ansicht-Steuerelemente", "aria/Chat: {{ channelName }}": "Chat: {{ channelName }}", "aria/Clear search": "Suche leeren", @@ -110,6 +114,8 @@ "aria/Command activated: {{ command }}": "Befehl aktiviert: {{ command }}", "aria/Command Suggestions": "Befehlsvorschläge", "aria/Complete recording": "Aufnahme abschließen", + "aria/Copied": "Kopiert", + "aria/Copy": "Kopieren", "aria/Copy Message Text": "Nachrichtentext kopieren", "aria/Decrease value": "Wert verringern", "aria/Delete Message": "Nachricht löschen", @@ -118,6 +124,7 @@ "aria/Dismiss notification": "Benachrichtigung schließen", "aria/Download attachment": "Anhang herunterladen", "aria/Dropped \"{{ option }}\" at position {{ position }}.": "„{{ option }}“ an Position {{ position }} abgelegt.", + "aria/Edit": "Bearbeiten", "aria/Edit Message": "Nachricht bearbeiten", "aria/Emoji picker": "Emoji-Auswahl", "aria/Emoji Suggestions": "Emoji-Vorschläge", @@ -133,6 +140,7 @@ "aria/Giphy image changed: {{ title }}": "Giphy-Bild geändert: {{ title }}", "aria/Giphy preview, only visible to you. Use the Send, Shuffle, or Cancel actions.": "Giphy-Vorschau, nur für dich sichtbar. Verwende die Aktionen Senden, Mischen oder Abbrechen.", "aria/Giphy sent": "Giphy gesendet", + "aria/Good response": "Gute Antwort", "aria/image": "Bild", "aria/Image failed to load": "Bild konnte nicht geladen werden", "aria/Increase value": "Wert erhöhen", @@ -141,6 +149,7 @@ "aria/Last activity: {{ time }}": "Letzte Aktivität: {{ time }}", "aria/Last message from {{ sender }}: {{ messagePreview }}": "Letzte Nachricht von {{ sender }}: {{ messagePreview }}", "aria/Last message: {{ messagePreview }}": "Letzte Nachricht: {{ messagePreview }}", + "aria/Loading older messages": "Ältere Nachrichten werden geladen", "aria/Mark Message Unread": "Als ungelesen markieren", "aria/Mark messages as read": "Nachrichten als gelesen markieren", "aria/Mention Suggestions": "Erwähnungsvorschläge", @@ -187,6 +196,7 @@ "aria/Recording paused": "Aufnahme pausiert", "aria/Recording resumed": "Aufnahme fortgesetzt", "aria/Recording started": "Aufnahme gestartet", + "aria/Regenerate": "Neu generieren", "aria/Remind Me Message": "Erinnern", "aria/Remove attachment": "Anhang entfernen", "aria/Remove location attachment": "Standortanhang entfernen", @@ -196,10 +206,12 @@ "aria/Removed option {{ option }}": "Option {{ option }} entfernt", "aria/Reorder \"{{ option }}\" at position {{ position }} of {{ total }}": "„{{ option }}“ neu anordnen, Position {{ position }} von {{ total }}", "aria/Reorder option {{ position }}": "Option {{ position }} neu anordnen", + "aria/Reply actions": "Antwortaktionen", "aria/Resend Message": "Nachricht erneut senden", "aria/Resume recording": "Aufnahme fortsetzen", "aria/Retry upload": "Upload erneut versuchen", "aria/Review bounced message": "Zurückgewiesene Nachricht prüfen", + "aria/Scroll to latest": "Zur neuesten Nachricht scrollen", "aria/Search cleared": "Suche gelöscht", "aria/Search results": "Suchergebnisse", "aria/Search results header filter button for: {{ source }}": "Filterbutton in der Kopfzeile der Suchergebnisse für: {{ source }}", @@ -208,6 +220,7 @@ "aria/Select User Channel: {{ name }}": "Benutzerkanal auswählen: {{ name }}", "aria/Send": "Senden", "aria/Sent": "Gesendet", + "aria/Share": "Teilen", "aria/Shared a link": "Link geteilt", "aria/Shared a link with title: {{ linkTitle }}": "Link geteilt mit Titel: {{ linkTitle }}", "aria/Shared location": "Geteilter Standort", @@ -246,6 +259,7 @@ "Browse channel members": "Kanalmitglieder durchsuchen", "Browse pinned messages": "Angeheftete Nachrichten durchsuchen", "Cancel": "Abbrechen", + "Cancelled": "Abgebrochen", "Cannot seek in the recording": "In der Aufnahme kann nicht gesucht werden", "Changes saved": "Änderungen gespeichert", "Channel archived": "Kanal archiviert", @@ -268,8 +282,11 @@ "Command not available while replying": "Befehl beim Antworten nicht verfügbar", "Commands": "Befehle", "Commands matching": "Übereinstimmende Befehle", + "Connect": "Verbinden", + "Connected to {{ provider }}": "Mit {{ provider }} verbunden", "Connecting": "Verbinde", "Connecting the dots": "Ich verbinde die Punkte", + "Connecting…": "Verbinden…", "Connection failure, reconnecting now...": "Verbindungsfehler, Wiederherstellung der Verbindung...", "Consulting the AI gods": "Ich befrage die KI-Götter", "Contact info": "Kontaktinfo", @@ -281,14 +298,17 @@ "Create poll": "Umfrage erstellen", "Crunching the numbers": "Ich zerkaue die Zahlen", "Current location": "Aktueller Standort", + "Declined": "Abgelehnt", "Delete": "Löschen", "Delete attachment": "Anhang löschen", "Delete chat": "Chat löschen", "Delete for me": "Für mich löschen", "Delete message": "Nachricht löschen", "Delivered": "Zugestellt", + "Didn't complete": "Nicht abgeschlossen", "Direct message": "Direktnachricht", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "Möchten Sie diese Umfrage jetzt beenden? Niemand wird mehr in dieser Umfrage abstimmen können.", + "Don't Allow": "Nicht erlauben", "Download {{ fileName }}": "{{ fileName }} herunterladen", "Download All": "Alle herunterladen", "Download Attachment": "Anhang herunterladen", @@ -502,6 +522,8 @@ "No results found": "Keine Ergebnisse gefunden", "No user found": "Kein Benutzer gefunden", "Nobody will be able to vote in this poll anymore.": "Niemand kann mehr in dieser Umfrage abstimmen.", + "Not connected": "Nicht verbunden", + "Not now": "Nicht jetzt", "Nothing yet...": "Noch nichts...", "Notify all {{ role }} members": "Alle Mitglieder mit Rolle {{ role }} benachrichtigen", "Offline": "Offline", @@ -626,15 +648,22 @@ "Summoning my inner genius": "Ich beschwöre mein inneres Genie", "Tap to remove": "Tippen zum Entfernen", "Tap to remove: {{ reactionName }}": "Tippen zum Entfernen: {{ reactionName }}", + "The sign-in couldn't be opened. Try again.": "Die Anmeldung konnte nicht geöffnet werden. Versuche es erneut.", + "The sign-in didn't complete.": "Die Anmeldung wurde nicht abgeschlossen.", "Thinking": "Denkt nach", "Thinking really hard": "Ich denke angestrengt nach", "Thinking...": "Denken...", + "Thinking…": "Denkt nach…", + "Thinking… {{ duration }}": "Denkt nach… {{ duration }}", "this content could not be displayed": "Dieser Inhalt konnte nicht angezeigt werden", "This field cannot be empty or contain only spaces": "Dieses Feld darf nicht leer sein oder nur Leerzeichen enthalten", "This message did not meet our content guidelines": "Diese Nachricht entsprach nicht unseren Inhaltsrichtlinien", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This sign-in has expired.": "Diese Anmeldung ist abgelaufen.", "This user will be able to message you again.": "Dieser Benutzer kann dir wieder Nachrichten senden.", "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", + "Thought for {{ duration }}": "{{ duration }} nachgedacht", + "Thought process": "Denkprozess", "Thread": "Thread", "Thread has not been found": "Thread wurde nicht gefunden", "Thread reply": "Thread-Antwort", @@ -716,8 +745,13 @@ "Vote ended": "Abstimmung beendet", "Votes": "Stimmen", "Wait until all attachments have uploaded": "Bitte warten, bis alle Anhänge hochgeladen wurden", + "Waiting for approval": "Wartet auf Bestätigung", "Waiting for network…": "Warte auf Netzwerk…", + "Waiting for the device": "Wartet auf das Gerät", + "Waiting for you to sign in": "Wartet auf deine Anmeldung", "Working my magic": "Ich lasse meine Magie wirken", "You": "Du", - "You've reached the maximum number of files": "Die maximale Anzahl an Dateien ist erreicht" + "You've reached the maximum number of files": "Die maximale Anzahl an Dateien ist erreicht", + "Your answer couldn't be sent. Try again.": "Deine Antwort konnte nicht gesendet werden. Versuche es erneut.", + "Your browser blocked the sign-in window. Allow pop-ups and try again.": "Dein Browser hat das Anmeldefenster blockiert. Erlaube Pop-ups und versuche es erneut." } diff --git a/src/i18n/en.json b/src/i18n/en.json index a9d61b90b..1996c7d07 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -42,6 +42,7 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} created: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} voted: {{pollOptionText}}", "📍Shared location": "📍Shared location", + "A step this app version can't show": "A step this app version can't show", "Actions": "Actions", "Add": "Add", "Add {{ count }} members_one": "Add {{ count }} member", @@ -54,6 +55,7 @@ "Add reaction": "Add reaction", "Admin": "Admin", "All results loaded": "All results loaded", + "Allow": "Allow", "Allow access to camera": "Allow access to camera", "Allow access to microphone": "Allow access to microphone", "Allow comments": "Allow comments", @@ -93,6 +95,7 @@ "aria/Audio position {{ progress }} percent": "Audio position {{ progress }} percent", "aria/Back to attachments": "Back to attachments", "aria/Back to parent menu button": "Back to parent menu button", + "aria/Bad response": "Bad response", "aria/Block User": "Block User", "aria/Bookmark Message": "Bookmark Message", "aria/Cancel recording": "Cancel recording", @@ -101,6 +104,7 @@ "aria/Channel Actions": "Channel Actions", "aria/Channel details": "Channel details", "aria/Channel list": "Channel list", + "aria/Chat options": "Chat options", "aria/Chat view controls": "Chat view controls", "aria/Chat: {{ channelName }}": "Chat: {{ channelName }}", "aria/Clear search": "Clear search", @@ -110,6 +114,8 @@ "aria/Command activated: {{ command }}": "Command activated: {{ command }}", "aria/Command Suggestions": "Command Suggestions", "aria/Complete recording": "Complete recording", + "aria/Copied": "Copied", + "aria/Copy": "Copy", "aria/Copy Message Text": "Copy Message Text", "aria/Decrease value": "Decrease value", "aria/Delete Message": "Delete Message", @@ -118,6 +124,7 @@ "aria/Dismiss notification": "Dismiss notification", "aria/Download attachment": "Download attachment", "aria/Dropped \"{{ option }}\" at position {{ position }}.": "Dropped \"{{ option }}\" at position {{ position }}.", + "aria/Edit": "Edit", "aria/Edit Message": "Edit Message", "aria/Emoji picker": "Emoji picker", "aria/Emoji Suggestions": "Emoji Suggestions", @@ -133,6 +140,7 @@ "aria/Giphy image changed: {{ title }}": "Giphy image changed: {{ title }}", "aria/Giphy preview, only visible to you. Use the Send, Shuffle, or Cancel actions.": "Giphy preview, only visible to you. Use the Send, Shuffle, or Cancel actions.", "aria/Giphy sent": "Giphy sent", + "aria/Good response": "Good response", "aria/image": "image", "aria/Image failed to load": "Image failed to load", "aria/Increase value": "Increase value", @@ -141,6 +149,7 @@ "aria/Last activity: {{ time }}": "Last activity: {{ time }}", "aria/Last message from {{ sender }}: {{ messagePreview }}": "Last message from {{ sender }}: {{ messagePreview }}", "aria/Last message: {{ messagePreview }}": "Last message: {{ messagePreview }}", + "aria/Loading older messages": "Loading older messages", "aria/Mark Message Unread": "Mark Message Unread", "aria/Mark messages as read": "Mark messages as read", "aria/Mention Suggestions": "Mention Suggestions", @@ -187,6 +196,7 @@ "aria/Recording paused": "Recording paused", "aria/Recording resumed": "Recording resumed", "aria/Recording started": "Recording started", + "aria/Regenerate": "Regenerate", "aria/Remind Me Message": "Remind Me Message", "aria/Remove attachment": "Remove attachment", "aria/Remove location attachment": "Remove location attachment", @@ -196,10 +206,12 @@ "aria/Removed option {{ option }}": "Removed option {{ option }}", "aria/Reorder \"{{ option }}\" at position {{ position }} of {{ total }}": "Reorder \"{{ option }}\" at position {{ position }} of {{ total }}", "aria/Reorder option {{ position }}": "Reorder option {{ position }}", + "aria/Reply actions": "Reply actions", "aria/Resend Message": "Resend Message", "aria/Resume recording": "Resume recording", "aria/Retry upload": "Retry upload", "aria/Review bounced message": "Review bounced message", + "aria/Scroll to latest": "Scroll to latest", "aria/Search cleared": "Search cleared", "aria/Search results": "Search results", "aria/Search results header filter button for: {{ source }}": "Search results header filter button for: {{ source }}", @@ -208,6 +220,7 @@ "aria/Select User Channel: {{ name }}": "Select User Channel: {{ name }}", "aria/Send": "Send", "aria/Sent": "Sent", + "aria/Share": "Share", "aria/Shared a link": "Shared a link", "aria/Shared a link with title: {{ linkTitle }}": "Shared a link with title: {{ linkTitle }}", "aria/Shared location": "Shared location", @@ -246,6 +259,7 @@ "Browse channel members": "Browse channel members", "Browse pinned messages": "Browse pinned messages", "Cancel": "Cancel", + "Cancelled": "Cancelled", "Cannot seek in the recording": "Cannot seek in the recording", "Changes saved": "Changes saved", "Channel archived": "Channel archived", @@ -268,8 +282,11 @@ "Command not available while replying": "Command not available while replying", "Commands": "Commands", "Commands matching": "Commands matching", + "Connect": "Connect", + "Connected to {{ provider }}": "Connected to {{ provider }}", "Connecting": "Connecting", "Connecting the dots": "Connecting the dots", + "Connecting…": "Connecting…", "Connection failure, reconnecting now...": "Connection failure, reconnecting now...", "Consulting the AI gods": "Consulting the AI gods", "Contact info": "Contact info", @@ -281,14 +298,17 @@ "Create poll": "Create Poll", "Crunching the numbers": "Crunching the numbers", "Current location": "Current location", + "Declined": "Declined", "Delete": "Delete", "Delete attachment": "Delete attachment", "Delete chat": "Delete chat", "Delete for me": "Delete for me", "Delete message": "Delete message", "Delivered": "Delivered", + "Didn't complete": "Didn't complete", "Direct message": "Direct message", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.", + "Don't Allow": "Don't Allow", "Download {{ fileName }}": "Download {{ fileName }}", "Download All": "Download All", "Download Attachment": "Download Attachment", @@ -502,6 +522,8 @@ "No results found": "No results found", "No user found": "No user found", "Nobody will be able to vote in this poll anymore.": "Nobody will be able to vote in this poll anymore.", + "Not connected": "Not connected", + "Not now": "Not now", "Nothing yet...": "Nothing yet...", "Notify all {{ role }} members": "Notify all {{ role }} members", "Offline": "Offline", @@ -626,15 +648,22 @@ "Summoning my inner genius": "Summoning my inner genius", "Tap to remove": "Tap to remove", "Tap to remove: {{ reactionName }}": "Tap to remove: {{ reactionName }}", + "The sign-in couldn't be opened. Try again.": "The sign-in couldn't be opened. Try again.", + "The sign-in didn't complete.": "The sign-in didn't complete.", "Thinking": "Thinking", "Thinking really hard": "Thinking really hard", "Thinking...": "Thinking...", + "Thinking…": "Thinking…", + "Thinking… {{ duration }}": "Thinking… {{ duration }}", "this content could not be displayed": "this content could not be displayed", "This field cannot be empty or contain only spaces": "This field cannot be empty or contain only spaces", "This message did not meet our content guidelines": "This message did not meet our content guidelines", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This sign-in has expired.": "This sign-in has expired.", "This user will be able to message you again.": "This user will be able to message you again.", "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", + "Thought for {{ duration }}": "Thought for {{ duration }}", + "Thought process": "Thought process", "Thread": "Thread", "Thread has not been found": "Thread has not been found", "Thread reply": "Thread reply", @@ -716,8 +745,13 @@ "Vote ended": "Vote ended", "Votes": "Votes", "Wait until all attachments have uploaded": "Wait until all attachments have uploaded", + "Waiting for approval": "Waiting for approval", "Waiting for network…": "Waiting for network…", + "Waiting for the device": "Waiting for the device", + "Waiting for you to sign in": "Waiting for you to sign in", "Working my magic": "Working my magic", "You": "You", - "You've reached the maximum number of files": "You've reached the maximum number of files" + "You've reached the maximum number of files": "You've reached the maximum number of files", + "Your answer couldn't be sent. Try again.": "Your answer couldn't be sent. Try again.", + "Your browser blocked the sign-in window. Allow pop-ups and try again.": "Your browser blocked the sign-in window. Allow pop-ups and try again." } diff --git a/src/i18n/es.json b/src/i18n/es.json index aff84ebbb..9a2205284 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -52,6 +52,7 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} creó: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} votó: {{pollOptionText}}", "📍Shared location": "📍Ubicación compartida", + "A step this app version can't show": "Un paso que esta versión de la app no puede mostrar", "Actions": "Actions", "Add": "Añadir", "Add {{ count }} members_one": "Añadir {{ count }} miembro", @@ -65,6 +66,7 @@ "Add reaction": "Añadir reacción", "Admin": "Administrador", "All results loaded": "Todos los resultados cargados", + "Allow": "Permitir", "Allow access to camera": "Permitir acceso a la cámara", "Allow access to microphone": "Permitir acceso al micrófono", "Allow comments": "Permitir comentarios", @@ -109,6 +111,7 @@ "aria/Audio position {{ progress }} percent": "Posición de audio {{ progress }} por ciento", "aria/Back to attachments": "Volver a adjuntos", "aria/Back to parent menu button": "Volver al menú superior botón", + "aria/Bad response": "Mala respuesta", "aria/Block User": "Bloquear usuario", "aria/Bookmark Message": "Guardar mensaje", "aria/Cancel recording": "Cancelar grabación", @@ -117,6 +120,7 @@ "aria/Channel Actions": "Acciones del canal", "aria/Channel details": "Detalles del canal", "aria/Channel list": "Lista de canales", + "aria/Chat options": "Opciones del chat", "aria/Chat view controls": "Controles de la vista del chat", "aria/Chat: {{ channelName }}": "Chat: {{ channelName }}", "aria/Clear search": "Borrar búsqueda", @@ -126,6 +130,8 @@ "aria/Command activated: {{ command }}": "Comando activado: {{ command }}", "aria/Command Suggestions": "Sugerencias de comandos", "aria/Complete recording": "Completar grabación", + "aria/Copied": "Copiado", + "aria/Copy": "Copiar", "aria/Copy Message Text": "Copiar texto del mensaje", "aria/Decrease value": "Disminuir valor", "aria/Delete Message": "Eliminar mensaje", @@ -134,6 +140,7 @@ "aria/Dismiss notification": "Descartar notificación", "aria/Download attachment": "Descargar adjunto", "aria/Dropped \"{{ option }}\" at position {{ position }}.": "«{{ option }}» soltada en la posición {{ position }}.", + "aria/Edit": "Editar", "aria/Edit Message": "Editar mensaje", "aria/Emoji picker": "Selector de emojis", "aria/Emoji Suggestions": "Sugerencias de emojis", @@ -149,6 +156,7 @@ "aria/Giphy image changed: {{ title }}": "Imagen de Giphy cambiada: {{ title }}", "aria/Giphy preview, only visible to you. Use the Send, Shuffle, or Cancel actions.": "Vista previa de Giphy, solo visible para ti. Usa las acciones Enviar, Mezclar o Cancelar.", "aria/Giphy sent": "Giphy enviado", + "aria/Good response": "Buena respuesta", "aria/image": "imagen", "aria/Image failed to load": "Error al cargar la imagen", "aria/Increase value": "Aumentar valor", @@ -157,6 +165,7 @@ "aria/Last activity: {{ time }}": "Última actividad: {{ time }}", "aria/Last message from {{ sender }}: {{ messagePreview }}": "Último mensaje de {{ sender }}: {{ messagePreview }}", "aria/Last message: {{ messagePreview }}": "Último mensaje: {{ messagePreview }}", + "aria/Loading older messages": "Cargando mensajes anteriores", "aria/Mark Message Unread": "Marcar como no leído", "aria/Mark messages as read": "Marcar mensajes como leídos", "aria/Mention Suggestions": "Sugerencias de menciones", @@ -204,6 +213,7 @@ "aria/Recording paused": "Grabación pausada", "aria/Recording resumed": "Grabación reanudada", "aria/Recording started": "Grabación iniciada", + "aria/Regenerate": "Regenerar", "aria/Remind Me Message": "Recordarme", "aria/Remove attachment": "Eliminar adjunto", "aria/Remove location attachment": "Eliminar adjunto de ubicación", @@ -213,10 +223,12 @@ "aria/Removed option {{ option }}": "Opción {{ option }} eliminada", "aria/Reorder \"{{ option }}\" at position {{ position }} of {{ total }}": "Reordenar «{{ option }}», posición {{ position }} de {{ total }}", "aria/Reorder option {{ position }}": "Reordenar opción {{ position }}", + "aria/Reply actions": "Acciones de respuesta", "aria/Resend Message": "Reenviar mensaje", "aria/Resume recording": "Reanudar grabación", "aria/Retry upload": "Reintentar carga", "aria/Review bounced message": "Revisar mensaje rebotado", + "aria/Scroll to latest": "Ir al último mensaje", "aria/Search cleared": "Búsqueda borrada", "aria/Search results": "Resultados de búsqueda", "aria/Search results header filter button for: {{ source }}": "Botón de filtro del encabezado de resultados de búsqueda para: {{ source }}", @@ -225,6 +237,7 @@ "aria/Select User Channel: {{ name }}": "Seleccionar canal de usuario: {{ name }}", "aria/Send": "Enviar", "aria/Sent": "Enviado", + "aria/Share": "Compartir", "aria/Shared a link": "Enlace compartido", "aria/Shared a link with title: {{ linkTitle }}": "Enlace compartido con título: {{ linkTitle }}", "aria/Shared location": "Ubicación compartida", @@ -263,6 +276,7 @@ "Browse channel members": "Explorar miembros del canal", "Browse pinned messages": "Explorar mensajes fijados", "Cancel": "Cancelar", + "Cancelled": "Cancelado", "Cannot seek in the recording": "No se puede buscar en la grabación", "Changes saved": "Cambios guardados", "Channel archived": "Canal archivado", @@ -285,8 +299,11 @@ "Command not available while replying": "Comando no disponible mientras se responde", "Commands": "Comandos", "Commands matching": "Coincidencia de comandos", + "Connect": "Conectar", + "Connected to {{ provider }}": "Conectado a {{ provider }}", "Connecting": "Conectando", "Connecting the dots": "Conectando los puntos", + "Connecting…": "Conectando…", "Connection failure, reconnecting now...": "Fallo de conexión, reconectando ahora...", "Consulting the AI gods": "Consultando a los dioses de la IA", "Contact info": "Información de contacto", @@ -298,14 +315,17 @@ "Create poll": "Crear encuesta", "Crunching the numbers": "Procesando los números", "Current location": "Ubicación actual", + "Declined": "Rechazado", "Delete": "Borrar", "Delete attachment": "Eliminar archivo adjunto", "Delete chat": "Eliminar chat", "Delete for me": "Eliminar para mí", "Delete message": "Eliminar mensaje", "Delivered": "Entregado", + "Didn't complete": "No se completó", "Direct message": "Mensaje directo", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "¿Quieres terminar esta encuesta ahora? Nadie podrá votar más en esta encuesta.", + "Don't Allow": "No permitir", "Download {{ fileName }}": "Descargar {{ fileName }}", "Download All": "Descargar todo", "Download Attachment": "Descargar archivo adjunto", @@ -522,6 +542,8 @@ "No results found": "No se han encontrado resultados", "No user found": "No se encontró ningún usuario", "Nobody will be able to vote in this poll anymore.": "Nadie podrá votar en esta encuesta.", + "Not connected": "No conectado", + "Not now": "Ahora no", "Nothing yet...": "Nada aún...", "Notify all {{ role }} members": "Notificar a todos los miembros con rol {{ role }}", "Offline": "Desconectado", @@ -651,15 +673,22 @@ "Summoning my inner genius": "Invocando a mi genio interior", "Tap to remove": "Toca para quitar", "Tap to remove: {{ reactionName }}": "Toca para quitar: {{ reactionName }}", + "The sign-in couldn't be opened. Try again.": "No se pudo abrir el inicio de sesión. Inténtalo de nuevo.", + "The sign-in didn't complete.": "El inicio de sesión no se completó.", "Thinking": "Pensando", "Thinking really hard": "Pensando muy fuerte", "Thinking...": "Pensando...", + "Thinking…": "Pensando…", + "Thinking… {{ duration }}": "Pensando… {{ duration }}", "this content could not be displayed": "Este contenido no se pudo mostrar", "This field cannot be empty or contain only spaces": "Este campo no puede estar vacío o contener solo espacios", "This message did not meet our content guidelines": "Este mensaje no cumple con nuestras directrices de contenido", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This sign-in has expired.": "Este inicio de sesión ha caducado.", "This user will be able to message you again.": "Este usuario podrá enviarte mensajes de nuevo.", "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", + "Thought for {{ duration }}": "Pensó durante {{ duration }}", + "Thought process": "Proceso de razonamiento", "Thread": "Hilo", "Thread has not been found": "No se ha encontrado el hilo", "Thread reply": "Respuesta en hilo", @@ -746,8 +775,13 @@ "Vote ended": "Votación finalizada", "Votes": "Votos", "Wait until all attachments have uploaded": "Espere hasta que se hayan cargado todos los archivos adjuntos", + "Waiting for approval": "Esperando aprobación", "Waiting for network…": "Esperando red…", + "Waiting for the device": "Esperando al dispositivo", + "Waiting for you to sign in": "Esperando a que inicies sesión", "Working my magic": "Haciendo mi magia", "You": "Tú", - "You've reached the maximum number of files": "Has alcanzado el número máximo de archivos" + "You've reached the maximum number of files": "Has alcanzado el número máximo de archivos", + "Your answer couldn't be sent. Try again.": "No se pudo enviar tu respuesta. Inténtalo de nuevo.", + "Your browser blocked the sign-in window. Allow pop-ups and try again.": "Tu navegador bloqueó la ventana de inicio de sesión. Permite las ventanas emergentes e inténtalo de nuevo." } diff --git a/src/i18n/fr.json b/src/i18n/fr.json index d5120dfd3..f304030c8 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -52,6 +52,7 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} a créé : {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} a voté : {{pollOptionText}}", "📍Shared location": "📍Emplacement partagé", + "A step this app version can't show": "Une étape que cette version de l'app ne peut pas afficher", "Actions": "Actions", "Add": "Ajouter", "Add {{ count }} members_one": "Ajouter {{ count }} membre", @@ -65,6 +66,7 @@ "Add reaction": "Ajouter une réaction", "Admin": "Admin", "All results loaded": "Tous les résultats sont chargés", + "Allow": "Autoriser", "Allow access to camera": "Autoriser l'accès à la caméra", "Allow access to microphone": "Autoriser l'accès au microphone", "Allow comments": "Autoriser les commentaires", @@ -109,6 +111,7 @@ "aria/Audio position {{ progress }} percent": "Position audio {{ progress }} pour cent", "aria/Back to attachments": "Retour aux pièces jointes", "aria/Back to parent menu button": "Retour au menu parent bouton", + "aria/Bad response": "Mauvaise réponse", "aria/Block User": "Bloquer l'utilisateur", "aria/Bookmark Message": "Enregistrer le message", "aria/Cancel recording": "Annuler l'enregistrement", @@ -117,6 +120,7 @@ "aria/Channel Actions": "Actions du canal", "aria/Channel details": "Détails du canal", "aria/Channel list": "Liste des canaux", + "aria/Chat options": "Options du chat", "aria/Chat view controls": "Commandes de la vue du chat", "aria/Chat: {{ channelName }}": "Chat : {{ channelName }}", "aria/Clear search": "Effacer la recherche", @@ -126,6 +130,8 @@ "aria/Command activated: {{ command }}": "Commande activée : {{ command }}", "aria/Command Suggestions": "Suggestions de commandes", "aria/Complete recording": "Terminer l'enregistrement", + "aria/Copied": "Copié", + "aria/Copy": "Copier", "aria/Copy Message Text": "Copier le texte du message", "aria/Decrease value": "Diminuer la valeur", "aria/Delete Message": "Supprimer le message", @@ -134,6 +140,7 @@ "aria/Dismiss notification": "Ignorer la notification", "aria/Download attachment": "Télécharger la pièce jointe", "aria/Dropped \"{{ option }}\" at position {{ position }}.": "« {{ option }} » déposé à la position {{ position }}.", + "aria/Edit": "Modifier", "aria/Edit Message": "Éditer un message", "aria/Emoji picker": "Sélecteur d'émojis", "aria/Emoji Suggestions": "Suggestions d'émojis", @@ -149,6 +156,7 @@ "aria/Giphy image changed: {{ title }}": "Image Giphy modifiée : {{ title }}", "aria/Giphy preview, only visible to you. Use the Send, Shuffle, or Cancel actions.": "Aperçu Giphy, visible uniquement par vous. Utilisez les actions Envoyer, Mélanger ou Annuler.", "aria/Giphy sent": "Giphy envoyé", + "aria/Good response": "Bonne réponse", "aria/image": "image", "aria/Image failed to load": "Échec du chargement de l'image", "aria/Increase value": "Augmenter la valeur", @@ -157,6 +165,7 @@ "aria/Last activity: {{ time }}": "Dernière activité : {{ time }}", "aria/Last message from {{ sender }}: {{ messagePreview }}": "Dernier message de {{ sender }} : {{ messagePreview }}", "aria/Last message: {{ messagePreview }}": "Dernier message : {{ messagePreview }}", + "aria/Loading older messages": "Chargement des messages précédents", "aria/Mark Message Unread": "Marquer comme non lu", "aria/Mark messages as read": "Marquer les messages comme lus", "aria/Mention Suggestions": "Suggestions de mentions", @@ -204,6 +213,7 @@ "aria/Recording paused": "Enregistrement en pause", "aria/Recording resumed": "Enregistrement repris", "aria/Recording started": "Enregistrement démarré", + "aria/Regenerate": "Régénérer", "aria/Remind Me Message": "Me rappeler", "aria/Remove attachment": "Supprimer la pièce jointe", "aria/Remove location attachment": "Supprimer la pièce jointe d'emplacement", @@ -213,10 +223,12 @@ "aria/Removed option {{ option }}": "Option {{ option }} supprimée", "aria/Reorder \"{{ option }}\" at position {{ position }} of {{ total }}": "Réorganiser « {{ option }} », position {{ position }} sur {{ total }}", "aria/Reorder option {{ position }}": "Réorganiser l'option {{ position }}", + "aria/Reply actions": "Actions de réponse", "aria/Resend Message": "Renvoyer le message", "aria/Resume recording": "Reprendre l'enregistrement", "aria/Retry upload": "Réessayer le téléchargement", "aria/Review bounced message": "Examiner le message rejeté", + "aria/Scroll to latest": "Aller au dernier message", "aria/Search cleared": "Recherche effacée", "aria/Search results": "Résultats de recherche", "aria/Search results header filter button for: {{ source }}": "Bouton de filtre d'en-tête des résultats de recherche pour : {{ source }}", @@ -225,6 +237,7 @@ "aria/Select User Channel: {{ name }}": "Sélectionner le canal utilisateur : {{ name }}", "aria/Send": "Envoyer", "aria/Sent": "Envoyé", + "aria/Share": "Partager", "aria/Shared a link": "Lien partagé", "aria/Shared a link with title: {{ linkTitle }}": "Lien partagé avec le titre : {{ linkTitle }}", "aria/Shared location": "Position partagée", @@ -263,6 +276,7 @@ "Browse channel members": "Parcourir les membres du canal", "Browse pinned messages": "Parcourir les messages épinglés", "Cancel": "Annuler", + "Cancelled": "Annulé", "Cannot seek in the recording": "Impossible de rechercher dans l'enregistrement", "Changes saved": "Modifications enregistrées", "Channel archived": "Canal archivé", @@ -285,8 +299,11 @@ "Command not available while replying": "Commande non disponible pendant la réponse", "Commands": "Commandes", "Commands matching": "Correspondance des commandes", + "Connect": "Connecter", + "Connected to {{ provider }}": "Connecté à {{ provider }}", "Connecting": "Connexion", "Connecting the dots": "Je relie les points", + "Connecting…": "Connexion…", "Connection failure, reconnecting now...": "Échec de la connexion, reconnexion en cours...", "Consulting the AI gods": "Je consulte les dieux de l'IA", "Contact info": "Informations du contact", @@ -298,14 +315,17 @@ "Create poll": "Créer un sondage", "Crunching the numbers": "Je fais chauffer les chiffres", "Current location": "Emplacement actuel", + "Declined": "Refusé", "Delete": "Supprimer", "Delete attachment": "Supprimer la pièce jointe", "Delete chat": "Supprimer le chat", "Delete for me": "Supprimer pour moi", "Delete message": "Supprimer le message", "Delivered": "Publié", + "Didn't complete": "Non terminé", "Direct message": "Message direct", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "Voulez-vous terminer ce sondage maintenant ? Personne ne pourra plus voter dans ce sondage.", + "Don't Allow": "Ne pas autoriser", "Download {{ fileName }}": "Télécharger {{ fileName }}", "Download All": "Tout télécharger", "Download Attachment": "Télécharger la pièce jointe", @@ -522,6 +542,8 @@ "No results found": "Aucun résultat trouvé", "No user found": "Aucun utilisateur trouvé", "Nobody will be able to vote in this poll anymore.": "Personne ne pourra plus voter dans ce sondage.", + "Not connected": "Non connecté", + "Not now": "Pas maintenant", "Nothing yet...": "Rien pour l'instant...", "Notify all {{ role }} members": "Notifier tous les membres ayant le rôle {{ role }}", "Offline": "Hors ligne", @@ -651,15 +673,22 @@ "Summoning my inner genius": "J'invoque mon génie intérieur", "Tap to remove": "Appuyez pour retirer", "Tap to remove: {{ reactionName }}": "Appuyez pour retirer: {{ reactionName }}", + "The sign-in couldn't be opened. Try again.": "La connexion n'a pas pu être ouverte. Réessayez.", + "The sign-in didn't complete.": "La connexion n'a pas abouti.", "Thinking": "Réfléchit", "Thinking really hard": "Je réfléchis très fort", "Thinking...": "Réflexion...", + "Thinking…": "Réflexion…", + "Thinking… {{ duration }}": "Réflexion… {{ duration }}", "this content could not be displayed": "ce contenu n'a pas pu être affiché", "This field cannot be empty or contain only spaces": "Ce champ ne peut pas être vide ou contenir uniquement des espaces", "This message did not meet our content guidelines": "Ce message ne respecte pas nos directives de contenu", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This sign-in has expired.": "Cette connexion a expiré.", "This user will be able to message you again.": "Cet utilisateur pourra à nouveau vous envoyer des messages.", "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", + "Thought for {{ duration }}": "A réfléchi pendant {{ duration }}", + "Thought process": "Raisonnement", "Thread": "Fil de discussion", "Thread has not been found": "Le fil de discussion n'a pas été trouvé", "Thread reply": "Réponse dans le fil", @@ -746,8 +775,13 @@ "Vote ended": "Vote terminé", "Votes": "Votes", "Wait until all attachments have uploaded": "Attendez que toutes les pièces jointes soient téléchargées", + "Waiting for approval": "En attente d'approbation", "Waiting for network…": "En attente du réseau…", + "Waiting for the device": "En attente de l'appareil", + "Waiting for you to sign in": "En attente de votre connexion", "Working my magic": "Je fais ma magie", "You": "Vous", - "You've reached the maximum number of files": "Vous avez atteint le nombre maximal de fichiers" + "You've reached the maximum number of files": "Vous avez atteint le nombre maximal de fichiers", + "Your answer couldn't be sent. Try again.": "Votre réponse n'a pas pu être envoyée. Réessayez.", + "Your browser blocked the sign-in window. Allow pop-ups and try again.": "Votre navigateur a bloqué la fenêtre de connexion. Autorisez les pop-ups et réessayez." } diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 9ec9fa6dc..7f5504b12 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -42,6 +42,7 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} ने बनाया: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} ने वोट दिया: {{pollOptionText}}", "📍Shared location": "📍साझा किया गया स्थान", + "A step this app version can't show": "एक चरण जिसे यह ऐप संस्करण नहीं दिखा सकता", "Actions": "Actions", "Add": "जोड़ें", "Add {{ count }} members_one": "{{ count }} सदस्य जोड़ें", @@ -54,6 +55,7 @@ "Add reaction": "प्रतिक्रिया जोड़ें", "Admin": "एडमिन", "All results loaded": "सभी परिणाम लोड हो गए", + "Allow": "अनुमति दें", "Allow access to camera": "कैमरा तक पहुँच दें", "Allow access to microphone": "माइक्रोफ़ोन तक पहुँच दें", "Allow comments": "टिप्पणियाँ की अनुमति दें", @@ -93,6 +95,7 @@ "aria/Audio position {{ progress }} percent": "ऑडियो स्थिति {{ progress }} प्रतिशत", "aria/Back to attachments": "अनुलग्नकों पर वापस जाएं", "aria/Back to parent menu button": "मूल मेनू पर वापस जाएँ बटन", + "aria/Bad response": "खराब जवाब", "aria/Block User": "उपयोगकर्ता को ब्लॉक करें", "aria/Bookmark Message": "संदेश बुकमार्क करें", "aria/Cancel recording": "रिकॉर्डिंग रद्द करें", @@ -101,6 +104,7 @@ "aria/Channel Actions": "चैनल क्रियाएँ", "aria/Channel details": "चैनल विवरण", "aria/Channel list": "चैनल सूची", + "aria/Chat options": "चैट विकल्प", "aria/Chat view controls": "चैट व्यू नियंत्रण", "aria/Chat: {{ channelName }}": "चैट: {{ channelName }}", "aria/Clear search": "खोज साफ़ करें", @@ -110,6 +114,8 @@ "aria/Command activated: {{ command }}": "कमांड सक्रिय किया गया: {{ command }}", "aria/Command Suggestions": "कमांड सुझाव", "aria/Complete recording": "रिकॉर्डिंग पूर्ण करें", + "aria/Copied": "कॉपी हो गया", + "aria/Copy": "कॉपी करें", "aria/Copy Message Text": "संदेश की टेक्स्ट कॉपी करें", "aria/Decrease value": "मान घटाएं", "aria/Delete Message": "संदेश डिलीट करें", @@ -118,6 +124,7 @@ "aria/Dismiss notification": "सूचना बंद करें", "aria/Download attachment": "अनुलग्नक डाउनलोड करें", "aria/Dropped \"{{ option }}\" at position {{ position }}.": "\"{{ option }}\" को स्थिति {{ position }} पर छोड़ा गया।", + "aria/Edit": "संपादित करें", "aria/Edit Message": "मैसेज में बदलाव करे", "aria/Emoji picker": "इमोजी चुनने वाला", "aria/Emoji Suggestions": "इमोजी सुझाव", @@ -133,6 +140,7 @@ "aria/Giphy image changed: {{ title }}": "Giphy छवि बदल गई: {{ title }}", "aria/Giphy preview, only visible to you. Use the Send, Shuffle, or Cancel actions.": "Giphy पूर्वावलोकन, केवल आपको दिखाई देता है। भेजें, शफल करें या रद्द करें क्रियाओं का उपयोग करें।", "aria/Giphy sent": "Giphy भेजा गया", + "aria/Good response": "अच्छा जवाब", "aria/image": "छवि", "aria/Image failed to load": "छवि लोड होने में विफल", "aria/Increase value": "मान बढ़ाएं", @@ -141,6 +149,7 @@ "aria/Last activity: {{ time }}": "अंतिम गतिविधि: {{ time }}", "aria/Last message from {{ sender }}: {{ messagePreview }}": "{{ sender }} का आखिरी संदेश: {{ messagePreview }}", "aria/Last message: {{ messagePreview }}": "आखिरी संदेश: {{ messagePreview }}", + "aria/Loading older messages": "पुराने संदेश लोड हो रहे हैं", "aria/Mark Message Unread": "अपठित चिह्नित करें", "aria/Mark messages as read": "संदेशों को पढ़ा हुआ चिह्नित करें", "aria/Mention Suggestions": "उल्लेख सुझाव", @@ -187,6 +196,7 @@ "aria/Recording paused": "रिकॉर्डिंग रोकी गई", "aria/Recording resumed": "रिकॉर्डिंग फिर से शुरू हुई", "aria/Recording started": "रिकॉर्डिंग शुरू हुई", + "aria/Regenerate": "फिर से बनाएं", "aria/Remind Me Message": "मुझे याद दिलाएं", "aria/Remove attachment": "संलग्नक हटाएं", "aria/Remove location attachment": "स्थान संलग्नक हटाएं", @@ -196,10 +206,12 @@ "aria/Removed option {{ option }}": "विकल्प {{ option }} हटाया गया", "aria/Reorder \"{{ option }}\" at position {{ position }} of {{ total }}": "\"{{ option }}\" को पुनर्व्यवस्थित करें, {{ total }} में से स्थिति {{ position }}", "aria/Reorder option {{ position }}": "विकल्प {{ position }} को पुनर्व्यवस्थित करें", + "aria/Reply actions": "जवाब के विकल्प", "aria/Resend Message": "संदेश फिर से भेजें", "aria/Resume recording": "रिकॉर्डिंग फिर शुरू करें", "aria/Retry upload": "अपलोड पुनः प्रयास करें", "aria/Review bounced message": "वापस लौटा संदेश समीक्षा करें", + "aria/Scroll to latest": "नवीनतम संदेश पर जाएं", "aria/Search cleared": "खोज साफ़ की गई", "aria/Search results": "खोज परिणाम", "aria/Search results header filter button for: {{ source }}": "{{ source }} के लिए खोज परिणाम शीर्षक फ़िल्टर बटन", @@ -208,6 +220,7 @@ "aria/Select User Channel: {{ name }}": "उपयोगकर्ता चैनल चुनें: {{ name }}", "aria/Send": "भेजें", "aria/Sent": "भेजा गया", + "aria/Share": "साझा करें", "aria/Shared a link": "लिंक साझा किया गया", "aria/Shared a link with title: {{ linkTitle }}": "शीर्षक के साथ लिंक साझा किया गया: {{ linkTitle }}", "aria/Shared location": "साझा किया गया स्थान", @@ -246,6 +259,7 @@ "Browse channel members": "चैनल सदस्य देखें", "Browse pinned messages": "पिन किए गए संदेश देखें", "Cancel": "रद्द करें", + "Cancelled": "रद्द किया गया", "Cannot seek in the recording": "रेकॉर्डिंग में खोज नहीं की जा सकती", "Changes saved": "बदलाव सहेजे गए", "Channel archived": "चैनल संग्रहीत किया गया", @@ -268,8 +282,11 @@ "Command not available while replying": "उत्तर देते समय कमांड उपलब्ध नहीं है", "Commands": "कमांड", "Commands matching": "मेल खाती है", + "Connect": "कनेक्ट करें", + "Connected to {{ provider }}": "{{ provider }} से कनेक्ट है", "Connecting": "कनेक्ट हो रहा है", "Connecting the dots": "कड़ियाँ जोड़ रहा हूँ", + "Connecting…": "कनेक्ट हो रहा है…", "Connection failure, reconnecting now...": "कनेक्शन विफल रहा, अब पुनः कनेक्ट हो रहा है ...", "Consulting the AI gods": "एआई देवताओं से सलाह ले रहा हूँ", "Contact info": "संपर्क जानकारी", @@ -281,14 +298,17 @@ "Create poll": "मतदान बनाएँ", "Crunching the numbers": "आँकड़ों का हिसाब लगा रहा हूँ", "Current location": "वर्तमान स्थान", + "Declined": "अस्वीकार किया गया", "Delete": "डिलीट", "Delete attachment": "अटैचमेंट हटाएँ", "Delete chat": "चैट हटाएं", "Delete for me": "मेरे लिए डिलीट करें", "Delete message": "संदेश हटाएं", "Delivered": "पहुंच गया", + "Didn't complete": "पूरा नहीं हुआ", "Direct message": "प्रत्यक्ष संदेश", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "क्या आप अभी इस पोल को समाप्त करना चाहते हैं? इस पोल में अब कोई भी वोट नहीं कर पाएगा।", + "Don't Allow": "अनुमति न दें", "Download {{ fileName }}": "{{ fileName }} डाउनलोड करें", "Download All": "सब डाउनलोड करें", "Download Attachment": "अटैचमेंट डाउनलोड करें", @@ -503,6 +523,8 @@ "No results found": "कोई परिणाम नहीं मिला", "No user found": "कोई उपयोगकर्ता नहीं मिला", "Nobody will be able to vote in this poll anymore.": "अब कोई भी इस मतदान में मतदान नहीं कर सकेगा।", + "Not connected": "कनेक्ट नहीं है", + "Not now": "अभी नहीं", "Nothing yet...": "कोई मैसेज नहीं है", "Notify all {{ role }} members": "{{ role }} भूमिका वाले सभी सदस्यों को सूचित करें", "Offline": "ऑफलाइन", @@ -627,15 +649,22 @@ "Summoning my inner genius": "अपने अंदर की प्रतिभा को बुला रहा हूँ", "Tap to remove": "हटाने के लिए टैप करें", "Tap to remove: {{ reactionName }}": "हटाने के लिए टैप करें: {{ reactionName }}", + "The sign-in couldn't be opened. Try again.": "साइन-इन नहीं खुल सका। फिर से कोशिश करें।", + "The sign-in didn't complete.": "साइन-इन पूरा नहीं हुआ।", "Thinking": "सोच रहा है", "Thinking really hard": "बहुत गहराई से सोच रहा हूँ", "Thinking...": "सोच रहा है...", + "Thinking…": "सोच रहा है…", + "Thinking… {{ duration }}": "सोच रहा है… {{ duration }}", "this content could not be displayed": "यह कॉन्टेंट लोड नहीं हो पाया", "This field cannot be empty or contain only spaces": "यह फ़ील्ड खाली नहीं हो सकता या केवल रिक्त स्थान नहीं रख सकता", "This message did not meet our content guidelines": "यह संदेश हमारे सामग्री दिशानिर्देशों के अनुरूप नहीं था", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This sign-in has expired.": "यह साइन-इन समाप्त हो गया है।", "This user will be able to message you again.": "यह उपयोगकर्ता आपको फिर से संदेश भेज सकेगा।", "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", + "Thought for {{ duration }}": "{{ duration }} तक सोचा", + "Thought process": "सोचने की प्रक्रिया", "Thread": "रिप्लाई थ्रेड", "Thread has not been found": "थ्रेड नहीं मिला", "Thread reply": "थ्रेड में उत्तर", @@ -717,8 +746,13 @@ "Vote ended": "मतदान समाप्त", "Votes": "वोट", "Wait until all attachments have uploaded": "सभी अटैचमेंट अपलोड होने तक प्रतीक्षा करें", + "Waiting for approval": "स्वीकृति की प्रतीक्षा", "Waiting for network…": "नेटवर्क की प्रतीक्षा…", + "Waiting for the device": "डिवाइस की प्रतीक्षा", + "Waiting for you to sign in": "आपके साइन-इन की प्रतीक्षा", "Working my magic": "अपना जादू चला रहा हूँ", "You": "आप", - "You've reached the maximum number of files": "आप अधिकतम फ़ाइलों तक पहुँच गए हैं" + "You've reached the maximum number of files": "आप अधिकतम फ़ाइलों तक पहुँच गए हैं", + "Your answer couldn't be sent. Try again.": "आपका जवाब नहीं भेजा जा सका। फिर से कोशिश करें।", + "Your browser blocked the sign-in window. Allow pop-ups and try again.": "आपके ब्राउज़र ने साइन-इन विंडो को ब्लॉक कर दिया। पॉप-अप की अनुमति दें और फिर से कोशिश करें।" } diff --git a/src/i18n/it.json b/src/i18n/it.json index b170b818e..97c19033a 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -52,6 +52,7 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} ha creato: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} ha votato: {{pollOptionText}}", "📍Shared location": "📍Posizione condivisa", + "A step this app version can't show": "Un passaggio che questa versione dell'app non può mostrare", "Actions": "Actions", "Add": "Aggiungi", "Add {{ count }} members_one": "Aggiungi {{ count }} membro", @@ -65,6 +66,7 @@ "Add reaction": "Aggiungi reazione", "Admin": "Admin", "All results loaded": "Tutti i risultati caricati", + "Allow": "Consenti", "Allow access to camera": "Consenti l'accesso alla fotocamera", "Allow access to microphone": "Consenti l'accesso al microfono", "Allow comments": "Consenti i commenti", @@ -109,6 +111,7 @@ "aria/Audio position {{ progress }} percent": "Posizione audio {{ progress }} percento", "aria/Back to attachments": "Torna agli allegati", "aria/Back to parent menu button": "Torna al menu principale pulsante", + "aria/Bad response": "Risposta negativa", "aria/Block User": "Blocca utente", "aria/Bookmark Message": "Salva messaggio", "aria/Cancel recording": "Annulla registrazione", @@ -117,6 +120,7 @@ "aria/Channel Actions": "Azioni canale", "aria/Channel details": "Dettagli canale", "aria/Channel list": "Elenco dei canali", + "aria/Chat options": "Opzioni della chat", "aria/Chat view controls": "Controlli visualizzazione chat", "aria/Chat: {{ channelName }}": "Chat: {{ channelName }}", "aria/Clear search": "Cancella ricerca", @@ -126,6 +130,8 @@ "aria/Command activated: {{ command }}": "Comando attivato: {{ command }}", "aria/Command Suggestions": "Suggerimenti comandi", "aria/Complete recording": "Completa registrazione", + "aria/Copied": "Copiato", + "aria/Copy": "Copia", "aria/Copy Message Text": "Copia testo messaggio", "aria/Decrease value": "Diminuisci valore", "aria/Delete Message": "Elimina messaggio", @@ -134,6 +140,7 @@ "aria/Dismiss notification": "Chiudi notifica", "aria/Download attachment": "Scarica l'allegato", "aria/Dropped \"{{ option }}\" at position {{ position }}.": "«{{ option }}» rilasciato in posizione {{ position }}.", + "aria/Edit": "Modifica", "aria/Edit Message": "Modifica messaggio", "aria/Emoji picker": "Selettore di emoji", "aria/Emoji Suggestions": "Suggerimenti emoji", @@ -149,6 +156,7 @@ "aria/Giphy image changed: {{ title }}": "Immagine Giphy cambiata: {{ title }}", "aria/Giphy preview, only visible to you. Use the Send, Shuffle, or Cancel actions.": "Anteprima Giphy, visibile solo a te. Usa le azioni Invia, Mescola o Annulla.", "aria/Giphy sent": "Giphy inviato", + "aria/Good response": "Risposta positiva", "aria/image": "immagine", "aria/Image failed to load": "Caricamento immagine non riuscito", "aria/Increase value": "Aumenta valore", @@ -157,6 +165,7 @@ "aria/Last activity: {{ time }}": "Ultima attività: {{ time }}", "aria/Last message from {{ sender }}: {{ messagePreview }}": "Ultimo messaggio da {{ sender }}: {{ messagePreview }}", "aria/Last message: {{ messagePreview }}": "Ultimo messaggio: {{ messagePreview }}", + "aria/Loading older messages": "Caricamento dei messaggi precedenti", "aria/Mark Message Unread": "Contrassegna come non letto", "aria/Mark messages as read": "Segna i messaggi come letti", "aria/Mention Suggestions": "Suggerimenti di menzione", @@ -204,6 +213,7 @@ "aria/Recording paused": "Registrazione in pausa", "aria/Recording resumed": "Registrazione ripresa", "aria/Recording started": "Registrazione avviata", + "aria/Regenerate": "Rigenera", "aria/Remind Me Message": "Ricordami", "aria/Remove attachment": "Rimuovi allegato", "aria/Remove location attachment": "Rimuovi allegato posizione", @@ -213,10 +223,12 @@ "aria/Removed option {{ option }}": "Opzione {{ option }} rimossa", "aria/Reorder \"{{ option }}\" at position {{ position }} of {{ total }}": "Riordina «{{ option }}», posizione {{ position }} di {{ total }}", "aria/Reorder option {{ position }}": "Riordina opzione {{ position }}", + "aria/Reply actions": "Azioni della risposta", "aria/Resend Message": "Invia di nuovo messaggio", "aria/Resume recording": "Riprendi registrazione", "aria/Retry upload": "Riprova caricamento", "aria/Review bounced message": "Rivedi il messaggio respinto", + "aria/Scroll to latest": "Vai all'ultimo messaggio", "aria/Search cleared": "Ricerca cancellata", "aria/Search results": "Risultati della ricerca", "aria/Search results header filter button for: {{ source }}": "Pulsante filtro intestazione risultati di ricerca per: {{ source }}", @@ -225,6 +237,7 @@ "aria/Select User Channel: {{ name }}": "Seleziona canale utente: {{ name }}", "aria/Send": "Invia", "aria/Sent": "Inviato", + "aria/Share": "Condividi", "aria/Shared a link": "Link condiviso", "aria/Shared a link with title: {{ linkTitle }}": "Link condiviso con titolo: {{ linkTitle }}", "aria/Shared location": "Posizione condivisa", @@ -263,6 +276,7 @@ "Browse channel members": "Sfoglia membri del canale", "Browse pinned messages": "Sfoglia messaggi fissati", "Cancel": "Annulla", + "Cancelled": "Annullato", "Cannot seek in the recording": "Impossibile cercare nella registrazione", "Changes saved": "Modifiche salvate", "Channel archived": "Canale archiviato", @@ -285,8 +299,11 @@ "Command not available while replying": "Comando non disponibile durante la risposta", "Commands": "Comandi", "Commands matching": "Comandi corrispondenti", + "Connect": "Connetti", + "Connected to {{ provider }}": "Connesso a {{ provider }}", "Connecting": "Connessione", "Connecting the dots": "Collego i puntini", + "Connecting…": "Connessione…", "Connection failure, reconnecting now...": "Errore di connessione, riconnessione in corso...", "Consulting the AI gods": "Consulto gli dei dell'IA", "Contact info": "Informazioni contatto", @@ -298,14 +315,17 @@ "Create poll": "Crea sondaggio", "Crunching the numbers": "Sto elaborando i numeri", "Current location": "Posizione attuale", + "Declined": "Rifiutato", "Delete": "Elimina", "Delete attachment": "Elimina allegato", "Delete chat": "Elimina chat", "Delete for me": "Elimina per me", "Delete message": "Elimina messaggio", "Delivered": "Consegnato", + "Didn't complete": "Non completato", "Direct message": "Messaggio diretto", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "Vuoi terminare questo sondaggio ora? Nessuno potrà più votare in questo sondaggio.", + "Don't Allow": "Non consentire", "Download {{ fileName }}": "Scarica {{ fileName }}", "Download All": "Scarica tutto", "Download Attachment": "Scarica allegato", @@ -522,6 +542,8 @@ "No results found": "Nessun risultato trovato", "No user found": "Nessun utente trovato", "Nobody will be able to vote in this poll anymore.": "Nessuno potrà più votare in questo sondaggio.", + "Not connected": "Non connesso", + "Not now": "Non ora", "Nothing yet...": "Ancora niente...", "Notify all {{ role }} members": "Notifica tutti i membri con ruolo {{ role }}", "Offline": "Offline", @@ -651,15 +673,22 @@ "Summoning my inner genius": "Evoco il mio genio interiore", "Tap to remove": "Tocca per rimuovere", "Tap to remove: {{ reactionName }}": "Tocca per rimuovere: {{ reactionName }}", + "The sign-in couldn't be opened. Try again.": "Impossibile aprire l'accesso. Riprova.", + "The sign-in didn't complete.": "L'accesso non è stato completato.", "Thinking": "Sta pensando", "Thinking really hard": "Sto pensando intensamente", "Thinking...": "Pensando...", + "Thinking…": "Sto pensando…", + "Thinking… {{ duration }}": "Sto pensando… {{ duration }}", "this content could not be displayed": "questo contenuto non può essere mostrato", "This field cannot be empty or contain only spaces": "Questo campo non può essere vuoto o contenere solo spazi", "This message did not meet our content guidelines": "Questo messaggio non soddisfa le nostre linee guida sui contenuti", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This sign-in has expired.": "Questo accesso è scaduto.", "This user will be able to message you again.": "Questo utente potrà inviarti di nuovo messaggi.", "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", + "Thought for {{ duration }}": "Ha pensato per {{ duration }}", + "Thought process": "Ragionamento", "Thread": "Discussione", "Thread has not been found": "Discussione non trovata", "Thread reply": "Risposta nella discussione", @@ -746,8 +775,13 @@ "Vote ended": "Voto terminato", "Votes": "Voti", "Wait until all attachments have uploaded": "Attendi il caricamento di tutti gli allegati", + "Waiting for approval": "In attesa di approvazione", "Waiting for network…": "In attesa della rete…", + "Waiting for the device": "In attesa del dispositivo", + "Waiting for you to sign in": "In attesa del tuo accesso", "Working my magic": "Faccio la mia magia", "You": "Tu", - "You've reached the maximum number of files": "Hai raggiunto il numero massimo di file" + "You've reached the maximum number of files": "Hai raggiunto il numero massimo di file", + "Your answer couldn't be sent. Try again.": "Impossibile inviare la tua risposta. Riprova.", + "Your browser blocked the sign-in window. Allow pop-ups and try again.": "Il browser ha bloccato la finestra di accesso. Consenti i pop-up e riprova." } diff --git a/src/i18n/ja.json b/src/i18n/ja.json index fe6db9701..553c80e31 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -39,6 +39,7 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} が作成: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} が投票: {{pollOptionText}}", "📍Shared location": "📍共有された位置情報", + "A step this app version can't show": "このアプリのバージョンでは表示できないステップ", "Actions": "Actions", "Add": "追加", "Add {{ count }} members_other": "{{ count }}人のメンバーを追加", @@ -50,6 +51,7 @@ "Add reaction": "リアクションを追加", "Admin": "管理者", "All results loaded": "すべての結果が読み込まれました", + "Allow": "許可", "Allow access to camera": "カメラへのアクセスを許可する", "Allow access to microphone": "マイクロフォンへのアクセスを許可する", "Allow comments": "コメントを許可", @@ -84,6 +86,7 @@ "aria/Audio position {{ progress }} percent": "音声位置 {{ progress }} パーセント", "aria/Back to attachments": "添付ファイルに戻る", "aria/Back to parent menu button": "親メニューに戻るボタン", + "aria/Bad response": "良くない回答", "aria/Block User": "ユーザーをブロック", "aria/Bookmark Message": "メッセージをブックマーク", "aria/Cancel recording": "録音をキャンセル", @@ -92,6 +95,7 @@ "aria/Channel Actions": "チャンネル操作", "aria/Channel details": "チャンネル詳細", "aria/Channel list": "チャンネル一覧", + "aria/Chat options": "チャットオプション", "aria/Chat view controls": "チャットビューのコントロール", "aria/Chat: {{ channelName }}": "チャット: {{ channelName }}", "aria/Clear search": "検索をクリア", @@ -101,6 +105,8 @@ "aria/Command activated: {{ command }}": "コマンドを有効化しました: {{ command }}", "aria/Command Suggestions": "コマンド候補", "aria/Complete recording": "録音を完了", + "aria/Copied": "コピーしました", + "aria/Copy": "コピー", "aria/Copy Message Text": "メッセージテキストをコピー", "aria/Decrease value": "値を減らす", "aria/Delete Message": "メッセージを削除", @@ -109,6 +115,7 @@ "aria/Dismiss notification": "通知を閉じる", "aria/Download attachment": "添付ファイルをダウンロード", "aria/Dropped \"{{ option }}\" at position {{ position }}.": "「{{ option }}」を位置 {{ position }} に配置しました。", + "aria/Edit": "編集", "aria/Edit Message": "メッセージを編集", "aria/Emoji picker": "絵文字ピッカー", "aria/Emoji Suggestions": "絵文字候補", @@ -124,6 +131,7 @@ "aria/Giphy image changed: {{ title }}": "Giphy画像が変更されました:{{ title }}", "aria/Giphy preview, only visible to you. Use the Send, Shuffle, or Cancel actions.": "Giphyのプレビューです。あなたにのみ表示されます。送信、シャッフル、またはキャンセルの操作を使用してください。", "aria/Giphy sent": "Giphyを送信しました", + "aria/Good response": "良い回答", "aria/image": "画像", "aria/Image failed to load": "画像の読み込みに失敗しました", "aria/Increase value": "値を増やす", @@ -132,6 +140,7 @@ "aria/Last activity: {{ time }}": "最終アクティビティ: {{ time }}", "aria/Last message from {{ sender }}: {{ messagePreview }}": "{{ sender }} からの最新メッセージ: {{ messagePreview }}", "aria/Last message: {{ messagePreview }}": "最新メッセージ: {{ messagePreview }}", + "aria/Loading older messages": "過去のメッセージを読み込み中", "aria/Mark Message Unread": "未読としてマーク", "aria/Mark messages as read": "メッセージを既読にする", "aria/Mention Suggestions": "メンション候補", @@ -178,6 +187,7 @@ "aria/Recording paused": "録音を一時停止しました", "aria/Recording resumed": "録音を再開しました", "aria/Recording started": "録音を開始しました", + "aria/Regenerate": "再生成", "aria/Remind Me Message": "リマインダー", "aria/Remove attachment": "添付ファイルを削除", "aria/Remove location attachment": "位置情報の添付ファイルを削除", @@ -187,10 +197,12 @@ "aria/Removed option {{ option }}": "選択肢「{{ option }}」を削除しました", "aria/Reorder \"{{ option }}\" at position {{ position }} of {{ total }}": "「{{ option }}」を並べ替え、{{ total }} 中 {{ position }} 番目", "aria/Reorder option {{ position }}": "オプション {{ position }} を並べ替える", + "aria/Reply actions": "回答のアクション", "aria/Resend Message": "メッセージを再送信", "aria/Resume recording": "録音を再開", "aria/Retry upload": "アップロードを再試行", "aria/Review bounced message": "バウンスされたメッセージを確認", + "aria/Scroll to latest": "最新のメッセージへ移動", "aria/Search cleared": "検索をクリアしました", "aria/Search results": "検索結果", "aria/Search results header filter button for: {{ source }}": "{{ source }} の検索結果ヘッダーのフィルターボタン", @@ -199,6 +211,7 @@ "aria/Select User Channel: {{ name }}": "ユーザーチャンネルを選択: {{ name }}", "aria/Send": "送信", "aria/Sent": "送信済み", + "aria/Share": "共有", "aria/Shared a link": "リンクを共有しました", "aria/Shared a link with title: {{ linkTitle }}": "タイトル付きのリンクを共有しました: {{ linkTitle }}", "aria/Shared location": "共有された位置情報", @@ -237,6 +250,7 @@ "Browse channel members": "チャンネルメンバーを表示", "Browse pinned messages": "ピン留めメッセージを表示", "Cancel": "キャンセル", + "Cancelled": "キャンセルされました", "Cannot seek in the recording": "録音中にシークできません", "Changes saved": "変更を保存しました", "Channel archived": "チャンネルをアーカイブしました", @@ -259,8 +273,11 @@ "Command not available while replying": "返信中はコマンドを使用できません", "Commands": "コマンド", "Commands matching": "一致するコマンド", + "Connect": "接続", + "Connected to {{ provider }}": "{{ provider }} に接続済み", "Connecting": "接続中", "Connecting the dots": "点と点をつなげています", + "Connecting…": "接続中…", "Connection failure, reconnecting now...": "接続が失敗しました。再接続中...", "Consulting the AI gods": "AIの神々に相談しています", "Contact info": "連絡先情報", @@ -272,14 +289,17 @@ "Create poll": "投票を作成", "Crunching the numbers": "数字を計算しています", "Current location": "現在の位置", + "Declined": "拒否されました", "Delete": "消去", "Delete attachment": "添付ファイルを削除", "Delete chat": "チャットを削除", "Delete for me": "自分用に削除", "Delete message": "メッセージを削除", "Delivered": "配信しました", + "Didn't complete": "完了しませんでした", "Direct message": "ダイレクトメッセージ", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "このアンケートを今終了しますか?終了すると、誰も投票できなくなります。", + "Don't Allow": "許可しない", "Download {{ fileName }}": "{{ fileName }} をダウンロード", "Download All": "すべてダウンロード", "Download Attachment": "添付ファイルをダウンロード", @@ -490,6 +510,8 @@ "No results found": "結果が見つかりません", "No user found": "ユーザーが見つかりません", "Nobody will be able to vote in this poll anymore.": "この投票では、誰も投票できなくなります。", + "Not connected": "未接続", + "Not now": "後で", "Nothing yet...": "まだ何もありません...", "Notify all {{ role }} members": "{{ role }} メンバー全員に通知", "Offline": "オフライン", @@ -612,15 +634,22 @@ "Summoning my inner genius": "内なる天才を呼び覚ましています", "Tap to remove": "タップして削除", "Tap to remove: {{ reactionName }}": "タップして削除: {{ reactionName }}", + "The sign-in couldn't be opened. Try again.": "サインインを開けませんでした。もう一度お試しください。", + "The sign-in didn't complete.": "サインインが完了しませんでした。", "Thinking": "考え中", "Thinking really hard": "一生懸命考えています", "Thinking...": "考え中...", + "Thinking…": "考え中…", + "Thinking… {{ duration }}": "考え中… {{ duration }}", "this content could not be displayed": "このコンテンツは表示できませんでした", "This field cannot be empty or contain only spaces": "このフィールドは空にすることはできません。また、空白文字のみを含むこともできません", "This message did not meet our content guidelines": "このメッセージはコンテンツガイドラインに適合していません", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This sign-in has expired.": "このサインインは期限切れです。", "This user will be able to message you again.": "このユーザーは再びあなたにメッセージを送信できるようになります。", "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", + "Thought for {{ duration }}": "{{ duration }} 考えました", + "Thought process": "思考プロセス", "Thread": "スレッド", "Thread has not been found": "スレッドが見つかりませんでした", "Thread reply": "スレッドの返信", @@ -698,8 +727,13 @@ "Vote ended": "投票が終了しました", "Votes": "投票", "Wait until all attachments have uploaded": "すべての添付ファイルがアップロードされるまでお待ちください", + "Waiting for approval": "承認を待っています", "Waiting for network…": "ネットワークを待機中…", + "Waiting for the device": "デバイスを待っています", + "Waiting for you to sign in": "サインインを待っています", "Working my magic": "魔法をかけています", "You": "あなた", - "You've reached the maximum number of files": "ファイルの最大数に達しました" + "You've reached the maximum number of files": "ファイルの最大数に達しました", + "Your answer couldn't be sent. Try again.": "回答を送信できませんでした。もう一度お試しください。", + "Your browser blocked the sign-in window. Allow pop-ups and try again.": "ブラウザがサインインウィンドウをブロックしました。ポップアップを許可して、もう一度お試しください。" } diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 57017eff1..ed434580a 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -39,6 +39,7 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}}이(가) 생성함: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}}이(가) 투표함: {{pollOptionText}}", "📍Shared location": "📍공유된 위치", + "A step this app version can't show": "이 앱 버전에서 표시할 수 없는 단계", "Actions": "Actions", "Add": "추가", "Add {{ count }} members_other": "{{ count }}명 멤버 추가", @@ -50,6 +51,7 @@ "Add reaction": "반응 추가", "Admin": "관리자", "All results loaded": "모든 결과가 로드되었습니다", + "Allow": "허용", "Allow access to camera": "카메라에 대한 액세스 허용", "Allow access to microphone": "마이크로폰에 대한 액세스 허용", "Allow comments": "댓글 허용", @@ -84,6 +86,7 @@ "aria/Audio position {{ progress }} percent": "오디오 위치 {{ progress }}퍼센트", "aria/Back to attachments": "첨부 파일로 돌아가기", "aria/Back to parent menu button": "상위 메뉴로 돌아가기 버튼", + "aria/Bad response": "별로인 답변", "aria/Block User": "사용자 차단", "aria/Bookmark Message": "메시지 북마크", "aria/Cancel recording": "녹음 취소", @@ -92,6 +95,7 @@ "aria/Channel Actions": "채널 작업", "aria/Channel details": "채널 세부 정보", "aria/Channel list": "채널 목록", + "aria/Chat options": "채팅 옵션", "aria/Chat view controls": "채팅 보기 컨트롤", "aria/Chat: {{ channelName }}": "채팅: {{ channelName }}", "aria/Clear search": "검색 지우기", @@ -101,6 +105,8 @@ "aria/Command activated: {{ command }}": "명령 활성화됨: {{ command }}", "aria/Command Suggestions": "명령어 제안", "aria/Complete recording": "녹음 완료", + "aria/Copied": "복사됨", + "aria/Copy": "복사", "aria/Copy Message Text": "메시지 텍스트 복사", "aria/Decrease value": "값 감소", "aria/Delete Message": "메시지 삭제", @@ -109,6 +115,7 @@ "aria/Dismiss notification": "알림 닫기", "aria/Download attachment": "첨부 파일 다운로드", "aria/Dropped \"{{ option }}\" at position {{ position }}.": "\"{{ option }}\"을(를) {{ position }}번 위치에 놓았습니다.", + "aria/Edit": "편집", "aria/Edit Message": "메시지 수정", "aria/Emoji picker": "이모지 선택기", "aria/Emoji Suggestions": "이모지 제안", @@ -124,6 +131,7 @@ "aria/Giphy image changed: {{ title }}": "Giphy 이미지가 변경되었습니다: {{ title }}", "aria/Giphy preview, only visible to you. Use the Send, Shuffle, or Cancel actions.": "Giphy 미리 보기이며 나에게만 표시됩니다. 보내기, 셔플 또는 취소 작업을 사용하세요.", "aria/Giphy sent": "Giphy를 보냈습니다", + "aria/Good response": "좋은 답변", "aria/image": "이미지", "aria/Image failed to load": "이미지를 불러오지 못했습니다", "aria/Increase value": "값 증가", @@ -132,6 +140,7 @@ "aria/Last activity: {{ time }}": "마지막 활동: {{ time }}", "aria/Last message from {{ sender }}: {{ messagePreview }}": "{{ sender }}님의 마지막 메시지: {{ messagePreview }}", "aria/Last message: {{ messagePreview }}": "마지막 메시지: {{ messagePreview }}", + "aria/Loading older messages": "이전 메시지 불러오는 중", "aria/Mark Message Unread": "읽지 않음으로 표시", "aria/Mark messages as read": "메시지를 읽음으로 표시", "aria/Mention Suggestions": "멘션 제안", @@ -178,6 +187,7 @@ "aria/Recording paused": "녹음 일시정지됨", "aria/Recording resumed": "녹음 재개됨", "aria/Recording started": "녹음 시작됨", + "aria/Regenerate": "다시 생성", "aria/Remind Me Message": "알림 설정", "aria/Remove attachment": "첨부 파일 제거", "aria/Remove location attachment": "위치 첨부 파일 제거", @@ -187,10 +197,12 @@ "aria/Removed option {{ option }}": "옵션 {{ option }}이(가) 제거되었습니다", "aria/Reorder \"{{ option }}\" at position {{ position }} of {{ total }}": "\"{{ option }}\" 순서 변경, {{ total }}개 중 {{ position }}번째", "aria/Reorder option {{ position }}": "옵션 {{ position }} 순서 변경", + "aria/Reply actions": "답변 작업", "aria/Resend Message": "메시지 다시 보내기", "aria/Resume recording": "녹음 재개", "aria/Retry upload": "업로드 다시 시도", "aria/Review bounced message": "반송된 메시지 검토", + "aria/Scroll to latest": "최신 메시지로 이동", "aria/Search cleared": "검색을 지웠습니다", "aria/Search results": "검색 결과", "aria/Search results header filter button for: {{ source }}": "{{ source }}에 대한 검색 결과 헤더 필터 버튼", @@ -199,6 +211,7 @@ "aria/Select User Channel: {{ name }}": "사용자 채널 선택: {{ name }}", "aria/Send": "보내기", "aria/Sent": "보냄", + "aria/Share": "공유", "aria/Shared a link": "링크를 공유했습니다", "aria/Shared a link with title: {{ linkTitle }}": "제목과 함께 링크를 공유했습니다: {{ linkTitle }}", "aria/Shared location": "공유된 위치", @@ -237,6 +250,7 @@ "Browse channel members": "채널 멤버 보기", "Browse pinned messages": "고정된 메시지 보기", "Cancel": "취소", + "Cancelled": "취소됨", "Cannot seek in the recording": "녹음에서 찾을 수 없습니다", "Changes saved": "변경 사항이 저장되었습니다", "Channel archived": "채널이 보관됨", @@ -259,8 +273,11 @@ "Command not available while replying": "답장 중에는 명령을 사용할 수 없습니다", "Commands": "명령어", "Commands matching": "일치하는 명령", + "Connect": "연결", + "Connected to {{ provider }}": "{{ provider }}에 연결됨", "Connecting": "연결 중", "Connecting the dots": "점들을 연결하는 중", + "Connecting…": "연결 중…", "Connection failure, reconnecting now...": "연결 실패, 지금 다시 연결 중...", "Consulting the AI gods": "AI 신들께 여쭤보는 중", "Contact info": "연락처 정보", @@ -272,14 +289,17 @@ "Create poll": "투표 생성", "Crunching the numbers": "숫자를 계산하는 중", "Current location": "현재 위치", + "Declined": "거절됨", "Delete": "삭제", "Delete attachment": "첨부 파일 삭제", "Delete chat": "채팅 삭제", "Delete for me": "나만 삭제", "Delete message": "메시지 삭제", "Delivered": "배달됨", + "Didn't complete": "완료되지 않음", "Direct message": "다이렉트 메시지", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "지금 이 투표를 종료하시겠습니까? 종료 후에는 더 이상 투표할 수 없습니다.", + "Don't Allow": "허용 안 함", "Download {{ fileName }}": "{{ fileName }} 다운로드", "Download All": "모두 다운로드", "Download Attachment": "첨부 파일 다운로드", @@ -490,6 +510,8 @@ "No results found": "검색 결과가 없습니다", "No user found": "사용자를 찾을 수 없습니다", "Nobody will be able to vote in this poll anymore.": "이 투표에 더 이상 아무도 투표할 수 없습니다.", + "Not connected": "연결되지 않음", + "Not now": "나중에", "Nothing yet...": "아직 아무것도...", "Notify all {{ role }} members": "{{ role }} 역할의 모든 멤버에게 알림", "Offline": "오프라인", @@ -612,15 +634,22 @@ "Summoning my inner genius": "내면의 천재를 불러내는 중", "Tap to remove": "제거하려면 탭하세요", "Tap to remove: {{ reactionName }}": "제거하려면 탭하세요: {{ reactionName }}", + "The sign-in couldn't be opened. Try again.": "로그인 창을 열 수 없습니다. 다시 시도하세요.", + "The sign-in didn't complete.": "로그인이 완료되지 않았습니다.", "Thinking": "생각 중", "Thinking really hard": "열심히 생각하는 중", "Thinking...": "생각 중...", + "Thinking…": "생각 중…", + "Thinking… {{ duration }}": "생각 중… {{ duration }}", "this content could not be displayed": "이 콘텐츠를 표시할 수 없습니다", "This field cannot be empty or contain only spaces": "이 필드는 비워둘 수 없으며 공백만 포함할 수도 없습니다", "This message did not meet our content guidelines": "이 메시지는 콘텐츠 가이드라인을 충족하지 않습니다.", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This sign-in has expired.": "이 로그인은 만료되었습니다.", "This user will be able to message you again.": "이 사용자가 다시 메시지를 보낼 수 있습니다.", "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", + "Thought for {{ duration }}": "{{ duration }} 동안 생각함", + "Thought process": "생각 과정", "Thread": "스레드", "Thread has not been found": "스레드를 찾을 수 없습니다", "Thread reply": "스레드 답장", @@ -698,8 +727,13 @@ "Vote ended": "투표 종료", "Votes": "투표", "Wait until all attachments have uploaded": "모든 첨부 파일이 업로드될 때까지 기다립니다.", + "Waiting for approval": "승인 대기 중", "Waiting for network…": "네트워크 대기 중…", + "Waiting for the device": "기기 대기 중", + "Waiting for you to sign in": "로그인 대기 중", "Working my magic": "마법을 부리는 중", "You": "당신", - "You've reached the maximum number of files": "최대 파일 수에 도달했습니다." + "You've reached the maximum number of files": "최대 파일 수에 도달했습니다.", + "Your answer couldn't be sent. Try again.": "답변을 보낼 수 없습니다. 다시 시도하세요.", + "Your browser blocked the sign-in window. Allow pop-ups and try again.": "브라우저가 로그인 창을 차단했습니다. 팝업을 허용하고 다시 시도하세요." } diff --git a/src/i18n/nl.json b/src/i18n/nl.json index e1db9c66b..69c6e3663 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -42,6 +42,7 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} heeft gemaakt: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} heeft gestemd: {{pollOptionText}}", "📍Shared location": "📍Gedeelde locatie", + "A step this app version can't show": "Een stap die deze app-versie niet kan tonen", "Actions": "Actions", "Add": "Toevoegen", "Add {{ count }} members_one": "{{ count }} lid toevoegen", @@ -54,6 +55,7 @@ "Add reaction": "Reactie toevoegen", "Admin": "Beheerder", "All results loaded": "Alle resultaten geladen", + "Allow": "Toestaan", "Allow access to camera": "Toegang tot camera toestaan", "Allow access to microphone": "Toegang tot microfoon toestaan", "Allow comments": "Sta opmerkingen toe", @@ -93,6 +95,7 @@ "aria/Audio position {{ progress }} percent": "Audiopositie {{ progress }} procent", "aria/Back to attachments": "Terug naar bijlagen", "aria/Back to parent menu button": "Terug naar bovenliggend menu knop", + "aria/Bad response": "Slecht antwoord", "aria/Block User": "Gebruiker blokkeren", "aria/Bookmark Message": "Bericht bookmarken", "aria/Cancel recording": "Opname annuleren", @@ -101,6 +104,7 @@ "aria/Channel Actions": "Kanaalacties", "aria/Channel details": "Kanaaldetails", "aria/Channel list": "Kanaallijst", + "aria/Chat options": "Chatopties", "aria/Chat view controls": "Bedieningselementen chatweergave", "aria/Chat: {{ channelName }}": "Chat: {{ channelName }}", "aria/Clear search": "Zoekopdracht wissen", @@ -110,6 +114,8 @@ "aria/Command activated: {{ command }}": "Opdracht geactiveerd: {{ command }}", "aria/Command Suggestions": "Opdrachtsuggesties", "aria/Complete recording": "Opname voltooien", + "aria/Copied": "Gekopieerd", + "aria/Copy": "Kopiëren", "aria/Copy Message Text": "Berichttekst kopiëren", "aria/Decrease value": "Waarde verlagen", "aria/Delete Message": "Bericht verwijderen", @@ -118,6 +124,7 @@ "aria/Dismiss notification": "Melding sluiten", "aria/Download attachment": "Bijlage downloaden", "aria/Dropped \"{{ option }}\" at position {{ position }}.": "'{{ option }}' losgelaten op positie {{ position }}.", + "aria/Edit": "Bewerken", "aria/Edit Message": "Bericht bewerken", "aria/Emoji picker": "Emoji kiezer", "aria/Emoji Suggestions": "Emoji-suggesties", @@ -133,6 +140,7 @@ "aria/Giphy image changed: {{ title }}": "Giphy-afbeelding gewijzigd: {{ title }}", "aria/Giphy preview, only visible to you. Use the Send, Shuffle, or Cancel actions.": "Giphy-voorbeeld, alleen zichtbaar voor jou. Gebruik de acties Verzenden, Shuffelen of Annuleren.", "aria/Giphy sent": "Giphy verzonden", + "aria/Good response": "Goed antwoord", "aria/image": "afbeelding", "aria/Image failed to load": "Afbeelding laden mislukt", "aria/Increase value": "Waarde verhogen", @@ -141,6 +149,7 @@ "aria/Last activity: {{ time }}": "Laatste activiteit: {{ time }}", "aria/Last message from {{ sender }}: {{ messagePreview }}": "Laatste bericht van {{ sender }}: {{ messagePreview }}", "aria/Last message: {{ messagePreview }}": "Laatste bericht: {{ messagePreview }}", + "aria/Loading older messages": "Oudere berichten laden", "aria/Mark Message Unread": "Markeren als ongelezen", "aria/Mark messages as read": "Markeer berichten als gelezen", "aria/Mention Suggestions": "Vermeldingssuggesties", @@ -187,6 +196,7 @@ "aria/Recording paused": "Opname gepauzeerd", "aria/Recording resumed": "Opname hervat", "aria/Recording started": "Opname gestart", + "aria/Regenerate": "Opnieuw genereren", "aria/Remind Me Message": "Herinner mij", "aria/Remove attachment": "Bijlage verwijderen", "aria/Remove location attachment": "Locatie bijlage verwijderen", @@ -196,10 +206,12 @@ "aria/Removed option {{ option }}": "Optie {{ option }} verwijderd", "aria/Reorder \"{{ option }}\" at position {{ position }} of {{ total }}": "'{{ option }}' opnieuw ordenen, positie {{ position }} van {{ total }}", "aria/Reorder option {{ position }}": "Optie {{ position }} opnieuw ordenen", + "aria/Reply actions": "Antwoordacties", "aria/Resend Message": "Bericht opnieuw verzenden", "aria/Resume recording": "Opname hervatten", "aria/Retry upload": "Upload opnieuw proberen", "aria/Review bounced message": "Controleer teruggestuurd bericht", + "aria/Scroll to latest": "Naar nieuwste bericht", "aria/Search cleared": "Zoekopdracht gewist", "aria/Search results": "Zoekresultaten", "aria/Search results header filter button for: {{ source }}": "Filterknop koptekst zoekresultaten voor: {{ source }}", @@ -208,6 +220,7 @@ "aria/Select User Channel: {{ name }}": "Gebruikerskanaal selecteren: {{ name }}", "aria/Send": "Verzenden", "aria/Sent": "Verzonden", + "aria/Share": "Delen", "aria/Shared a link": "Link gedeeld", "aria/Shared a link with title: {{ linkTitle }}": "Link gedeeld met titel: {{ linkTitle }}", "aria/Shared location": "Gedeelde locatie", @@ -246,6 +259,7 @@ "Browse channel members": "Kanaalleden bekijken", "Browse pinned messages": "Vastgemaakte berichten bekijken", "Cancel": "Annuleer", + "Cancelled": "Geannuleerd", "Cannot seek in the recording": "Kan niet zoeken in de opname", "Changes saved": "Wijzigingen opgeslagen", "Channel archived": "Kanaal gearchiveerd", @@ -268,8 +282,11 @@ "Command not available while replying": "Opdracht niet beschikbaar tijdens beantwoorden", "Commands": "Commando's", "Commands matching": "Bijpassende opdrachten", + "Connect": "Verbinden", + "Connected to {{ provider }}": "Verbonden met {{ provider }}", "Connecting": "Verbinden", "Connecting the dots": "Ik verbind de puntjes", + "Connecting…": "Verbinden…", "Connection failure, reconnecting now...": "Verbindingsfout, opnieuw verbinden...", "Consulting the AI gods": "Ik raadpleeg de AI-goden", "Contact info": "Contactgegevens", @@ -281,14 +298,17 @@ "Create poll": "Maak peiling", "Crunching the numbers": "Ik kraak de cijfers", "Current location": "Huidige locatie", + "Declined": "Geweigerd", "Delete": "Verwijder", "Delete attachment": "Bijlage verwijderen", "Delete chat": "Chat verwijderen", "Delete for me": "Voor mij verwijderen", "Delete message": "Bericht verwijderen", "Delivered": "Afgeleverd", + "Didn't complete": "Niet voltooid", "Direct message": "Direct bericht", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "Wil je deze peiling nu beëindigen? Niemand kan daarna meer op deze peiling stemmen.", + "Don't Allow": "Niet toestaan", "Download {{ fileName }}": "{{ fileName }} downloaden", "Download All": "Alles downloaden", "Download Attachment": "Bijlage downloaden", @@ -502,6 +522,8 @@ "No results found": "Geen resultaten gevonden", "No user found": "Geen gebruiker gevonden", "Nobody will be able to vote in this poll anymore.": "Niemand kan meer stemmen in deze peiling.", + "Not connected": "Niet verbonden", + "Not now": "Niet nu", "Nothing yet...": "Nog niets ...", "Notify all {{ role }} members": "Alle leden met rol {{ role }} informeren", "Offline": "Offline", @@ -628,15 +650,22 @@ "Summoning my inner genius": "Ik roep mijn innerlijke genie op", "Tap to remove": "Tik om te verwijderen", "Tap to remove: {{ reactionName }}": "Tik om te verwijderen: {{ reactionName }}", + "The sign-in couldn't be opened. Try again.": "De aanmelding kon niet worden geopend. Probeer het opnieuw.", + "The sign-in didn't complete.": "De aanmelding is niet voltooid.", "Thinking": "Denkt na", "Thinking really hard": "Ik denk heel diep na", "Thinking...": "Denken...", + "Thinking…": "Denkt na…", + "Thinking… {{ duration }}": "Denkt na… {{ duration }}", "this content could not be displayed": "Deze inhoud kan niet weergegeven worden", "This field cannot be empty or contain only spaces": "Dit veld mag niet leeg zijn of alleen spaties bevatten", "This message did not meet our content guidelines": "Dit bericht voldeed niet aan onze inhoudsrichtlijnen", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This sign-in has expired.": "Deze aanmelding is verlopen.", "This user will be able to message you again.": "Deze gebruiker kan je weer berichten sturen.", "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", + "Thought for {{ duration }}": "{{ duration }} nagedacht", + "Thought process": "Denkproces", "Thread": "Draadje", "Thread has not been found": "Draadje niet gevonden", "Thread reply": "Draadje antwoord", @@ -718,8 +747,13 @@ "Vote ended": "Stemmen beëindigd", "Votes": "Stemmen", "Wait until all attachments have uploaded": "Wacht tot alle bijlagen zijn geüpload", + "Waiting for approval": "Wacht op goedkeuring", "Waiting for network…": "Wachten op netwerk…", + "Waiting for the device": "Wacht op het apparaat", + "Waiting for you to sign in": "Wacht tot je je aanmeldt", "Working my magic": "Ik doe mijn toverkunsten", "You": "Jij", - "You've reached the maximum number of files": "Je hebt het maximale aantal bestanden bereikt" + "You've reached the maximum number of files": "Je hebt het maximale aantal bestanden bereikt", + "Your answer couldn't be sent. Try again.": "Je antwoord kon niet worden verzonden. Probeer het opnieuw.", + "Your browser blocked the sign-in window. Allow pop-ups and try again.": "Je browser heeft het aanmeldvenster geblokkeerd. Sta pop-ups toe en probeer het opnieuw." } diff --git a/src/i18n/pt.json b/src/i18n/pt.json index b0e5c3192..b93177b6f 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -52,6 +52,7 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} criou: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} votou: {{pollOptionText}}", "📍Shared location": "📍Localização compartilhada", + "A step this app version can't show": "Uma etapa que esta versão do app não consegue mostrar", "Actions": "Actions", "Add": "Adicionar", "Add {{ count }} members_one": "Adicionar {{ count }} membro", @@ -65,6 +66,7 @@ "Add reaction": "Adicionar reação", "Admin": "Admin", "All results loaded": "Todos os resultados carregados", + "Allow": "Permitir", "Allow access to camera": "Permitir acesso à câmera", "Allow access to microphone": "Permitir acesso ao microfone", "Allow comments": "Permitir comentários", @@ -109,6 +111,7 @@ "aria/Audio position {{ progress }} percent": "Posição do áudio {{ progress }} por cento", "aria/Back to attachments": "Voltar aos anexos", "aria/Back to parent menu button": "Voltar ao menu principal botão", + "aria/Bad response": "Resposta ruim", "aria/Block User": "Bloquear usuário", "aria/Bookmark Message": "Marcar mensagem", "aria/Cancel recording": "Cancelar gravação", @@ -117,6 +120,7 @@ "aria/Channel Actions": "Ações do canal", "aria/Channel details": "Detalhes do canal", "aria/Channel list": "Lista de canais", + "aria/Chat options": "Opções do chat", "aria/Chat view controls": "Controles da visualização do chat", "aria/Chat: {{ channelName }}": "Chat: {{ channelName }}", "aria/Clear search": "Limpar pesquisa", @@ -126,6 +130,8 @@ "aria/Command activated: {{ command }}": "Comando ativado: {{ command }}", "aria/Command Suggestions": "Sugestões de comandos", "aria/Complete recording": "Concluir gravação", + "aria/Copied": "Copiado", + "aria/Copy": "Copiar", "aria/Copy Message Text": "Copiar texto da mensagem", "aria/Decrease value": "Diminuir valor", "aria/Delete Message": "Excluir mensagem", @@ -134,6 +140,7 @@ "aria/Dismiss notification": "Dispensar notificação", "aria/Download attachment": "Baixar anexo", "aria/Dropped \"{{ option }}\" at position {{ position }}.": "\"{{ option }}\" solto na posição {{ position }}.", + "aria/Edit": "Editar", "aria/Edit Message": "Editar Mensagem", "aria/Emoji picker": "Seletor de emojis", "aria/Emoji Suggestions": "Sugestões de emojis", @@ -149,6 +156,7 @@ "aria/Giphy image changed: {{ title }}": "Imagem do Giphy alterada: {{ title }}", "aria/Giphy preview, only visible to you. Use the Send, Shuffle, or Cancel actions.": "Pré-visualização do Giphy, visível apenas para você. Use as ações Enviar, Embaralhar ou Cancelar.", "aria/Giphy sent": "Giphy enviado", + "aria/Good response": "Boa resposta", "aria/image": "imagem", "aria/Image failed to load": "Falha ao carregar a imagem", "aria/Increase value": "Aumentar valor", @@ -157,6 +165,7 @@ "aria/Last activity: {{ time }}": "Última atividade: {{ time }}", "aria/Last message from {{ sender }}: {{ messagePreview }}": "Última mensagem de {{ sender }}: {{ messagePreview }}", "aria/Last message: {{ messagePreview }}": "Última mensagem: {{ messagePreview }}", + "aria/Loading older messages": "Carregando mensagens anteriores", "aria/Mark Message Unread": "Marcar como não lida", "aria/Mark messages as read": "Marcar mensagens como lidas", "aria/Mention Suggestions": "Sugestões de menções", @@ -204,6 +213,7 @@ "aria/Recording paused": "Gravação pausada", "aria/Recording resumed": "Gravação retomada", "aria/Recording started": "Gravação iniciada", + "aria/Regenerate": "Gerar novamente", "aria/Remind Me Message": "Lembrar-me", "aria/Remove attachment": "Remover anexo", "aria/Remove location attachment": "Remover anexo de localização", @@ -213,10 +223,12 @@ "aria/Removed option {{ option }}": "Opção {{ option }} removida", "aria/Reorder \"{{ option }}\" at position {{ position }} of {{ total }}": "Reordenar \"{{ option }}\", posição {{ position }} de {{ total }}", "aria/Reorder option {{ position }}": "Reordenar opção {{ position }}", + "aria/Reply actions": "Ações da resposta", "aria/Resend Message": "Reenviar mensagem", "aria/Resume recording": "Retomar gravação", "aria/Retry upload": "Tentar upload novamente", "aria/Review bounced message": "Revisar mensagem devolvida", + "aria/Scroll to latest": "Ir para a última mensagem", "aria/Search cleared": "Pesquisa limpa", "aria/Search results": "Resultados da pesquisa", "aria/Search results header filter button for: {{ source }}": "Botão de filtro do cabeçalho dos resultados da pesquisa para: {{ source }}", @@ -225,6 +237,7 @@ "aria/Select User Channel: {{ name }}": "Selecionar canal do usuário: {{ name }}", "aria/Send": "Enviar", "aria/Sent": "Enviada", + "aria/Share": "Compartilhar", "aria/Shared a link": "Link compartilhado", "aria/Shared a link with title: {{ linkTitle }}": "Link compartilhado com título: {{ linkTitle }}", "aria/Shared location": "Localização compartilhada", @@ -263,6 +276,7 @@ "Browse channel members": "Ver membros do canal", "Browse pinned messages": "Ver mensagens fixadas", "Cancel": "Cancelar", + "Cancelled": "Cancelado", "Cannot seek in the recording": "Não é possível buscar na gravação", "Changes saved": "Alterações salvas", "Channel archived": "Canal arquivado", @@ -285,8 +299,11 @@ "Command not available while replying": "Comando não disponível durante a resposta", "Commands": "Comandos", "Commands matching": "Comandos correspondentes", + "Connect": "Conectar", + "Connected to {{ provider }}": "Conectado a {{ provider }}", "Connecting": "Conectando", "Connecting the dots": "Ligando os pontos", + "Connecting…": "Conectando…", "Connection failure, reconnecting now...": "Falha de conexão, reconectando agora...", "Consulting the AI gods": "Consultando os deuses da IA", "Contact info": "Informações do contato", @@ -298,14 +315,17 @@ "Create poll": "Criar enquete", "Crunching the numbers": "Processando os números", "Current location": "Localização atual", + "Declined": "Recusado", "Delete": "Excluir", "Delete attachment": "Excluir anexo", "Delete chat": "Excluir chat", "Delete for me": "Excluir para mim", "Delete message": "Excluir mensagem", "Delivered": "Entregue", + "Didn't complete": "Não concluído", "Direct message": "Mensagem direta", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "Deseja encerrar esta enquete agora? Ninguém poderá mais votar nesta enquete.", + "Don't Allow": "Não permitir", "Download {{ fileName }}": "Baixar {{ fileName }}", "Download All": "Baixar tudo", "Download Attachment": "Baixar anexo", @@ -522,6 +542,8 @@ "No results found": "Nenhum resultado encontrado", "No user found": "Nenhum usuário encontrado", "Nobody will be able to vote in this poll anymore.": "Ninguém mais poderá votar nesta pesquisa.", + "Not connected": "Não conectado", + "Not now": "Agora não", "Nothing yet...": "Nada ainda...", "Notify all {{ role }} members": "Notificar todos os membros com a função {{ role }}", "Offline": "Offline", @@ -651,15 +673,22 @@ "Summoning my inner genius": "Invocando meu gênio interior", "Tap to remove": "Toque para remover", "Tap to remove: {{ reactionName }}": "Toque para remover: {{ reactionName }}", + "The sign-in couldn't be opened. Try again.": "Não foi possível abrir o login. Tente novamente.", + "The sign-in didn't complete.": "O login não foi concluído.", "Thinking": "Pensando", "Thinking really hard": "Pensando bem forte", "Thinking...": "Pensando...", + "Thinking…": "Pensando…", + "Thinking… {{ duration }}": "Pensando… {{ duration }}", "this content could not be displayed": "este conteúdo não pôde ser exibido", "This field cannot be empty or contain only spaces": "Este campo não pode estar vazio ou conter apenas espaços", "This message did not meet our content guidelines": "Esta mensagem não corresponde às nossas diretrizes de conteúdo", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This sign-in has expired.": "Este login expirou.", "This user will be able to message you again.": "Este usuário poderá enviar mensagens para você novamente.", "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", + "Thought for {{ duration }}": "Pensou por {{ duration }}", + "Thought process": "Raciocínio", "Thread": "Fio", "Thread has not been found": "Fio não encontrado", "Thread reply": "Resposta no fio", @@ -746,8 +775,13 @@ "Vote ended": "Votação encerrada", "Votes": "Votos", "Wait until all attachments have uploaded": "Espere até que todos os anexos tenham sido carregados", + "Waiting for approval": "Aguardando aprovação", "Waiting for network…": "Aguardando rede…", + "Waiting for the device": "Aguardando o dispositivo", + "Waiting for you to sign in": "Aguardando seu login", "Working my magic": "Fazendo minha mágica", "You": "Você", - "You've reached the maximum number of files": "Você atingiu o número máximo de arquivos" + "You've reached the maximum number of files": "Você atingiu o número máximo de arquivos", + "Your answer couldn't be sent. Try again.": "Não foi possível enviar sua resposta. Tente novamente.", + "Your browser blocked the sign-in window. Allow pop-ups and try again.": "Seu navegador bloqueou a janela de login. Permita pop-ups e tente novamente." } diff --git a/src/i18n/ru.json b/src/i18n/ru.json index bdb9f115e..7f7ac7bca 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -63,6 +63,7 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} создал(а): {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} проголосовал(а): {{pollOptionText}}", "📍Shared location": "📍Общее местоположение", + "A step this app version can't show": "Шаг, который эта версия приложения не может показать", "Actions": "Actions", "Add": "Добавить", "Add {{ count }} members_one": "Добавить {{ count }} участника", @@ -77,6 +78,7 @@ "Add reaction": "Добавить реакцию", "Admin": "Администратор", "All results loaded": "Все результаты загружены", + "Allow": "Разрешить", "Allow access to camera": "Разрешить доступ к камере", "Allow access to microphone": "Разрешить доступ к микрофону", "Allow comments": "Разрешить комментарии", @@ -126,6 +128,7 @@ "aria/Audio position {{ progress }} percent": "Позиция аудио {{ progress }} процентов", "aria/Back to attachments": "Назад к вложениям", "aria/Back to parent menu button": "Назад в родительское меню кнопка", + "aria/Bad response": "Плохой ответ", "aria/Block User": "Заблокировать пользователя", "aria/Bookmark Message": "Сохранить сообщение", "aria/Cancel recording": "Отменить запись", @@ -134,6 +137,7 @@ "aria/Channel Actions": "Действия канала", "aria/Channel details": "Сведения о канале", "aria/Channel list": "Список каналов", + "aria/Chat options": "Параметры чата", "aria/Chat view controls": "Элементы управления видом чата", "aria/Chat: {{ channelName }}": "Чат: {{ channelName }}", "aria/Clear search": "Очистить поиск", @@ -143,6 +147,8 @@ "aria/Command activated: {{ command }}": "Команда активирована: {{ command }}", "aria/Command Suggestions": "Подсказки команд", "aria/Complete recording": "Завершить запись", + "aria/Copied": "Скопировано", + "aria/Copy": "Копировать", "aria/Copy Message Text": "Копировать текст сообщения", "aria/Decrease value": "Уменьшить значение", "aria/Delete Message": "Удалить сообщение", @@ -151,6 +157,7 @@ "aria/Dismiss notification": "Закрыть уведомление", "aria/Download attachment": "Скачать вложение", "aria/Dropped \"{{ option }}\" at position {{ position }}.": "Вариант «{{ option }}» помещён на позицию {{ position }}.", + "aria/Edit": "Изменить", "aria/Edit Message": "Редактировать сообщение", "aria/Emoji picker": "Выбор эмодзи", "aria/Emoji Suggestions": "Подсказки эмодзи", @@ -166,6 +173,7 @@ "aria/Giphy image changed: {{ title }}": "Giphy-изображение изменено: {{ title }}", "aria/Giphy preview, only visible to you. Use the Send, Shuffle, or Cancel actions.": "Предпросмотр Giphy, виден только вам. Используйте действия «Отправить», «Перемешать» или «Отменить».", "aria/Giphy sent": "Giphy отправлен", + "aria/Good response": "Хороший ответ", "aria/image": "изображение", "aria/Image failed to load": "Не удалось загрузить изображение", "aria/Increase value": "Увеличить значение", @@ -174,6 +182,7 @@ "aria/Last activity: {{ time }}": "Последняя активность: {{ time }}", "aria/Last message from {{ sender }}: {{ messagePreview }}": "Последнее сообщение от {{ sender }}: {{ messagePreview }}", "aria/Last message: {{ messagePreview }}": "Последнее сообщение: {{ messagePreview }}", + "aria/Loading older messages": "Загрузка более ранних сообщений", "aria/Mark Message Unread": "Отметить как непрочитанное", "aria/Mark messages as read": "Отметить сообщения как прочитанные", "aria/Mention Suggestions": "Подсказки упоминаний", @@ -222,6 +231,7 @@ "aria/Recording paused": "Запись приостановлена", "aria/Recording resumed": "Запись возобновлена", "aria/Recording started": "Запись начата", + "aria/Regenerate": "Сгенерировать заново", "aria/Remind Me Message": "Напомнить мне", "aria/Remove attachment": "Удалить вложение", "aria/Remove location attachment": "Удалить вложение местоположения", @@ -231,10 +241,12 @@ "aria/Removed option {{ option }}": "Вариант {{ option }} удалён", "aria/Reorder \"{{ option }}\" at position {{ position }} of {{ total }}": "Изменить порядок варианта «{{ option }}», позиция {{ position }} из {{ total }}", "aria/Reorder option {{ position }}": "Изменить порядок варианта {{ position }}", + "aria/Reply actions": "Действия с ответом", "aria/Resend Message": "Отправить сообщение повторно", "aria/Resume recording": "Возобновить запись", "aria/Retry upload": "Повторить загрузку", "aria/Review bounced message": "Проверить отклонённое сообщение", + "aria/Scroll to latest": "К последнему сообщению", "aria/Search cleared": "Поиск очищен", "aria/Search results": "Результаты поиска", "aria/Search results header filter button for: {{ source }}": "Кнопка фильтра заголовка результатов поиска для: {{ source }}", @@ -243,6 +255,7 @@ "aria/Select User Channel: {{ name }}": "Выбрать пользовательский канал: {{ name }}", "aria/Send": "Отправить", "aria/Sent": "Отправлено", + "aria/Share": "Поделиться", "aria/Shared a link": "Поделились ссылкой", "aria/Shared a link with title: {{ linkTitle }}": "Поделились ссылкой с заголовком: {{ linkTitle }}", "aria/Shared location": "Местоположение", @@ -281,6 +294,7 @@ "Browse channel members": "Просмотреть участников канала", "Browse pinned messages": "Просмотреть закрепленные сообщения", "Cancel": "Отмена", + "Cancelled": "Отменено", "Cannot seek in the recording": "Невозможно осуществить поиск в записи", "Changes saved": "Изменения сохранены", "Channel archived": "Канал в архиве", @@ -303,8 +317,11 @@ "Command not available while replying": "Команда недоступна при ответе", "Commands": "Команды", "Commands matching": "Соответствие команд", + "Connect": "Подключить", + "Connected to {{ provider }}": "Подключено к {{ provider }}", "Connecting": "Подключение", "Connecting the dots": "Соединяю точки", + "Connecting…": "Подключение…", "Connection failure, reconnecting now...": "Ошибка соединения, переподключение...", "Consulting the AI gods": "Советуюсь с богами ИИ", "Contact info": "Информация о контакте", @@ -316,14 +333,17 @@ "Create poll": "Создать опрос", "Crunching the numbers": "Перемалываю числа", "Current location": "Текущее местоположение", + "Declined": "Отклонено", "Delete": "Удалить", "Delete attachment": "Удалить вложение", "Delete chat": "Удалить чат", "Delete for me": "Удалить для меня", "Delete message": "Удалить сообщение", "Delivered": "Отправлено", + "Didn't complete": "Не выполнено", "Direct message": "Личное сообщение", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "Вы хотите завершить этот опрос сейчас? После этого никто не сможет голосовать в этом опросе.", + "Don't Allow": "Не разрешать", "Download {{ fileName }}": "Скачать {{ fileName }}", "Download All": "Скачать всё", "Download Attachment": "Скачать вложение", @@ -546,6 +566,8 @@ "No results found": "Результаты не найдены", "No user found": "Пользователь не найден", "Nobody will be able to vote in this poll anymore.": "Никто больше не сможет голосовать в этом опросе.", + "Not connected": "Не подключено", + "Not now": "Не сейчас", "Nothing yet...": "Пока ничего нет...", "Notify all {{ role }} members": "Уведомить всех участников с ролью {{ role }}", "Offline": "Не в сети", @@ -680,15 +702,22 @@ "Summoning my inner genius": "Пробуждаю внутреннего гения", "Tap to remove": "Нажмите, чтобы удалить", "Tap to remove: {{ reactionName }}": "Нажмите, чтобы удалить: {{ reactionName }}", + "The sign-in couldn't be opened. Try again.": "Не удалось открыть вход. Попробуйте ещё раз.", + "The sign-in didn't complete.": "Вход не завершён.", "Thinking": "Думает", "Thinking really hard": "Усердно думаю", "Thinking...": "Думаю...", + "Thinking…": "Думает…", + "Thinking… {{ duration }}": "Думает… {{ duration }}", "this content could not be displayed": "Этот контент не может быть отображен в данный момент", "This field cannot be empty or contain only spaces": "Это поле не может быть пустым или содержать только пробелы", "This message did not meet our content guidelines": "Сообщение не соответствует правилам", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This sign-in has expired.": "Срок действия этого входа истёк.", "This user will be able to message you again.": "Этот пользователь снова сможет отправлять вам сообщения.", "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", + "Thought for {{ duration }}": "Думал {{ duration }}", + "Thought process": "Ход рассуждений", "Thread": "Ветка", "Thread has not been found": "Ветка не найдена", "Thread reply": "Ответ в ветке", @@ -780,8 +809,13 @@ "Vote ended": "Голосование завершено", "Votes": "Голоса", "Wait until all attachments have uploaded": "Подождите, пока все вложения загрузятся", + "Waiting for approval": "Ожидает подтверждения", "Waiting for network…": "Ожидание сети…", + "Waiting for the device": "Ожидает устройство", + "Waiting for you to sign in": "Ожидает вашего входа", "Working my magic": "Колдую", "You": "Вы", - "You've reached the maximum number of files": "Вы достигли максимального количества файлов" + "You've reached the maximum number of files": "Вы достигли максимального количества файлов", + "Your answer couldn't be sent. Try again.": "Не удалось отправить ваш ответ. Попробуйте ещё раз.", + "Your browser blocked the sign-in window. Allow pop-ups and try again.": "Браузер заблокировал окно входа. Разрешите всплывающие окна и попробуйте ещё раз." } diff --git a/src/i18n/tr.json b/src/i18n/tr.json index c94716817..d46a3da3e 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -42,6 +42,7 @@ "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} oluşturdu: {{ pollName}}", "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} oy verdi: {{pollOptionText}}", "📍Shared location": "📍Paylaşılan konum", + "A step this app version can't show": "Bu uygulama sürümünün gösteremediği bir adım", "Actions": "Actions", "Add": "Ekle", "Add {{ count }} members_one": "{{ count }} üye ekle", @@ -54,6 +55,7 @@ "Add reaction": "Tepki ekle", "Admin": "Yönetici", "All results loaded": "Tüm sonuçlar yüklendi", + "Allow": "İzin ver", "Allow access to camera": "Kameraya erişime izin ver", "Allow access to microphone": "Mikrofona erişime izin ver", "Allow comments": "Yorumlara izin ver", @@ -93,6 +95,7 @@ "aria/Audio position {{ progress }} percent": "Ses konumu yüzde {{ progress }}", "aria/Back to attachments": "Eklere geri dön", "aria/Back to parent menu button": "Üst menüye geri dön düğme", + "aria/Bad response": "Kötü yanıt", "aria/Block User": "Kullanıcıyı engelle", "aria/Bookmark Message": "Mesajı yer imi ekle", "aria/Cancel recording": "Kaydı iptal et", @@ -101,6 +104,7 @@ "aria/Channel Actions": "Kanal işlemleri", "aria/Channel details": "Kanal ayrıntıları", "aria/Channel list": "Kanal listesi", + "aria/Chat options": "Sohbet seçenekleri", "aria/Chat view controls": "Sohbet görünümü kontrolleri", "aria/Chat: {{ channelName }}": "Sohbet: {{ channelName }}", "aria/Clear search": "Aramayı temizle", @@ -110,6 +114,8 @@ "aria/Command activated: {{ command }}": "Komut etkinleştirildi: {{ command }}", "aria/Command Suggestions": "Komut önerileri", "aria/Complete recording": "Kaydı tamamla", + "aria/Copied": "Kopyalandı", + "aria/Copy": "Kopyala", "aria/Copy Message Text": "Mesaj metnini kopyala", "aria/Decrease value": "Değeri azalt", "aria/Delete Message": "Mesajı sil", @@ -118,6 +124,7 @@ "aria/Dismiss notification": "Bildirimi kapat", "aria/Download attachment": "Ek indir", "aria/Dropped \"{{ option }}\" at position {{ position }}.": "\"{{ option }}\" {{ position }}. konuma bırakıldı.", + "aria/Edit": "Düzenle", "aria/Edit Message": "Mesajı Düzenle", "aria/Emoji picker": "Emoji seçici", "aria/Emoji Suggestions": "Emoji önerileri", @@ -133,6 +140,7 @@ "aria/Giphy image changed: {{ title }}": "Giphy görseli değişti: {{ title }}", "aria/Giphy preview, only visible to you. Use the Send, Shuffle, or Cancel actions.": "Giphy önizlemesi, yalnızca size görünür. Gönder, Karıştır veya İptal işlemlerini kullanın.", "aria/Giphy sent": "Giphy gönderildi", + "aria/Good response": "İyi yanıt", "aria/image": "görsel", "aria/Image failed to load": "Görsel yüklenemedi", "aria/Increase value": "Değeri artır", @@ -141,6 +149,7 @@ "aria/Last activity: {{ time }}": "Son etkinlik: {{ time }}", "aria/Last message from {{ sender }}: {{ messagePreview }}": "{{ sender }} kişisinden son mesaj: {{ messagePreview }}", "aria/Last message: {{ messagePreview }}": "Son mesaj: {{ messagePreview }}", + "aria/Loading older messages": "Eski mesajlar yükleniyor", "aria/Mark Message Unread": "Okunmamış olarak işaretle", "aria/Mark messages as read": "Mesajları okundu olarak işaretle", "aria/Mention Suggestions": "Bahsetme önerileri", @@ -187,6 +196,7 @@ "aria/Recording paused": "Kayıt duraklatıldı", "aria/Recording resumed": "Kayıt devam ettirildi", "aria/Recording started": "Kayıt başladı", + "aria/Regenerate": "Yeniden oluştur", "aria/Remind Me Message": "Hatırlat", "aria/Remove attachment": "Eki kaldır", "aria/Remove location attachment": "Konum ekini kaldır", @@ -196,10 +206,12 @@ "aria/Removed option {{ option }}": "{{ option }} seçeneği kaldırıldı", "aria/Reorder \"{{ option }}\" at position {{ position }} of {{ total }}": "\"{{ option }}\" sırasını değiştir, {{ total }} konumdan {{ position }}.", "aria/Reorder option {{ position }}": "Seçenek {{ position }} sırasını değiştir", + "aria/Reply actions": "Yanıt işlemleri", "aria/Resend Message": "Mesajı tekrar gönder", "aria/Resume recording": "Kaydı devam ettir", "aria/Retry upload": "Yüklemeyi Tekrar Dene", "aria/Review bounced message": "Geri dönen mesajı incele", + "aria/Scroll to latest": "En son mesaja git", "aria/Search cleared": "Arama temizlendi", "aria/Search results": "Arama sonuçları", "aria/Search results header filter button for: {{ source }}": "{{ source }} için arama sonuçları başlık filtre düğmesi", @@ -208,6 +220,7 @@ "aria/Select User Channel: {{ name }}": "Kullanıcı kanalını seç: {{ name }}", "aria/Send": "Gönder", "aria/Sent": "Gönderildi", + "aria/Share": "Paylaş", "aria/Shared a link": "Bağlantı paylaşıldı", "aria/Shared a link with title: {{ linkTitle }}": "Başlıklı bağlantı paylaşıldı: {{ linkTitle }}", "aria/Shared location": "Paylaşılan konum", @@ -246,6 +259,7 @@ "Browse channel members": "Kanal üyelerine göz at", "Browse pinned messages": "Sabitlenmiş mesajlara göz at", "Cancel": "İptal", + "Cancelled": "İptal edildi", "Cannot seek in the recording": "Kayıtta arama yapılamıyor", "Changes saved": "Değişiklikler kaydedildi", "Channel archived": "Kanal arşivlendi", @@ -268,8 +282,11 @@ "Command not available while replying": "Yanıtlama sırasında komut kullanılamaz", "Commands": "Komutlar", "Commands matching": "Eşleşen komutlar", + "Connect": "Bağlan", + "Connected to {{ provider }}": "{{ provider }} ile bağlandı", "Connecting": "Bağlanıyor", "Connecting the dots": "Noktaları birleştiriyorum", + "Connecting…": "Bağlanıyor…", "Connection failure, reconnecting now...": "Bağlantı hatası, tekrar bağlanılıyor...", "Consulting the AI gods": "Yapay zekâ tanrılarına danışıyorum", "Contact info": "İletişim bilgileri", @@ -281,14 +298,17 @@ "Create poll": "Anket oluştur", "Crunching the numbers": "Sayıları hesaplıyorum", "Current location": "Mevcut konum", + "Declined": "Reddedildi", "Delete": "Sil", "Delete attachment": "Eki sil", "Delete chat": "Sohbeti sil", "Delete for me": "Benim için sil", "Delete message": "Mesajı sil", "Delivered": "İletildi", + "Didn't complete": "Tamamlanmadı", "Direct message": "Doğrudan mesaj", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "Bu anketi şimdi sonlandırmak istiyor musunuz? Sonlandırdıktan sonra bu ankette kimse oy kullanamayacak.", + "Don't Allow": "İzin verme", "Download {{ fileName }}": "{{ fileName }} dosyasını indir", "Download All": "Tümünü indir", "Download Attachment": "Eki indir", @@ -502,6 +522,8 @@ "No results found": "Sonuç bulunamadı", "No user found": "Kullanıcı bulunamadı", "Nobody will be able to vote in this poll anymore.": "Artık bu ankette kimse oy kullanamayacak.", + "Not connected": "Bağlı değil", + "Not now": "Şimdi değil", "Nothing yet...": "Şimdilik hiçbir şey...", "Notify all {{ role }} members": "{{ role }} rolündeki tüm üyelere bildir", "Offline": "Çevrimdışı", @@ -626,15 +648,22 @@ "Summoning my inner genius": "İçimdeki dahiyi uyandırıyorum", "Tap to remove": "Kaldırmak için dokunun", "Tap to remove: {{ reactionName }}": "Kaldırmak için dokunun: {{ reactionName }}", + "The sign-in couldn't be opened. Try again.": "Oturum açma penceresi açılamadı. Tekrar deneyin.", + "The sign-in didn't complete.": "Oturum açma tamamlanmadı.", "Thinking": "Düşünüyor", "Thinking really hard": "Çok derin düşünüyorum", "Thinking...": "Düşünüyor...", + "Thinking…": "Düşünüyor…", + "Thinking… {{ duration }}": "Düşünüyor… {{ duration }}", "this content could not be displayed": "bu içerik gösterilemiyor", "This field cannot be empty or contain only spaces": "Bu alan boş olamaz veya sadece boşluk içeremez", "This message did not meet our content guidelines": "Bu mesaj içerik yönergelerimize uygun değil", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", + "This sign-in has expired.": "Bu oturum açmanın süresi doldu.", "This user will be able to message you again.": "Bu kullanıcı size tekrar mesaj gönderebilecek.", "This user won't be able to message you anymore. You can unblock them anytime.": "This user won't be able to message you anymore. You can unblock them anytime.", + "Thought for {{ duration }}": "{{ duration }} düşündü", + "Thought process": "Düşünme süreci", "Thread": "Konu", "Thread has not been found": "Konu bulunamadı", "Thread reply": "Konu yanıtı", @@ -716,8 +745,13 @@ "Vote ended": "Oylama sona erdi", "Votes": "Oylar", "Wait until all attachments have uploaded": "Tüm ekler yüklenene kadar bekleyin", + "Waiting for approval": "Onay bekleniyor", "Waiting for network…": "Ağ bekleniyor…", + "Waiting for the device": "Cihaz bekleniyor", + "Waiting for you to sign in": "Oturum açmanız bekleniyor", "Working my magic": "Büyümü yapıyorum", "You": "Sen", - "You've reached the maximum number of files": "Maksimum dosya sayısına ulaştınız" + "You've reached the maximum number of files": "Maksimum dosya sayısına ulaştınız", + "Your answer couldn't be sent. Try again.": "Yanıtınız gönderilemedi. Tekrar deneyin.", + "Your browser blocked the sign-in window. Allow pop-ups and try again.": "Tarayıcınız oturum açma penceresini engelledi. Açılır pencerelere izin verin ve tekrar deneyin." } diff --git a/src/plugins/AIComponents/AIMessageComposer/AIMessageComposer.tsx b/src/plugins/AIComponents/AIMessageComposer/AIMessageComposer.tsx index d3d9fd2f7..825f97f6a 100644 --- a/src/plugins/AIComponents/AIMessageComposer/AIMessageComposer.tsx +++ b/src/plugins/AIComponents/AIMessageComposer/AIMessageComposer.tsx @@ -20,6 +20,7 @@ import { useStateStore } from '../../../store'; import { useStableCallback } from '../../../utils/useStableCallback'; import { useStableId } from '../../../components/UtilityComponents/useStableId'; import { AttachmentPreview } from './AttachmentPreview'; +import { ChatOptions as ChatOptionsBase, type ChatOptionsProps } from './ChatOptions'; import { useSpeechToText, type UseSpeechToTextOptions } from './hooks/useSpeechToText'; const nanoId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 15); @@ -268,10 +269,12 @@ interface AIMessageComposer { SubmitButton: typeof SubmitButton; ModelSelect: typeof ModelSelect; AttachmentPreview: typeof AttachmentPreview; + ChatOptions: typeof ChatOptions; } export const AIMessageComposer: AIMessageComposer = ({ children, + className, disabled, nameMapping, onChange, @@ -345,7 +348,7 @@ export const AIMessageComposer: AIMessageComposer = ({ return (
{ + const { disabled } = useIsDisabled(); + return ; +}; + const availableModels = [ { label: 'GPT-4o mini', platform: 'openai', value: 'gpt-4o-mini' }, { label: 'GPT-4o', platform: 'openai', value: 'gpt-4o' }, @@ -510,3 +519,4 @@ AIMessageComposer.SpeechToTextButton = SpeechToTextButton; AIMessageComposer.SubmitButton = SubmitButton; AIMessageComposer.ModelSelect = ModelSelect; AIMessageComposer.AttachmentPreview = AttachmentPreview; +AIMessageComposer.ChatOptions = ChatOptions; diff --git a/src/plugins/AIComponents/AIMessageComposer/AttachmentPreview.tsx b/src/plugins/AIComponents/AIMessageComposer/AttachmentPreview.tsx index 58f6961fd..b02f031cd 100644 --- a/src/plugins/AIComponents/AIMessageComposer/AttachmentPreview.tsx +++ b/src/plugins/AIComponents/AIMessageComposer/AttachmentPreview.tsx @@ -1,4 +1,9 @@ -import { type ComponentPropsWithoutRef, type MouseEvent } from 'react'; +import { + type ComponentPropsWithoutRef, + type MouseEvent, + useEffect, + useState, +} from 'react'; import { useAttachments } from './AIMessageComposer'; import clsx from 'clsx'; import { useComponentContextIcons } from '../../../context/useComponentContextIcons'; @@ -87,23 +92,66 @@ export const Item = ({ ); }; +type Attachments = ReturnType; + +// An object URL per picked image, revoked once it is removed or the preview unmounts. +const useImagePreviewSources = (attachments: Attachments['attachments']) => { + const [sources, setSources] = useState>({}); + useEffect(() => { + if (typeof URL.createObjectURL !== 'function') return; + const created: Record = {}; + for (const { file, id } of attachments) { + if (file.type.startsWith('image/')) created[id] = URL.createObjectURL(file); + } + setSources(created); + return () => Object.values(created).forEach((url) => URL.revokeObjectURL(url)); + }, [attachments]); + return sources; +}; + +const DefaultItems = ({ attachments, removeAttachment }: Attachments) => { + const sources = useImagePreviewSources(attachments); + return ( + <> + {attachments.map(({ file, id, meta }) => ( + removeAttachment(id)} + state={typeof meta?.state === 'string' ? meta.state : undefined} + /> + ))} + + ); +}; + +/** + * The attachments picked in the composer. Its children render them: elements, or a function of + * `useAttachments()`. Without children it shows an `Item` per picked attachment, each with a + * delete button (an attachment's `meta.state` and `meta.previewUri` are used when set), and + * nothing while none are picked. + */ export const AttachmentPreview = ({ children, + className, ...restProps -}: ComponentPropsWithoutRef<'div'> & { - children?: - | React.ReactNode - | ((_: ReturnType) => React.ReactNode); +}: Omit, 'children'> & { + children?: React.ReactNode | ((_: Attachments) => React.ReactNode); }) => { - const _ = useAttachments(); + const attachments = useAttachments(); - if (!children) { + if (!children && attachments.attachments.length === 0) { return null; } return ( -
- {typeof children === 'function' ? children(_) : children} +
+ {typeof children === 'function' + ? children(attachments) + : (children ?? )}
); }; diff --git a/src/plugins/AIComponents/AIMessageComposer/ChatOptions.tsx b/src/plugins/AIComponents/AIMessageComposer/ChatOptions.tsx new file mode 100644 index 000000000..a0d158de3 --- /dev/null +++ b/src/plugins/AIComponents/AIMessageComposer/ChatOptions.tsx @@ -0,0 +1,248 @@ +import React, { + type KeyboardEvent, + type ReactNode, + useEffect, + useRef, + useState, +} from 'react'; +import clsx from 'clsx'; + +import { useTranslationContext } from '../../../context/TranslationContext'; +import { useComponentContextIcons } from '../../../context/useComponentContextIcons'; +import { useStableId } from '../../../components/UtilityComponents/useStableId'; + +/** + * A way to ask, which the person picks for their next message, such as "Think harder". The + * counterpart of `ChatOption` in StreamChatAI. + */ +export type AIChatOption = { + id: string; + /** The option's name in the menu, such as "Think harder". */ + title: string; + /** Data of your own, such as what the option sends with the message. */ + customData?: Record; + /** A line under the title in the menu, such as "Takes longer, for harder questions". */ + description?: string; + /** An icon shown in the menu and on the chip. */ + icon?: ReactNode; + /** The option's name on the chip once picked, such as "Think". Defaults to `title`. */ + shortTitle?: string; +}; + +export type ChatOptionsListProps = { + onSelect: (option: AIChatOption) => void; + options: AIChatOption[]; + className?: string; + id?: string; + /** Called when the person leaves the list with Escape or Tab. */ + onDismiss?: () => void; + selectedId?: string | null; +}; + +/** The menu of chat options. The counterpart of `ChatOptionsList`. */ +export const ChatOptionsList = ({ + className, + id, + onDismiss, + onSelect, + options, + selectedId, +}: ChatOptionsListProps) => { + const { t } = useTranslationContext(); + const listRef = useRef(null); + + useEffect(() => { + const items = listRef.current?.querySelectorAll( + '[role="menuitemradio"]', + ); + const selected = [...(items ?? [])].find( + (item) => item.getAttribute('aria-checked') === 'true', + ); + (selected ?? items?.[0])?.focus(); + }, []); + + const onKeyDown = (event: KeyboardEvent) => { + const items = [ + ...(listRef.current?.querySelectorAll('[role="menuitemradio"]') ?? []), + ]; + const index = items.indexOf(document.activeElement as HTMLElement); + const move = (to: number) => { + event.preventDefault(); + items[(to + items.length) % items.length]?.focus(); + }; + if (event.key === 'ArrowDown') move(index + 1); + else if (event.key === 'ArrowUp') move(index - 1); + else if (event.key === 'Home') move(0); + else if (event.key === 'End') move(items.length - 1); + else if (event.key === 'Escape' || event.key === 'Tab') onDismiss?.(); + }; + + return ( + + ); +}; + +export type SelectedChatOptionChipProps = { + onRemove: () => void; + option: AIChatOption; + className?: string; + disabled?: boolean; +}; + +/** The picked option, with a button that removes it. The counterpart of `SelectedChatOptionChip`. */ +export const SelectedChatOptionChip = ({ + className, + disabled, + onRemove, + option, +}: SelectedChatOptionChipProps) => { + const { t } = useTranslationContext(); + const { IconXmarkSmall } = useComponentContextIcons(); + const title = option.shortTitle ?? option.title; + return ( + + {option.icon && ( + {option.icon} + )} + {title} + + + ); +}; + +export type ChatOptionsProps = { + options: AIChatOption[]; + className?: string; + /** The option picked at first, when uncontrolled. */ + defaultSelectedId?: string | null; + disabled?: boolean; + /** The menu button's icon. Defaults to a plus. */ + icon?: ReactNode; + /** Submits the picked option's id with the composer's form under this name. */ + name?: string; + /** Called when the person picks an option, or removes it with `null`. */ + onSelectedChange?: (option: AIChatOption | null) => void; + /** The picked option's id, when controlled. */ + selectedId?: string | null; +}; + +/** + * A menu of ways to ask, such as "Think harder", and a chip for the one picked, with a button + * that removes it. Place it inside `AIMessageComposer`; with `name`, the picked option's id is + * submitted with the form. The counterpart of the chat options of `AIComposerView`. + */ +export const ChatOptions = ({ + className, + defaultSelectedId = null, + disabled, + icon, + name, + onSelectedChange, + options, + selectedId: selectedIdProp, +}: ChatOptionsProps) => { + const { t } = useTranslationContext(); + const { IconPlus } = useComponentContextIcons(); + const listId = `str-chat__ai-chat-options-${useStableId()}`; + const [isOpen, setIsOpen] = useState(false); + const [uncontrolledId, setUncontrolledId] = useState(defaultSelectedId); + const selectedId = selectedIdProp === undefined ? uncontrolledId : selectedIdProp; + const selected = options.find((option) => option.id === selectedId); + const rootRef = useRef(null); + const buttonRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + const onPointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setIsOpen(false); + }; + document.addEventListener('pointerdown', onPointerDown); + return () => document.removeEventListener('pointerdown', onPointerDown); + }, [isOpen]); + + const select = (option: AIChatOption | null) => { + setUncontrolledId(option?.id ?? null); + onSelectedChange?.(option); + }; + + return ( +
+ + {isOpen && ( + { + setIsOpen(false); + buttonRef.current?.focus(); + }} + onSelect={(option) => { + setIsOpen(false); + select(option.id === selectedId ? null : option); + buttonRef.current?.focus(); + }} + options={options} + selectedId={selectedId} + /> + )} + {selected && ( + select(null)} + option={selected} + /> + )} + {name && } +
+ ); +}; diff --git a/src/plugins/AIComponents/AIMessageComposer/index.ts b/src/plugins/AIComponents/AIMessageComposer/index.ts index aa746f854..9ed4fd50a 100644 --- a/src/plugins/AIComponents/AIMessageComposer/index.ts +++ b/src/plugins/AIComponents/AIMessageComposer/index.ts @@ -1,2 +1,10 @@ export * from './AIMessageComposer'; export * from './hooks/useSpeechToText'; +export { + type AIChatOption, + ChatOptionsList, + type ChatOptionsListProps, + type ChatOptionsProps, + SelectedChatOptionChip, + type SelectedChatOptionChipProps, +} from './ChatOptions'; diff --git a/src/plugins/AIComponents/Conversation/AIConversation.tsx b/src/plugins/AIComponents/Conversation/AIConversation.tsx new file mode 100644 index 000000000..4740e5039 --- /dev/null +++ b/src/plugins/AIComponents/Conversation/AIConversation.tsx @@ -0,0 +1,526 @@ +import React, { + type ComponentType, + memo, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import clsx from 'clsx'; + +import { useTranslationContext } from '../../../context/TranslationContext'; +import { useComponentContextIcons } from '../../../context/useComponentContextIcons'; +import { AIAssistantMessage } from '../Message/AIAssistantMessage'; +import type { AIMessageAttachmentData } from '../Message/AIMessageAttachment'; +import { AIReplyActions, type AIReplyActionsProps } from '../Message/AIReplyActions'; +import { AIUserMessage } from '../Message/AIUserMessage'; +import type { AIMessagePartData } from '../MessageParts/types'; +import { AIThinking } from '../Reasoning/AIReasoning'; +import { AIThinkingClock, AIThinkingTurnContext } from '../Reasoning/thinkingClock'; +import { type AIToolApprover, AIToolApproverProvider } from '../ToolCalls/AIToolApprover'; +import { prefersReducedMotion } from '../utils'; +import { + type AIConversationEntry, + type AIConversationTurn, + AIPendingMessageTracker, + turnsOf, +} from './entries'; +import type { AIMessage, AIPendingMessage } from './types'; + +// The layout effect warns under server rendering, where there is nothing to measure anyway. +const useIsomorphicLayoutEffect = + typeof window === 'undefined' ? useEffect : useLayoutEffect; + +// How far from the end still counts as at the end, in pixels. +const END_SLACK = 32; +// How long the spinner above the turns stays in view before older messages load, so a list +// that opens at its end does not load for the frame its start shows. +const LOAD_OLDER_DELAY_MS = 300; + +export type AIConversationUserMessageProps = { + /** What the person sent with it, such as images and files. */ + attachments: AIMessageAttachmentData[]; + /** What the person wrote. */ + text: string; + /** The message, or `undefined` for one on its way. */ + message?: AIMessage; +}; + +export type AIConversationAssistantMessageProps = { + attachments: AIMessageAttachmentData[]; + isGenerating: boolean; + message: AIMessage; + parts: AIMessagePartData[]; + text: string; + approver?: AIToolApprover; + reasoningText?: (stepId: string) => string | undefined; +}; + +export type AIConversationReplyActionsProps = { + /** Whether it is the reply that ends the conversation, which can be asked for again. */ + isLatestReply: boolean; + message: AIMessage; + text: string; +}; + +export type AIConversationProps = { + /** The conversation, oldest message first. */ + messages: AIMessage[]; + /** Who answers tool calls' questions and sign-ins on this device, to ask them. */ + approver?: AIToolApprover; + /** The view of the agent's replies. Defaults to `AIAssistantMessage`. */ + AssistantMessage?: ComponentType; + /** + * Room left under the latest turn, in pixels, for a composer floating over the bottom of the + * conversation. The question of a new turn pins to the top of what is visible above it. + */ + bottomInset?: number; + className?: string; + /** What the conversation shows while there are no messages, centered in it. */ + emptyContent?: ReactNode; + /** A view under the latest turn's messages, such as what the agent is doing, or an error with a way to retry. */ + footer?: ReactNode; + /** + * Whether the agent is answering the latest message and its reply shows nothing yet. + * Meanwhile the latest turn ends with `AIThinking`, and the reply's first reasoning step takes + * over its seconds. The conversation also knows it is while `pendingMessage` waits, and while + * the latest reply is being written but shows nothing. + */ + isThinking?: boolean; + /** + * Loads the messages before the first one, which the conversation asks for as the person + * scrolls to its start. It shows a spinner meanwhile, and what is on screen stays in place as + * they arrive. Pass `undefined` once there are none left. + */ + loadOlderMessages?: () => Promise | void; + /** + * A message the person sent that is not in `messages` yet. Keep passing it until its reply + * starts: the conversation stops showing it once its copy arrives. + */ + pendingMessage?: AIPendingMessage | null; + /** The actions under each finished reply. Defaults to `AIReplyActions`; `null` shows none. */ + ReplyActions?: ComponentType | null; + /** + * What the default `AIReplyActions` offers for a reply besides copy and share, such as its + * rating and how to rate or regenerate it. + */ + replyActions?: ( + message: AIMessage, + context: { isLatestReply: boolean }, + ) => Omit | undefined; + /** The view of the person's messages. Defaults to `AIUserMessage`. */ + UserMessage?: ComponentType; +}; + +const DefaultUserMessage = ({ attachments, text }: AIConversationUserMessageProps) => ( + +); + +const DefaultAssistantMessage = ({ + approver, + attachments, + isGenerating, + parts, + reasoningText, + text, +}: AIConversationAssistantMessageProps) => ( + +); + +type EntryViewProps = { + AssistantMessage: ComponentType; + entry: AIConversationEntry; + ReplyActions: ComponentType | null; + UserMessage: ComponentType; + approver?: AIToolApprover; +}; + +const EntryView = memo( + ({ approver, AssistantMessage, entry, ReplyActions, UserMessage }: EntryViewProps) => { + const { message, pending } = entry; + if (!message) { + return pending ? ( + + ) : null; + } + if (message.role === 'user') { + return ( + + ); + } + return ( +
+ + {ReplyActions && !message.isGenerating && message.text !== '' && ( + + )} +
+ ); + }, +); +EntryView.displayName = 'AIConversationEntry'; + +type HeldTurn = { id: string; top: number }; + +const scrollScroller = (scroller: HTMLElement, top: number, smooth: boolean) => { + if (typeof scroller.scrollTo !== 'function') { + scroller.scrollTop = top; + return; + } + scroller.scrollTo({ + behavior: smooth && !prefersReducedMotion() ? 'smooth' : 'auto', + top, + }); +}; + +/** + * The conversation with an AI agent, laid out by turn: each of the person's questions starts + * a turn, with the agent's reply under it. The counterpart of `AIConversationView`. + * + * - A new question pins to the top of what is visible, and the reply writes out below it, + * without moving what is on screen. The latest turn is at least as tall as the conversation. + * - It opens at the end; older messages load as the person scrolls to the start, and what is + * on screen stays in place as they arrive. + * - A button scrolls back to the latest message when the end is out of view. + * - A message the person sent shows at once (`pendingMessage`) and the agent thinks under it + * (`AIThinking`) until its reply shows something. + */ +export const AIConversation = ({ + approver, + AssistantMessage = DefaultAssistantMessage, + bottomInset = 0, + className, + emptyContent, + footer, + isThinking = false, + loadOlderMessages, + messages, + pendingMessage, + ReplyActions: ReplyActionsProp, + replyActions, + UserMessage = DefaultUserMessage, +}: AIConversationProps) => { + const { t } = useTranslationContext(); + const { IconArrowDown, IconLoading } = useComponentContextIcons(); + + const [tracker] = useState(() => new AIPendingMessageTracker()); + const [clock] = useState(() => new AIThinkingClock()); + + const entries = useMemo( + () => tracker.entries(messages, pendingMessage), + [messages, pendingMessage, tracker], + ); + // after `entries`, which matched the copies + const thinking = isThinking || tracker.isAwaitingReply(messages, pendingMessage); + const turns = useMemo(() => turnsOf(entries), [entries]); + const latest = turns[turns.length - 1]; + // Repeats harmlessly on every render: a turn's thinking starts and ends once. + clock.update(latest?.id, thinking, (latest?.entries.length ?? 0) > 1); + + const replyActionsRef = useRef(replyActions); + replyActionsRef.current = replyActions; + const DefaultReplyActions = useMemo(() => { + const ConversationReplyActions = ({ + isLatestReply, + message, + text, + }: AIConversationReplyActionsProps) => ( + + ); + return ConversationReplyActions; + }, []); + const ReplyActions = + ReplyActionsProp === undefined ? DefaultReplyActions : ReplyActionsProp; + + const scrollerRef = useRef(null); + const contentRef = useRef(null); + const turnElements = useRef(new Map()); + const [height, setHeight] = useState(0); + const [isEndInView, setIsEndInView] = useState(true); + const lastTurnIdRef = useRef(undefined); + lastTurnIdRef.current = latest?.id; + const bottomInsetRef = useRef(bottomInset); + bottomInsetRef.current = bottomInset; + + // The end is where the latest turn's content ends, above the floating composer: not the + // room the latest turn's minimum height leaves under a short reply. + const checkEnd = useCallback(() => { + const scroller = scrollerRef.current; + if (!scroller) return; + const turn = + lastTurnIdRef.current === undefined + ? undefined + : turnElements.current.get(lastTurnIdRef.current); + const last = turn?.lastElementChild as HTMLElement | null | undefined; + const end = last + ? last.offsetTop + last.offsetHeight + : scroller.scrollHeight - bottomInsetRef.current; + const visibleEnd = + scroller.scrollTop + scroller.clientHeight - bottomInsetRef.current; + setIsEndInView(end <= visibleEnd + END_SLACK); + }, []); + + useIsomorphicLayoutEffect(() => { + const scroller = scrollerRef.current; + if (!scroller) return; + setHeight(scroller.clientHeight); + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => { + setHeight(scroller.clientHeight); + checkEnd(); + }); + observer.observe(scroller); + return () => observer.disconnect(); + }, [checkEnd]); + + // Content that grows without this component rendering, such as an answer typing out or an + // image loading, moves the end too. + const hasTurns = turns.length > 0; + useEffect(() => { + const content = contentRef.current; + if (!content || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => checkEnd()); + observer.observe(content); + return () => observer.disconnect(); + }, [checkEnd, hasTurns]); + + // Where the turns in view were, the one at the top first, so older messages arriving above + // them leave them in place. + const held = useRef([]); + const noteHeld = useCallback(() => { + const scroller = scrollerRef.current; + if (!scroller) return; + const top = scroller.scrollTop; + const bottom = top + scroller.clientHeight; + held.current = [...turnElements.current] + .map(([id, element]) => ({ element, id })) + .filter( + ({ element }) => + element.offsetTop + element.offsetHeight > top && element.offsetTop < bottom, + ) + .sort((a, b) => a.element.offsetTop - b.element.offsetTop) + .map(({ element, id }) => ({ id, top: element.offsetTop - top })); + }, []); + + const scrollToTurnTop = (id: string, smooth: boolean) => { + const scroller = scrollerRef.current; + const element = turnElements.current.get(id); + if (scroller && element) scrollScroller(scroller, element.offsetTop, smooth); + }; + + const scrollToEnd = useCallback((smooth: boolean) => { + const scroller = scrollerRef.current; + if (scroller) scrollScroller(scroller, scroller.scrollHeight, smooth); + }, []); + + const firstTurnId = turns[0]?.id; + const lastTurnId = latest?.id; + const loadsOlder = !!loadOlderMessages && turns.length > 0; + const shown = useRef<{ + firstTurnId?: string; + height: number; + lastTurnId?: string; + loadsOlder: boolean; + mounted: boolean; + }>({ height: 0, loadsOlder, mounted: false }); + + useIsomorphicLayoutEffect(() => { + const previous = shown.current; + shown.current = { firstTurnId, height, lastTurnId, loadsOlder, mounted: true }; + const scroller = scrollerRef.current; + if (!scroller) return; + + // Opens at its end, and again once its height is known, which sets the latest turn's + // minimum height and so where the end is. + if (!previous.mounted || (previous.height === 0 && height > 0)) { + scroller.scrollTop = scroller.scrollHeight; + } else if ( + previous.firstTurnId !== undefined && + (previous.firstTurnId !== firstTurnId || previous.loadsOlder !== loadsOlder) && + previous.lastTurnId === lastTurnId + ) { + // Older messages arrived above what is in view, or the spinner came or went: the turn at + // the top goes back to where it was, rather than being pushed. The top one can be gone: a + // turn that starts with a reply joins the turn of its question when older messages bring it. + const anchor = held.current.find(({ id }) => turnElements.current.has(id)); + const element = anchor && turnElements.current.get(anchor.id); + if (anchor && element) scroller.scrollTop = element.offsetTop - anchor.top; + } else if (lastTurnId !== previous.lastTurnId && lastTurnId !== undefined) { + // A new question pins to the top, once laid out at least as tall as the conversation. A + // conversation replaced as a whole opens at its end. + const continues = + previous.lastTurnId !== undefined && + turns.some((turn) => turn.id === previous.lastTurnId); + if (continues) scrollToTurnTop(lastTurnId, true); + else scroller.scrollTop = scroller.scrollHeight; + } + noteHeld(); + checkEnd(); + }); + + // Loads older messages once the spinner above the turns has been in view a moment, and again + // whenever the first turn changes while it is in view. + const loadOlderRef = useRef(loadOlderMessages); + loadOlderRef.current = loadOlderMessages; + const spinnerRef = useRef(null); + useEffect(() => { + const spinner = spinnerRef.current; + const scroller = scrollerRef.current; + if ( + !loadsOlder || + !spinner || + !scroller || + typeof IntersectionObserver === 'undefined' + ) { + return; + } + let timer: ReturnType | undefined; + let loading = false; + const observer = new IntersectionObserver( + ([visible]) => { + clearTimeout(timer); + if (!visible?.isIntersecting || loading) return; + timer = setTimeout(() => { + loading = true; + Promise.resolve(loadOlderRef.current?.()) + .catch(() => undefined) + .finally(() => { + loading = false; + }); + }, LOAD_OLDER_DELAY_MS); + }, + { root: scroller }, + ); + observer.observe(spinner); + return () => { + clearTimeout(timer); + observer.disconnect(); + }; + }, [firstTurnId, loadsOlder]); + + const renderTurn = (turn: AIConversationTurn) => { + const isLatest = turn.id === lastTurnId; + return ( +
{ + if (element) turnElements.current.set(turn.id, element); + else turnElements.current.delete(turn.id); + }} + style={ + isLatest && height + ? { minHeight: Math.max(0, height - bottomInset) } + : undefined + } + > + + {turn.entries.map((entry) => ( + + ))} + {isLatest && thinking && } + {isLatest && footer} + +
+ ); + }; + + return ( + +
+
{ + noteHeld(); + checkEnd(); + }} + ref={scrollerRef} + > + {turns.length === 0 ? ( +
+ {emptyContent} +
+ ) : ( +
+ {loadsOlder && ( +
+ +
+ )} + {turns.map(renderTurn)} +
+ )} +
+ {!isEndInView && turns.length > 0 && ( + + )} +
+
+ ); +}; diff --git a/src/plugins/AIComponents/Conversation/entries.ts b/src/plugins/AIComponents/Conversation/entries.ts new file mode 100644 index 000000000..509f84a63 --- /dev/null +++ b/src/plugins/AIComponents/Conversation/entries.ts @@ -0,0 +1,136 @@ +import type { AIMessageAttachmentData } from '../Message/AIMessageAttachment'; +import type { AIMessage, AIPendingMessage } from './types'; + +/** A row of the conversation: a message, or a message the person sent that is on its way. */ +export type AIConversationEntry = { + /** The message's id, or, for the copy of a message that was on its way, that message's. */ + id: string; + isFromUser: boolean; + /** Whether it is a reply that ends the conversation. */ + isLatestReply: boolean; + /** The message, or `undefined` for one on its way. */ + message?: AIMessage; + /** The message on its way, until its copy arrives. */ + pending?: AIPendingMessage; + /** What the person sent with a message on its way, which its copy shows rather than load it again. */ + sentAttachments: AIMessageAttachmentData[]; +}; + +/** The person's message and the replies to it. */ +export type AIConversationTurn = { entries: AIConversationEntry[]; id: string }; + +// A reply the agent has started but that has nothing to show yet. +const showsNothing = (message: AIMessage) => + message.role !== 'user' && + message.text === '' && + !message.parts?.length && + !message.attachments?.length; + +/** Groups the rows into turns, each starting with one of the person's messages. */ +export const turnsOf = (entries: AIConversationEntry[]) => + entries.reduce((turns, entry) => { + const last = turns[turns.length - 1]; + if (!last || entry.isFromUser) turns.push({ entries: [entry], id: entry.id }); + else last.entries.push(entry); + return turns; + }, []); + +/** + * Follows a message the person sent until its copy arrives in the messages, so the copy takes + * over its row (and its key) without a flicker, and tells whether the agent is surely about to + * answer. The counterpart of `AIPendingMessageTracker`. + */ +export class AIPendingMessageTracker { + // The person's messages each pending message's copy cannot be: those there were when it was + // first seen, and those that arrived since without being its copy, such as older ones. + private seen = new Map>(); + // The pending message each copy took over, by the copy's id. + private copies = new Map(); + // The pending message of the last update, whose copy can arrive in the update that stops + // passing it. + private lastPending?: AIPendingMessage; + + // The rows of a conversation: its messages, apart from replies with nothing to show yet, and + // a message on its way until its copy arrives. + entries( + messages: AIMessage[], + pending?: AIPendingMessage | null, + ): AIConversationEntry[] { + let showsPending = !!pending; + // A copy that arrives in the update that stops passing its pending message still takes over + // its row. Only then: a message sent later is not the copy of one that failed. + const awaited = pending ?? this.lastPending; + if (awaited) { + const sent = messages.filter((message) => message.role === 'user'); + const ids = new Set(sent.map((message) => message.id)); + const known = this.seen.get(awaited.id) ?? ids; + const matched = [...this.copies].find( + ([copyId, original]) => original.id === awaited.id && ids.has(copyId), + )?.[0]; + const copy = matched ?? this.copyOf(awaited, sent, known); + if (copy !== undefined) { + if (!this.copies.has(copy)) this.copies.set(copy, awaited); + showsPending = false; + } else { + this.seen.set(awaited.id, new Set([...known, ...ids])); + } + } + this.lastPending = pending ?? undefined; + + const entries: AIConversationEntry[] = messages + .filter((message) => !showsNothing(message)) + .map((message) => { + const taken = this.copies.get(message.id); + return { + id: taken?.id ?? message.id, + isFromUser: message.role === 'user', + isLatestReply: false, + message, + sentAttachments: taken?.attachments ?? [], + }; + }); + if (showsPending && pending) { + entries.push({ + id: pending.id, + isFromUser: true, + isLatestReply: false, + pending, + sentAttachments: [], + }); + } + const last = entries[entries.length - 1]; + if (last && !last.isFromUser) last.isLatestReply = true; + return entries; + } + + // The copy of a message that was on its way: the person's message with its id, when the app + // knew it. Otherwise their first after those known, which older messages loading above cannot + // be. With none of theirs known, it is their latest, when that is the only new one or says the + // same. + private copyOf(pending: AIPendingMessage, sent: AIMessage[], known: Set) { + const ids = sent.map((message) => message.id); + if (ids.includes(pending.id)) return pending.id; + let lastKnown = -1; + ids.forEach((id, index) => { + if (known.has(id)) lastKnown = index; + }); + if (lastKnown >= 0) return ids[lastKnown + 1]; + const fresh = sent.filter((message) => !known.has(message.id)); + const latest = fresh[fresh.length - 1]; + if (!latest || (fresh.length !== 1 && latest.text !== pending.text)) return undefined; + return latest.id; + } + + // Whether the latest message is surely waiting for a reply that shows nothing yet: it is on its + // way, it is the copy of the one on its way, or its reply is being written but shows nothing. + // Call it after `entries`, which matches the copies. + isAwaitingReply(messages: AIMessage[], pending?: AIPendingMessage | null) { + const last = messages[messages.length - 1]; + if (last && last.role !== 'user' && last.isGenerating && showsNothing(last)) + return true; + if (!pending) return false; + const copy = [...this.copies].find(([, original]) => original.id === pending.id)?.[0]; + if (copy === undefined) return true; + return last?.id === copy; + } +} diff --git a/src/plugins/AIComponents/Conversation/fromStreamMessage.ts b/src/plugins/AIComponents/Conversation/fromStreamMessage.ts new file mode 100644 index 000000000..6b4c19ae0 --- /dev/null +++ b/src/plugins/AIComponents/Conversation/fromStreamMessage.ts @@ -0,0 +1,63 @@ +import type { Attachment, LocalMessage, MessageResponse } from 'stream-chat'; + +import type { AIMessageAttachmentData } from '../Message/AIMessageAttachment'; +import { parseMessageParts } from '../MessageParts/parseMessageParts'; +import type { AIMessage } from './types'; + +type StreamMessage = Pick & + Record; + +export type FromStreamMessageOptions = { + /** Whether the agent wrote it. Defaults to the message's `ai_generated` field. */ + isAssistant?: (message: StreamMessage) => boolean; + /** The whole of a reasoning step's thinking, when the app has it, such as from live updates. */ + reasoningText?: (messageId: string, stepId: string) => string | undefined; +}; + +// Images and files only: the steps (`ai_*`) are read as parts, and other kinds are the app's. +const attachmentOf = (attachment: Attachment): AIMessageAttachmentData | undefined => { + const name = attachment.title ?? attachment.fallback ?? undefined; + if (attachment.type === 'image') { + const url = attachment.image_url ?? attachment.asset_url ?? attachment.thumb_url; + return url ? { kind: 'image', mimeType: attachment.mime_type, name, url } : undefined; + } + if (attachment.type === 'file') { + const size = Number(attachment.file_size); + return attachment.asset_url + ? { + kind: 'file', + mimeType: attachment.mime_type, + name, + size: Number.isFinite(size) && size > 0 ? size : undefined, + url: attachment.asset_url, + } + : undefined; + } + return undefined; +}; + +/** + * Adapts a Stream message for `AIConversation`: a reply is one with `ai_generated: true`, it is + * being written while `generating: true`, its steps are its `ai_reasoning` and `ai_tool_call` + * attachments, and its images and files are its other attachments. + */ +export const fromStreamMessage = ( + message: StreamMessage, + { isAssistant, reasoningText }: FromStreamMessageOptions = {}, +): AIMessage => { + const attachments = message.attachments ?? []; + const assistant = isAssistant ? isAssistant(message) : message.ai_generated === true; + return { + attachments: attachments + .map(attachmentOf) + .filter((attachment): attachment is AIMessageAttachmentData => !!attachment), + id: message.id, + isGenerating: message.generating === true, + parts: parseMessageParts(attachments), + reasoningText: reasoningText + ? (stepId: string) => reasoningText(message.id, stepId) + : undefined, + role: assistant ? 'assistant' : 'user', + text: message.text ?? '', + }; +}; diff --git a/src/plugins/AIComponents/Conversation/index.ts b/src/plugins/AIComponents/Conversation/index.ts new file mode 100644 index 000000000..6917067c6 --- /dev/null +++ b/src/plugins/AIComponents/Conversation/index.ts @@ -0,0 +1,4 @@ +export * from './AIConversation'; +export * from './fromStreamMessage'; +export * from './types'; +export type { AIConversationEntry, AIConversationTurn } from './entries'; diff --git a/src/plugins/AIComponents/Conversation/types.ts b/src/plugins/AIComponents/Conversation/types.ts new file mode 100644 index 000000000..965e32279 --- /dev/null +++ b/src/plugins/AIComponents/Conversation/types.ts @@ -0,0 +1,45 @@ +import type { AIMessageAttachmentData } from '../Message/AIMessageAttachment'; +import type { AIMessagePartData } from '../MessageParts/types'; + +/** Who wrote a message: the person using the app (`user`) or the agent (`assistant`). */ +export type AIMessageRole = 'user' | 'assistant' | (string & {}); + +/** + * A message of the conversation as the AI components show it. The counterpart of the + * `AIMessage` protocol in StreamChatAI. `fromStreamMessage` makes one from a Stream message. + */ +export type AIMessage = { + id: string; + /** Who wrote it. Each of the person's messages starts a turn. */ + role: AIMessageRole; + /** What it says. A reply's text is Markdown. */ + text: string; + /** What it carries besides its text, such as images and files. */ + attachments?: AIMessageAttachmentData[]; + /** Whether the agent is still writing it. */ + isGenerating?: boolean; + /** The steps the agent took for it, in order: reasoning, tool calls and kinds of its own. */ + parts?: AIMessagePartData[]; + /** + * The whole of a reasoning step's thinking when the app has it, such as from live updates. + * Without it, the step shows its own excerpt. It is read on every render, so look it up + * rather than work it out. + */ + reasoningText?: (stepId: string) => string | undefined; +}; + +/** + * A message the person sent that is not in the conversation's messages yet. The conversation + * shows it until its copy arrives, and shows the agent thinking meanwhile. + */ +export type AIPendingMessage = { + /** + * The message's id once it arrives, when you know it (such as the id you send it with). Its + * copy is then found by it rather than by where it lands. Keep it the same while it waits. + */ + id: string; + /** What the person wrote. */ + text: string; + /** What they sent with it, such as the photos and files they picked. Its copy shows these too. */ + attachments?: AIMessageAttachmentData[]; +}; diff --git a/src/plugins/AIComponents/Message/AIAssistantMessage.tsx b/src/plugins/AIComponents/Message/AIAssistantMessage.tsx new file mode 100644 index 000000000..c84573675 --- /dev/null +++ b/src/plugins/AIComponents/Message/AIAssistantMessage.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import clsx from 'clsx'; + +import { AIMessageParts } from '../MessageParts/AIMessageParts'; +import type { AIMessagePartData } from '../MessageParts/types'; +import { StreamingMessage } from '../StreamingMessage/StreamingMessage'; +import type { AIToolApprover } from '../ToolCalls/AIToolApprover'; +import { AIMessageAttachment, type AIMessageAttachmentData } from './AIMessageAttachment'; + +export type AIAssistantMessageProps = { + /** The answer, in Markdown. */ + text: string; + /** + * Who answers tool calls' questions and sign-ins on this device, to ask them. Defaults to the + * one of the nearest `AIToolApproverProvider`. + */ + approver?: AIToolApprover; + /** What the agent sent with it, such as images and files. */ + attachments?: AIMessageAttachmentData[]; + className?: string; + /** Whether the agent is still writing it. The text that arrives meanwhile is typed out. */ + isGenerating?: boolean; + /** The steps the agent took, in order, shown before the answer. */ + parts?: AIMessagePartData[]; + /** The whole of a reasoning step's thinking, when the app has it. */ + reasoningText?: (stepId: string) => string | undefined; +}; + +/** + * The agent's reply: its steps (reasoning, tool calls and their questions), then its answer, + * typed out as it streams in, then what it sent with it. The counterpart of + * `AIAssistantMessageView`. + */ +export const AIAssistantMessage = ({ + approver, + attachments = [], + className, + isGenerating = false, + parts = [], + reasoningText, + text, +}: AIAssistantMessageProps) => ( +
+ {parts.length > 0 && ( + + )} + {/* What arrives after mount is typed out; a reply loaded from history shows at once. */} + {text !== '' && } + {attachments.length > 0 && ( +
+ {attachments.map((attachment, index) => ( + + ))} +
+ )} +
+); diff --git a/src/plugins/AIComponents/Message/AIMessageAttachment.tsx b/src/plugins/AIComponents/Message/AIMessageAttachment.tsx new file mode 100644 index 000000000..87ee3742b --- /dev/null +++ b/src/plugins/AIComponents/Message/AIMessageAttachment.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import clsx from 'clsx'; + +import { useTranslationContext } from '../../../context/TranslationContext'; +import { useComponentContextIcons } from '../../../context/useComponentContextIcons'; + +/** + * What a message carries besides its text, such as an image or a file. The counterpart of + * `AIMessageAttachment` in StreamChatAI. + */ +export type AIMessageAttachmentData = { + /** `image` shows a thumbnail; `file` shows its name, type and size. An open set. */ + kind: 'image' | 'file' | (string & {}); + /** Data of its own, such as what an attachment of a kind of your own needs. */ + extraData?: Record; + /** Its media type, such as `application/pdf`. Without it, the type comes from its name or URL. */ + mimeType?: string; + /** Its name, such as the file's. */ + name?: string; + /** Its size, in bytes. */ + size?: number; + /** Where it is: a web URL, or an object or data URL of something the app holds, such as a photo just picked. */ + url?: string; +}; + +const extensionOf = (attachment: AIMessageAttachmentData) => { + for (const candidate of [attachment.name, attachment.url]) { + if (!candidate) continue; + const path = candidate.split(/[?#]/)[0] ?? ''; + const file = path.split('/').pop() ?? ''; + const dot = file.lastIndexOf('.'); + if (dot > 0 && dot < file.length - 1) return file.slice(dot + 1); + } + return attachment.mimeType?.split('/')[1]?.split(/[+;]/)[0]; +}; + +const formatSize = (bytes: number, locale: string) => { + const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte'] as const; + let value = bytes; + let index = 0; + while (value >= 1000 && index < units.length - 1) { + value /= 1000; + index += 1; + } + try { + return new Intl.NumberFormat(locale, { + maximumFractionDigits: index === 0 ? 0 : 1, + style: 'unit', + unit: units[index], + unitDisplay: 'short', + }).format(value); + } catch { + return `${value.toFixed(index === 0 ? 0 : 1)} ${['B', 'KB', 'MB', 'GB'][index]}`; + } +}; + +/** Its type and size, such as "PDF · 2.4 MB". */ +export const attachmentDetails = (attachment: AIMessageAttachmentData, locale = 'en') => + [ + extensionOf(attachment)?.toUpperCase(), + attachment.size !== undefined ? formatSize(attachment.size, locale) : undefined, + ] + .filter(Boolean) + .join(' · '); + +export type AIMessageAttachmentProps = { + attachment: AIMessageAttachmentData; + /** Who sent it, which sets the side it leans to. */ + role?: 'user' | 'assistant' | (string & {}); + className?: string; +}; + +/** + * An image or a file on a message: an image as a thumbnail that opens it, a file with its + * name, type and size. The counterpart of `AIMessageAttachmentView`. + */ +export const AIMessageAttachment = ({ + attachment, + className, + role = 'assistant', +}: AIMessageAttachmentProps) => { + const { t, userLanguage } = useTranslationContext(); + const { IconFile } = useComponentContextIcons(); + const name = + attachment.name || + (attachment.url && !attachment.url.startsWith('data:') + ? attachment.url.split(/[?#]/)[0]?.split('/').pop() + : undefined) || + (attachment.kind === 'image' ? t('Image') : t('File')); + + const classes = clsx( + 'str-chat__ai-message-attachment', + `str-chat__ai-message-attachment--${attachment.kind}`, + `str-chat__ai-message-attachment--${role}`, + className, + ); + + if (attachment.kind === 'image' && attachment.url) { + return ( + + {name} + + ); + } + + const details = attachmentDetails(attachment, userLanguage); + const content = ( + <> + + + + + {name} + {details && ( + {details} + )} + + + ); + + return attachment.url ? ( + + {content} + + ) : ( +
{content}
+ ); +}; diff --git a/src/plugins/AIComponents/Message/AIReplyActions.tsx b/src/plugins/AIComponents/Message/AIReplyActions.tsx new file mode 100644 index 000000000..7e53d3f37 --- /dev/null +++ b/src/plugins/AIComponents/Message/AIReplyActions.tsx @@ -0,0 +1,151 @@ +import React, { useEffect, useRef, useState } from 'react'; +import clsx from 'clsx'; + +import { useTranslationContext } from '../../../context/TranslationContext'; +import { useComponentContextIcons } from '../../../context/useComponentContextIcons'; +import { + IconAIShare, + IconAIThumbsDown, + IconAIThumbsDownFill, + IconAIThumbsUp, + IconAIThumbsUpFill, +} from '../icons'; + +/** How the person rated a reply. */ +export type AIMessageRating = 'positive' | 'negative'; + +export type AIReplyActionsProps = { + /** The reply's text, which copying and sharing hand on. */ + text: string; + className?: string; + /** Rates the reply, or takes the rating back with `null`. Without it, no rating is offered. */ + onRate?: (rating: AIMessageRating | null) => void; + /** Asks the agent for a new reply in its place. Without it, no regenerate button. */ + onRegenerate?: () => void; + /** How the person rated the reply, if they did. */ + rating?: AIMessageRating | null; +}; + +const copyText = async (text: string) => { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + // Clipboard API unavailable (insecure context): fall back to a selection copy. + const area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.style.position = 'fixed'; + area.style.opacity = '0'; + document.body.appendChild(area); + area.select(); + document.execCommand('copy'); + area.remove(); +}; + +/** + * The actions under a finished reply: copy, share (the system share sheet where the browser + * has one, else a copy), regenerate, and a thumbs up or down rating. The counterpart of + * `AIReplyActionsView`. + */ +export const AIReplyActions = ({ + className, + onRate, + onRegenerate, + rating, + text, +}: AIReplyActionsProps) => { + const { t } = useTranslationContext(); + const { IconCheckmark, IconCopy, IconRefresh } = useComponentContextIcons(); + const [isCopied, setIsCopied] = useState(false); + const timer = useRef>(undefined); + useEffect(() => () => clearTimeout(timer.current), []); + + const copy = () => { + copyText(text).then( + () => { + setIsCopied(true); + clearTimeout(timer.current); + timer.current = setTimeout(() => setIsCopied(false), 1500); + }, + () => undefined, + ); + }; + + const share = () => { + if (typeof navigator.share === 'function') { + navigator.share({ text }).catch((error: unknown) => { + // The person closing the share sheet is not a failure. + if (error instanceof Error && error.name === 'AbortError') return; + copy(); + }); + return; + } + copy(); + }; + + const positive = rating === 'positive'; + const negative = rating === 'negative'; + + return ( +
+ + + {onRegenerate && ( + + )} + {onRate && ( + <> + + + + )} +
+ ); +}; diff --git a/src/plugins/AIComponents/Message/AITypingIndicator.tsx b/src/plugins/AIComponents/Message/AITypingIndicator.tsx new file mode 100644 index 000000000..4673427eb --- /dev/null +++ b/src/plugins/AIComponents/Message/AITypingIndicator.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import clsx from 'clsx'; + +export type AITypingIndicatorProps = { + /** What the agent is doing, such as "Working on your calendar". */ + text: string; + className?: string; +}; + +/** + * A label followed by three pulsing dots, for what the agent is doing while its reply shows + * nothing new. The counterpart of `AITypingIndicatorView`. + */ +export const AITypingIndicator = ({ className, text }: AITypingIndicatorProps) => ( +
+ {text} + + + + + +
+); diff --git a/src/plugins/AIComponents/Message/AIUserMessage.tsx b/src/plugins/AIComponents/Message/AIUserMessage.tsx new file mode 100644 index 000000000..517447d6b --- /dev/null +++ b/src/plugins/AIComponents/Message/AIUserMessage.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import clsx from 'clsx'; + +import { useTranslationContext } from '../../../context/TranslationContext'; +import { useComponentContextIcons } from '../../../context/useComponentContextIcons'; +import { AIMessageAttachment, type AIMessageAttachmentData } from './AIMessageAttachment'; + +export type AIUserMessageProps = { + /** What the person wrote. */ + text: string; + /** What they sent with it, such as images and files. */ + attachments?: AIMessageAttachmentData[]; + className?: string; + /** Edits the message, such as by putting its text back in the composer. Without it, no edit button. */ + onEdit?: () => void; +}; + +/** + * The person's message: its attachments, then its text in a bubble on the trailing side. The + * counterpart of `AIUserMessageView`. + */ +export const AIUserMessage = ({ + attachments = [], + className, + onEdit, + text, +}: AIUserMessageProps) => { + const { t } = useTranslationContext(); + const { IconEdit } = useComponentContextIcons(); + return ( +
+ {attachments.length > 0 && ( +
+ {attachments.map((attachment, index) => ( + + ))} +
+ )} + {text !== '' && ( +
+ {onEdit && ( + + )} +
{text}
+
+ )} +
+ ); +}; diff --git a/src/plugins/AIComponents/Message/index.ts b/src/plugins/AIComponents/Message/index.ts new file mode 100644 index 000000000..aa6186b39 --- /dev/null +++ b/src/plugins/AIComponents/Message/index.ts @@ -0,0 +1,5 @@ +export * from './AIAssistantMessage'; +export * from './AIMessageAttachment'; +export * from './AIReplyActions'; +export * from './AITypingIndicator'; +export * from './AIUserMessage'; diff --git a/src/plugins/AIComponents/MessageParts/AIMessageParts.tsx b/src/plugins/AIComponents/MessageParts/AIMessageParts.tsx new file mode 100644 index 000000000..d2959902f --- /dev/null +++ b/src/plugins/AIComponents/MessageParts/AIMessageParts.tsx @@ -0,0 +1,79 @@ +import React, { type ReactNode } from 'react'; +import clsx from 'clsx'; + +import { useTranslationContext } from '../../../context/TranslationContext'; +import { useComponentContextIcons } from '../../../context/useComponentContextIcons'; +import { AIReasoning } from '../Reasoning/AIReasoning'; +import type { AIToolApprover } from '../ToolCalls/AIToolApprover'; +import { AIToolApproval } from './AIToolApproval'; +import { AIToolAuthorization } from './AIToolAuthorization'; +import { AIToolCall } from './AIToolCall'; +import type { AIMessagePartData } from './types'; + +export type AIMessagePartProps = { + part: AIMessagePartData; + /** + * Who answers tool calls' questions and sign-ins on this device, to ask them. Defaults to the + * one of the nearest `AIToolApproverProvider`. + */ + approver?: AIToolApprover; + /** The whole of a reasoning step's thinking, when the app has it. */ + reasoningText?: (stepId: string) => string | undefined; +}; + +/** + * One step of a reply: a round of reasoning (`AIReasoning`), a tool call (`AIToolCall`, with + * its question or sign-in when it waits for this person here), or a neutral line for a step + * this SDK can't show. The counterpart of `AIMessagePartView`. + */ +export const AIMessagePart = ({ approver, part, reasoningText }: AIMessagePartProps) => { + const { t } = useTranslationContext(); + const { IconInfo } = useComponentContextIcons(); + + if (part.reasoning) { + return ; + } + if (part.toolCall) { + return ( +
+ + + +
+ ); + } + return ( +
+ + {t("A step this app version can't show")} +
+ ); +}; + +export type AIMessagePartsProps = Omit & { + parts: AIMessagePartData[]; + /** Renders a step, instead of `AIMessagePart`. */ + children?: (part: AIMessagePartData) => ReactNode; + className?: string; +}; + +/** The steps of a reply, in order. The counterpart of `AIMessagePartsView`. */ +export const AIMessageParts = ({ + approver, + children, + className, + parts, + reasoningText, +}: AIMessagePartsProps) => ( +
+ {parts.map((part) => ( +
+ {children ? ( + children(part) + ) : ( + + )} +
+ ))} +
+); diff --git a/src/plugins/AIComponents/MessageParts/AIToolApproval.tsx b/src/plugins/AIComponents/MessageParts/AIToolApproval.tsx new file mode 100644 index 000000000..d3cbab273 --- /dev/null +++ b/src/plugins/AIComponents/MessageParts/AIToolApproval.tsx @@ -0,0 +1,158 @@ +import React, { type ReactNode, useEffect, useRef, useState } from 'react'; +import clsx from 'clsx'; + +import { useTranslationContext } from '../../../context/TranslationContext'; +import { useComponentContextIcons } from '../../../context/useComponentContextIcons'; +import { type AIToolApprover, useAIToolApprover } from '../ToolCalls/AIToolApprover'; +import { isAwaitingApproval } from './parseMessageParts'; +import type { AIToolApprovalData, AIToolCallPart } from './types'; + +/** Where the person's answer to a tool call's question is. */ +export type AIToolApprovalState = { + /** The answer couldn't be sent, and the person can answer again. */ + failed: boolean; + /** The answer is on its way, or arrived and the call's step hasn't changed yet. */ + isSending: boolean; +}; + +const idle: AIToolApprovalState = { failed: false, isSending: false }; + +/** The agent's reason, as a sentence, then what allowing it shares. */ +export const approvalLines = (approval: AIToolApprovalData) => { + const lines: string[] = []; + const reason = approval.reason?.trim(); + if (reason) { + const sentence = reason[0].toUpperCase() + reason.slice(1); + lines.push(/[.!?…]$/.test(sentence) ? sentence : `${sentence}.`); + } + if (approval.message) lines.push(approval.message); + return lines; +}; + +export type AIToolApprovalCardProps = { + approval: AIToolApprovalData; + /** Called with the person's answer. */ + onDecide: (allowed: boolean) => void; + className?: string; + state?: AIToolApprovalState; +}; + +/** + * Asks the person a tool call's question, such as "Share your location?", with the agent's + * reason and what allowing it shares, and an Allow and a Don't Allow button. Used inline under + * a step by `AIToolApproval`, and on its own, such as on a voice call. The counterpart of + * `AIToolApprovalCard`. + */ +export const AIToolApprovalCard = ({ + approval, + className, + onDecide, + state = idle, +}: AIToolApprovalCardProps) => { + const { t } = useTranslationContext(); + const { IconLoading } = useComponentContextIcons(); + return ( +
+
+
{approval.title}
+ {approvalLines(approval).map((line) => ( +
+ {line} +
+ ))} +
+
+ + + {state.isSending && ( + + + + )} +
+ {state.failed && ( +
+ {t("Your answer couldn't be sent. Try again.")} +
+ )} +
+ ); +}; + +export type AIToolApprovalProps = { + part: AIToolCallPart; + /** Who answers on this device. Defaults to the one of the nearest `AIToolApproverProvider`. */ + approver?: AIToolApprover; + /** Renders the question, instead of `AIToolApprovalCard`. */ + children?: ( + approval: AIToolApprovalData, + state: AIToolApprovalState, + decide: (allowed: boolean) => void, + ) => ReactNode; +}; + +/** + * Asks the question of a tool call that waits for this person on this device, and sends their + * answer with `approver.decide`. Until the call's step changes the buttons wait; a failed send + * lets them answer again. Renders nothing for a call that does not wait for them here. The + * counterpart of `AIToolApprovalView`. + */ +export const AIToolApproval = ({ + approver: approverProp, + children, + part, +}: AIToolApprovalProps) => { + const approver = useAIToolApprover(approverProp); + const [state, setState] = useState(idle); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + if ( + !approver || + !part.approval || + !isAwaitingApproval(part, approver.userId, approver.clientId) + ) { + return null; + } + + const decide = (allowed: boolean) => { + if (state.isSending) return; + setState({ failed: false, isSending: true }); + approver.decide(part, allowed).catch(() => { + if (mounted.current) setState({ failed: true, isSending: false }); + }); + }; + + return ( + <> + {children ? ( + children(part.approval, state, decide) + ) : ( + + )} + + ); +}; diff --git a/src/plugins/AIComponents/MessageParts/AIToolAuthorization.tsx b/src/plugins/AIComponents/MessageParts/AIToolAuthorization.tsx new file mode 100644 index 000000000..5dc0750f7 --- /dev/null +++ b/src/plugins/AIComponents/MessageParts/AIToolAuthorization.tsx @@ -0,0 +1,248 @@ +import React, { type ReactNode, useEffect, useRef, useState } from 'react'; +import clsx from 'clsx'; + +import { useTranslationContext } from '../../../context/TranslationContext'; +import { useComponentContextIcons } from '../../../context/useComponentContextIcons'; +import { + type AIToolApprover, + type AIToolAuthorizer, + useAIToolApprover, +} from '../ToolCalls/AIToolApprover'; +import { AIConnectorSignInError } from '../ToolCalls/popupAuthorizer'; +import { useNow } from '../utils'; +import { isAuthorizationExpired, isAwaitingAuthorization } from './parseMessageParts'; +import type { AIToolAuthorizationData, AIToolCallPart } from './types'; + +/** Where the person's answer to a sign-in is. */ +export type AIToolAuthorizationState = { + /** The sign-in couldn't be opened, and the person can try again. */ + connectFailed: boolean; + /** Their "not now" couldn't be sent, and the person can answer again. */ + declineFailed: boolean; + /** The sign-in is open, or done and the call's step hasn't changed yet. */ + isConnecting: boolean; + /** Their "not now" is on its way, or arrived and the call's step hasn't changed yet. */ + isDeclining: boolean; + /** The browser blocked the sign-in's popup. */ + popupBlocked?: boolean; +}; + +const idle: AIToolAuthorizationState = { + connectFailed: false, + declineFailed: false, + isConnecting: false, + isDeclining: false, +}; + +export type AIToolAuthorizationCardProps = { + authorization: AIToolAuthorizationData; + onConnect: () => void; + onDecline: () => void; + className?: string; + state?: AIToolAuthorizationState; +}; + +/** + * Asks the person to sign in at a provider a tool call needs, such as "Connect Linear", with a + * Connect and a Not now button. Once the sign-in is over it says how it ended (connected, + * declined, expired or failed), and a sign-in past its expiry can no longer be opened. The + * counterpart of `AIToolAuthorizationCard`. + */ +export const AIToolAuthorizationCard = ({ + authorization, + className, + onConnect, + onDecline, + state = idle, +}: AIToolAuthorizationCardProps) => { + const { t } = useTranslationContext(); + const { IconLoading } = useComponentContextIcons(); + const open = authorization.status === undefined; + // Re-checked every few seconds while it is open, so it turns expired on its own. + const now = useNow(open && authorization.expiresAt !== undefined, 5000); + const expired = isAuthorizationExpired(authorization, now); + const provider = authorization.providerName ?? authorization.provider; + const busy = state.isConnecting || state.isDeclining; + + const outcome = (() => { + if (expired) return t('This sign-in has expired.'); + switch (authorization.status) { + case undefined: + return undefined; + case 'connected': + return t('Connected to {{ provider }}', { provider }); + case 'declined': + return t('Not connected'); + default: + return t("The sign-in didn't complete."); + } + })(); + + const failure = (() => { + if (state.popupBlocked) + return t('Your browser blocked the sign-in window. Allow pop-ups and try again.'); + if (state.connectFailed) return t("The sign-in couldn't be opened. Try again."); + if (state.declineFailed) return t("Your answer couldn't be sent. Try again."); + return undefined; + })(); + + return ( +
+
+
+ {authorization.title} +
+ {authorization.message && ( +
+ {authorization.message} +
+ )} +
+ {outcome && ( +
+ {outcome} +
+ )} + {open && ( +
+ {!expired && ( + + )} + {/* Left enabled while the sign-in is open, so one that never comes back leaves a way out. */} + + {busy && ( + + + + )} +
+ )} + {failure && ( +
+ {failure} +
+ )} +
+ ); +}; + +/** + * The sign-in this person is asked for on `call` here: one the call waits for them to finish, + * of a kind this device opens, and only when they can sign in on it. + */ +export const signInFor = (call: AIToolCallPart, approver: AIToolApprover | undefined) => { + if (!approver?.authorizer || !isAwaitingAuthorization(call, approver.userId)) return; + return call.authorization?.type === 'oauth' ? call.authorization : undefined; +}; + +export type AIToolAuthorizationProps = { + part: AIToolCallPart; + /** Who answers on this device, with the `authorizer` the sign-in needs. Defaults to the one of the nearest `AIToolApproverProvider`. */ + approver?: AIToolApprover; + /** Renders the sign-in, instead of `AIToolAuthorizationCard`. */ + children?: ( + authorization: AIToolAuthorizationData, + state: AIToolAuthorizationState, + actions: { connect: () => void; decline: () => void }, + ) => ReactNode; +}; + +/** + * Asks for the sign-in of a tool call that waits for this person, and runs it with + * `approver.authorizer`. Renders nothing for a call that does not wait for them, or without an + * authorizer. The counterpart of `AIToolAuthorizationView`. + */ +export const AIToolAuthorization = ({ + approver: approverProp, + children, + part, +}: AIToolAuthorizationProps) => { + const approver = useAIToolApprover(approverProp); + const [state, setState] = useState(idle); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const authorization = signInFor(part, approver); + const authorizer = approver?.authorizer; + if (!authorization || !authorizer) return null; + + const update = (next: AIToolAuthorizationState) => { + if (mounted.current) setState(next); + }; + + const connect = (signIn: AIToolAuthorizer) => { + if (state.isConnecting || state.isDeclining) return; + setState({ ...idle, isConnecting: true }); + // Called in the click itself: a popup opened before anything is awaited is not blocked. + signIn.connect(part).then( + // Your backend has the provider's answer before the popup comes back, and its change to + // the step hides this. Until then it says it is connecting, unless the person closed the + // popup or the provider did not connect them. + (connected) => { + if (!connected) update(idle); + }, + (error: unknown) => + update({ + ...idle, + connectFailed: true, + popupBlocked: + error instanceof AIConnectorSignInError && error.reason === 'blocked', + }), + ); + }; + + const decline = (signIn: AIToolAuthorizer) => { + if (state.isDeclining) return; + setState({ ...idle, isDeclining: true }); + signIn.decline(part).catch(() => update({ ...idle, declineFailed: true })); + }; + + const actions = { + connect: () => connect(authorizer), + decline: () => decline(authorizer), + }; + + return ( + <> + {children ? ( + children(authorization, state, actions) + ) : ( + + )} + + ); +}; diff --git a/src/plugins/AIComponents/MessageParts/AIToolCall.tsx b/src/plugins/AIComponents/MessageParts/AIToolCall.tsx new file mode 100644 index 000000000..60aa592a7 --- /dev/null +++ b/src/plugins/AIComponents/MessageParts/AIToolCall.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import clsx from 'clsx'; + +import { useTranslationContext } from '../../../context/TranslationContext'; +import { useComponentContextIcons } from '../../../context/useComponentContextIcons'; +import { IconAIDevice, IconAIHandRaised, IconAIKey } from '../icons'; +import { formatCallDuration } from '../utils'; +import { isDeclined, isToolCallFinished } from './parseMessageParts'; +import type { AIToolCallPart } from './types'; + +export type AIToolCallProps = { + part: AIToolCallPart; + className?: string; +}; + +/** + * One tool call of a reply: what it is doing (its `displayTitle`, else its name), how it is + * going, and how long it took once it is done. The counterpart of `AIToolCallView`. + */ +export const AIToolCall = ({ className, part }: AIToolCallProps) => { + const { t, userLanguage } = useTranslationContext(); + const { IconCheckmark, IconExclamationCircleFill, IconLoading, IconMinusCircle } = + useComponentContextIcons(); + + const title = part.displayTitle ?? part.name.replace(/_/g, ' '); + const detail = (() => { + if (isDeclined(part)) return part.summary ?? t('Declined'); + switch (part.status) { + case 'awaiting_approval': + return part.summary ?? t('Waiting for approval'); + case 'awaiting_authorization': + return part.summary ?? t('Waiting for you to sign in'); + case 'awaiting_client': + return part.summary ?? t('Waiting for the device'); + case 'failed': + return part.summary ?? t("Didn't complete"); + case 'cancelled': + return part.summary ?? t('Cancelled'); + default: + return part.summary; + } + })(); + + // A status this SDK doesn't know reads as still in progress. + const icon = (() => { + switch (part.status) { + case 'awaiting_approval': + return ; + case 'awaiting_authorization': + return ; + case 'awaiting_client': + return ; + case 'completed': + return ; + case 'failed': + return ; + case 'cancelled': + return ; + default: + return ; + } + })(); + + const finished = isToolCallFinished(part.status); + const waiting = part.status.startsWith('awaiting_'); + + return ( +
+ + {icon} + + + {title} + {detail && {detail}} + + {finished && part.durationMs !== undefined && ( + + {formatCallDuration(part.durationMs / 1000, userLanguage)} + + )} +
+ ); +}; diff --git a/src/plugins/AIComponents/MessageParts/index.ts b/src/plugins/AIComponents/MessageParts/index.ts new file mode 100644 index 000000000..38f552b89 --- /dev/null +++ b/src/plugins/AIComponents/MessageParts/index.ts @@ -0,0 +1,6 @@ +export * from './types'; +export * from './parseMessageParts'; +export * from './AIMessageParts'; +export * from './AIToolApproval'; +export * from './AIToolAuthorization'; +export * from './AIToolCall'; diff --git a/src/plugins/AIComponents/MessageParts/parseMessageParts.ts b/src/plugins/AIComponents/MessageParts/parseMessageParts.ts new file mode 100644 index 000000000..c6e615f0e --- /dev/null +++ b/src/plugins/AIComponents/MessageParts/parseMessageParts.ts @@ -0,0 +1,215 @@ +import type { + AIMessagePartData, + AIReasoningPart, + AIToolApprovalData, + AIToolAuthorizationData, + AIToolCallPart, + AIToolCallStatus, +} from './types'; + +/** The attachment type prefix every AI step uses. */ +export const AI_MESSAGE_PART_TYPE_PREFIX = 'ai_'; +/** The newest format version of the built-in kinds this SDK understands. */ +export const AI_MESSAGE_PART_SUPPORTED_VERSION = 1; + +type JSONObject = Record; + +// Lenient reads: a field of the wrong type reads as missing, and an empty string as absent. +const isObject = (value: unknown): value is JSONObject => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const string = (object: JSONObject, key: string) => { + const value = object[key]; + return typeof value === 'string' && value !== '' ? value : undefined; +}; + +const int = (object: JSONObject, key: string) => { + const value = object[key]; + return typeof value === 'number' && Number.isFinite(value) + ? Math.trunc(value) + : undefined; +}; + +const date = (object: JSONObject, key: string) => { + const value = string(object, key); + if (value === undefined) return undefined; + const time = Date.parse(value); + return Number.isNaN(time) ? undefined : time; +}; + +const parseApproval = (object: JSONObject): AIToolApprovalData | undefined => { + // A question with no title asks nothing. + const title = string(object, 'title'); + if (!title) return undefined; + return { + allowTitle: string(object, 'allow_title'), + decision: string(object, 'decision'), + declineTitle: string(object, 'decline_title'), + message: string(object, 'message'), + reason: string(object, 'reason'), + title, + }; +}; + +const isWebURL = (value: string) => { + try { + const url = new URL(value); + return (url.protocol === 'https:' || url.protocol === 'http:') && url.host !== ''; + } catch { + return false; + } +}; + +const parseAuthorization = (object: JSONObject): AIToolAuthorizationData | undefined => { + // A sign-in with no title, or no web page to open, asks nothing. + const title = string(object, 'title'); + const url = string(object, 'url'); + if (!title || !url || !isWebURL(url)) return undefined; + return { + expiresAt: date(object, 'expires_at'), + handoffToken: string(object, 'handoff_token'), + message: string(object, 'message'), + provider: string(object, 'provider') ?? '', + providerName: string(object, 'provider_name'), + status: string(object, 'status'), + title, + type: string(object, 'type') ?? 'oauth', + url, + }; +}; + +const parseReasoning = (id: string, object: JSONObject): AIReasoningPart => ({ + durationMs: int(object, 'duration_ms'), + id, + preview: string(object, 'preview'), + status: string(object, 'status') ?? 'completed', + summary: string(object, 'summary'), +}); + +const parseToolCall = (id: string, object: JSONObject): AIToolCallPart => { + const approval = object.approval; + const authorization = object.authorization; + const args = object.arguments; + return { + approval: isObject(approval) ? parseApproval(approval) : undefined, + arguments: args === null ? undefined : args, + authorization: isObject(authorization) + ? parseAuthorization(authorization) + : undefined, + displayTitle: string(object, 'display_title'), + durationMs: int(object, 'duration_ms'), + executor: string(object, 'executor') ?? 'server', + id, + name: string(object, 'name') ?? '', + status: string(object, 'status') ?? 'running', + summary: string(object, 'summary'), + targetClientId: string(object, 'target_client_id'), + targetUserId: string(object, 'target_user_id'), + }; +}; + +/** + * Decodes one attachment, or returns `undefined` when it is not an AI step. + * + * @param attachment The attachment, as it is on the message (with its `type`). + * @param position The attachment's index, used as the identity of a step with no ID of its own. + */ +export const parseMessagePart = ( + attachment: unknown, + position = 0, +): AIMessagePartData | undefined => { + if (!isObject(attachment)) return undefined; + const type = attachment.type; + if (typeof type !== 'string' || !type.startsWith(AI_MESSAGE_PART_TYPE_PREFIX)) { + return undefined; + } + const id = string(attachment, 'id') ?? `${type}-${position}`; + const version = int(attachment, 'v') ?? 1; + const known = version <= AI_MESSAGE_PART_SUPPORTED_VERSION; + return { + id, + kind: type, + payload: attachment, + reasoning: + known && type === 'ai_reasoning' ? parseReasoning(id, attachment) : undefined, + toolCall: + known && type === 'ai_tool_call' ? parseToolCall(id, attachment) : undefined, + version, + }; +}; + +/** + * Decodes the AI steps among a message's attachments, in order. Attachments that are not AI + * steps (images, files and so on) are skipped. Decoding is lenient: missing fields get + * defaults, and a step newer than this SDK understands keeps its payload but has no typed + * view. + * + * ```ts + * const parts = parseMessageParts(message.attachments); + * ``` + */ +export const parseMessageParts = (attachments: readonly unknown[] | null | undefined) => + (attachments ?? []).reduce((parts, attachment, position) => { + const part = parseMessagePart(attachment, position); + if (part) parts.push(part); + return parts; + }, []); + +/** Whether this SDK has a typed view of the step. */ +export const isSupportedPart = (part: AIMessagePartData) => + part.reasoning !== undefined || part.toolCall !== undefined; + +/** Whether the call has finished, one way or another. */ +export const isToolCallFinished = (status: AIToolCallStatus) => + status === 'completed' || status === 'failed' || status === 'cancelled'; + +/** + * Whether the person declined the call, or the sign-in it waited for, so it never ran. + */ +export const isDeclined = (call: AIToolCallPart) => + call.approval?.decision === 'declined' || call.authorization?.status === 'declined'; + +/** + * Whether this call is waiting for this device: a client tool, still awaiting its result, + * targeted at this person and this install. + */ +export const isAwaitingClient = ( + call: AIToolCallPart, + userId: string, + clientId: string, +) => + call.executor === 'client' && + call.status === 'awaiting_client' && + call.targetUserId === userId && + call.targetClientId === clientId; + +/** + * Whether this call is waiting for this person to allow it, on this device: still awaiting + * approval, targeted at this person, and at this install when it names one (a client tool + * does; a server tool's question may be answered from any of their devices). + */ +export const isAwaitingApproval = ( + call: AIToolCallPart, + userId: string, + clientId: string, +) => + call.status === 'awaiting_approval' && + call.approval !== undefined && + call.targetUserId === userId && + (call.targetClientId === undefined || call.targetClientId === clientId); + +/** Whether this call is waiting for this person to sign in, from any of their devices. */ +export const isAwaitingAuthorization = (call: AIToolCallPart, userId: string) => + call.status === 'awaiting_authorization' && + call.authorization !== undefined && + call.targetUserId === userId; + +/** Whether a sign-in that is still asked for has passed its `expiresAt`. */ +export const isAuthorizationExpired = ( + authorization: AIToolAuthorizationData, + now = Date.now(), +) => + authorization.status === 'expired' || + (authorization.status === undefined && + authorization.expiresAt !== undefined && + authorization.expiresAt <= now); diff --git a/src/plugins/AIComponents/MessageParts/types.ts b/src/plugins/AIComponents/MessageParts/types.ts new file mode 100644 index 000000000..d1be0d40f --- /dev/null +++ b/src/plugins/AIComponents/MessageParts/types.ts @@ -0,0 +1,160 @@ +/** + * The steps an AI agent took while replying, such as a round of reasoning or a tool call. + * + * The agent writes each step as a custom attachment on its reply (`ai_reasoning`, + * `ai_tool_call`), and the order of the attachments is the order of the steps. The final + * answer stays in the message text and is shown after them. The web counterpart of + * `AIMessagePart` in StreamChatAI (iOS) and stream-chat-android-ai-compose. + * + * The kinds, statuses and decisions are open sets: compare against the values you know and + * give the rest a fallback. A step newer than this SDK understands keeps its payload but has + * no typed view. + */ + +// Keeps autocompletion for the known values while accepting any string. +type Open = T | (string & {}); + +export type AIMessagePartKind = Open<'ai_reasoning' | 'ai_tool_call'>; + +/** Where a round of reasoning is. Defaults to `completed` when the agent sends none. */ +export type AIReasoningStatus = Open<'streaming' | 'completed'>; + +/** A round of the model's reasoning (`ai_reasoning`). */ +export type AIReasoningPart = { + id: string; + status: AIReasoningStatus; + /** How long the model thought, in milliseconds. */ + durationMs?: number; + /** + * A capped excerpt: the latest thoughts while streaming, the opening once done. The full + * reasoning, when an app has it, arrives separately. + */ + preview?: string; + /** A one-line summary of the reasoning, once it is done. */ + summary?: string; +}; + +/** + * Where a tool call is. Treat a status this SDK doesn't know as still in progress. + * + * - `running`: the agent's backend or a device is running it. + * - `awaiting_client`: waiting for the targeted device to run the tool and send its result. + * - `awaiting_approval`: waiting for the targeted person to allow or decline it. + * - `awaiting_authorization`: waiting for the targeted person to sign in at a provider. + * - `completed`, `failed`, `cancelled`: finished, one way or another. + */ +export type AIToolCallStatus = Open< + | 'running' + | 'awaiting_client' + | 'awaiting_approval' + | 'awaiting_authorization' + | 'completed' + | 'failed' + | 'cancelled' +>; + +/** Who runs a tool: the agent's backend (`server`) or a person's device (`client`). */ +export type AIToolExecutor = Open<'server' | 'client'>; + +export type AIToolApprovalDecision = Open<'allowed' | 'declined'>; + +/** + * What a tool call asks the person it waits for before it runs, such as "Share your + * location?", and how they answered. Allowed, the call goes on; declined, it is cancelled + * and never runs. + */ +export type AIToolApprovalData = { + /** The question, such as "Share your location?". */ + title: string; + /** The label of the button that allows the call. Defaults to "Allow" when the agent sends none. */ + allowTitle?: string; + /** How the person answered, once they did. */ + decision?: AIToolApprovalDecision; + /** The label of the button that declines the call. Defaults to "Don't Allow". */ + declineTitle?: string; + /** What allowing it shares or does, such as "Only your city is shared." */ + message?: string; + /** The agent's own words for why it wants the call, such as "to check the local weather". */ + reason?: string; +}; + +/** How a person signs in: `oauth` is a consent at the provider, in a browser. */ +export type AIToolAuthorizationType = Open<'oauth'>; + +/** + * How a sign-in ended. `failed` is one the provider or the agent's backend ended without + * connecting, such as when the person came back as another account. + */ +export type AIToolAuthorizationStatus = Open< + 'connected' | 'declined' | 'expired' | 'failed' +>; + +/** + * A sign-in a tool call waits for, such as "Connect Linear", and how it ended. It holds no + * credential: `url` is the agent's own sign-in page, and the provider hands what it grants to + * the agent's backend, never to the app. + */ +export type AIToolAuthorizationData = { + /** The provider, such as `linear`. */ + provider: string; + /** The question, such as "Connect Linear". */ + title: string; + type: AIToolAuthorizationType; + /** The sign-in page to open (the router's launch page). An `http(s)` URL. */ + url: string; + /** When the sign-in ends, in milliseconds since the epoch. */ + expiresAt?: number; + /** + * What binds the sign-in to the first browser that opens it. The launch page asks its opener + * for it. Gone once the sign-in is over. + */ + handoffToken?: string; + /** What signing in means for the person. */ + message?: string; + /** What people call the provider, such as "Linear". */ + providerName?: string; + /** How it ended, once it did. */ + status?: AIToolAuthorizationStatus; +}; + +/** A tool the agent called, run by the agent's backend or by a person's device. */ +export type AIToolCallPart = { + /** The model provider's tool-call ID. A device's result is matched against it. */ + id: string; + name: string; + /** Defaults to `server` when the agent sends none. */ + executor: AIToolExecutor; + /** Defaults to `running` when the agent sends none. */ + status: AIToolCallStatus; + /** What the call asks the person it waits for, and how they answered, when its tool asks first. */ + approval?: AIToolApprovalData; + /** The call's arguments, present for client tools, which need them to run. */ + arguments?: unknown; + /** The sign-in the call waits for, and how it ended, when it needs the person's account at a provider. */ + authorization?: AIToolAuthorizationData; + /** What the call is doing, in words for people, such as "Checking your location". */ + displayTitle?: string; + durationMs?: number; + /** A short, shareable outcome, such as "Found your location". */ + summary?: string; + /** The install that must run a client tool (see `AIClientIdentity.installId()`). */ + targetClientId?: string; + /** The person the call waits for. */ + targetUserId?: string; +}; + +/** One step of a reply, decoded from one `ai_*` attachment. */ +export type AIMessagePartData = { + /** The step's stable identity, for keys and animation. Tool calls use the provider's tool-call ID. */ + id: string; + /** The attachment type, such as `ai_reasoning`. */ + kind: AIMessagePartKind; + /** The attachment as it came, for kinds of your own or fields this SDK does not read. */ + payload: Record; + /** The step's format version (`v`). It changes only when a kind changes incompatibly. */ + version: number; + /** The step as a round of reasoning, when it is one this SDK can read. */ + reasoning?: AIReasoningPart; + /** The step as a tool call, when it is one this SDK can read. */ + toolCall?: AIToolCallPart; +}; diff --git a/src/plugins/AIComponents/README.md b/src/plugins/AIComponents/README.md index 7c8ac9529..918b1dd03 100644 --- a/src/plugins/AIComponents/README.md +++ b/src/plugins/AIComponents/README.md @@ -29,12 +29,145 @@ Load `ai-components.css` in addition to `stream-chat-react/css/index.css`. From `stream-chat-react/ai-components`: -- Components: `AIMarkdown`, `AIMessageComposer` (with the static sub-components `FileInput`, `TextInput`, `SpeechToTextButton`, `SubmitButton`, `ModelSelect`, `AttachmentPreview`, `AttachmentPreview.Item`), `StreamingMessage`, `AIStateIndicator`, `VoiceWave`, `VoiceGlow` -- Hooks: `useAttachments`, `useText`, `useIsDisabled`, `useAIMessageComposerContext`, `useSpeechToText`, `useAudioLevel`, `useHeardText` -- Types: `AIMessageComposerStore`, `StreamingMessageRef`, `UseMessageTextStreamingProps`, `ToolComponentProps`, `UseSpeechToTextOptions`, `VoiceAgentState`, `VoiceGlowRendering`, `VoiceVisualizationProps`, `VoiceWaveProps`, `VoiceGlowProps`, `HeardTextProgress`, `HeardTextFunction`, `HeardTextSubscribe`, `UseHeardTextParams`, `UseHeardTextResult` +- Components: `AIMarkdown`, `AIMessageComposer` (with the static sub-components `FileInput`, `TextInput`, `SpeechToTextButton`, `SubmitButton`, `ModelSelect`, `ChatOptions`, `AttachmentPreview`, `AttachmentPreview.Item`), `StreamingMessage`, `AIStateIndicator`, `VoiceWave`, `VoiceGlow` +- Agent conversations (see [Agent conversations](#agent-conversations)): `AIConversation`, `AIUserMessage`, `AIAssistantMessage`, `AIMessageAttachment`, `AIReplyActions`, `AITypingIndicator`, `AISuggestions`, `AIMessageParts`, `AIMessagePart`, `AIToolCall`, `AIToolApproval`, `AIToolApprovalCard`, `AIToolAuthorization`, `AIToolAuthorizationCard`, `AIReasoning`, `AIThinking`, `AIToolApproverProvider`, `ChatOptionsList`, `SelectedChatOptionChip` +- Functions: `parseMessageParts`, `parseMessagePart`, `isAwaitingApproval`, `isAwaitingAuthorization`, `isAwaitingClient`, `isDeclined`, `isToolCallFinished`, `isSupportedPart`, `isAuthorizationExpired`, `fromStreamMessage`, `createPopupAuthorizer`, `completeConnectorSignIn`, `readConnectorSignInResult`, `AIClientIdentity.installId()` +- Hooks: `useAttachments`, `useText`, `useIsDisabled`, `useAIMessageComposerContext`, `useSpeechToText`, `useAudioLevel`, `useHeardText`, `useAIToolApprover` +- Types: `AIMessageComposerStore`, `StreamingMessageRef`, `UseMessageTextStreamingProps`, `ToolComponentProps`, `UseSpeechToTextOptions`, `VoiceAgentState`, `VoiceGlowRendering`, `VoiceVisualizationProps`, `VoiceWaveProps`, `VoiceGlowProps`, `HeardTextProgress`, `HeardTextFunction`, `HeardTextSubscribe`, `UseHeardTextParams`, `UseHeardTextResult`, `AIMessage`, `AIMessageRole`, `AIPendingMessage`, `AIMessagePartData`, `AIReasoningPart`, `AIToolCallPart`, `AIToolApprovalData`, `AIToolAuthorizationData`, `AIToolApprover`, `AIToolAuthorizer`, `AIToolApprovalState`, `AIToolAuthorizationState`, `AIMessageAttachmentData`, `AIMessageRating`, `AIChatOption`, `PopupAuthorizerOptions`, `ConnectorSignInResult` `useMessageTextStreaming` is not exported from here; import it from the main entry point (`stream-chat-react`). +## Agent conversations + +The components of a conversation with an AI agent, such as a personal assistant, that mirror StreamChatAI (iOS) and `stream-chat-android-ai-compose` (Android): the same names, the same message parts, and the same behaviour, in React idiom. A web app built on them renders the same conversations as the iOS and Android apps. + +> Until the `stream-chat-react` 14.x release that includes them is published, build the package from the `release-v14` branch and install the tarball: `yarn install && yarn build && npm pack`, then `npm install ./stream-chat-react-0.0.0-development.tgz` in the app. Pin the release once it ships. + +### Message parts + +An agent writes each step it takes while replying as a custom attachment on its reply, in order: a round of reasoning (`ai_reasoning`) or a tool call (`ai_tool_call`), at format version `v: 1`. The answer stays in the message text, shown after the steps. + +```ts +import { parseMessageParts } from 'stream-chat-react/ai-components'; + +const parts = parseMessageParts(message.attachments); +for (const part of parts) { + if (part.reasoning) { + /* part.reasoning: { id, status, summary?, preview?, durationMs? } */ + } else if (part.toolCall) { + /* part.toolCall: { id, name, displayTitle?, status, executor, approval?, authorization?, … } */ + } else { + /* a kind of your own, or a newer one: part.kind, part.payload */ + } +} +``` + +- Attachments that are not AI steps (images, files) are skipped. Decoding is lenient: a field of the wrong type reads as missing, missing fields get defaults (`status` is `completed` for reasoning and `running` for a tool call, `executor` is `server`), and a step of a newer version keeps its `payload` without a typed view. +- A tool call's `status` is one of `running`, `awaiting_client`, `awaiting_approval`, `awaiting_authorization`, `completed`, `failed` or `cancelled`. Treat one you don't know as still in progress. +- `approval` is the question a call asks before it runs (`title`, `message`, `reason`, `allowTitle`, `declineTitle`) and the person's `decision` (`allowed` or `declined`). +- `authorization` is the sign-in a call waits for (`type`, `provider`, `providerName`, `title`, `message`, `url`, `handoffToken`, `expiresAt`, and `status`: `connected`, `declined`, `expired` or `failed` once over). +- `isAwaitingApproval(call, userId, clientId)` is true for a call waiting for this person on this install. A call with no `targetClientId` (a server tool) may be answered from any of their installs. `isAwaitingAuthorization(call, userId)` is true for a sign-in waiting for them, from any install. `isDeclined(call)` is true when they declined the call or its sign-in. + +The data types are named `AIMessagePartData`, `AIToolApprovalData` and `AIToolAuthorizationData`, since `AIMessagePart`, `AIToolApproval` and `AIToolAuthorization` are the components that show them. + +### `AIConversation` + +The conversation laid out by turn, the counterpart of `AIConversationView`. Each of the person's questions starts a turn, with the agent's reply under it. + +```tsx +import { + AIClientIdentity, + AIConversation, + createPopupAuthorizer, + fromStreamMessage, +} from 'stream-chat-react/ai-components'; + +const approver = { + authorizer: createPopupAuthorizer({ + decline: (call) => backend.declineSignIn(call.id), + returnUrl: `${window.location.origin}/connected`, + }), + clientId: AIClientIdentity.installId(), + decide: (call, allowed) => backend.answerApproval(call.id, allowed), + userId: client.userID, +}; + +} + footer={failed && } + isThinking={isThinking} + loadOlderMessages={hasMore ? loadOlder : undefined} + messages={messages.map((message) => + fromStreamMessage(message, { reasoningText: liveReasoning.get }), + )} + pendingMessage={pending} + replyActions={(message) => ({ + onRate: (rating) => rate(message.id, rating), + rating: ratings[message.id], + })} +/>; +``` + +| Prop | Description | +| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `messages` | `AIMessage[]`, oldest first: `{ id, role, text, isGenerating?, attachments?, parts?, reasoningText?(stepId) }`. `role` is `user` or `assistant`. `fromStreamMessage(message, options?)` makes one from a Stream message: a reply has `ai_generated: true` and is being written while `generating: true`. | +| `pendingMessage` | `{ id, text, attachments? }`: a message the person sent that is not in `messages` yet. It shows at once, with the agent thinking under it. Keep passing it until its reply starts; its copy takes over its row (matched by `id` when it is the id you sent it with, else by where it lands). | +| `isThinking` | The agent is answering and its reply shows nothing yet. The latest turn then ends with `AIThinking`, whose seconds the reply's first reasoning step takes over. Also inferred while `pendingMessage` waits and while the latest reply is generating with nothing to show. | +| `approver` | Who answers tool calls' questions and sign-ins on this device. Also provided to the parts below through `AIToolApproverProvider`. | +| `loadOlderMessages` | Called once a spinner above the first turn has been in view for 300 ms, and again when the first turn changes while it is. What is on screen stays in place as older messages arrive. Pass `undefined` once there are none left. | +| `footer` | Rendered under the latest turn's messages, such as what the agent is doing (`AITypingIndicator`) or an error with a way to retry. | +| `emptyContent` | Shown, centred, while there are no messages. | +| `bottomInset` | Room in pixels under the latest turn for a composer floating over the conversation. The scroll-to-latest button sits above it. | +| `UserMessage`, `AssistantMessage`, `ReplyActions` | Slots for the person's messages, the agent's replies and the actions under each finished reply (`null` for none). Default to `AIUserMessage`, `AIAssistantMessage` and `AIReplyActions`. | +| `replyActions` | `(message, { isLatestReply }) => ({ rating?, onRate?, onRegenerate? })` for the default reply actions. | + +Behaviour: + +- A new question pins to the top of what is visible, and the reply writes out below it without moving what is on screen: the latest turn is at least as tall as the conversation (less `bottomInset`). +- It opens at its end. A conversation replaced as a whole (another chat opened) opens at its end again. +- A round button scrolls to the latest message while the end is out of view. +- Steps render with `AIMessageParts`; the answer types out with `StreamingMessage` as it streams in, and a reply loaded from history shows at once. + +### Steps, questions and sign-ins + +- `AIMessageParts` / `AIMessagePart`: a reply's steps, each as `AIReasoning`, `AIToolCall` (with `AIToolApproval` and `AIToolAuthorization` under it), or a neutral line for a step this SDK can't show. +- `AIToolCall`: the call's `displayTitle` (else its name), what it waits for or how it ended, and its duration once finished. +- `AIToolApproval`: the question of a call that waits for this person here, sent with `approver.decide(call, allowed)`. The buttons wait until the call's step changes; a rejected `decide` shows "Your answer couldn't be sent. Try again." and lets them answer again (`AIToolApprovalState`: `isSending`, `failed`). `AIToolApprovalCard` is the same question on its own, such as on a voice call: ``. +- `AIToolAuthorization`: the sign-in of a call that waits for this person, with Connect and Not now, run by `approver.authorizer`. Connect reads "Connecting…" until the call's step changes; Not now stays available meanwhile. `AIToolAuthorizationCard` also says how a sign-in ended (connected, declined, failed, expired) and stops offering Connect past `expiresAt`. +- `AIReasoning`: a round of reasoning. While the model thinks the header counts ("Thinking… 7s") and the reasoning streams in; once done it says "Thought for 12s" with the step's summary and folds away. Clicking the header opens or folds it. Pass `part` and `reasoningText` (the live reasoning, by step id), or `text`, `isThinking`, `duration` and `summary` yourself. `AIThinking` is the thinking before a reply shows anything. + +### Signing in at a provider + +`createPopupAuthorizer({ returnUrl, decline })` returns an `AIToolAuthorizer` that runs a call's sign-in in a popup, the web counterpart of the web authentication session on iOS: + +1. Connect opens `authorization.url` (the router's launch page) in a popup, in the click itself, so the browser does not block it. +2. The page posts `{ type: 'va.connector.oauth.ready' }` to this window; the authorizer answers `{ type: 'va.connector.oauth.handoff', handoff_token }`, to the page's origin only. +3. The page goes to the provider, which sends the person back through the router to `returnUrl` with `connection_id` and `status` in its query. +4. The return page calls `completeConnectorSignIn()`, which reports the outcome over a `BroadcastChannel` and closes the popup. + +`connect` resolves `true` once connected (the card then waits for the call's step to change), `false` when the person closed the popup or the sign-in ended otherwise, and rejects with an `AIConnectorSignInError` when the popup was blocked (`reason: 'blocked'`) or the launch page never asked for the handoff within 15 s (`'unreachable'`). Give your sessions `returnUrl` as their `return_url`: the launch page takes the handoff only from a page of its origin. + +```html + + +``` + +### Addressing + +`AIClientIdentity.installId()` is a stable id for this browser profile (`web-…`), kept in `localStorage` (in memory where storage is unavailable). Send it in the custom data of the person's message (`client_id`) so the agent can address client tools and questions to this install, and pass it to the approver as `clientId`. + +### Reply actions, suggestions and chat options + +- `AIReplyActions`: copy, share (`navigator.share` where the browser has it, else a copy), regenerate (with `onRegenerate`) and a thumbs up or down rating (with `onRate`; `AIMessageRating` is `positive` or `negative`, and `null` takes a rating back). +- `AISuggestions`: starter chips for an empty conversation; picking one calls `onSelect(text)`. +- `AIMessageComposer.ChatOptions`: a menu of ways to ask, such as "Think harder" (`AIChatOption`: `id`, `title`, `description?`, `icon?`, `shortTitle?`, `customData?`), and a chip for the one picked with a button that removes it. Controlled with `selectedId` and `onSelectedChange`, or uncontrolled with `defaultSelectedId`; with `name`, the picked id is submitted with the form. `ChatOptionsList` and `SelectedChatOptionChip` are its parts, for layouts of your own. +- `AIUserMessage`, `AIAssistantMessage`, `AIMessageAttachment` (an image thumbnail, or a file with its name, type and size) and `AITypingIndicator` (a label with three pulsing dots) are the views `AIConversation` uses, for lists of your own. + ## Migrating from `@stream-io/chat-react-ai` ### 1. Dependencies @@ -192,6 +325,20 @@ Precedence for every token except the code palette is: your `--str-chat__ai-*` o | `scrollbar-thumb` | attachment list scrollbar thumb | `border-core-default` | `#ddd` | | `voice-color` | `VoiceWave` / `VoiceGlow` color when `colors` is unset | `accent-primary` | `#005fff` | | `voice-backdrop` | what `VoiceWave` / `VoiceGlow` sit on (light or dark) | `background-core-elevation-0` | `#ffffff` | +| `accent` | tool call icons, reasoning rule while thinking, chips | `accent-primary` | `#005fff` | +| `success` | completed tool calls, connected sign-ins | `accent-success` | `#00a46e` | +| `error` | failed tool calls, answers that couldn't be sent | `accent-error` | `#d92f26` | +| `text-tertiary` | reasoning footnote | `text-tertiary` | `#7a7d87` | +| `card-bg` | approval and sign-in cards, files, suggestions | `background-core-surface-card` | `#f7f7f8` | +| `card-border` | approval and sign-in cards, files | `border-core-subtle` | `#e9ecef` | +| `elevated-bg` | scroll-to-latest button, chat options menu | `background-core-elevation-1` | `#ffffff` | +| `user-message-bg` | the person's message bubble | `chat-bg-outgoing` | `#d2e3ff` | +| `user-message-text` | the person's message text | `chat-text-outgoing` | `#001a4d` | +| `button-primary-bg` | Allow and Connect buttons | `button-primary-bg` | `#005fff` | +| `button-primary-text` | Allow and Connect button text | `button-primary-text-on-accent` | `#ffffff` | +| `button-secondary-bg` | Don't Allow and Not now buttons | `background-core-elevation-1` | `#ffffff` | +| `button-secondary-text` | Don't Allow and Not now button text | `text-primary` | `#353740` | +| `button-secondary-border` | Don't Allow and Not now button border | `button-secondary-border` | `#ccc` | | `syntax-*` | code palette (see below) | none | built-in palette | The code palette tokens are `--str-chat__ai-syntax-text`, `-selection-bg`, `-comment`, `-punctuation`, `-number`, `-keyword`, `-tag`, `-string`, `-function`, `-cyan`, `-error` and `-whitespace`. diff --git a/src/plugins/AIComponents/Reasoning/AIReasoning.tsx b/src/plugins/AIComponents/Reasoning/AIReasoning.tsx new file mode 100644 index 000000000..2a9458b18 --- /dev/null +++ b/src/plugins/AIComponents/Reasoning/AIReasoning.tsx @@ -0,0 +1,356 @@ +import React, { memo, type ReactNode, useEffect, useRef, useState } from 'react'; +import clsx from 'clsx'; + +import { useTranslationContext } from '../../../context/TranslationContext'; +import { useComponentContextIcons } from '../../../context/useComponentContextIcons'; +import { useStableId } from '../../../components/UtilityComponents/useStableId'; +import { AIMarkdown } from '../AIMarkdown'; +import { IconAIReasoning } from '../icons'; +import type { AIReasoningPart } from '../MessageParts/types'; +import { formatMinutesAndSeconds, useNow } from '../utils'; +import { FINISHED_REASONING_SHOWN_MS, useAIThinkingTurn } from './thinkingClock'; + +/** + * Splits reasoning at blank lines. Reasoning only grows at its end, so a paragraph's position + * is a stable identity. + */ +export const splitReasoningParagraphs = (text: string) => + text + .split('\n\n') + .map((paragraph) => paragraph.trim()) + .filter(Boolean); + +// A heading reads as a bold line, since a paragraph of thinking has no document to structure. +const headingsAsBold = (paragraph: string) => + paragraph + .split('\n') + .map((line) => { + const heading = /^ *#+\s*(.*)$/.exec(line)?.[1]?.trim(); + return heading ? `**${heading}**` : line; + }) + .join('\n'); + +const ReasoningParagraph = memo(({ text }: { text: string }) => ( +
+ {headingsAsBold(text)} +
+)); +ReasoningParagraph.displayName = 'ReasoningParagraph'; + +// Reveals text that grows a little at a time, a sixth of the backlog every frame at 30 fps, as +// new thoughts arrive about that often. Text that is replaced, or not animated, shows at once. +const useTextReveal = (text: string, animated: boolean) => { + const [shown, setShown] = useState(text); + const shownRef = useRef(text); + const targetRef = useRef(text); + + useEffect(() => { + const grows = animated && text.startsWith(shownRef.current); + targetRef.current = text; + if (!grows) { + shownRef.current = text; + setShown(text); + return; + } + const timer = setInterval(() => { + const target = targetRef.current; + const current = shownRef.current; + if (current.length >= target.length) { + clearInterval(timer); + return; + } + let end = + current.length + Math.max(1, Math.floor((target.length - current.length) / 6)); + // never split a surrogate pair + const code = target.charCodeAt(end - 1); + if (code >= 0xd800 && code <= 0xdbff) end += 1; + shownRef.current = target.slice(0, end); + setShown(shownRef.current); + }, 1000 / 30); + return () => clearInterval(timer); + }, [animated, text]); + + return shown; +}; + +const ReasoningPanel = ({ + id, + isLive, + maxHeight, + text, +}: { + id: string; + isLive: boolean; + maxHeight: number; + text: string; +}) => { + const shown = useTextReveal(text, isLive); + const scrollerRef = useRef(null); + // Whether the reader is at the end, so new thoughts keep it there. A finished trace opens at + // its start. + const following = useRef(isLive); + const [overflowing, setOverflowing] = useState(false); + + useEffect(() => { + const scroller = scrollerRef.current; + if (!scroller) return; + setOverflowing(scroller.scrollHeight > scroller.clientHeight + 1); + if (following.current) scroller.scrollTop = scroller.scrollHeight; + }, [shown]); + + return ( +
{ + const scroller = event.currentTarget; + following.current = + scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 24; + }} + ref={scrollerRef} + style={{ maxHeight }} + tabIndex={overflowing ? 0 : undefined} + > + {splitReasoningParagraphs(shown).map((paragraph, index) => ( + + ))} +
+ ); +}; + +export type AIReasoningProps = { + className?: string; + /** How long the model has thought, in seconds, shown as "Thought for 12s" once it is done. */ + duration?: number; + /** A note under the open reasoning, such as how long it is kept. */ + footnote?: ReactNode; + /** Whether the reasoning is open once the model is done. Defaults to `false`. */ + initiallyExpanded?: boolean; + /** Whether the model is still thinking. Defaults to whether `part` is streaming. */ + isThinking?: boolean; + /** How tall the reasoning grows, in pixels, before it scrolls. Defaults to 260. */ + maxExpandedHeight?: number; + /** A reasoning step, whose preview, summary, status and duration it shows. */ + part?: AIReasoningPart; + /** + * The whole of a reasoning step's thinking when the app has it, such as from live updates. + * Read on every render; without it, the step shows its own preview. + */ + reasoningText?: (stepId: string) => string | undefined; + /** Whether the reasoning is open while the model thinks. Defaults to `true`. */ + showsLiveReasoning?: boolean; + /** When the thinking began (`Date.now()`), for a header that counts from before it appeared. */ + since?: number; + /** A one-line summary shown beside the header once the model is done. */ + summary?: string; + /** + * The reasoning so far. Blank lines separate paragraphs, and Markdown is rendered. Defaults to + * `reasoningText(part.id)`, else the step's preview or summary. + */ + text?: string; +}; + +const opensByDefault = ( + isThinking: boolean, + showsLiveReasoning: boolean, + initiallyExpanded: boolean, +) => (isThinking ? showsLiveReasoning : initiallyExpanded); + +/** + * A round of the model's reasoning: a header that counts the seconds while it thinks ("Thinking… + * 7s") and says how long it thought once done ("Thought for 12s"), over the reasoning itself, + * which streams into view while the model thinks and folds away when it is done. Clicking the + * header opens or folds it. The counterpart of `StreamingReasoningView`. + * + * Inside an `AIConversation` that showed the agent thinking, the reply's first reasoning step + * takes over that thinking: its seconds count from when the question was asked. + */ +export const AIReasoning = ({ + className, + duration: durationProp, + footnote, + initiallyExpanded = false, + isThinking: isThinkingProp, + maxExpandedHeight = 260, + part, + reasoningText, + showsLiveReasoning = true, + since, + summary: summaryProp, + text: textProp, +}: AIReasoningProps) => { + const { t, userLanguage } = useTranslationContext(); + const { IconChevronRight } = useComponentContextIcons(); + const panelId = `str-chat__ai-reasoning-${useStableId()}`; + const turn = useAIThinkingTurn(); + + const text = + textProp ?? + (part ? (reasoningText?.(part.id) ?? part.preview ?? part.summary) : undefined) ?? + ''; + const isThinking = isThinkingProp ?? part?.status === 'streaming'; + const ownDuration = + durationProp ?? (part?.durationMs !== undefined ? part.durationMs / 1000 : undefined); + const summary = summaryProp ?? part?.summary; + + // The turn whose thinking this step takes over, when it is the turn's first step. + const thinkingTurn = + part && turn && turn.clock.isFirstStep(part.id, turn.key) ? turn : undefined; + if (isThinking && thinkingTurn) + thinkingTurn.clock.firstStepIsStreaming(thinkingTurn.key); + + // What the reader chose by clicking the header. Until they do, the reasoning is open while + // the model thinks and folded once it is done. + const [choice, setChoice] = useState(null); + const [open, setOpen] = useState(() => + opensByDefault(isThinking, showsLiveReasoning, initiallyExpanded), + ); + const [thinkingSince, setThinkingSince] = useState(() => since ?? Date.now()); + const [thoughtFor, setThoughtFor] = useState(); + + // A conversation's first step that arrived done, with the answer, shows its reasoning before + // it folds: the model sent none of it while it thought. + const [showsFinished] = useState( + () => + !isThinking && + text !== '' && + !!thinkingTurn && + thinkingTurn.clock.showsFirstStep(thinkingTurn.key), + ); + useEffect(() => { + if (!showsFinished) return; + setOpen(true); + const timer = setTimeout(() => setOpen(false), FINISHED_REASONING_SHOWN_MS); + return () => clearTimeout(timer); + }, [showsFinished]); + + const wasThinking = useRef(isThinking); + useEffect(() => { + if (wasThinking.current === isThinking) return; + wasThinking.current = isThinking; + if (isThinking) { + setThinkingSince(Date.now()); + } else { + setThoughtFor(Math.max(ownDuration ?? 0, (Date.now() - thinkingSince) / 1000)); + } + setOpen(choice ?? opensByDefault(isThinking, showsLiveReasoning, initiallyExpanded)); + }, [ + choice, + initiallyExpanded, + isThinking, + ownDuration, + showsLiveReasoning, + thinkingSince, + ]); + + const start = + since ?? (thinkingTurn ? thinkingTurn.clock.start(thinkingTurn.key) : undefined); + const now = useNow(isThinking); + const elapsed = Math.max(ownDuration ?? 0, (now - (start ?? thinkingSince)) / 1000); + const measured = + !isThinking && thinkingTurn + ? thinkingTurn.clock.durationOfFirstStep(thinkingTurn.key, ownDuration) + : undefined; + const thought = measured ?? ownDuration ?? thoughtFor; + + const title = isThinking + ? elapsed >= 1 + ? t('Thinking… {{ duration }}', { + duration: formatMinutesAndSeconds(elapsed, userLanguage), + }) + : t('Thinking…') + : thought !== undefined + ? t('Thought for {{ duration }}', { + duration: formatMinutesAndSeconds(Math.max(1, thought), userLanguage), + }) + : t('Thought process'); + + // Live reasoning is open before any of it arrives, but there is nothing to show until then. + const expanded = open && text !== ''; + + return ( +
+ + {text !== '' && ( +
+
+ + {footnote && !isThinking && (choice === true || initiallyExpanded) && ( +
{footnote}
+ )} +
+
+ )} +
+ ); +}; + +export type AIThinkingProps = { + className?: string; + /** When the thinking began (`Date.now()`). Inside an `AIConversation`, when the question was asked. */ + since?: number; +}; + +/** + * The agent thinking before its reply shows anything: a header that counts the seconds. In an + * `AIConversation`, the reply's first reasoning step takes over its seconds. The counterpart of + * `AIThinkingView`. + */ +export const AIThinking = ({ className, since }: AIThinkingProps) => { + const turn = useAIThinkingTurn(); + return ( + + ); +}; diff --git a/src/plugins/AIComponents/Reasoning/index.ts b/src/plugins/AIComponents/Reasoning/index.ts new file mode 100644 index 000000000..a6b6f449d --- /dev/null +++ b/src/plugins/AIComponents/Reasoning/index.ts @@ -0,0 +1 @@ +export * from './AIReasoning'; diff --git a/src/plugins/AIComponents/Reasoning/thinkingClock.ts b/src/plugins/AIComponents/Reasoning/thinkingClock.ts new file mode 100644 index 000000000..662a257e4 --- /dev/null +++ b/src/plugins/AIComponents/Reasoning/thinkingClock.ts @@ -0,0 +1,107 @@ +import { createContext, useContext } from 'react'; + +/** How long a first step that arrived done stays open, showing its reasoning, before it folds. */ +export const FINISHED_REASONING_SHOWN_MS = 1600; +/** How long the reasoning takes to fold. */ +export const REASONING_FOLD_MS = 450; +// How soon after a turn stops thinking its first step has to arrive, done, to open and show its +// reasoning: it came with the reply that ended the thinking. +const ARRIVAL_WINDOW_MS = 1000; + +type Turn = { + start: number; + end?: number; + firstStep?: string; + firstStepDuration?: number; + // When a first step that arrived done opened to show its reasoning. + firstStepShown?: number; + firstStepStreamed: boolean; + // Whether a reply of the turn showed. + hadReplies: boolean; +}; + +/** + * Times each turn's thinking, so the reply's first reasoning step takes over the seconds the + * conversation counted while it showed the agent thinking. The counterpart of `AIThinkingClock`. + */ +export class AIThinkingClock { + private turns = new Map(); + + // A turn's thinking starts the first time it is the latest turn and thinking, and ends when it + // stops. Thinking that resumes before the first step shows carries on. A turn whose replies were + // taken away starts thinking afresh. + update( + latest: string | undefined, + isThinking: boolean, + hasReplies: boolean, + now = Date.now(), + ) { + if (latest === undefined) return; + const timed = this.turns.get(latest); + if (!timed || (isThinking && timed.hadReplies && !hasReplies)) { + if (isThinking) { + this.turns.set(latest, { + firstStepStreamed: false, + hadReplies: false, + start: now, + }); + } + return; + } + if (hasReplies) timed.hadReplies = true; + if (isThinking) { + if (timed.firstStep === undefined) timed.end = undefined; + } else if (timed.end === undefined) { + timed.end = now; + } + } + + start(turn: string) { + return this.turns.get(turn)?.start; + } + + // Whether `step` takes over a turn's thinking: the first reasoning step to show in a turn that + // was seen thinking. + isFirstStep(step: string, turn: string) { + const timed = this.turns.get(turn); + if (!timed) return false; + timed.firstStep ??= step; + return timed.firstStep === step; + } + + firstStepIsStreaming(turn: string) { + const timed = this.turns.get(turn); + if (timed) timed.firstStepStreamed = true; + } + + // How long a turn's first step thought, in seconds: from when the turn started thinking until + // the step was done. The step's own time counts when it is longer. + durationOfFirstStep(turn: string, own: number | undefined, now = Date.now()) { + const timed = this.turns.get(turn); + if (!timed) return own; + if (timed.firstStepDuration !== undefined) return timed.firstStepDuration; + const done = timed.firstStepStreamed ? now : (timed.end ?? now); + const measured = Math.max(own ?? 0, (done - timed.start) / 1000); + timed.firstStepDuration = measured; + return measured; + } + + // Whether a turn's first step, which arrived done with the reply that ended the thinking, opens + // to show its reasoning before it folds and the answer starts. + showsFirstStep(turn: string, now = Date.now()) { + const timed = this.turns.get(turn); + if (!timed || timed.firstStepStreamed) return false; + if (timed.firstStepShown !== undefined) { + return now - timed.firstStepShown < ARRIVAL_WINDOW_MS; + } + if (timed.end === undefined || now - timed.end >= ARRIVAL_WINDOW_MS) return false; + timed.firstStepShown = now; + return true; + } +} + +export type AIThinkingTurn = { clock: AIThinkingClock; key: string }; + +export const AIThinkingTurnContext = createContext(undefined); + +export const useAIThinkingTurn = () => useContext(AIThinkingTurnContext); diff --git a/src/plugins/AIComponents/Suggestions/AISuggestions.tsx b/src/plugins/AIComponents/Suggestions/AISuggestions.tsx new file mode 100644 index 000000000..69d46d057 --- /dev/null +++ b/src/plugins/AIComponents/Suggestions/AISuggestions.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import clsx from 'clsx'; + +import { useTranslationContext } from '../../../context/TranslationContext'; + +export type AISuggestionsProps = { + /** Called with the suggestion the person picked, such as to send it. */ + onSelect: (suggestion: string) => void; + /** Starters for a conversation, such as "What's on my calendar today?". */ + suggestions: string[]; + className?: string; +}; + +/** + * A row of starter chips for an empty conversation. Picking one hands its text to + * `onSelect`. The counterpart of `SuggestionsView`. + */ +export const AISuggestions = ({ + className, + onSelect, + suggestions, +}: AISuggestionsProps) => { + const { t } = useTranslationContext(); + if (suggestions.length === 0) return null; + return ( +
+ {suggestions.map((suggestion) => ( + + ))} +
+ ); +}; diff --git a/src/plugins/AIComponents/ToolCalls/AIClientIdentity.ts b/src/plugins/AIComponents/ToolCalls/AIClientIdentity.ts new file mode 100644 index 000000000..dd146d29b --- /dev/null +++ b/src/plugins/AIComponents/ToolCalls/AIClientIdentity.ts @@ -0,0 +1,50 @@ +import { nanoid } from 'nanoid'; + +const STORAGE_KEY = 'io.getstream.ai.client-id'; + +// Used when the browser keeps nothing (storage blocked, private mode, server rendering), so +// the id still holds for as long as the page lives. +let inMemoryId: string | undefined; + +const createId = () => + 'web-' + + (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : nanoid()); + +const readStored = () => { + try { + return globalThis.localStorage?.getItem(STORAGE_KEY) ?? undefined; + } catch { + return undefined; + } +}; + +const store = (id: string) => { + try { + globalThis.localStorage?.setItem(STORAGE_KEY, id); + } catch { + // Kept in memory only. + } +}; + +/** + * A stable identifier for this install (this browser profile), for addressing client tool + * calls to it. Put it in the custom data of the person's message (`client_id`) so the agent + * can copy it onto the calls it makes while answering, and pass it to `AIToolApprover` as + * `clientId`. + */ +export const AIClientIdentity = { + /** + * This install's identifier, created on first use and kept in `localStorage`. Where storage + * is unavailable it lives in memory, for as long as the page does. + */ + installId(): string { + const saved = readStored() ?? inMemoryId; + if (saved) return saved; + const created = createId(); + inMemoryId = created; + store(created); + return created; + }, +}; diff --git a/src/plugins/AIComponents/ToolCalls/AIToolApprover.tsx b/src/plugins/AIComponents/ToolCalls/AIToolApprover.tsx new file mode 100644 index 000000000..ca3145e36 --- /dev/null +++ b/src/plugins/AIComponents/ToolCalls/AIToolApprover.tsx @@ -0,0 +1,65 @@ +import React, { createContext, type PropsWithChildren, useContext } from 'react'; + +import type { AIToolCallPart } from '../MessageParts'; + +/** + * How the person signs in on this device when a tool call waits for their account at a + * provider, and how their "not now" reaches your backend, which holds the call until one or + * the other. `createPopupAuthorizer` makes one that opens the sign-in in a popup. + */ +export type AIToolAuthorizer = { + /** + * Opens the call's sign-in. It is called in the click handler, so a popup it opens before + * anything is awaited is not blocked. + * + * Resolves `true` once the account is connected: your backend then updates the call's step, + * and until it does the sign-in reads as connecting. Resolves `false` when the person closed + * the sign-in or it ended without connecting, so they can try again. Rejects when the sign-in + * could not be opened. + */ + connect: (call: AIToolCallPart) => Promise; + /** + * Tells your backend the person won't sign in now. Your backend ends the call, which never + * runs. A rejection lets the person answer again. + */ + decline: (call: AIToolCallPart) => Promise; +}; + +/** + * The person answering tool calls' questions on this device, and how their answer reaches + * your backend, which holds each call until it gets one. + */ +export type AIToolApprover = { + /** This install, matched against a client tool call's `target_client_id`. Use `AIClientIdentity.installId()`. */ + clientId: string; + /** + * Sends the answer. Your backend checks it is this person's (and this install's, for a + * client tool), then updates the call's step; until then the question stays, with its + * buttons disabled. A rejection lets the person answer again. + */ + decide: (call: AIToolCallPart, allowed: boolean) => Promise; + /** The person signed in, matched against a call's `target_user_id`. */ + userId: string; + /** How this person signs in when a call waits for their account. Without one, no sign-in is offered here. */ + authorizer?: AIToolAuthorizer; +}; + +const AIToolApproverContext = createContext(undefined); + +/** + * Provides the approver to the AI components below it, for those not given one as a prop. + */ +export const AIToolApproverProvider = ({ + approver, + children, +}: PropsWithChildren<{ approver?: AIToolApprover }>) => ( + + {children} + +); + +/** The approver given as a prop, else the one of the nearest `AIToolApproverProvider`. */ +export const useAIToolApprover = (approver?: AIToolApprover) => { + const fromContext = useContext(AIToolApproverContext); + return approver ?? fromContext; +}; diff --git a/src/plugins/AIComponents/ToolCalls/index.ts b/src/plugins/AIComponents/ToolCalls/index.ts new file mode 100644 index 000000000..ca9546029 --- /dev/null +++ b/src/plugins/AIComponents/ToolCalls/index.ts @@ -0,0 +1,3 @@ +export * from './AIClientIdentity'; +export * from './AIToolApprover'; +export * from './popupAuthorizer'; diff --git a/src/plugins/AIComponents/ToolCalls/popupAuthorizer.ts b/src/plugins/AIComponents/ToolCalls/popupAuthorizer.ts new file mode 100644 index 000000000..7ce29db04 --- /dev/null +++ b/src/plugins/AIComponents/ToolCalls/popupAuthorizer.ts @@ -0,0 +1,234 @@ +import type { AIToolCallPart } from '../MessageParts'; +import type { AIToolAuthorizer } from './AIToolApprover'; + +// The sign-in runs the router's launch page in a popup. The page asks its opener for the +// handoff token, then leaves for the provider, clearing its opener on the way. So the page the +// router finally sends the popup back to (the session's return_url) reports how it ended over +// a BroadcastChannel instead. The web counterpart of `AIWebAuthentication` in StreamChatAI. + +/** The BroadcastChannel the return page reports the sign-in's outcome on. */ +export const AI_CONNECTOR_SIGN_IN_CHANNEL = 'stream-chat-ai-connector-sign-in'; + +const READY = 'va.connector.oauth.ready'; +const HANDOFF = 'va.connector.oauth.handoff'; +const RESULT = 'va.connector.oauth.result'; + +/** How the router says a consent ended, in the return page's `status` query parameter. */ +export type ConnectorSignInStatus = + | 'connected' + | 'denied' + | 'failed' + | 'account_mismatch'; + +const OUTCOMES: readonly string[] = [ + 'connected', + 'denied', + 'failed', + 'account_mismatch', +] satisfies ConnectorSignInStatus[]; + +const CONNECTION_ID = /^[\w-]{1,128}$/; + +/** The sign-in's outcome, as the return page read it from its query. */ +export type ConnectorSignInResult = { + connectionId: string; + status: ConnectorSignInStatus; +}; + +/** + * Why a sign-in could not be opened: the browser blocked the popup (`blocked`), the launch + * page never asked for the handoff, such as when it does not take it from this origin + * (`unreachable`), or the call carries no sign-in to open (`unavailable`). + */ +export class AIConnectorSignInError extends Error { + readonly reason: 'blocked' | 'unavailable' | 'unreachable'; + + constructor(reason: AIConnectorSignInError['reason']) { + super(`The sign-in couldn't be opened (${reason}).`); + this.name = 'AIConnectorSignInError'; + this.reason = reason; + } +} + +export type PopupAuthorizerOptions = { + /** + * Tells your backend the person won't sign in now. Your backend ends the call. A rejection + * lets the person answer again. + */ + decline: (call: AIToolCallPart) => Promise; + /** + * The page the sign-in comes back to, which runs `completeConnectorSignIn()`. Give it to your + * sessions as their `return_url`: the launch page takes the handoff only from a page of its + * origin, so it must be this app's origin. + */ + returnUrl: string; + /** The BroadcastChannel name, the same as the return page's. Defaults to `AI_CONNECTOR_SIGN_IN_CHANNEL`. */ + channelName?: string; + /** How often to check whether the person closed the popup, in milliseconds. Defaults to 500. */ + closedPollIntervalMs?: number; + /** The popup's window features. Defaults to a 520 × 720 popup. */ + popupFeatures?: string; + /** The popup's window name. */ + popupName?: string; + /** How long the launch page has to ask for the handoff, in milliseconds. Defaults to 15 s. */ + readyTimeoutMs?: number; + /** + * How long to wait for the outcome once handed off, in milliseconds, when the sign-in names + * no expiry. Defaults to 10 minutes, the router's consent lifetime. + */ + timeoutMs?: number; +}; + +export type PopupAuthorizer = AIToolAuthorizer & { returnUrl: string }; + +/** + * An `AIToolAuthorizer` that opens a call's sign-in in a popup, from the click itself, and + * hands the sign-in its token over `postMessage`: + * + * 1. the popup opens the call's `authorization.url` (the router's launch page); + * 2. the page posts `{ type: 'va.connector.oauth.ready' }` to this window, from its origin; + * 3. this window posts back `{ type: 'va.connector.oauth.handoff', handoff_token }`, to that + * origin only; + * 4. the page goes to the provider, which sends the person back through the router to + * `returnUrl`, where `completeConnectorSignIn()` reports the outcome over a BroadcastChannel. + * + * `connect` resolves `true` when the account is connected and `false` when the popup was closed + * or the sign-in ended otherwise. It rejects with an `AIConnectorSignInError` when the browser + * blocked the popup or the launch page never asked for the handoff. + */ +export const createPopupAuthorizer = ({ + channelName = AI_CONNECTOR_SIGN_IN_CHANNEL, + closedPollIntervalMs = 500, + decline, + popupFeatures = 'popup,width=520,height=720', + popupName = 'stream-chat-ai-sign-in', + readyTimeoutMs = 15_000, + returnUrl, + timeoutMs = 10 * 60_000, +}: PopupAuthorizerOptions): PopupAuthorizer => { + const connect = (call: AIToolCallPart): Promise => { + const authorization = call.authorization; + if (!authorization?.handoffToken) { + return Promise.reject(new AIConnectorSignInError('unavailable')); + } + const handoffToken = authorization.handoffToken; + const target = new URL(authorization.url); + // Opened before anything is awaited, so the click still counts as the user's gesture. + const popup = window.open(target.href, popupName, popupFeatures); + if (!popup) return Promise.reject(new AIConnectorSignInError('blocked')); + + return new Promise((resolve, reject) => { + let handedOff = false; + let done = false; + const stops: Array<() => void> = []; + const finish = (outcome: boolean | AIConnectorSignInError) => { + if (done) return; + done = true; + stops.forEach((stop) => stop()); + if (outcome instanceof AIConnectorSignInError) reject(outcome); + else resolve(outcome); + }; + + const onMessage = (event: MessageEvent) => { + if (handedOff || event.source !== popup || event.origin !== target.origin) return; + const data = event.data as { type?: unknown } | null; + if (!data || data.type !== READY) return; + handedOff = true; + popup.postMessage({ handoff_token: handoffToken, type: HANDOFF }, target.origin); + }; + window.addEventListener('message', onMessage); + stops.push(() => window.removeEventListener('message', onMessage)); + + if (typeof BroadcastChannel !== 'undefined') { + const channel = new BroadcastChannel(channelName); + channel.onmessage = (event: MessageEvent) => { + const data = event.data as { status?: unknown; type?: unknown } | null; + if (!handedOff || !data || data.type !== RESULT) return; + if (typeof data.status !== 'string' || !OUTCOMES.includes(data.status)) return; + finish(data.status === 'connected'); + }; + stops.push(() => channel.close()); + } + + // A launch page that never asks has refused this origin as its opener. + const readyTimer = setTimeout(() => { + if (handedOff) return; + popup.close(); + finish(new AIConnectorSignInError('unreachable')); + }, readyTimeoutMs); + stops.push(() => clearTimeout(readyTimer)); + + const remaining = + authorization.expiresAt !== undefined + ? Math.max(0, authorization.expiresAt - Date.now()) + : timeoutMs; + const expiryTimer = setTimeout( + () => finish(false), + Math.max(remaining, readyTimeoutMs), + ); + stops.push(() => clearTimeout(expiryTimer)); + + // A provider's page can sever the popup from this window, which then looks closed while + // the person is still deciding. Your backend's change to the step is the record either way. + const closedTimer = setInterval(() => { + if (popup.closed) finish(false); + }, closedPollIntervalMs); + stops.push(() => clearInterval(closedTimer)); + }); + }; + + return { connect, decline, returnUrl }; +}; + +/** Reads the router's redirect to the return page: `?connection_id=…&status=…`. */ +export const readConnectorSignInResult = ( + search: string, +): ConnectorSignInResult | null => { + const params = new URLSearchParams(search); + const connectionId = params.get('connection_id') ?? ''; + const status = params.get('status') ?? ''; + if (!CONNECTION_ID.test(connectionId) || !OUTCOMES.includes(status)) return null; + return { connectionId, status: status as ConnectorSignInStatus }; +}; + +export type CompleteConnectorSignInOptions = { + /** The BroadcastChannel name, the same as the authorizer's. Defaults to `AI_CONNECTOR_SIGN_IN_CHANNEL`. */ + channelName?: string; + /** Whether to close the popup once the outcome is reported. Defaults to `true`. */ + close?: boolean; + /** The query to read. Defaults to `window.location.search`. */ + search?: string; +}; + +/** + * Runs on the page a sign-in comes back to (the `returnUrl` of `createPopupAuthorizer`): reads + * `connection_id` and `status` from its query, reports them to the page that opened the + * sign-in over a BroadcastChannel, and closes the popup. + * + * ```ts + * // connected.html + * import { completeConnectorSignIn } from 'stream-chat-react/ai-components'; + * completeConnectorSignIn(); + * ``` + * + * Returns what it read, or `null` when the query holds no outcome. + */ +export const completeConnectorSignIn = ({ + channelName = AI_CONNECTOR_SIGN_IN_CHANNEL, + close = true, + search = typeof window === 'undefined' ? '' : window.location.search, +}: CompleteConnectorSignInOptions = {}): ConnectorSignInResult | null => { + const result = readConnectorSignInResult(search); + if (!result) return null; + if (typeof BroadcastChannel !== 'undefined') { + const channel = new BroadcastChannel(channelName); + channel.postMessage({ + connection_id: result.connectionId, + status: result.status, + type: RESULT, + }); + channel.close(); + } + if (close && typeof window !== 'undefined') window.close(); + return result; +}; diff --git a/src/plugins/AIComponents/__tests__/AIConversation.test.tsx b/src/plugins/AIComponents/__tests__/AIConversation.test.tsx new file mode 100644 index 000000000..cba23545b --- /dev/null +++ b/src/plugins/AIComponents/__tests__/AIConversation.test.tsx @@ -0,0 +1,327 @@ +import React from 'react'; +import { act, fireEvent, screen } from '@testing-library/react'; + +import { + AIConversation, + type AIConversationAssistantMessageProps, + type AIConversationUserMessageProps, + type AIMessage, + type AIToolApprover, + fromStreamMessage, + parseMessageParts, +} from '..'; +import { renderWithEnglish } from './renderWithEnglish'; + +const user = (id: string, text: string): AIMessage => ({ id, role: 'user', text }); +const reply = (id: string, text: string, extra: Partial = {}): AIMessage => ({ + id, + role: 'assistant', + text, + ...extra, +}); + +const turnsIn = (container: HTMLElement) => + [...container.querySelectorAll('.str-chat__ai-conversation__turn')].map( + (turn) => turn.dataset.turnId, + ); + +describe('AIConversation', () => { + it('shows the empty content while there are no messages', () => { + renderWithEnglish( + Ask me anything

} messages={[]} />, + ); + expect(screen.getByText('Ask me anything')).toBeInTheDocument(); + }); + + it('groups the messages into turns, each starting with a question', () => { + const { container } = renderWithEnglish( + footer

} + messages={[ + reply('a0', 'Hi, I am Juno.'), + user('u1', 'What time is it?'), + reply('a1', 'It is noon.'), + user('u2', 'And the weather?'), + reply('a2', 'Sunny.'), + ]} + />, + ); + expect(turnsIn(container)).toEqual(['a0', 'u1', 'u2']); + const latest = container.querySelector('.str-chat__ai-conversation__turn--latest'); + expect(latest).toHaveAttribute('data-turn-id', 'u2'); + expect(latest).toHaveTextContent('And the weather?'); + expect(latest).toHaveTextContent('footer'); + expect(screen.getAllByText('footer')).toHaveLength(1); + }); + + it('shows a pending message with the agent thinking, until its reply starts', () => { + const history = [user('u1', 'Hello'), reply('a1', 'Hi!')]; + const pending = { id: 'local-1', text: 'Plan my day' }; + const { container, rerender } = renderWithEnglish( + , + ); + expect(screen.getByText('Plan my day')).toBeInTheDocument(); + expect(screen.getByText('Thinking…')).toBeInTheDocument(); + const pendingTurn = container.querySelector('[data-turn-id="local-1"]'); + + // Stream's copy arrives with another id: it takes over the pending row, under its key. + rerender( + , + ); + expect(screen.getAllByText('Plan my day')).toHaveLength(1); + expect(container.querySelector('[data-turn-id="local-1"]')).toBe(pendingTurn); + expect(screen.getByText('Thinking…')).toBeInTheDocument(); + + // A reply being written that shows nothing yet: still thinking. + rerender( + , + ); + expect(screen.getByText('Thinking…')).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.queryByText('Thinking…')).not.toBeInTheDocument(); + expect(screen.getByText('Here is your plan.')).toBeInTheDocument(); + expect(container.querySelector('[data-turn-id="local-1"]')).toBe(pendingTurn); + }); + + it('finds the copy of a pending message by its id', () => { + const pending = { id: 'm-2', text: 'Draft' }; + const { rerender } = renderWithEnglish( + , + ); + // the earlier message saying the same is not its copy + expect(screen.getAllByText('Draft')).toHaveLength(2); + rerender( + , + ); + expect(screen.getAllByText('Draft')).toHaveLength(2); + }); + + it('shows the agent thinking when told to', () => { + renderWithEnglish(); + expect(screen.getByRole('button', { name: /Thinking…/ })).toBeInTheDocument(); + }); + + it('shows reply actions under finished replies only', () => { + const onRate = vi.fn(); + const { rerender } = renderWithEnglish( + ({ + onRate: (rating) => onRate(message.id, rating, isLatestReply), + rating: null, + })} + />, + ); + const toolbars = screen.getAllByRole('toolbar', { name: 'Reply actions' }); + expect(toolbars).toHaveLength(1); + fireEvent.click(screen.getByRole('button', { name: 'Good response' })); + expect(onRate).toHaveBeenCalledWith('a1', 'positive', false); + + rerender( + , + ); + expect(screen.queryByRole('toolbar')).not.toBeInTheDocument(); + }); + + it('renders messages through the slots', () => { + const UserMessage = ({ text }: AIConversationUserMessageProps) =>

user:{text}

; + const AssistantMessage = ({ message, text }: AIConversationAssistantMessageProps) => ( +

+ agent:{message.id}:{text} +

+ ); + renderWithEnglish( +

actions

} + UserMessage={UserMessage} + />, + ); + expect(screen.getByText('user:Hi')).toBeInTheDocument(); + expect(screen.getByText('agent:a1:Hello')).toBeInTheDocument(); + expect(screen.getByText('actions')).toBeInTheDocument(); + }); + + it('shows reasoning, tool calls and approvals of a reply', async () => { + let fail = true; + const decide = vi.fn(() => + fail ? Promise.reject(new Error('offline')) : new Promise(() => undefined), + ); + const approver: AIToolApprover = { clientId: 'web-1', decide, userId: 'alex' }; + const parts = parseMessageParts([ + { duration_ms: 3000, id: 'r1', preview: 'Thinking about the weather.', status: 'completed', type: 'ai_reasoning', v: 1 }, + { + approval: { title: 'Share your location?' }, + display_title: 'Checking your location', + executor: 'client', + id: 'call_1', + name: 'get_location', + status: 'awaiting_approval', + target_client_id: 'web-1', + target_user_id: 'alex', + type: 'ai_tool_call', + v: 1, + }, + ]); // prettier-ignore + renderWithEnglish( + , + ); + expect(screen.getByRole('button', { name: /Thought for 3s/ })).toBeInTheDocument(); + expect(screen.getByText('Checking your location')).toBeInTheDocument(); + expect(screen.getByText('Waiting for approval')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Allow' })); + expect(decide).toHaveBeenCalledWith(parts[1].toolCall, true); + expect(screen.getByRole('button', { name: 'Allow' })).toBeDisabled(); + await act(() => Promise.resolve()); + expect(screen.getByRole('alert')).toHaveTextContent( + "Your answer couldn't be sent. Try again.", + ); + + fail = false; + fireEvent.click(screen.getByRole('button', { name: "Don't Allow" })); + expect(decide).toHaveBeenLastCalledWith(parts[1].toolCall, false); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: "Don't Allow" })).toBeDisabled(); + }); + + describe('loading older messages', () => { + let trigger: (isIntersecting: boolean) => void; + + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(callback: (entries: { isIntersecting: boolean }[]) => void) { + trigger = (isIntersecting) => callback([{ isIntersecting }]); + } + disconnect() {} + observe() {} + }, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('loads once the spinner has been in view a moment', () => { + const loadOlderMessages = vi.fn(() => Promise.resolve()); + const { rerender } = renderWithEnglish( + , + ); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + + act(() => trigger(true)); + act(() => trigger(false)); + act(() => vi.advanceTimersByTime(300)); + expect(loadOlderMessages).not.toHaveBeenCalled(); + + act(() => trigger(true)); + act(() => vi.advanceTimersByTime(300)); + expect(loadOlderMessages).toHaveBeenCalledTimes(1); + + rerender( + , + ); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + }); +}); + +describe('fromStreamMessage', () => { + it('adapts a reply with its steps and attachments', () => { + const reasoningText = vi.fn(() => 'The whole thought'); + const message = fromStreamMessage( + { + ai_generated: true, + attachments: [ + { id: 'r1', status: 'streaming', type: 'ai_reasoning', v: 1 }, + { image_url: 'https://cdn.example.com/a.jpg', title: 'a.jpg', type: 'image' }, + { + asset_url: 'https://cdn.example.com/plan.pdf', + file_size: 2400, + mime_type: 'application/pdf', + title: 'plan.pdf', + type: 'file', + }, + ], + generating: true, + id: 'm1', + text: 'Working on it', + }, + { reasoningText }, + ); + expect(message).toMatchObject({ + attachments: [ + { kind: 'image', name: 'a.jpg', url: 'https://cdn.example.com/a.jpg' }, + { + kind: 'file', + mimeType: 'application/pdf', + name: 'plan.pdf', + size: 2400, + url: 'https://cdn.example.com/plan.pdf', + }, + ], + id: 'm1', + isGenerating: true, + role: 'assistant', + text: 'Working on it', + }); + expect(message.parts?.map((part) => part.id)).toEqual(['r1']); + expect(message.reasoningText?.('r1')).toBe('The whole thought'); + expect(reasoningText).toHaveBeenCalledWith('m1', 'r1'); + }); + + it('adapts the person’s message', () => { + expect(fromStreamMessage({ attachments: [], id: 'u1', text: 'Hi' })).toMatchObject({ + isGenerating: false, + parts: [], + role: 'user', + }); + }); +}); diff --git a/src/plugins/AIComponents/__tests__/AIMessageComposer.test.tsx b/src/plugins/AIComponents/__tests__/AIMessageComposer.test.tsx index f057503d5..fdf152c88 100644 --- a/src/plugins/AIComponents/__tests__/AIMessageComposer.test.tsx +++ b/src/plugins/AIComponents/__tests__/AIMessageComposer.test.tsx @@ -30,6 +30,14 @@ describe('AIMessageComposer', () => { expect(screen.getByLabelText('b input')).toHaveValue(''); }); + it('keeps its own class next to a className it is given', () => { + const { container } = render(); + expect(container.querySelector('form')).toHaveClass( + 'str-chat__ai-message-composer__form', + 'floating', + ); + }); + it('renders without throwing when the Web Speech API is unavailable', () => { expect('SpeechRecognition' in window).toBe(false); expect(() => render()).not.toThrow(); diff --git a/src/plugins/AIComponents/__tests__/AIMessagePartViews.test.tsx b/src/plugins/AIComponents/__tests__/AIMessagePartViews.test.tsx new file mode 100644 index 000000000..3f2d1b293 --- /dev/null +++ b/src/plugins/AIComponents/__tests__/AIMessagePartViews.test.tsx @@ -0,0 +1,466 @@ +import React from 'react'; +import { act, fireEvent, screen } from '@testing-library/react'; + +import { + AIConnectorSignInError, + AIMessageComposer, + AIMessagePart, + AIReasoning, + AIReplyActions, + AISuggestions, + AIThinking, + AIToolApprovalCard, + type AIToolApprover, + AIToolAuthorization, + AIToolAuthorizationCard, + type AIToolAuthorizer, + AIToolCall, + type AIToolCallPart, + AITypingIndicator, + parseMessagePart, +} from '..'; +import { renderWithEnglish } from './renderWithEnglish'; + +const toolCall = (overrides: Partial = {}): AIToolCallPart => ({ + executor: 'server', + id: 'call_1', + name: 'get_weather', + status: 'running', + ...overrides, +}); + +describe('AIToolCall', () => { + it('names the call from its display title, else its name', () => { + const { rerender } = renderWithEnglish( + , + ); + expect(screen.getByText('Checking the weather')).toBeInTheDocument(); + rerender(); + expect(screen.getByText('get weather')).toBeInTheDocument(); + }); + + it.each([ + ['awaiting_approval', 'Waiting for approval'], + ['awaiting_authorization', 'Waiting for you to sign in'], + ['awaiting_client', 'Waiting for the device'], + ['failed', "Didn't complete"], + ['cancelled', 'Cancelled'], + ])('says what a %s call waits for', (status, detail) => { + renderWithEnglish(); + expect(screen.getByText(detail)).toBeInTheDocument(); + }); + + it('prefers the summary, and says a declined call was declined', () => { + const { rerender } = renderWithEnglish( + , + ); + expect(screen.getByText('No network')).toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByText('Declined')).toBeInTheDocument(); + }); + + it('shows how long a finished call took', () => { + const { container, rerender } = renderWithEnglish( + , + ); + expect(container).toHaveTextContent('0.4s'); + rerender(); + expect(container).toHaveTextContent('1m 5s'); + rerender(); + expect(container).not.toHaveTextContent('1m 5s'); + }); +}); + +describe('AIToolApprovalCard', () => { + it('asks with the reason as a sentence and the agent’s own button titles', () => { + const onDecide = vi.fn(); + renderWithEnglish( + , + ); + expect(screen.getByText('To check the local weather.')).toBeInTheDocument(); + expect(screen.getByText('Only your city is shared.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Share' })); + fireEvent.click(screen.getByRole('button', { name: "Don't Allow" })); + expect(onDecide.mock.calls).toEqual([[true], [false]]); + }); +}); + +describe('AIToolAuthorization', () => { + const signIn = parseMessagePart({ + authorization: { + handoff_token: 'tok', + provider: 'linear', + provider_name: 'Linear', + title: 'Connect Linear', + type: 'oauth', + url: 'https://router.example.com/launch/abc', + }, + id: 'call_2', + name: 'linear_create_issue', + status: 'awaiting_authorization', + target_user_id: 'alex', + type: 'ai_tool_call', + })?.toolCall as AIToolCallPart; + + const approverWith = (authorizer?: AIToolAuthorizer): AIToolApprover => ({ + authorizer, + clientId: 'web-1', + decide: vi.fn(() => Promise.resolve()), + userId: 'alex', + }); + + it('asks nothing without an authorizer, or of another person', () => { + const { container, rerender } = renderWithEnglish( + , + ); + expect(container).toBeEmptyDOMElement(); + rerender( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('connects, and lets the person try again when the popup closes', async () => { + let finish: (connected: boolean) => void = () => undefined; + const connect = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + renderWithEnglish( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Connect' })); + expect(connect).toHaveBeenCalledWith(signIn); + expect(screen.getByRole('button', { name: 'Connecting…' })).toBeDisabled(); + // Not now stays available while the sign-in is open + expect(screen.getByRole('button', { name: 'Not now' })).toBeEnabled(); + + await act(() => { + finish(false); + return Promise.resolve(); + }); + expect(screen.getByRole('button', { name: 'Connect' })).toBeEnabled(); + }); + + it('keeps connecting once connected, until the step changes', async () => { + renderWithEnglish( + Promise.resolve(true), + decline: vi.fn(), + })} + part={signIn} + />, + ); + fireEvent.click(screen.getByRole('button', { name: 'Connect' })); + await act(() => Promise.resolve()); + expect(screen.getByRole('button', { name: 'Connecting…' })).toBeDisabled(); + }); + + it('says when the popup was blocked or the sign-in could not open', async () => { + const connect = vi + .fn() + .mockRejectedValueOnce(new AIConnectorSignInError('blocked')) + .mockRejectedValueOnce(new AIConnectorSignInError('unreachable')); + renderWithEnglish( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Connect' })); + await act(() => Promise.resolve()); + expect(screen.getByRole('alert')).toHaveTextContent( + 'Your browser blocked the sign-in window. Allow pop-ups and try again.', + ); + fireEvent.click(screen.getByRole('button', { name: 'Connect' })); + await act(() => Promise.resolve()); + expect(screen.getByRole('alert')).toHaveTextContent( + "The sign-in couldn't be opened. Try again.", + ); + }); + + it('declines, and lets the person answer again when that fails', async () => { + const decline = vi.fn().mockRejectedValueOnce(new Error('offline')); + renderWithEnglish( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Not now' })); + expect(decline).toHaveBeenCalledWith(signIn); + expect(screen.getByRole('button', { name: 'Not now' })).toBeDisabled(); + await act(() => Promise.resolve()); + expect(screen.getByRole('alert')).toHaveTextContent( + "Your answer couldn't be sent. Try again.", + ); + expect(screen.getByRole('button', { name: 'Not now' })).toBeEnabled(); + }); + + it.each([ + ['connected', 'Connected to Linear'], + ['declined', 'Not connected'], + ['failed', "The sign-in didn't complete."], + ['expired', 'This sign-in has expired.'], + ])('says how a %s sign-in ended, without buttons', (status, outcome) => { + renderWithEnglish( + , + ); + expect(screen.getByRole('status')).toHaveTextContent(outcome); + expect(screen.queryByRole('button', { name: 'Connect' })).not.toBeInTheDocument(); + }); + + it('can no longer be opened past its expiry', () => { + renderWithEnglish( + , + ); + expect(screen.getByRole('status')).toHaveTextContent('This sign-in has expired.'); + expect(screen.queryByRole('button', { name: 'Connect' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Not now' })).toBeEnabled(); + }); +}); + +describe('AIMessagePart', () => { + it('shows a placeholder for a step it cannot show', () => { + renderWithEnglish( + , + ); + expect(screen.getByText("A step this app version can't show")).toBeInTheDocument(); + }); +}); + +describe('AIReasoning', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('counts the seconds while thinking, and folds when done', () => { + vi.useFakeTimers(); + const { rerender } = renderWithEnglish( + , + ); + const header = screen.getByRole('button', { name: /Thinking…/ }); + expect(header).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('Plan')).toContainHTML('strong'); + + act(() => vi.advanceTimersByTime(7_000)); + expect(header).toHaveTextContent('Thinking… 7s'); + + rerender( + , + ); + expect(header).toHaveTextContent('Thought for 12s'); + expect(header).toHaveAttribute('aria-expanded', 'false'); + expect(header).toHaveTextContent('Planned the day'); + + fireEvent.click(header); + expect(header).toHaveAttribute('aria-expanded', 'true'); + }); + + it('shows a step’s live text, else its preview', () => { + const part = { + durationMs: 2000, + id: 'r1', + preview: 'The opening', + status: 'completed', + }; + const { rerender } = renderWithEnglish( + 'The whole thought'} + />, + ); + expect(screen.getByText('The whole thought')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Thought for 2s/ })).toBeInTheDocument(); + rerender(); + expect(screen.getByText('The opening')).toBeInTheDocument(); + }); + + it('says Thinking… for thinking that shows nothing yet', () => { + renderWithEnglish(); + expect(screen.getByRole('button', { name: 'Thinking…' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + }); +}); + +describe('AIReplyActions', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('copies the reply', async () => { + const writeText = vi.fn(() => Promise.resolve()); + vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } }); + renderWithEnglish(); + fireEvent.click(screen.getByRole('button', { name: 'Copy' })); + expect(writeText).toHaveBeenCalledWith('The answer'); + await act(() => Promise.resolve()); + expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument(); + }); + + it('shares with the system share sheet, else copies', async () => { + const share = vi.fn(() => Promise.resolve()); + const writeText = vi.fn(() => Promise.resolve()); + vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText }, share }); + const { unmount } = renderWithEnglish(); + fireEvent.click(screen.getByRole('button', { name: 'Share' })); + expect(share).toHaveBeenCalledWith({ text: 'The answer' }); + expect(writeText).not.toHaveBeenCalled(); + unmount(); + + vi.stubGlobal('navigator', { + ...navigator, + clipboard: { writeText }, + share: undefined, + }); + renderWithEnglish(); + fireEvent.click(screen.getByRole('button', { name: 'Share' })); + expect(writeText).toHaveBeenCalledWith('The answer'); + await act(() => Promise.resolve()); + }); + + it('rates, and takes a rating back', () => { + const onRate = vi.fn(); + const onRegenerate = vi.fn(); + const { rerender } = renderWithEnglish( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Bad response' })); + expect(onRate).toHaveBeenLastCalledWith('negative'); + rerender(); + expect(screen.getByRole('button', { name: 'Bad response' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + fireEvent.click(screen.getByRole('button', { name: 'Bad response' })); + expect(onRate).toHaveBeenLastCalledWith(null); + expect(screen.queryByRole('button', { name: 'Regenerate' })).not.toBeInTheDocument(); + }); +}); + +describe('AIMessageComposer.ChatOptions', () => { + const options = [ + { + description: 'Takes longer, for harder questions', + id: 'think', + shortTitle: 'Think', + title: 'Think harder', + }, + { id: 'search', title: 'Search the web' }, + ]; + + it('picks an option from the menu, shows it as a chip and submits it', () => { + const onSelectedChange = vi.fn(); + const onSubmit = vi.fn((event: React.FormEvent) => { + event.preventDefault(); + return new FormData(event.currentTarget).get('chat_option'); + }); + renderWithEnglish( + + + + , + ); + const menuButton = screen.getByRole('button', { name: 'Chat options' }); + expect(menuButton).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(menuButton); + expect(screen.getByRole('menu')).toBeInTheDocument(); + expect(screen.getByText('Takes longer, for harder questions')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('menuitemradio', { name: /Think harder/ })); + expect(onSelectedChange).toHaveBeenLastCalledWith(options[0]); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(screen.getByText('Think')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + expect(onSubmit).toHaveReturnedWith('think'); + + fireEvent.click(screen.getByRole('button', { name: 'Remove option: Think' })); + expect(onSelectedChange).toHaveBeenLastCalledWith(null); + expect(screen.queryByText('Think')).not.toBeInTheDocument(); + }); + + it('closes the menu with Escape', () => { + renderWithEnglish( + + + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Chat options' })); + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + it('follows the composer’s disabled state', () => { + renderWithEnglish( + + + , + ); + expect(screen.getByRole('button', { name: 'Chat options' })).toBeDisabled(); + }); +}); + +describe('AISuggestions and AITypingIndicator', () => { + it('hands the picked suggestion on', () => { + const onSelect = vi.fn(); + renderWithEnglish( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Plan my day' })); + expect(onSelect).toHaveBeenCalledWith('Plan my day'); + }); + + it('labels what the agent is doing', () => { + renderWithEnglish(); + expect(screen.getByRole('status')).toHaveTextContent('Working on your calendar'); + }); +}); diff --git a/src/plugins/AIComponents/__tests__/AttachmentPreview.test.tsx b/src/plugins/AIComponents/__tests__/AttachmentPreview.test.tsx index f98cfd3aa..39e6cb6bb 100644 --- a/src/plugins/AIComponents/__tests__/AttachmentPreview.test.tsx +++ b/src/plugins/AIComponents/__tests__/AttachmentPreview.test.tsx @@ -102,8 +102,34 @@ describe('AIMessageComposer.AttachmentPreview', () => { expect(screen.getByText('b.txt')).toBeInTheDocument(); }); - it('renders nothing without children', () => { + it('renders nothing without children while nothing is picked', () => { const { container } = render(); expect(container).toBeEmptyDOMElement(); }); + + it('shows the picked attachments, each removable, without children', () => { + const createObjectURL = vi.fn(() => 'blob:preview'); + const revokeObjectURL = vi.fn(); + Object.assign(URL, { createObjectURL, revokeObjectURL }); + const { container, unmount } = render( + + + + , + ); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + const image = new File([new Uint8Array(4)], 'photo.png', { type: 'image/png' }); + fireEvent.change(input, { target: { files: [textFile('a.txt'), image] } }); + + const preview = container.querySelector('.str-chat__ai-attachment-preview'); + expect(preview).toHaveClass('str-chat__ai-attachment-preview', 'custom'); + expect(screen.getByText('a.txt')).toBeInTheDocument(); + expect(screen.getByAltText('photo.png')).toHaveAttribute('src', 'blob:preview'); + expect(createObjectURL).toHaveBeenCalledWith(image); + + fireEvent.click(screen.getAllByLabelText('Delete attachment')[0] as HTMLElement); + expect(screen.queryByText('a.txt')).toBeNull(); + unmount(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview'); + }); }); diff --git a/src/plugins/AIComponents/__tests__/messageParts.test.ts b/src/plugins/AIComponents/__tests__/messageParts.test.ts new file mode 100644 index 000000000..3bb4a8ed7 --- /dev/null +++ b/src/plugins/AIComponents/__tests__/messageParts.test.ts @@ -0,0 +1,246 @@ +import { + isAuthorizationExpired, + isAwaitingApproval, + isAwaitingAuthorization, + isAwaitingClient, + isDeclined, + isSupportedPart, + isToolCallFinished, + parseMessagePart, + parseMessageParts, +} from '../MessageParts'; +import type { AIToolCallPart } from '../MessageParts'; + +// Shaped as the router writes them (acceleration/internal/conversation/parts.go). +const reasoning = { + duration_ms: 4200, + finished_at: '2026-11-02T10:00:04Z', + id: 'r1', + preview: 'Let me check the calendar first.', + started_at: '2026-11-02T10:00:00Z', + status: 'completed', + summary: 'Checked the calendar', + type: 'ai_reasoning', + v: 1, +}; + +const approvalCall = { + approval: { + allow_title: 'Share', + decline_title: 'Not now', + message: 'Only your city is shared.', + reason: 'to check the local weather', + title: 'Share your location?', + }, + arguments: { reason: 'to check the local weather' }, + display_title: 'Checking your location', + executor: 'client', + id: 'call_1', + name: 'get_location', + status: 'awaiting_approval', + target_client_id: 'web-1', + target_user_id: 'alex', + type: 'ai_tool_call', + v: 1, +}; + +const authorizationCall = { + authorization: { + expires_at: '2026-11-02T10:10:00.123Z', + handoff_token: 'tok', + message: 'Juno needs your Linear account to file the issue.', + provider: 'linear', + provider_name: 'Linear', + title: 'Connect Linear', + type: 'oauth', + url: 'https://router.example.com/v1/agents/connectors/oauth/launch/abc', + }, + display_title: 'Filing a Linear issue', + executor: 'server', + id: 'call_2', + name: 'linear_create_issue', + status: 'awaiting_authorization', + target_user_id: 'alex', + type: 'ai_tool_call', + v: 1, +}; + +describe('parseMessageParts', () => { + it('reads the steps in order and skips other attachments', () => { + const parts = parseMessageParts([ + reasoning, + { image_url: 'https://example.com/a.png', type: 'image' }, + approvalCall, + authorizationCall, + ]); + expect(parts.map((part) => part.id)).toEqual(['r1', 'call_1', 'call_2']); + expect(parts.map((part) => part.kind)).toEqual([ + 'ai_reasoning', + 'ai_tool_call', + 'ai_tool_call', + ]); + expect(parts.every(isSupportedPart)).toBe(true); + }); + + it('reads a reasoning step', () => { + expect(parseMessagePart(reasoning)?.reasoning).toEqual({ + durationMs: 4200, + id: 'r1', + preview: 'Let me check the calendar first.', + status: 'completed', + summary: 'Checked the calendar', + }); + }); + + it('reads a tool call with its approval', () => { + const call = parseMessagePart(approvalCall)?.toolCall; + expect(call).toMatchObject({ + approval: { + allowTitle: 'Share', + declineTitle: 'Not now', + message: 'Only your city is shared.', + reason: 'to check the local weather', + title: 'Share your location?', + }, + arguments: { reason: 'to check the local weather' }, + displayTitle: 'Checking your location', + executor: 'client', + id: 'call_1', + name: 'get_location', + status: 'awaiting_approval', + targetClientId: 'web-1', + targetUserId: 'alex', + }); + }); + + it('reads a tool call with its authorization', () => { + const call = parseMessagePart(authorizationCall)?.toolCall; + expect(call?.authorization).toEqual({ + expiresAt: Date.parse('2026-11-02T10:10:00.123Z'), + handoffToken: 'tok', + message: 'Juno needs your Linear account to file the issue.', + provider: 'linear', + providerName: 'Linear', + status: undefined, + title: 'Connect Linear', + type: 'oauth', + url: 'https://router.example.com/v1/agents/connectors/oauth/launch/abc', + }); + }); + + it('defaults missing fields', () => { + const part = parseMessagePart({ type: 'ai_tool_call' }, 3); + expect(part?.id).toBe('ai_tool_call-3'); + expect(part?.version).toBe(1); + expect(part?.toolCall).toMatchObject({ + executor: 'server', + name: '', + status: 'running', + }); + expect(parseMessagePart({ type: 'ai_reasoning' })?.reasoning?.status).toBe( + 'completed', + ); + }); + + it('reads fields of the wrong type as missing', () => { + const call = parseMessagePart({ + approval: { message: 'no title' }, + authorization: { title: 'Connect', url: 'ftp://example.com/launch' }, + duration_ms: '12', + id: 7, + name: ['x'], + status: '', + type: 'ai_tool_call', + })?.toolCall; + expect(call).toMatchObject({ durationMs: undefined, id: 'ai_tool_call-0', name: '' }); + expect(call?.status).toBe('running'); + // a question with no title asks nothing, and a sign-in needs a web page + expect(call?.approval).toBeUndefined(); + expect(call?.authorization).toBeUndefined(); + }); + + it('keeps steps it cannot read, without a typed view', () => { + const newer = parseMessagePart({ id: 'x', type: 'ai_tool_call', v: 2 }); + expect(newer?.toolCall).toBeUndefined(); + expect(newer && isSupportedPart(newer)).toBe(false); + const custom = parseMessagePart({ id: 'c', type: 'ai_citation', url: 'u' }); + expect(custom?.kind).toBe('ai_citation'); + expect(custom?.payload).toMatchObject({ url: 'u' }); + expect(custom && isSupportedPart(custom)).toBe(false); + }); + + it('ignores what is not an AI step', () => { + expect(parseMessageParts(undefined)).toEqual([]); + expect(parseMessageParts([null, 'x', { type: 'file' }, {}])).toEqual([]); + }); +}); + +describe('tool call addressing', () => { + const call = (overrides: Partial): AIToolCallPart => ({ + ...(parseMessagePart(approvalCall)?.toolCall as AIToolCallPart), + ...overrides, + }); + + it('asks the targeted person on the targeted install', () => { + expect(isAwaitingApproval(call({}), 'alex', 'web-1')).toBe(true); + expect(isAwaitingApproval(call({}), 'alex', 'ios-2')).toBe(false); + expect(isAwaitingApproval(call({}), 'sam', 'web-1')).toBe(false); + }); + + it('asks a server tool on any of the person’s installs', () => { + const server = call({ executor: 'server', targetClientId: undefined }); + expect(isAwaitingApproval(server, 'alex', 'web-1')).toBe(true); + expect(isAwaitingApproval(server, 'alex', 'android-9')).toBe(true); + }); + + it('asks nothing once answered or without a question', () => { + expect(isAwaitingApproval(call({ status: 'running' }), 'alex', 'web-1')).toBe(false); + expect(isAwaitingApproval(call({ approval: undefined }), 'alex', 'web-1')).toBe( + false, + ); + }); + + it('waits for this device to run a client tool', () => { + const device = call({ status: 'awaiting_client' }); + expect(isAwaitingClient(device, 'alex', 'web-1')).toBe(true); + expect(isAwaitingClient(device, 'alex', 'web-2')).toBe(false); + expect(isAwaitingClient(call({ executor: 'server' }), 'alex', 'web-1')).toBe(false); + }); + + it('asks the targeted person to sign in from any install', () => { + const signIn = parseMessagePart(authorizationCall)?.toolCall as AIToolCallPart; + expect(isAwaitingAuthorization(signIn, 'alex')).toBe(true); + expect(isAwaitingAuthorization(signIn, 'sam')).toBe(false); + expect(isAwaitingAuthorization({ ...signIn, status: 'completed' }, 'alex')).toBe( + false, + ); + }); + + it('knows a declined call', () => { + const declinedApproval = call({ + approval: { decision: 'declined', title: 'Share?' }, + status: 'cancelled', + }); + expect(isDeclined(declinedApproval)).toBe(true); + const signIn = parseMessagePart(authorizationCall)?.toolCall as AIToolCallPart; + expect( + isDeclined({ + ...signIn, + authorization: { ...signIn.authorization!, status: 'declined' }, + }), + ).toBe(true); + expect(isDeclined(call({}))).toBe(false); + }); + + it('knows finished statuses and expired sign-ins', () => { + expect(['completed', 'failed', 'cancelled'].every(isToolCallFinished)).toBe(true); + expect(isToolCallFinished('awaiting_client')).toBe(false); + const signIn = parseMessagePart(authorizationCall)?.toolCall + ?.authorization as NonNullable; + expect(isAuthorizationExpired(signIn, signIn.expiresAt! - 1)).toBe(false); + expect(isAuthorizationExpired(signIn, signIn.expiresAt!)).toBe(true); + expect(isAuthorizationExpired({ ...signIn, status: 'connected' }, Infinity)).toBe( + false, + ); + }); +}); diff --git a/src/plugins/AIComponents/__tests__/popupAuthorizer.test.ts b/src/plugins/AIComponents/__tests__/popupAuthorizer.test.ts new file mode 100644 index 000000000..fcd0ab8aa --- /dev/null +++ b/src/plugins/AIComponents/__tests__/popupAuthorizer.test.ts @@ -0,0 +1,282 @@ +import { + AI_CONNECTOR_SIGN_IN_CHANNEL, + AIClientIdentity, + AIConnectorSignInError, + completeConnectorSignIn, + createPopupAuthorizer, + readConnectorSignInResult, +} from '..'; +import type { AIToolCallPart } from '..'; + +const LAUNCH = 'https://router.example.com/v1/agents/connectors/oauth/launch/abc'; +const ORIGIN = 'https://router.example.com'; + +// A BroadcastChannel that delivers within the page, synchronously, to the other channels of +// the same name. +class FakeBroadcastChannel { + static channels: FakeBroadcastChannel[] = []; + onmessage: ((event: { data: unknown }) => void) | null = null; + closed = false; + constructor(public name: string) { + FakeBroadcastChannel.channels.push(this); + } + postMessage(data: unknown) { + FakeBroadcastChannel.channels + .filter( + (channel) => channel !== this && !channel.closed && channel.name === this.name, + ) + .forEach((channel) => channel.onmessage?.({ data })); + } + close() { + this.closed = true; + } +} + +const createPopup = () => ({ + close: vi.fn(function (this: { closed: boolean }) { + this.closed = true; + }), + closed: false, + postMessage: vi.fn(), +}); + +type Popup = ReturnType; + +const call = ( + authorization?: Partial, +): AIToolCallPart => ({ + authorization: { + handoffToken: 'tok', + provider: 'linear', + title: 'Connect Linear', + type: 'oauth', + url: LAUNCH, + ...authorization, + }, + executor: 'server', + id: 'call_2', + name: 'linear_create_issue', + status: 'awaiting_authorization', + targetUserId: 'alex', +}); + +// jsdom only takes a real window as a MessageEvent's source, so the popup is set on it after. +const postFromPopup = (popup: Popup | object, data: unknown, origin = ORIGIN) => { + const event = new MessageEvent('message', { data, origin }); + Object.defineProperty(event, 'source', { value: popup }); + window.dispatchEvent(event); +}; + +const report = (status: string, connectionId = 'conn_1') => + new FakeBroadcastChannel(AI_CONNECTOR_SIGN_IN_CHANNEL).postMessage({ + connection_id: connectionId, + status, + type: 'va.connector.oauth.result', + }); + +describe('createPopupAuthorizer', () => { + let popup: Popup; + let open: ReturnType; + const decline = vi.fn(() => Promise.resolve()); + + beforeEach(() => { + vi.useFakeTimers(); + FakeBroadcastChannel.channels = []; + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + popup = createPopup(); + open = vi.fn(() => popup); + vi.spyOn(window, 'open').mockImplementation(open as unknown as typeof window.open); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + const authorizer = () => + createPopupAuthorizer({ decline, returnUrl: 'http://localhost:5173/connected' }); + + it('opens the launch page synchronously and hands off the token once it is ready', async () => { + const connecting = authorizer().connect(call()); + expect(open).toHaveBeenCalledWith(LAUNCH, expect.any(String), expect.any(String)); + + postFromPopup(popup, { type: 'va.connector.oauth.ready' }); + expect(popup.postMessage).toHaveBeenCalledWith( + { handoff_token: 'tok', type: 'va.connector.oauth.handoff' }, + ORIGIN, + ); + + report('connected'); + await expect(connecting).resolves.toBe(true); + // the channel is closed once done + expect(FakeBroadcastChannel.channels[0].closed).toBe(true); + }); + + it('hands off once, and only to the popup from the launch page’s origin', async () => { + const connecting = authorizer().connect(call()); + postFromPopup({}, { type: 'va.connector.oauth.ready' }); + postFromPopup( + popup, + { type: 'va.connector.oauth.ready' }, + 'https://evil.example.com', + ); + postFromPopup(popup, { type: 'something.else' }); + expect(popup.postMessage).not.toHaveBeenCalled(); + + postFromPopup(popup, { type: 'va.connector.oauth.ready' }); + postFromPopup(popup, { type: 'va.connector.oauth.ready' }); + expect(popup.postMessage).toHaveBeenCalledTimes(1); + + report('denied'); + await expect(connecting).resolves.toBe(false); + }); + + it('ignores outcomes before the handoff and unknown statuses', async () => { + const connecting = authorizer().connect(call()); + report('connected'); + postFromPopup(popup, { type: 'va.connector.oauth.ready' }); + report('bogus'); + report('account_mismatch'); + await expect(connecting).resolves.toBe(false); + }); + + it('resolves false when the person closes the popup', async () => { + const connecting = authorizer().connect(call()); + postFromPopup(popup, { type: 'va.connector.oauth.ready' }); + popup.closed = true; + vi.advanceTimersByTime(500); + await expect(connecting).resolves.toBe(false); + }); + + it('rejects when the browser blocks the popup', async () => { + open.mockReturnValue(null); + const connecting = authorizer().connect(call()); + await expect(connecting).rejects.toBeInstanceOf(AIConnectorSignInError); + await expect(connecting).rejects.toMatchObject({ reason: 'blocked' }); + }); + + it('rejects and closes the popup when the launch page never asks', async () => { + const connecting = authorizer().connect(call()); + vi.advanceTimersByTime(15_000); + await expect(connecting).rejects.toMatchObject({ reason: 'unreachable' }); + expect(popup.close).toHaveBeenCalled(); + }); + + it('gives up once the sign-in expires', async () => { + vi.setSystemTime(new Date('2026-11-02T10:00:00Z')); + const connecting = authorizer().connect( + call({ expiresAt: Date.parse('2026-11-02T10:01:00Z') }), + ); + postFromPopup(popup, { type: 'va.connector.oauth.ready' }); + vi.advanceTimersByTime(59_000); + let settled = false; + connecting.then(() => (settled = true)); + await Promise.resolve(); + expect(settled).toBe(false); + vi.advanceTimersByTime(1_000); + await expect(connecting).resolves.toBe(false); + }); + + it('rejects a call with no handoff token without opening anything', async () => { + await expect( + authorizer().connect(call({ handoffToken: undefined })), + ).rejects.toMatchObject({ reason: 'unavailable' }); + expect(open).not.toHaveBeenCalled(); + }); + + it('exposes the return URL and declines through the app', async () => { + const instance = authorizer(); + expect(instance.returnUrl).toBe('http://localhost:5173/connected'); + await instance.decline(call()); + expect(decline).toHaveBeenCalledWith(call()); + }); +}); + +describe('completeConnectorSignIn', () => { + beforeEach(() => { + FakeBroadcastChannel.channels = []; + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('reports the outcome over the channel and closes the popup', () => { + const close = vi.spyOn(window, 'close').mockImplementation(() => undefined); + const listener = new FakeBroadcastChannel(AI_CONNECTOR_SIGN_IN_CHANNEL); + const received = vi.fn(); + listener.onmessage = ({ data }) => received(data); + + const result = completeConnectorSignIn({ + search: '?connection_id=conn_1&status=connected', + }); + + expect(result).toEqual({ connectionId: 'conn_1', status: 'connected' }); + expect(received).toHaveBeenCalledWith({ + connection_id: 'conn_1', + status: 'connected', + type: 'va.connector.oauth.result', + }); + expect(close).toHaveBeenCalled(); + }); + + it('reads nothing from a query without an outcome', () => { + const close = vi.spyOn(window, 'close').mockImplementation(() => undefined); + expect(completeConnectorSignIn({ search: '?status=connected' })).toBeNull(); + expect(readConnectorSignInResult('?connection_id=a&status=whatever')).toBeNull(); + expect(readConnectorSignInResult('?connection_id=a%20b&status=failed')).toBeNull(); + expect(close).not.toHaveBeenCalled(); + }); + + it('works end to end with the authorizer', async () => { + vi.useFakeTimers(); + const popup = createPopup(); + vi.spyOn(window, 'open').mockImplementation( + (() => popup) as unknown as typeof window.open, + ); + vi.spyOn(window, 'close').mockImplementation(() => undefined); + const connecting = createPopupAuthorizer({ + decline: () => Promise.resolve(), + returnUrl: 'http://localhost/connected', + }).connect(call()); + postFromPopup(popup, { type: 'va.connector.oauth.ready' }); + completeConnectorSignIn({ search: '?connection_id=conn_9&status=connected' }); + await expect(connecting).resolves.toBe(true); + vi.useRealTimers(); + }); +}); + +describe('AIClientIdentity', () => { + afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it('creates a stable id and keeps it in localStorage', () => { + localStorage.clear(); + const id = AIClientIdentity.installId(); + expect(id).toMatch(/^web-/); + expect(AIClientIdentity.installId()).toBe(id); + expect(localStorage.getItem('io.getstream.ai.client-id')).toBe(id); + }); + + it('reads an id kept from before', () => { + localStorage.setItem('io.getstream.ai.client-id', 'web-kept'); + expect(AIClientIdentity.installId()).toBe('web-kept'); + }); + + it('keeps the id in memory when storage is unavailable', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blocked'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('blocked'); + }); + const id = AIClientIdentity.installId(); + expect(id).toMatch(/^web-/); + expect(AIClientIdentity.installId()).toBe(id); + }); +}); diff --git a/src/plugins/AIComponents/__tests__/renderWithEnglish.tsx b/src/plugins/AIComponents/__tests__/renderWithEnglish.tsx new file mode 100644 index 000000000..4e92952c8 --- /dev/null +++ b/src/plugins/AIComponents/__tests__/renderWithEnglish.tsx @@ -0,0 +1,23 @@ +import React, { type ReactElement } from 'react'; +import { render } from '@testing-library/react'; + +import { TranslationProvider } from '../../../context'; +import en from '../../../i18n/en.json'; + +// English with i18next-style interpolation, so tests read the strings people see. +export const englishT = ((key: string, options?: Record) => + ((en as Record)[key] ?? key).replace( + /\{\{\s*(\w+)\s*\}\}/g, + (_, name: string) => String(options?.[name] ?? ''), + )) as never; + +export const EnglishProvider = ({ children }: { children: ReactElement }) => ( + null) as never, userLanguage: 'en' }} + > + {children} + +); + +export const renderWithEnglish = (ui: ReactElement) => + render(ui, { wrapper: EnglishProvider }); diff --git a/src/plugins/AIComponents/__tests__/ssr.test.tsx b/src/plugins/AIComponents/__tests__/ssr.test.tsx index 31d28383a..11d826d00 100644 --- a/src/plugins/AIComponents/__tests__/ssr.test.tsx +++ b/src/plugins/AIComponents/__tests__/ssr.test.tsx @@ -3,9 +3,12 @@ import React from 'react'; import { renderToString } from 'react-dom/server'; import { + AIConversation, AIMarkdown, AIMessageComposer, AIStateIndicator, + AISuggestions, + parseMessageParts, StreamingMessage, VoiceGlow, VoiceWave, @@ -41,3 +44,51 @@ it('server-renders without touching browser globals', () => { expect(html.match(/role="img"/g)).toHaveLength(3); expect(html.match(/ { + const parts = parseMessageParts([ + { + id: 'r1', + preview: 'ssr-reasoning-marker', + status: 'streaming', + type: 'ai_reasoning', + }, + { + display_title: 'ssr-tool-marker', + id: 'c1', + status: 'running', + type: 'ai_tool_call', + }, + ]); + const html = renderToString( + <> + ssr-footer-marker

} + isThinking + loadOlderMessages={() => undefined} + messages={[ + { id: 'u1', role: 'user', text: 'ssr-question-marker' }, + { id: 'a1', isGenerating: true, parts, role: 'assistant', text: 'ssr-answer' }, + ]} + pendingMessage={{ id: 'p1', text: 'ssr-pending-marker' }} + /> + + + + undefined} suggestions={['ssr-suggestion']} /> + , + ); + for (const marker of [ + 'ssr-question-marker', + 'ssr-reasoning-marker', + 'ssr-tool-marker', + 'ssr-answer', + 'ssr-pending-marker', + 'ssr-footer-marker', + 'ssr-suggestion', + ]) { + expect(html).toContain(marker); + } +}); diff --git a/src/plugins/AIComponents/icons.tsx b/src/plugins/AIComponents/icons.tsx new file mode 100644 index 000000000..721620dd6 --- /dev/null +++ b/src/plugins/AIComponents/icons.tsx @@ -0,0 +1,77 @@ +import React from 'react'; + +import { createIcon } from '../../components/Icons/createIcon'; + +// Icons the AI components need that the SDK's icon set lacks. They follow its 20 × 20 grid +// and 1.5 stroke, and take `currentColor`. + +const stroke = { + fill: 'none', + stroke: 'currentColor', + strokeLinecap: 'round', + strokeLinejoin: 'round', + strokeWidth: 1.5, +} as const; + +export const IconAIReasoning = createIcon( + 'IconAIReasoning', + , +); + +export const IconAIShare = createIcon( + 'IconAIShare', + , +); + +const thumb = + 'M5.625 8.75V16.25M5.625 8.75H3.75C3.405 8.75 3.125 9.03 3.125 9.375V15.625C3.125 15.97 3.405 16.25 3.75 16.25H5.625M5.625 8.75L8.75 2.5C9.44 2.5 10.1 2.77 10.59 3.26C11.08 3.75 11.25 4.41 11.25 5.1V6.875H15.94C16.12 6.875 16.29 6.91 16.45 6.99C16.61 7.06 16.75 7.17 16.86 7.3C16.98 7.43 17.06 7.59 17.11 7.75C17.16 7.92 17.17 8.09 17.15 8.27L16.21 15.77C16.17 16.07 16.03 16.34 15.8 16.54C15.58 16.74 15.29 16.85 14.99 16.85H5.625'; // prettier-ignore + +export const IconAIThumbsUp = createIcon( + 'IconAIThumbsUp', + , +); + +export const IconAIThumbsDown = createIcon( + 'IconAIThumbsDown', + , +); + +export const IconAIThumbsUpFill = createIcon( + 'IconAIThumbsUpFill', + , +); + +export const IconAIThumbsDownFill = createIcon( + 'IconAIThumbsDownFill', + , +); + +export const IconAIHandRaised = createIcon( + 'IconAIHandRaised', + , +); + +export const IconAIKey = createIcon( + 'IconAIKey', + , +); + +export const IconAIDevice = createIcon( + 'IconAIDevice', + , +); diff --git a/src/plugins/AIComponents/index.ts b/src/plugins/AIComponents/index.ts index 8ff685868..49dc099c2 100644 --- a/src/plugins/AIComponents/index.ts +++ b/src/plugins/AIComponents/index.ts @@ -1,6 +1,12 @@ export * from './AIMarkdown'; export * from './AIMessageComposer'; export * from './AIStateIndicator/AIStateIndicator'; +export * from './Conversation'; export * from './hooks/useHeardText'; +export * from './Message'; +export * from './MessageParts'; +export * from './Reasoning'; export * from './StreamingMessage/StreamingMessage'; +export * from './Suggestions/AISuggestions'; +export * from './ToolCalls'; export * from './VoiceVisualization'; diff --git a/src/plugins/AIComponents/styling/_tokens.scss b/src/plugins/AIComponents/styling/_tokens.scss index 259776faa..260ee457e 100644 --- a/src/plugins/AIComponents/styling/_tokens.scss +++ b/src/plugins/AIComponents/styling/_tokens.scss @@ -130,6 +130,62 @@ $ai-tokens: ( '--str-chat__background-core-elevation-0', #ffffff, ), + 'accent': ( + '--str-chat__accent-primary', + #005fff, + ), + 'success': ( + '--str-chat__accent-success', + #00a46e, + ), + 'error': ( + '--str-chat__accent-error', + #d92f26, + ), + 'text-tertiary': ( + '--str-chat__text-tertiary', + #7a7d87, + ), + 'card-bg': ( + '--str-chat__background-core-surface-card', + #f7f7f8, + ), + 'card-border': ( + '--str-chat__border-core-subtle', + #e9ecef, + ), + 'elevated-bg': ( + '--str-chat__background-core-elevation-1', + #ffffff, + ), + 'user-message-bg': ( + '--str-chat__chat-bg-outgoing', + #d2e3ff, + ), + 'user-message-text': ( + '--str-chat__chat-text-outgoing', + #001a4d, + ), + 'button-primary-bg': ( + '--str-chat__button-primary-bg', + #005fff, + ), + 'button-primary-text': ( + '--str-chat__button-primary-text-on-accent', + #ffffff, + ), + 'button-secondary-bg': ( + '--str-chat__background-core-elevation-1', + #ffffff, + ), + 'button-secondary-text': ( + '--str-chat__text-primary', + #353740, + ), + 'button-secondary-border': ( + '--str-chat__button-secondary-border', + #ccc, + ), ); @function ai-token($name) { diff --git a/src/plugins/AIComponents/styling/ai-conversation.scss b/src/plugins/AIComponents/styling/ai-conversation.scss new file mode 100644 index 000000000..832034a65 --- /dev/null +++ b/src/plugins/AIComponents/styling/ai-conversation.scss @@ -0,0 +1,433 @@ +@use './tokens' as *; + +// The conversation, its messages, and what sits around them. + +@mixin ai-icon($size: 1rem) { + svg { + flex-shrink: 0; + width: $size; + height: $size; + } +} + +@mixin ai-icon-button($size: 2rem) { + all: unset; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + width: $size; + height: $size; + border-radius: 9999px; + color: ai-token(text-secondary); + cursor: pointer; + transition: background-color 0.1s ease-in-out; + + @include ai-icon(1.125rem); + + &:hover:not(:disabled) { + background-color: ai-token(surface-hover); + color: ai-token(text-primary); + } + + &[aria-pressed='true'] { + color: ai-token(text-primary); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + &:focus-visible { + @include ai-focus-ring; + } +} + +.str-chat__ai-conversation { + position: relative; + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + + &__scroller { + // the offset parent of the turns, which scrolling measures them against + position: relative; + flex: 1; + overflow-y: auto; + overscroll-behavior: contain; + } + + &__empty { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + min-height: 100%; + padding-inline: 1rem; + } + + &__turns { + box-sizing: border-box; + display: flex; + flex-direction: column; + padding-inline: 1rem; + } + + &__turn { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 1rem; + padding-top: 1rem; + } + + &__reply { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + &__loading-older { + display: flex; + justify-content: center; + padding: 0.75rem 0; + color: ai-token(text-secondary); + + @include ai-icon(1.25rem); + + svg { + animation: str-chat__ai-spin 0.9s linear infinite; + } + } + + &__scroll-to-latest { + @include ai-icon-button(2.5rem); + + position: absolute; + inset-inline-start: 50%; + margin-bottom: 0.5rem; + border: 1px solid ai-token(border); + background-color: ai-token(elevated-bg); + color: ai-token(text-primary); + transform: translateX(-50%); + + &:hover:not(:disabled) { + background-color: ai-token(elevated-bg); + } + } +} + +[dir='rtl'] .str-chat__ai-conversation__scroll-to-latest { + transform: translateX(50%); +} + +// Messages + +.str-chat__ai-user-message { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + padding-inline-start: 3rem; + + &__attachments { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.5rem; + } + + &__row { + display: flex; + align-items: flex-end; + gap: 0.25rem; + max-width: 100%; + } + + &__edit { + opacity: 0; + } + + &__row:hover &__edit, + &__edit:focus-visible { + opacity: 1; + } + + &__bubble { + padding: 0.625rem 1rem; + border-radius: 1.25rem; + background-color: ai-token(user-message-bg); + color: ai-token(user-message-text); + overflow-wrap: anywhere; + white-space: pre-wrap; + } +} + +.str-chat__ai-assistant-message { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-width: 0; + color: ai-token(text-primary); + + &__attachments { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } +} + +.str-chat__ai-message-attachment { + color: inherit; + text-decoration: none; + + &--image { + display: block; + overflow: hidden; + max-width: 15rem; + border-radius: 1rem; + } + + &__image { + display: block; + width: 100%; + max-height: 15rem; + object-fit: cover; + } + + &--file { + display: flex; + align-items: center; + gap: 0.75rem; + max-width: 18rem; + padding: 0.625rem 0.75rem; + border: 1px solid ai-token(card-border); + border-radius: 1rem; + background-color: ai-token(card-bg); + } + + &__file-icon { + display: inline-flex; + color: ai-token(text-secondary); + + @include ai-icon(1.5rem); + } + + &__file-text { + display: flex; + flex-direction: column; + min-width: 0; + } + + &__file-name { + overflow: hidden; + font-size: 0.875rem; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__file-details { + color: ai-token(text-secondary); + font-size: 0.75rem; + } + + &:focus-visible { + @include ai-focus-ring; + } +} + +.str-chat__ai-reply-actions { + display: flex; + align-items: center; + gap: 0.125rem; + + &__button { + @include ai-icon-button; + } +} + +// Indicators + +.str-chat__ai-typing-indicator { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: ai-token(text-secondary); + font-size: 0.9375rem; + + &__dots { + display: inline-flex; + gap: 0.25rem; + } + + &__dot { + width: 0.375rem; + height: 0.375rem; + border-radius: 50%; + background-color: currentColor; + animation: str-chat__ai-thinking 1.4s ease-in-out infinite; + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } + } +} + +// Suggestions + +.str-chat__ai-suggestions { + display: flex; + gap: 0.5rem; + overflow-x: auto; + padding: 0.25rem; + scrollbar-width: none; + + &__item { + all: unset; + box-sizing: border-box; + display: -webkit-box; + overflow: hidden; + flex-shrink: 0; + max-width: 10rem; + padding: 0.75rem 1rem; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + border-radius: 1rem; + background-color: ai-token(card-bg); + color: ai-token(text-primary); + cursor: pointer; + font-size: 0.875rem; + line-height: 1.3; + transition: background-color 0.1s ease-in-out; + + &:hover { + background-color: ai-token(surface-hover); + } + + &:focus-visible { + @include ai-focus-ring; + } + } +} + +// Composer chat options + +.str-chat__ai-chat-options { + position: relative; + display: inline-flex; + align-items: center; + gap: 0.5rem; + + // the composer's own round button styles apply inside its form; outside it, the same look + > .str-chat__ai-message-composer__round-button { + @include ai-icon-button(2rem); + + @include ai-icon(1.5rem); + } + + &__list { + position: absolute; + bottom: calc(100% + 0.5rem); + inset-inline-start: 0; + z-index: 1; + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 16rem; + max-width: min(22rem, 90vw); + padding: 0.375rem; + border: 1px solid ai-token(border); + border-radius: 1rem; + background-color: ai-token(elevated-bg); + } + + &__item { + all: unset; + box-sizing: border-box; + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.5rem 0.625rem; + border-radius: 0.75rem; + color: ai-token(text-primary); + cursor: pointer; + + &:hover, + &:focus-visible { + background-color: ai-token(surface-hover); + } + + &:focus-visible { + @include ai-focus-ring; + } + + &[aria-checked='true'] { + background-color: ai-token(surface-pressed); + } + } + + &__item-icon { + display: inline-flex; + margin-top: 0.125rem; + + @include ai-icon(1.25rem); + } + + &__item-text { + display: flex; + flex-direction: column; + gap: 0.125rem; + } + + &__item-title { + font-size: 0.9375rem; + font-weight: 500; + } + + &__item-description { + color: ai-token(text-secondary); + font-size: 0.8125rem; + } + + &__chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.25rem 0.25rem 0.625rem; + border-radius: 9999px; + background-color: ai-token(surface-pressed); + color: ai-token(accent); + font-size: 0.875rem; + font-weight: 500; + } + + &__chip-icon { + display: inline-flex; + + @include ai-icon; + } + + &__chip-remove { + @include ai-icon-button(1.5rem); + + color: inherit; + } +} + +@media (prefers-reduced-motion: reduce) { + .str-chat__ai-typing-indicator__dot { + animation: none; + opacity: 0.7; + } + + .str-chat__ai-conversation__loading-older svg { + animation-duration: 2.7s; + } +} diff --git a/src/plugins/AIComponents/styling/ai-message-composer.scss b/src/plugins/AIComponents/styling/ai-message-composer.scss index eb77ccf11..786e5bb2b 100644 --- a/src/plugins/AIComponents/styling/ai-message-composer.scss +++ b/src/plugins/AIComponents/styling/ai-message-composer.scss @@ -63,6 +63,12 @@ flex-grow: 1; font-size: 16px; padding-inline: 0.25rem; + + // the form shows the focus; this beats the `.str-chat *:not(:disabled):focus-visible` + // outline, which a clicked text field matches + &:not(:disabled):focus-visible { + outline: none; + } } .str-chat__ai-message-composer__select { diff --git a/src/plugins/AIComponents/styling/ai-message-parts.scss b/src/plugins/AIComponents/styling/ai-message-parts.scss new file mode 100644 index 000000000..d04af8ca1 --- /dev/null +++ b/src/plugins/AIComponents/styling/ai-message-parts.scss @@ -0,0 +1,385 @@ +@use './tokens' as *; + +// The steps of a reply: reasoning, tool calls, and the questions and sign-ins they wait for. + +@mixin ai-icon($size: 1rem) { + svg { + flex-shrink: 0; + width: $size; + height: $size; + } +} + +.str-chat__ai-message-parts { + display: flex; + flex-direction: column; + gap: 0.625rem; + width: 100%; +} + +.str-chat__ai-message-part__tool { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.str-chat__ai-message-part--unsupported { + display: flex; + align-items: center; + gap: 0.5rem; + color: ai-token(text-secondary); + font-size: 0.875rem; + + @include ai-icon; +} + +// A text that sweeps a highlight across while it waits or thinks. +.str-chat__ai-shimmer { + animation: str-chat__ai-shimmer 1.6s ease-in-out infinite; +} + +@keyframes str-chat__ai-shimmer { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} + +@keyframes str-chat__ai-spin { + to { + transform: rotate(360deg); + } +} + +.str-chat__ai-spinner { + display: inline-flex; + color: ai-token(text-secondary); + + @include ai-icon(1.125rem); + + svg { + animation: str-chat__ai-spin 0.9s linear infinite; + } +} + +// Tool call + +.str-chat__ai-tool-call { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.9375rem; + line-height: 1.35; + + &__icon { + display: inline-flex; + margin-top: 0.125rem; + color: ai-token(accent); + + @include ai-icon; + + &--spinning svg { + animation: str-chat__ai-spin 0.9s linear infinite; + } + } + + &--completed &__icon { + color: ai-token(success); + } + + &--failed &__icon { + color: ai-token(error); + } + + &--cancelled &__icon { + color: ai-token(text-secondary); + } + + &__text { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.125rem; + min-width: 0; + } + + &__title { + color: ai-token(text-primary); + } + + &__detail { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + color: ai-token(text-secondary); + font-size: 0.8125rem; + } + + &__duration { + color: ai-token(text-secondary); + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } +} + +// Approval and sign-in cards + +.str-chat__ai-tool-approval-card, +.str-chat__ai-tool-authorization-card { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem; + border: 1px solid ai-token(card-border); + border-radius: 1rem; + background-color: ai-token(card-bg); + color: ai-token(text-primary); + font-size: 0.9375rem; +} + +.str-chat__ai-tool-approval-card { + &__text { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + &__title { + font-weight: 600; + } + + &__message { + color: ai-token(text-secondary); + font-size: 0.875rem; + } + + &__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + } + + &__failure { + color: ai-token(error); + font-size: 0.8125rem; + } +} + +.str-chat__ai-tool-authorization-card { + &__outcome { + color: ai-token(text-secondary); + font-size: 0.875rem; + } + + &--connected &__outcome { + color: ai-token(success); + } + + &--failed &__outcome, + &--expired &__outcome { + color: ai-token(error); + } +} + +.str-chat__ai-button { + all: unset; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2rem; + padding: 0.375rem 0.875rem; + border-radius: 9999px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 600; + transition: opacity 0.1s ease-in-out; + + &--primary { + background-color: ai-token(button-primary-bg); + color: ai-token(button-primary-text); + } + + &--secondary { + border: 1px solid ai-token(button-secondary-border); + background-color: ai-token(button-secondary-bg); + color: ai-token(button-secondary-text); + } + + &:hover:not(:disabled) { + opacity: 0.88; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + &:focus-visible { + @include ai-focus-ring; + } +} + +// Reasoning + +.str-chat__ai-reasoning { + position: relative; + padding-inline-start: 0.75rem; + color: ai-token(text-secondary); + font-size: 0.9375rem; + + // the rule along the reasoning + &::before { + position: absolute; + inset-block: 0; + inset-inline-start: 0; + width: 2px; + border-radius: 1px; + background-color: ai-token(border-subtle); + content: ''; + } + + &--thinking::before { + background-color: ai-token(accent); + opacity: 0.6; + } + + &__header { + all: unset; + box-sizing: border-box; + display: flex; + align-items: center; + gap: 0.375rem; + max-width: 100%; + color: ai-token(text-primary); + cursor: pointer; + font-weight: 500; + + &:focus-visible { + @include ai-focus-ring; + } + } + + &__icon { + display: inline-flex; + + @include ai-icon; + } + + &--thinking &__icon svg { + animation: str-chat__ai-shimmer 1.6s ease-in-out infinite; + } + + &__title { + flex-shrink: 0; + font-variant-numeric: tabular-nums; + } + + &__summary { + overflow: hidden; + min-width: 0; + color: ai-token(text-secondary); + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__disclosure { + display: inline-flex; + transition: transform 0.2s ease-in-out; + + @include ai-icon(0.75rem); + } + + &--expanded &__disclosure { + transform: rotate(90deg); + } + + [dir='rtl'] &--expanded &__disclosure { + transform: rotate(-90deg); + } + + // Folding shrinks the panel in place rather than removing it, so what sits below glides with it. + &__fold { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: + grid-template-rows 0.45s cubic-bezier(0.2, 0.9, 0.3, 1), + opacity 0.3s ease-in-out; + + &[data-open='true'] { + grid-template-rows: 1fr; + opacity: 1; + } + } + + &__fold-inner { + overflow: hidden; + min-height: 0; + // out of the tab order and the accessibility tree once folded + visibility: hidden; + transition: visibility 0s linear 0.45s; + } + + &__fold[data-open='true'] &__fold-inner { + visibility: visible; + transition-delay: 0s; + } + + &__scroller { + overflow-y: auto; + padding-top: 0.5rem; + overscroll-behavior: contain; + + &--overflowing { + mask-image: linear-gradient(to bottom, transparent 0, currentColor 14%); + } + + &:focus-visible { + @include ai-focus-ring; + } + } + + &__paragraph { + line-height: 1.5; + + & + & { + margin-top: 0.625rem; + } + + p { + margin: 0; + } + } + + &__footnote { + padding-top: 0.5rem; + color: ai-token(text-tertiary); + font-size: 0.8125rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .str-chat__ai-shimmer, + .str-chat__ai-reasoning--thinking .str-chat__ai-reasoning__icon svg { + animation: none; + } + + .str-chat__ai-reasoning__fold, + .str-chat__ai-reasoning__fold-inner, + .str-chat__ai-reasoning__disclosure { + transition: none; + } + + .str-chat__ai-tool-call__icon--spinning svg, + .str-chat__ai-spinner svg { + animation-duration: 2.7s; + } +} diff --git a/src/plugins/AIComponents/styling/index.scss b/src/plugins/AIComponents/styling/index.scss index d94180285..4c60a7f1b 100644 --- a/src/plugins/AIComponents/styling/index.scss +++ b/src/plugins/AIComponents/styling/index.scss @@ -4,5 +4,7 @@ @use './ai-state-indicator.scss'; @use './voice-visualization.scss'; @use './voice-glow-css.scss'; +@use './ai-message-parts.scss'; +@use './ai-conversation.scss'; // Intentionally declares no `--str-chat__ai-*` custom properties; see _tokens.scss. diff --git a/src/plugins/AIComponents/utils.ts b/src/plugins/AIComponents/utils.ts new file mode 100644 index 000000000..a98a5cdd9 --- /dev/null +++ b/src/plugins/AIComponents/utils.ts @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react'; + +const unit = (locale: string, value: number, name: 'minute' | 'second') => { + try { + return new Intl.NumberFormat(locale, { + style: 'unit', + unit: name, + unitDisplay: 'narrow', + }).format(value); + } catch { + return `${value}${name === 'minute' ? 'm' : 's'}`; + } +}; + +/** + * A whole number of seconds as minutes and seconds, in narrow units: "7s", "1m 5s". + */ +export const formatMinutesAndSeconds = (totalSeconds: number, locale = 'en') => { + const seconds = Math.max(0, Math.floor(totalSeconds)); + const minutes = Math.floor(seconds / 60); + const rest = seconds % 60; + if (minutes === 0) return unit(locale, rest, 'second'); + if (rest === 0) return unit(locale, minutes, 'minute'); + return `${unit(locale, minutes, 'minute')} ${unit(locale, rest, 'second')}`; +}; + +/** A tool call's duration: tenths below a second ("0.4s"), then minutes and seconds. */ +export const formatCallDuration = (durationSeconds: number, locale = 'en') => + durationSeconds < 1 + ? unit(locale, Math.round(durationSeconds * 10) / 10, 'second') + : formatMinutesAndSeconds(Math.round(durationSeconds), locale); + +/** `Date.now()`, refreshed every `intervalMs` while `active`. */ +export const useNow = (active: boolean, intervalMs = 1000) => { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!active) return; + setNow(Date.now()); + const timer = setInterval(() => setNow(Date.now()), intervalMs); + return () => clearInterval(timer); + }, [active, intervalMs]); + return now; +}; + +export const prefersReducedMotion = () => + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; From ba27cf239736f5417951741f903ce0787a387abe Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 3 Nov 2026 10:34:45 +0100 Subject: [PATCH 02/22] fix(ai-components): address review findings for agent conversations - Sanitise attachment links and images, keeping blob and image data URLs. - Keep waiting for a sign-in while a provider page severs the popup. - Retry loading older messages after a failure or an empty page. - Measure turns only when they change, and on scroll once a frame. - Drop the entry memo, so a new rating shows with unchanged messages. - Write the thinking clock only from effects. - Build ChatOptions on the SDK's Dropdown, and sizes on prettifyFileSize. - Keep one preview URL per picked image. --- .../AIMessageComposer/AttachmentPreview.tsx | 34 ++- .../AIMessageComposer/ChatOptions.tsx | 202 +++++++----------- .../Conversation/AIConversation.tsx | 155 +++++++++----- .../Message/AIMessageAttachment.tsx | 54 ++--- .../AIComponents/Reasoning/AIReasoning.tsx | 20 +- .../AIComponents/Reasoning/thinkingClock.ts | 56 ++--- .../AIComponents/ToolCalls/popupAuthorizer.ts | 8 +- .../__tests__/AIConversation.test.tsx | 54 ++++- .../__tests__/AIMessagePartViews.test.tsx | 46 ++++ .../__tests__/AttachmentPreview.test.tsx | 3 + .../__tests__/popupAuthorizer.test.ts | 22 +- .../AIComponents/styling/ai-conversation.scss | 8 +- 12 files changed, 401 insertions(+), 261 deletions(-) diff --git a/src/plugins/AIComponents/AIMessageComposer/AttachmentPreview.tsx b/src/plugins/AIComponents/AIMessageComposer/AttachmentPreview.tsx index b02f031cd..0290c0e74 100644 --- a/src/plugins/AIComponents/AIMessageComposer/AttachmentPreview.tsx +++ b/src/plugins/AIComponents/AIMessageComposer/AttachmentPreview.tsx @@ -2,6 +2,8 @@ import { type ComponentPropsWithoutRef, type MouseEvent, useEffect, + useLayoutEffect, + useRef, useState, } from 'react'; import { useAttachments } from './AIMessageComposer'; @@ -94,18 +96,38 @@ export const Item = ({ type Attachments = ReturnType; -// An object URL per picked image, revoked once it is removed or the preview unmounts. +// The layout effect warns under server rendering, where nothing is picked anyway. +const useIsomorphicLayoutEffect = + typeof window === 'undefined' ? useEffect : useLayoutEffect; + +// An object URL per picked image, made once per attachment, before the first paint, and revoked +// once it is removed or the preview unmounts. const useImagePreviewSources = (attachments: Attachments['attachments']) => { + const urls = useRef(new Map()); const [sources, setSources] = useState>({}); - useEffect(() => { + useIsomorphicLayoutEffect(() => { if (typeof URL.createObjectURL !== 'function') return; - const created: Record = {}; + const made = urls.current; + const images = new Set(); for (const { file, id } of attachments) { - if (file.type.startsWith('image/')) created[id] = URL.createObjectURL(file); + if (!file.type.startsWith('image/')) continue; + images.add(id); + if (!made.has(id)) made.set(id, URL.createObjectURL(file)); } - setSources(created); - return () => Object.values(created).forEach((url) => URL.revokeObjectURL(url)); + for (const [id, url] of made) { + if (images.has(id)) continue; + URL.revokeObjectURL(url); + made.delete(id); + } + setSources(Object.fromEntries(made)); }, [attachments]); + useEffect(() => { + const made = urls.current; + return () => { + made.forEach((url) => URL.revokeObjectURL(url)); + made.clear(); + }; + }, []); return sources; }; diff --git a/src/plugins/AIComponents/AIMessageComposer/ChatOptions.tsx b/src/plugins/AIComponents/AIMessageComposer/ChatOptions.tsx index a0d158de3..aded19f8d 100644 --- a/src/plugins/AIComponents/AIMessageComposer/ChatOptions.tsx +++ b/src/plugins/AIComponents/AIMessageComposer/ChatOptions.tsx @@ -1,15 +1,9 @@ -import React, { - type KeyboardEvent, - type ReactNode, - useEffect, - useRef, - useState, -} from 'react'; +import React, { createContext, type ReactNode, useContext, useState } from 'react'; import clsx from 'clsx'; import { useTranslationContext } from '../../../context/TranslationContext'; import { useComponentContextIcons } from '../../../context/useComponentContextIcons'; -import { useStableId } from '../../../components/UtilityComponents/useStableId'; +import { Dropdown, type DropdownTriggerProps } from '../../../components/Form/Dropdown'; /** * A way to ask, which the person picks for their next message, such as "Think harder". The @@ -32,86 +26,44 @@ export type AIChatOption = { export type ChatOptionsListProps = { onSelect: (option: AIChatOption) => void; options: AIChatOption[]; - className?: string; - id?: string; - /** Called when the person leaves the list with Escape or Tab. */ - onDismiss?: () => void; selectedId?: string | null; }; -/** The menu of chat options. The counterpart of `ChatOptionsList`. */ +/** + * The items of the chat options menu, one per option. `ChatOptions` shows them in the SDK's + * `Dropdown`, which moves focus between them and closes on Escape. The counterpart of + * `ChatOptionsList`. + */ export const ChatOptionsList = ({ - className, - id, - onDismiss, onSelect, options, selectedId, -}: ChatOptionsListProps) => { - const { t } = useTranslationContext(); - const listRef = useRef(null); - - useEffect(() => { - const items = listRef.current?.querySelectorAll( - '[role="menuitemradio"]', - ); - const selected = [...(items ?? [])].find( - (item) => item.getAttribute('aria-checked') === 'true', - ); - (selected ?? items?.[0])?.focus(); - }, []); - - const onKeyDown = (event: KeyboardEvent) => { - const items = [ - ...(listRef.current?.querySelectorAll('[role="menuitemradio"]') ?? []), - ]; - const index = items.indexOf(document.activeElement as HTMLElement); - const move = (to: number) => { - event.preventDefault(); - items[(to + items.length) % items.length]?.focus(); - }; - if (event.key === 'ArrowDown') move(index + 1); - else if (event.key === 'ArrowUp') move(index - 1); - else if (event.key === 'Home') move(0); - else if (event.key === 'End') move(items.length - 1); - else if (event.key === 'Escape' || event.key === 'Tab') onDismiss?.(); - }; - - return ( - - ); -}; + + + ))} + +); export type SelectedChatOptionChipProps = { onRemove: () => void; @@ -165,6 +117,36 @@ export type ChatOptionsProps = { selectedId?: string | null; }; +// What the menu button needs beyond what `Dropdown` hands its trigger. +const ChatOptionsTriggerContext = createContext<{ disabled?: boolean; icon?: ReactNode }>( + {}, +); + +// Defined once, since `Dropdown` closes whenever its trigger component changes. +const ChatOptionsTrigger = ({ + onClick, + referenceRef, + ...props +}: DropdownTriggerProps) => { + const { t } = useTranslationContext(); + const { IconPlus } = useComponentContextIcons(); + const { disabled, icon } = useContext(ChatOptionsTriggerContext); + return ( + + ); +}; + /** * A menu of ways to ask, such as "Think harder", and a chip for the one picked, with a button * that removes it. Place it inside `AIMessageComposer`; with `name`, the picked option's id is @@ -181,23 +163,9 @@ export const ChatOptions = ({ selectedId: selectedIdProp, }: ChatOptionsProps) => { const { t } = useTranslationContext(); - const { IconPlus } = useComponentContextIcons(); - const listId = `str-chat__ai-chat-options-${useStableId()}`; - const [isOpen, setIsOpen] = useState(false); const [uncontrolledId, setUncontrolledId] = useState(defaultSelectedId); const selectedId = selectedIdProp === undefined ? uncontrolledId : selectedIdProp; const selected = options.find((option) => option.id === selectedId); - const rootRef = useRef(null); - const buttonRef = useRef(null); - - useEffect(() => { - if (!isOpen) return; - const onPointerDown = (event: PointerEvent) => { - if (!rootRef.current?.contains(event.target as Node)) setIsOpen(false); - }; - document.addEventListener('pointerdown', onPointerDown); - return () => document.removeEventListener('pointerdown', onPointerDown); - }, [isOpen]); const select = (option: AIChatOption | null) => { setUncontrolledId(option?.id ?? null); @@ -205,36 +173,22 @@ export const ChatOptions = ({ }; return ( -
- - {isOpen && ( - { - setIsOpen(false); - buttonRef.current?.focus(); - }} - onSelect={(option) => { - setIsOpen(false); - select(option.id === selectedId ? null : option); - buttonRef.current?.focus(); - }} - options={options} - selectedId={selectedId} - /> - )} +
+ + +
+ select(option.id === selectedId ? null : option)} + options={options} + selectedId={selectedId} + /> +
+
+
{selected && ( { - const { message, pending } = entry; - if (!message) { - return pending ? ( - - ) : null; - } - if (message.role === 'user') { - return ( - - ); - } +// Not memoised: an app's messages are new objects on every update while a reply streams, and a +// rating given through `replyActions` must reach the reply's actions whether or not they are. +const EntryView = ({ + approver, + AssistantMessage, + entry, + ReplyActions, + UserMessage, +}: EntryViewProps) => { + const { message, pending } = entry; + if (!message) { + return pending ? ( + + ) : null; + } + if (message.role === 'user') { return ( -
- + ); + } + return ( +
+ + {ReplyActions && !message.isGenerating && message.text !== '' && ( + - {ReplyActions && !message.isGenerating && message.text !== '' && ( - - )} -
- ); - }, -); -EntryView.displayName = 'AIConversationEntry'; + )} +
+ ); +}; type HeldTurn = { id: string; top: number }; +// The turn's first reasoning step, which takes over the seconds the turn was seen thinking. +const firstStepOf = (turn: AIConversationTurn | undefined) => { + for (const { message } of turn?.entries ?? []) { + if (!message || message.role === 'user') continue; + const step = message.parts?.find((part) => part.reasoning); + if (step) return step.id; + } + return undefined; +}; + const scrollScroller = (scroller: HTMLElement, top: number, smooth: boolean) => { if (typeof scroller.scrollTo !== 'function') { scroller.scrollTop = top; @@ -244,8 +260,13 @@ export const AIConversation = ({ const thinking = isThinking || tracker.isAwaitingReply(messages, pendingMessage); const turns = useMemo(() => turnsOf(entries), [entries]); const latest = turns[turns.length - 1]; - // Repeats harmlessly on every render: a turn's thinking starts and ends once. - clock.update(latest?.id, thinking, (latest?.entries.length ?? 0) > 1); + const latestHasReplies = (latest?.entries.length ?? 0) > 1; + const latestHasFirstStep = firstStepOf(latest) !== undefined; + // Before the views below read it in their effects and next renders: a turn's thinking starts + // and ends once. + useIsomorphicLayoutEffect(() => { + clock.update(latest?.id, thinking, latestHasReplies, latestHasFirstStep); + }, [clock, latest?.id, latestHasFirstStep, latestHasReplies, thinking]); const replyActionsRef = useRef(replyActions); replyActionsRef.current = replyActions; @@ -335,11 +356,17 @@ export const AIConversation = ({ .map(({ element, id }) => ({ id, top: element.offsetTop - top })); }, []); - const scrollToTurnTop = (id: string, smooth: boolean) => { - const scroller = scrollerRef.current; - const element = turnElements.current.get(id); - if (scroller && element) scrollScroller(scroller, element.offsetTop, smooth); + // Noting where the turns are reads the layout of each, so scrolling does it once a frame. + const scrollFrame = useRef(undefined); + const onScroll = () => { + if (scrollFrame.current !== undefined) return; + scrollFrame.current = requestAnimationFrame(() => { + scrollFrame.current = undefined; + noteHeld(); + checkEnd(); + }); }; + useEffect(() => () => cancelAnimationFrame(scrollFrame.current ?? 0), []); const scrollToEnd = useCallback((smooth: boolean) => { const scroller = scrollerRef.current; @@ -357,6 +384,8 @@ export const AIConversation = ({ mounted: boolean; }>({ height: 0, loadsOlder, mounted: false }); + // Runs when the turns around what is in view change, not on every render: a reply streaming in + // grows only the latest turn, which the observers above follow. useIsomorphicLayoutEffect(() => { const previous = shown.current; shown.current = { firstTurnId, height, lastTurnId, loadsOlder, mounted: true }; @@ -383,13 +412,14 @@ export const AIConversation = ({ // conversation replaced as a whole opens at its end. const continues = previous.lastTurnId !== undefined && - turns.some((turn) => turn.id === previous.lastTurnId); - if (continues) scrollToTurnTop(lastTurnId, true); + turnElements.current.has(previous.lastTurnId); + const element = turnElements.current.get(lastTurnId); + if (continues && element) scrollScroller(scroller, element.offsetTop, true); else scroller.scrollTop = scroller.scrollHeight; } noteHeld(); checkEnd(); - }); + }, [checkEnd, firstTurnId, height, lastTurnId, loadsOlder, noteHeld]); // Loads older messages once the spinner above the turns has been in view a moment, and again // whenever the first turn changes while it is in view. @@ -409,16 +439,29 @@ export const AIConversation = ({ } let timer: ReturnType | undefined; let loading = false; + let disposed = false; const observer = new IntersectionObserver( ([visible]) => { clearTimeout(timer); if (!visible?.isIntersecting || loading) return; timer = setTimeout(() => { loading = true; + let failed = false; Promise.resolve(loadOlderRef.current?.()) - .catch(() => undefined) + .catch(() => { + failed = true; + }) .finally(() => { loading = false; + if (disposed) return; + // Watching the spinner afresh reports whether it is still in view, so a load that + // failed, or brought nothing while there is more, asks again: after a pause when + // it failed. A load that brought older messages re-runs this effect instead. + observer.unobserve(spinner); + timer = setTimeout( + () => observer.observe(spinner), + failed ? LOAD_OLDER_RETRY_MS : 0, + ); }); }, LOAD_OLDER_DELAY_MS); }, @@ -426,6 +469,7 @@ export const AIConversation = ({ ); observer.observe(spinner); return () => { + disposed = true; clearTimeout(timer); observer.disconnect(); }; @@ -451,7 +495,9 @@ export const AIConversation = ({ : undefined } > - + {turn.entries.map((entry) => (
{ - noteHeld(); - checkEnd(); - }} + onScroll={onScroll} ref={scrollerRef} > {turns.length === 0 ? ( diff --git a/src/plugins/AIComponents/Message/AIMessageAttachment.tsx b/src/plugins/AIComponents/Message/AIMessageAttachment.tsx index 87ee3742b..e593f4e1b 100644 --- a/src/plugins/AIComponents/Message/AIMessageAttachment.tsx +++ b/src/plugins/AIComponents/Message/AIMessageAttachment.tsx @@ -1,8 +1,10 @@ import React from 'react'; import clsx from 'clsx'; +import { sanitizeUrl } from '@braintree/sanitize-url'; import { useTranslationContext } from '../../../context/TranslationContext'; import { useComponentContextIcons } from '../../../context/useComponentContextIcons'; +import { prettifyFileSize } from '../../../components/MessageComposer/hooks/utils'; /** * What a message carries besides its text, such as an image or a file. The counterpart of @@ -34,31 +36,20 @@ const extensionOf = (attachment: AIMessageAttachmentData) => { return attachment.mimeType?.split('/')[1]?.split(/[+;]/)[0]; }; -const formatSize = (bytes: number, locale: string) => { - const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte'] as const; - let value = bytes; - let index = 0; - while (value >= 1000 && index < units.length - 1) { - value /= 1000; - index += 1; - } - try { - return new Intl.NumberFormat(locale, { - maximumFractionDigits: index === 0 ? 0 : 1, - style: 'unit', - unit: units[index], - unitDisplay: 'short', - }).format(value); - } catch { - return `${value.toFixed(index === 0 ? 0 : 1)} ${['B', 'KB', 'MB', 'GB'][index]}`; - } -}; +// A web or blob URL as it is, and an image's data URL for its thumbnail (a photo just picked); +// anything else, such as `javascript:`, is blanked as the rest of the SDK's links are. +const safeUrl = (url: string, kind: AIMessageAttachmentData['kind']) => + /^blob:/i.test(url) || (kind === 'image' && /^data:image\//i.test(url)) + ? url + : sanitizeUrl(url); + +const BLANK_URL = 'about:blank'; -/** Its type and size, such as "PDF · 2.4 MB". */ -export const attachmentDetails = (attachment: AIMessageAttachmentData, locale = 'en') => +/** Its type and size, such as "PDF · 2.34 MB". */ +export const attachmentDetails = (attachment: AIMessageAttachmentData) => [ extensionOf(attachment)?.toUpperCase(), - attachment.size !== undefined ? formatSize(attachment.size, locale) : undefined, + attachment.size !== undefined ? prettifyFileSize(attachment.size) : undefined, ] .filter(Boolean) .join(' · '); @@ -79,8 +70,9 @@ export const AIMessageAttachment = ({ className, role = 'assistant', }: AIMessageAttachmentProps) => { - const { t, userLanguage } = useTranslationContext(); + const { t } = useTranslationContext(); const { IconFile } = useComponentContextIcons(); + const url = attachment.url ? safeUrl(attachment.url, attachment.kind) : undefined; const name = attachment.name || (attachment.url && !attachment.url.startsWith('data:') @@ -95,19 +87,15 @@ export const AIMessageAttachment = ({ className, ); - if (attachment.kind === 'image' && attachment.url) { + if (attachment.kind === 'image' && url && url !== BLANK_URL) { return ( - - {name} + + {name} ); } - const details = attachmentDetails(attachment, userLanguage); + const details = attachmentDetails(attachment); const content = ( <> @@ -122,11 +110,11 @@ export const AIMessageAttachment = ({ ); - return attachment.url ? ( + return url && url !== BLANK_URL ? ( diff --git a/src/plugins/AIComponents/Reasoning/AIReasoning.tsx b/src/plugins/AIComponents/Reasoning/AIReasoning.tsx index 2a9458b18..a158da0ad 100644 --- a/src/plugins/AIComponents/Reasoning/AIReasoning.tsx +++ b/src/plugins/AIComponents/Reasoning/AIReasoning.tsx @@ -196,11 +196,20 @@ export const AIReasoning = ({ durationProp ?? (part?.durationMs !== undefined ? part.durationMs / 1000 : undefined); const summary = summaryProp ?? part?.summary; - // The turn whose thinking this step takes over, when it is the turn's first step. + // The turn whose thinking this step takes over, when it is the first step of a turn that was + // seen thinking. const thinkingTurn = - part && turn && turn.clock.isFirstStep(part.id, turn.key) ? turn : undefined; - if (isThinking && thinkingTurn) - thinkingTurn.clock.firstStepIsStreaming(thinkingTurn.key); + part && + turn && + turn.firstStepId === part.id && + turn.clock.start(turn.key) !== undefined + ? turn + : undefined; + const clock = thinkingTurn?.clock; + const turnKey = thinkingTurn?.key; + useEffect(() => { + if (isThinking && clock && turnKey !== undefined) clock.firstStepStreams(turnKey); + }, [clock, isThinking, turnKey]); // What the reader chose by clicking the header. Until they do, the reasoning is open while // the model thinks and folded once it is done. @@ -234,16 +243,19 @@ export const AIReasoning = ({ if (isThinking) { setThinkingSince(Date.now()); } else { + if (clock && turnKey !== undefined) clock.firstStepDone(turnKey, ownDuration); setThoughtFor(Math.max(ownDuration ?? 0, (Date.now() - thinkingSince) / 1000)); } setOpen(choice ?? opensByDefault(isThinking, showsLiveReasoning, initiallyExpanded)); }, [ choice, + clock, initiallyExpanded, isThinking, ownDuration, showsLiveReasoning, thinkingSince, + turnKey, ]); const start = diff --git a/src/plugins/AIComponents/Reasoning/thinkingClock.ts b/src/plugins/AIComponents/Reasoning/thinkingClock.ts index 662a257e4..7c40c0fab 100644 --- a/src/plugins/AIComponents/Reasoning/thinkingClock.ts +++ b/src/plugins/AIComponents/Reasoning/thinkingClock.ts @@ -11,10 +11,8 @@ const ARRIVAL_WINDOW_MS = 1000; type Turn = { start: number; end?: number; - firstStep?: string; + // How long the first step thought, once it was done, in seconds. firstStepDuration?: number; - // When a first step that arrived done opened to show its reasoning. - firstStepShown?: number; firstStepStreamed: boolean; // Whether a reply of the turn showed. hadReplies: boolean; @@ -23,6 +21,9 @@ type Turn = { /** * Times each turn's thinking, so the reply's first reasoning step takes over the seconds the * conversation counted while it showed the agent thinking. The counterpart of `AIThinkingClock`. + * + * It is written only from effects (`update`, `firstStepStreams`, `firstStepDone`) and read + * during render, so a render React throws away changes nothing. */ export class AIThinkingClock { private turns = new Map(); @@ -34,6 +35,7 @@ export class AIThinkingClock { latest: string | undefined, isThinking: boolean, hasReplies: boolean, + hasFirstStep: boolean, now = Date.now(), ) { if (latest === undefined) return; @@ -50,7 +52,7 @@ export class AIThinkingClock { } if (hasReplies) timed.hadReplies = true; if (isThinking) { - if (timed.firstStep === undefined) timed.end = undefined; + if (!hasFirstStep) timed.end = undefined; } else if (timed.end === undefined) { timed.end = now; } @@ -60,47 +62,47 @@ export class AIThinkingClock { return this.turns.get(turn)?.start; } - // Whether `step` takes over a turn's thinking: the first reasoning step to show in a turn that - // was seen thinking. - isFirstStep(step: string, turn: string) { + firstStepStreams(turn: string) { const timed = this.turns.get(turn); - if (!timed) return false; - timed.firstStep ??= step; - return timed.firstStep === step; + if (timed) timed.firstStepStreamed = true; } - firstStepIsStreaming(turn: string) { + // The first step stopped streaming: it thought from when the turn started thinking until now, + // or for its own time when that is longer. + firstStepDone(turn: string, own: number | undefined, now = Date.now()) { const timed = this.turns.get(turn); - if (timed) timed.firstStepStreamed = true; + if (timed && timed.firstStepDuration === undefined) { + timed.firstStepDuration = Math.max(own ?? 0, (now - timed.start) / 1000); + } } - // How long a turn's first step thought, in seconds: from when the turn started thinking until - // the step was done. The step's own time counts when it is longer. + // How long a turn's first step thought, in seconds. One that arrived done thought until the + // turn stopped thinking, which with no end yet is the render it arrives in; one that streamed, + // until `firstStepDone`. durationOfFirstStep(turn: string, own: number | undefined, now = Date.now()) { const timed = this.turns.get(turn); if (!timed) return own; if (timed.firstStepDuration !== undefined) return timed.firstStepDuration; - const done = timed.firstStepStreamed ? now : (timed.end ?? now); - const measured = Math.max(own ?? 0, (done - timed.start) / 1000); - timed.firstStepDuration = measured; - return measured; + if (timed.firstStepStreamed) return own; + return Math.max(own ?? 0, ((timed.end ?? now) - timed.start) / 1000); } - // Whether a turn's first step, which arrived done with the reply that ended the thinking, opens - // to show its reasoning before it folds and the answer starts. + // Whether a turn's first step, which arrived done with the reply that ends the thinking, opens + // to show its reasoning before it folds and the answer starts. The thinking's end is noted + // after the render the step arrives in, so no end yet counts as ending now. showsFirstStep(turn: string, now = Date.now()) { const timed = this.turns.get(turn); if (!timed || timed.firstStepStreamed) return false; - if (timed.firstStepShown !== undefined) { - return now - timed.firstStepShown < ARRIVAL_WINDOW_MS; - } - if (timed.end === undefined || now - timed.end >= ARRIVAL_WINDOW_MS) return false; - timed.firstStepShown = now; - return true; + return timed.end === undefined || now - timed.end < ARRIVAL_WINDOW_MS; } } -export type AIThinkingTurn = { clock: AIThinkingClock; key: string }; +/** A turn's clock, and the id of its first reasoning step: the one that takes over its thinking. */ +export type AIThinkingTurn = { + clock: AIThinkingClock; + key: string; + firstStepId?: string; +}; export const AIThinkingTurnContext = createContext(undefined); diff --git a/src/plugins/AIComponents/ToolCalls/popupAuthorizer.ts b/src/plugins/AIComponents/ToolCalls/popupAuthorizer.ts index 7ce29db04..c19b31787 100644 --- a/src/plugins/AIComponents/ToolCalls/popupAuthorizer.ts +++ b/src/plugins/AIComponents/ToolCalls/popupAuthorizer.ts @@ -168,10 +168,12 @@ export const createPopupAuthorizer = ({ ); stops.push(() => clearTimeout(expiryTimer)); - // A provider's page can sever the popup from this window, which then looks closed while - // the person is still deciding. Your backend's change to the step is the record either way. + // A provider's page can sever the popup from this window (Google's consent does), which + // then looks closed while the person is still deciding there. So once handed off, a closed + // popup ends the sign-in only when the person is back on this page; until then the outcome + // or the expiry does. Your backend's change to the step is the record either way. const closedTimer = setInterval(() => { - if (popup.closed) finish(false); + if (popup.closed && (!handedOff || document.hasFocus())) finish(false); }, closedPollIntervalMs); stops.push(() => clearInterval(closedTimer)); }); diff --git a/src/plugins/AIComponents/__tests__/AIConversation.test.tsx b/src/plugins/AIComponents/__tests__/AIConversation.test.tsx index cba23545b..4cdd80848 100644 --- a/src/plugins/AIComponents/__tests__/AIConversation.test.tsx +++ b/src/plugins/AIComponents/__tests__/AIConversation.test.tsx @@ -153,6 +153,22 @@ describe('AIConversation', () => { expect(screen.queryByRole('toolbar')).not.toBeInTheDocument(); }); + it('shows a new rating when the messages stay the same', () => { + const messages = [user('u1', 'One'), reply('a1', 'Done.')]; + const rated = (rating: 'positive' | null) => ( + ({ onRate: vi.fn(), rating })} + /> + ); + const { rerender } = renderWithEnglish(rated(null)); + rerender(rated('positive')); + expect(screen.getByRole('button', { name: 'Good response' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + }); + it('renders messages through the slots', () => { const UserMessage = ({ text }: AIConversationUserMessageProps) =>

user:{text}

; const AssistantMessage = ({ message, text }: AIConversationAssistantMessageProps) => ( @@ -224,17 +240,28 @@ describe('AIConversation', () => { describe('loading older messages', () => { let trigger: (isIntersecting: boolean) => void; + // Whether the spinner is in view, which a real observer reports when it starts watching. + let inView = false; beforeEach(() => { vi.useFakeTimers(); + inView = false; vi.stubGlobal( 'IntersectionObserver', class { + private callback: (entries: { isIntersecting: boolean }[]) => void; constructor(callback: (entries: { isIntersecting: boolean }[]) => void) { - trigger = (isIntersecting) => callback([{ isIntersecting }]); + this.callback = callback; + trigger = (isIntersecting) => { + inView = isIntersecting; + callback([{ isIntersecting }]); + }; } disconnect() {} - observe() {} + observe() { + if (inView) this.callback([{ isIntersecting: true }]); + } + unobserve() {} }, ); }); @@ -270,6 +297,29 @@ describe('AIConversation', () => { ); expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); }); + + it('asks again after a load that failed, while the spinner stays in view', async () => { + const loadOlderMessages = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValue(undefined); + renderWithEnglish( + , + ); + act(() => trigger(true)); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(loadOlderMessages).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000 + 300); + }); + expect(loadOlderMessages).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/src/plugins/AIComponents/__tests__/AIMessagePartViews.test.tsx b/src/plugins/AIComponents/__tests__/AIMessagePartViews.test.tsx index 3f2d1b293..d8f14692f 100644 --- a/src/plugins/AIComponents/__tests__/AIMessagePartViews.test.tsx +++ b/src/plugins/AIComponents/__tests__/AIMessagePartViews.test.tsx @@ -3,6 +3,7 @@ import { act, fireEvent, screen } from '@testing-library/react'; import { AIConnectorSignInError, + AIMessageAttachment, AIMessageComposer, AIMessagePart, AIReasoning, @@ -449,6 +450,51 @@ describe('AIMessageComposer.ChatOptions', () => { }); }); +describe('AIMessageAttachment', () => { + it('links no script a message sends as an attachment URL', () => { + // spelt in parts, since a literal script URL is what the linter forbids + const script = (code: string) => ['javascript', code].join(':'); + renderWithEnglish( + <> + + + , + ); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.getByText('plan.pdf')).toBeInTheDocument(); + }); + + it('shows a photo just picked from its data URL, and links a web file', () => { + const photo = 'data:image/jpeg;base64,AAAA'; + renderWithEnglish( + <> + + + , + ); + expect(screen.getByAltText('cat.jpg')).toHaveAttribute('src', photo); + expect(screen.getByRole('link', { name: /plan\.pdf/ })).toHaveAttribute( + 'href', + 'https://cdn.example.com/plan.pdf', + ); + expect(screen.getByText('PDF · 2.34 kB')).toBeInTheDocument(); + }); +}); + describe('AISuggestions and AITypingIndicator', () => { it('hands the picked suggestion on', () => { const onSelect = vi.fn(); diff --git a/src/plugins/AIComponents/__tests__/AttachmentPreview.test.tsx b/src/plugins/AIComponents/__tests__/AttachmentPreview.test.tsx index 39e6cb6bb..0ef310bb0 100644 --- a/src/plugins/AIComponents/__tests__/AttachmentPreview.test.tsx +++ b/src/plugins/AIComponents/__tests__/AttachmentPreview.test.tsx @@ -129,6 +129,9 @@ describe('AIMessageComposer.AttachmentPreview', () => { fireEvent.click(screen.getAllByLabelText('Delete attachment')[0] as HTMLElement); expect(screen.queryByText('a.txt')).toBeNull(); + // The photo that stays keeps its URL rather than getting a new one. + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(revokeObjectURL).not.toHaveBeenCalled(); unmount(); expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview'); }); diff --git a/src/plugins/AIComponents/__tests__/popupAuthorizer.test.ts b/src/plugins/AIComponents/__tests__/popupAuthorizer.test.ts index fcd0ab8aa..984d0cbc1 100644 --- a/src/plugins/AIComponents/__tests__/popupAuthorizer.test.ts +++ b/src/plugins/AIComponents/__tests__/popupAuthorizer.test.ts @@ -141,7 +141,8 @@ describe('createPopupAuthorizer', () => { await expect(connecting).resolves.toBe(false); }); - it('resolves false when the person closes the popup', async () => { + it('resolves false when the person closes the popup and is back on the page', async () => { + vi.spyOn(document, 'hasFocus').mockReturnValue(true); const connecting = authorizer().connect(call()); postFromPopup(popup, { type: 'va.connector.oauth.ready' }); popup.closed = true; @@ -149,6 +150,25 @@ describe('createPopupAuthorizer', () => { await expect(connecting).resolves.toBe(false); }); + it('keeps waiting while a provider page severs the popup and the person is still in it', async () => { + const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(false); + let settled = false; + const connecting = authorizer().connect(call()); + void connecting.then(() => { + settled = true; + }); + postFromPopup(popup, { type: 'va.connector.oauth.ready' }); + // Google's consent page severs the popup, which then reads as closed. + popup.closed = true; + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + expect(settled).toBe(false); + + hasFocus.mockReturnValue(true); + vi.advanceTimersByTime(500); + await expect(connecting).resolves.toBe(false); + }); + it('rejects when the browser blocks the popup', async () => { open.mockReturnValue(null); const connecting = authorizer().connect(call()); diff --git a/src/plugins/AIComponents/styling/ai-conversation.scss b/src/plugins/AIComponents/styling/ai-conversation.scss index 832034a65..c8800fb0f 100644 --- a/src/plugins/AIComponents/styling/ai-conversation.scss +++ b/src/plugins/AIComponents/styling/ai-conversation.scss @@ -326,16 +326,14 @@ gap: 0.5rem; // the composer's own round button styles apply inside its form; outside it, the same look - > .str-chat__ai-message-composer__round-button { + .str-chat__dropdown > .str-chat__ai-message-composer__round-button { @include ai-icon-button(2rem); @include ai-icon(1.5rem); } - &__list { - position: absolute; - bottom: calc(100% + 0.5rem); - inset-inline-start: 0; + // placed by the SDK's Dropdown; scoped past its own `.str-chat__dropdown__items` rules + .str-chat__dropdown &__list { z-index: 1; display: flex; flex-direction: column; From a60876952b71adc720ac56d0c24522e5f02ce4a4 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 3 Nov 2026 13:32:08 +0100 Subject: [PATCH 03/22] feat(ai-components): show the UI an agent writes for a reply The counterpart of StreamChatAI's generative UI. A reply's ai_ui field shows in place of its text when the conversation's AIUIHost has a renderer for its format and version; otherwise the text shows. The components know no format: renderers plug one in. - AIUIDocument, AIUIAction and aiUIActionJSON: the router's ai_ui and ui_action, read and written as the Swift SDK does. - AIUIHost: renderers (first match wins), one engine per renderer kept while it lives, actions sent in order and checked against the router's limits first, web and email links only by default, client tools, per-reply state and capabilities. - App components described once in portable JSON shared with iOS and Android, with the agent's arguments checked and decoded before the view gets them. - AIConversation takes ui: the thinking indicator gives way to a reply whose UI shows, and reply actions wait until it is written. - A live reply's UI and its parts fade in, 0.1 s apart and at most 0.4 s later; replies from history show at once. --- .../Conversation/AIConversation.tsx | 39 +- .../AIComponents/Conversation/entries.ts | 32 +- .../Conversation/fromStreamMessage.ts | 8 +- .../AIComponents/Conversation/index.ts | 6 +- .../AIComponents/Conversation/types.ts | 6 + .../AIComponents/GenerativeUI/AIUIAction.ts | 72 +++ .../GenerativeUI/AIUIArguments.ts | 123 ++++ .../GenerativeUI/AIUIComponent.tsx | 75 +++ .../GenerativeUI/AIUIComponentDefinition.ts | 167 +++++ .../AIComponents/GenerativeUI/AIUIDocument.ts | 69 +++ .../GenerativeUI/AIUIEntrance.tsx | 140 +++++ .../AIComponents/GenerativeUI/AIUIHost.ts | 226 +++++++ .../AIComponents/GenerativeUI/AIUIRenderer.ts | 54 ++ .../AIComponents/GenerativeUI/AIUIView.tsx | 42 ++ .../AIComponents/GenerativeUI/index.ts | 9 + src/plugins/AIComponents/README.md | 29 + .../__tests__/AIConversationUI.test.tsx | 287 +++++++++ .../__tests__/generativeUI.test.ts | 570 ++++++++++++++++++ .../AIComponents/__tests__/ssr.test.tsx | 35 ++ src/plugins/AIComponents/index.ts | 1 + src/plugins/AIComponents/styling/ai-ui.scss | 34 ++ src/plugins/AIComponents/styling/index.scss | 1 + 22 files changed, 2011 insertions(+), 14 deletions(-) create mode 100644 src/plugins/AIComponents/GenerativeUI/AIUIAction.ts create mode 100644 src/plugins/AIComponents/GenerativeUI/AIUIArguments.ts create mode 100644 src/plugins/AIComponents/GenerativeUI/AIUIComponent.tsx create mode 100644 src/plugins/AIComponents/GenerativeUI/AIUIComponentDefinition.ts create mode 100644 src/plugins/AIComponents/GenerativeUI/AIUIDocument.ts create mode 100644 src/plugins/AIComponents/GenerativeUI/AIUIEntrance.tsx create mode 100644 src/plugins/AIComponents/GenerativeUI/AIUIHost.ts create mode 100644 src/plugins/AIComponents/GenerativeUI/AIUIRenderer.ts create mode 100644 src/plugins/AIComponents/GenerativeUI/AIUIView.tsx create mode 100644 src/plugins/AIComponents/GenerativeUI/index.ts create mode 100644 src/plugins/AIComponents/__tests__/AIConversationUI.test.tsx create mode 100644 src/plugins/AIComponents/__tests__/generativeUI.test.ts create mode 100644 src/plugins/AIComponents/styling/ai-ui.scss diff --git a/src/plugins/AIComponents/Conversation/AIConversation.tsx b/src/plugins/AIComponents/Conversation/AIConversation.tsx index b53abc130..89c5e8664 100644 --- a/src/plugins/AIComponents/Conversation/AIConversation.tsx +++ b/src/plugins/AIComponents/Conversation/AIConversation.tsx @@ -12,6 +12,8 @@ import clsx from 'clsx'; import { useTranslationContext } from '../../../context/TranslationContext'; import { useComponentContextIcons } from '../../../context/useComponentContextIcons'; +import type { AIUIHost } from '../GenerativeUI/AIUIHost'; +import { AIUIGenerated } from '../GenerativeUI/AIUIView'; import { AIAssistantMessage } from '../Message/AIAssistantMessage'; import type { AIMessageAttachmentData } from '../Message/AIMessageAttachment'; import { AIReplyActions, type AIReplyActionsProps } from '../Message/AIReplyActions'; @@ -112,6 +114,12 @@ export type AIConversationProps = { message: AIMessage, context: { isLatestReply: boolean }, ) => Omit | undefined; + /** + * Shows the UI the agent writes for its replies (`AIMessage.uiDocument`) in place of their + * text, with the renderers of the formats it writes, such as A2UI. A reply whose UI none of + * them shows shows its text. Keep one host per conversation, for as long as it shows. + */ + ui?: AIUIHost; /** The view of the person's messages. Defaults to `AIUserMessage`. */ UserMessage?: ComponentType; }; @@ -144,6 +152,7 @@ type EntryViewProps = { ReplyActions: ComponentType | null; UserMessage: ComponentType; approver?: AIToolApprover; + ui?: AIUIHost; }; // Not memoised: an app's messages are new objects on every update while a reply streams, and a @@ -153,6 +162,7 @@ const EntryView = ({ AssistantMessage, entry, ReplyActions, + ui, UserMessage, }: EntryViewProps) => { const { message, pending } = entry; @@ -174,6 +184,15 @@ const EntryView = ({ /> ); } + // The UI the agent generated for the reply, when one of the host's renderers shows it. It + // shows in place of the reply's text, after the reply's steps. + const { uiDocument } = message; + const generated = ui && uiDocument ? ui.content(uiDocument, message.id) : null; + const showsUI = !!uiDocument && generated !== null && generated !== undefined; + // A finished reply has its actions under it whether it answers in text or in UI, once the + // UI is written. + const hasAnswer = message.text !== '' || showsUI; + const isWritingUI = showsUI && uiDocument.isStreaming; return (
- {ReplyActions && !message.isGenerating && message.text !== '' && ( + {showsUI && ( + {generated} + )} + {ReplyActions && !message.isGenerating && hasAnswer && !isWritingUI && ( { const { t } = useTranslationContext(); @@ -252,12 +275,17 @@ export const AIConversation = ({ const [tracker] = useState(() => new AIPendingMessageTracker()); const [clock] = useState(() => new AIThinkingClock()); + // Every reply's UI reaches its renderer before any row asks for its view, oldest first, + // whether or not the row is on screen. Only what arrived or changed is passed on. + ui?.receive(messages); + const canShowUI = useMemo(() => (ui ? ui.canShow.bind(ui) : undefined), [ui]); const entries = useMemo( - () => tracker.entries(messages, pendingMessage), - [messages, pendingMessage, tracker], + () => tracker.entries(messages, pendingMessage, canShowUI), + [canShowUI, messages, pendingMessage, tracker], ); // after `entries`, which matched the copies - const thinking = isThinking || tracker.isAwaitingReply(messages, pendingMessage); + const thinking = + isThinking || tracker.isAwaitingReply(messages, pendingMessage, canShowUI); const turns = useMemo(() => turnsOf(entries), [entries]); const latest = turns[turns.length - 1]; const latestHasReplies = (latest?.entries.length ?? 0) > 1; @@ -505,6 +533,7 @@ export const AIConversation = ({ entry={entry} key={entry.id} ReplyActions={ReplyActions} + ui={ui} UserMessage={UserMessage} /> ))} diff --git a/src/plugins/AIComponents/Conversation/entries.ts b/src/plugins/AIComponents/Conversation/entries.ts index 509f84a63..bd1bf42ad 100644 --- a/src/plugins/AIComponents/Conversation/entries.ts +++ b/src/plugins/AIComponents/Conversation/entries.ts @@ -1,3 +1,4 @@ +import type { AIUIDocument } from '../GenerativeUI/AIUIDocument'; import type { AIMessageAttachmentData } from '../Message/AIMessageAttachment'; import type { AIMessage, AIPendingMessage } from './types'; @@ -19,12 +20,19 @@ export type AIConversationEntry = { /** The person's message and the replies to it. */ export type AIConversationTurn = { entries: AIConversationEntry[]; id: string }; -// A reply the agent has started but that has nothing to show yet. -const showsNothing = (message: AIMessage) => +/** Whether a reply's generated UI shows, such as `AIUIHost.canShow`. */ +export type AIConversationCanShowUI = (document: AIUIDocument) => boolean; + +const showsNoUI: AIConversationCanShowUI = () => false; + +// A reply the agent has started but that has nothing to show yet: no text, steps or attachments, +// and no UI that `canShowUI` says shows. +const showsNothing = (message: AIMessage, canShowUI: AIConversationCanShowUI) => message.role !== 'user' && message.text === '' && !message.parts?.length && - !message.attachments?.length; + !message.attachments?.length && + !(message.uiDocument && canShowUI(message.uiDocument)); /** Groups the rows into turns, each starting with one of the person's messages. */ export const turnsOf = (entries: AIConversationEntry[]) => @@ -51,10 +59,11 @@ export class AIPendingMessageTracker { private lastPending?: AIPendingMessage; // The rows of a conversation: its messages, apart from replies with nothing to show yet, and - // a message on its way until its copy arrives. + // a message on its way until its copy arrives. A reply's UI shows when `canShowUI` says. entries( messages: AIMessage[], pending?: AIPendingMessage | null, + canShowUI: AIConversationCanShowUI = showsNoUI, ): AIConversationEntry[] { let showsPending = !!pending; // A copy that arrives in the update that stops passing its pending message still takes over @@ -78,7 +87,7 @@ export class AIPendingMessageTracker { this.lastPending = pending ?? undefined; const entries: AIConversationEntry[] = messages - .filter((message) => !showsNothing(message)) + .filter((message) => !showsNothing(message, canShowUI)) .map((message) => { const taken = this.copies.get(message.id); return { @@ -124,9 +133,18 @@ export class AIPendingMessageTracker { // Whether the latest message is surely waiting for a reply that shows nothing yet: it is on its // way, it is the copy of the one on its way, or its reply is being written but shows nothing. // Call it after `entries`, which matches the copies. - isAwaitingReply(messages: AIMessage[], pending?: AIPendingMessage | null) { + isAwaitingReply( + messages: AIMessage[], + pending?: AIPendingMessage | null, + canShowUI: AIConversationCanShowUI = showsNoUI, + ) { const last = messages[messages.length - 1]; - if (last && last.role !== 'user' && last.isGenerating && showsNothing(last)) + if ( + last && + last.role !== 'user' && + last.isGenerating && + showsNothing(last, canShowUI) + ) return true; if (!pending) return false; const copy = [...this.copies].find(([, original]) => original.id === pending.id)?.[0]; diff --git a/src/plugins/AIComponents/Conversation/fromStreamMessage.ts b/src/plugins/AIComponents/Conversation/fromStreamMessage.ts index 6b4c19ae0..54d98a757 100644 --- a/src/plugins/AIComponents/Conversation/fromStreamMessage.ts +++ b/src/plugins/AIComponents/Conversation/fromStreamMessage.ts @@ -1,5 +1,6 @@ import type { Attachment, LocalMessage, MessageResponse } from 'stream-chat'; +import { AI_UI_CUSTOM_DATA_KEY, parseAIUIDocument } from '../GenerativeUI/AIUIDocument'; import type { AIMessageAttachmentData } from '../Message/AIMessageAttachment'; import { parseMessageParts } from '../MessageParts/parseMessageParts'; import type { AIMessage } from './types'; @@ -39,7 +40,8 @@ const attachmentOf = (attachment: Attachment): AIMessageAttachmentData | undefin /** * Adapts a Stream message for `AIConversation`: a reply is one with `ai_generated: true`, it is * being written while `generating: true`, its steps are its `ai_reasoning` and `ai_tool_call` - * attachments, and its images and files are its other attachments. + * attachments, its images and files are its other attachments, and the UI the agent wrote for + * it is its `ai_ui` field. A person's message has no UI, whatever its fields say. */ export const fromStreamMessage = ( message: StreamMessage, @@ -47,6 +49,9 @@ export const fromStreamMessage = ( ): AIMessage => { const attachments = message.attachments ?? []; const assistant = isAssistant ? isAssistant(message) : message.ai_generated === true; + const uiDocument = assistant + ? parseAIUIDocument(message[AI_UI_CUSTOM_DATA_KEY]) + : undefined; return { attachments: attachments .map(attachmentOf) @@ -59,5 +64,6 @@ export const fromStreamMessage = ( : undefined, role: assistant ? 'assistant' : 'user', text: message.text ?? '', + ...(uiDocument ? { uiDocument } : {}), }; }; diff --git a/src/plugins/AIComponents/Conversation/index.ts b/src/plugins/AIComponents/Conversation/index.ts index 6917067c6..2ed522d1a 100644 --- a/src/plugins/AIComponents/Conversation/index.ts +++ b/src/plugins/AIComponents/Conversation/index.ts @@ -1,4 +1,8 @@ export * from './AIConversation'; export * from './fromStreamMessage'; export * from './types'; -export type { AIConversationEntry, AIConversationTurn } from './entries'; +export type { + AIConversationCanShowUI, + AIConversationEntry, + AIConversationTurn, +} from './entries'; diff --git a/src/plugins/AIComponents/Conversation/types.ts b/src/plugins/AIComponents/Conversation/types.ts index 965e32279..5facba8eb 100644 --- a/src/plugins/AIComponents/Conversation/types.ts +++ b/src/plugins/AIComponents/Conversation/types.ts @@ -1,3 +1,4 @@ +import type { AIUIDocument } from '../GenerativeUI/AIUIDocument'; import type { AIMessageAttachmentData } from '../Message/AIMessageAttachment'; import type { AIMessagePartData } from '../MessageParts/types'; @@ -26,6 +27,11 @@ export type AIMessage = { * rather than work it out. */ reasoningText?: (stepId: string) => string | undefined; + /** + * The UI the agent wrote for a reply, which shows in place of its text when the + * conversation's `ui` host has a renderer for it. A person's message has none. + */ + uiDocument?: AIUIDocument; }; /** diff --git a/src/plugins/AIComponents/GenerativeUI/AIUIAction.ts b/src/plugins/AIComponents/GenerativeUI/AIUIAction.ts new file mode 100644 index 000000000..b06b9e67e --- /dev/null +++ b/src/plugins/AIComponents/GenerativeUI/AIUIAction.ts @@ -0,0 +1,72 @@ +import type { AIUIFormat } from './AIUIDocument'; + +/** + * What the person did in a reply's UI, such as tapping a place or submitting a form. It goes to + * the agent as their next message: `label` is its text and `aiUIActionJSON(action)` what it + * carries. The counterpart of `AIUIAction` in StreamChatAI. + */ +export type AIUIAction = { + /** The format of the UI it came from. */ + format: AIUIFormat; + /** What the person did, in their words, such as "Tell me more about St. Naum". */ + label: string; + /** The reply whose UI it came from. */ + messageId: string; + /** + * What happened, such as an event of an app's component (`ask_about`) or OpenUI's + * `continue_conversation`. + */ + name: string; + /** The details in the format's own shape, such as what was chosen and a form's values. */ + payload: Record; +}; + +/** The action as the JSON object an agent's backend reads. */ +export type AIUIActionJSON = { + format: AIUIFormat; + message_id: string; + name: string; + payload: Record; + v: 1; +}; + +/** The custom field of the person's message that holds the action. */ +export const AI_UI_ACTION_CUSTOM_DATA_KEY = 'ai_ui_action'; +/** + * The most an action may take as JSON. The agents router refuses a larger one, and its + * refusal would lose the person's message with it. + */ +export const AI_UI_ACTION_MAX_BYTES = 8 * 1024; + +/** + * The action as the JSON object an agent's backend reads, such as the `uiAction` of + * `session.responses.create(action.label, { uiAction })`. The label is not in it: it is the + * text of the person's message. + */ +export const aiUIActionJSON = (action: AIUIAction): AIUIActionJSON => ({ + format: action.format, + message_id: action.messageId, + name: action.name, + payload: action.payload, + v: 1, +}); + +/** + * Why the agent's backend would refuse the action, or `undefined` when it would take it: it + * names its reply, its format and what happened, in at most `AI_UI_ACTION_MAX_BYTES` of JSON. + */ +export const aiUIActionProblem = (action: AIUIAction): string | undefined => { + if (!action.messageId) return 'an action names the reply it came from'; + if (!action.format) return 'an action names the format of its UI'; + if (!action.name) return 'an action names what happened'; + let json: string; + try { + json = JSON.stringify(aiUIActionJSON(action)); + } catch { + return 'an action carries only JSON'; + } + const bytes = new TextEncoder().encode(json).length; + return bytes > AI_UI_ACTION_MAX_BYTES + ? `an action takes at most ${AI_UI_ACTION_MAX_BYTES} bytes as JSON, and this one takes ${bytes}` + : undefined; +}; diff --git a/src/plugins/AIComponents/GenerativeUI/AIUIArguments.ts b/src/plugins/AIComponents/GenerativeUI/AIUIArguments.ts new file mode 100644 index 000000000..af4175591 --- /dev/null +++ b/src/plugins/AIComponents/GenerativeUI/AIUIArguments.ts @@ -0,0 +1,123 @@ +import type { AIUIJSONSchema, AIUIParameter } from './AIUIComponentDefinition'; +import { aiUIParametersOf } from './AIUIComponentDefinition'; + +// Thrown inside decoding only, so a value that does not fit stops it at once. +class DoesNotFit extends Error {} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const RFC_3339 = + /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/; +const DAY = /^(\d{4})-(\d{2})-(\d{2})$/; + +/** + * A date the agent wrote: an RFC 3339 moment, or a whole day as `yyyy-MM-dd` that the calendar + * has, which is that day's start where the person is. Anything else is not a date. + */ +export const aiUIDate = (text: string): Date | undefined => { + if (RFC_3339.test(text)) { + const moment = new Date(text); + return Number.isNaN(moment.getTime()) ? undefined : moment; + } + const day = DAY.exec(text); + if (!day) return undefined; + const [year, month, date] = [Number(day[1]), Number(day[2]), Number(day[3])]; + const start = new Date(year, month - 1, date); + start.setFullYear(year); + return start.getFullYear() === year && + start.getMonth() === month - 1 && + start.getDate() === date + ? start + : undefined; +}; + +/** `place_name` as `placeName`, the way the agent's snake_case reads in JavaScript. */ +export const aiUICamelCase = (key: string) => { + const [, leading = '', middle = '', trailing = ''] = /^(_*)(.*?)(_*)$/.exec(key) ?? []; + if (!middle.includes('_')) return key; + const [first = '', ...rest] = middle.split('_').filter(Boolean); + return ( + leading + + first + + rest.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join('') + + trailing + ); +}; + +const camelCased = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(camelCased); + if (!isRecord(value)) return value; + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [aiUICamelCase(key), camelCased(item)]), + ); +}; + +const decodeObject = (value: unknown, parameters: AIUIParameter[]) => { + if (!isRecord(value)) throw new DoesNotFit(); + const declared = new Set(parameters.map(({ name }) => name)); + const decoded: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (!declared.has(key)) decoded[aiUICamelCase(key)] = camelCased(item); + } + for (const { isRequired, name, type } of parameters) { + const item = value[name]; + if (item === undefined || item === null) { + if (isRequired) throw new DoesNotFit(); + continue; + } + decoded[aiUICamelCase(name)] = decodeValue(item, type); + } + return decoded; +}; + +const decodeValue = (value: unknown, schema: AIUIJSONSchema): unknown => { + switch (schema.type) { + case 'string': { + if (typeof value !== 'string') throw new DoesNotFit(); + if (Array.isArray(schema.enum) && !schema.enum.includes(value)) + throw new DoesNotFit(); + if (schema.format === 'date' || schema.format === 'date-time') { + const date = aiUIDate(value); + if (!date) throw new DoesNotFit(); + return date; + } + return value; + } + case 'number': + if (typeof value !== 'number' || !Number.isFinite(value)) throw new DoesNotFit(); + return value; + case 'integer': + if (!Number.isInteger(value)) throw new DoesNotFit(); + return value; + case 'boolean': + if (typeof value !== 'boolean') throw new DoesNotFit(); + return value; + case 'array': { + if (!Array.isArray(value)) throw new DoesNotFit(); + const items = isRecord(schema.items) ? schema.items : {}; + return value.map((item) => decodeValue(item, items)); + } + case 'object': + return decodeObject(value, aiUIParametersOf(schema)); + default: + return camelCased(value); + } +}; + +/** + * The arguments the agent wrote for a component, as its view reads them: keys in camelCase, + * dates as `Date`s. `undefined` while they do not fit its parameters, such as while they are + * still streaming in: a required one is missing, or one is of the wrong type. + */ +export const decodeAIUIArguments = >( + args: Record, + parameters: AIUIParameter[], +): Arguments | undefined => { + try { + return decodeObject(args, parameters) as Arguments; + } catch (error) { + if (error instanceof DoesNotFit) return undefined; + throw error; + } +}; diff --git a/src/plugins/AIComponents/GenerativeUI/AIUIComponent.tsx b/src/plugins/AIComponents/GenerativeUI/AIUIComponent.tsx new file mode 100644 index 000000000..f277ca1fc --- /dev/null +++ b/src/plugins/AIComponents/GenerativeUI/AIUIComponent.tsx @@ -0,0 +1,75 @@ +import React, { type ComponentType } from 'react'; + +import { decodeAIUIArguments } from './AIUIArguments'; +import type { AIUIComponentDefinition } from './AIUIComponentDefinition'; +import { AIUIEntrance } from './AIUIEntrance'; +import type { AIUIHost } from './AIUIHost'; + +/** Where an app's component is shown, and how it tells the agent what the person did. */ +export type AIUIComponentContext = { + /** + * Tells the agent about one of the component's events, as the person's next message. + * `label` is what the person did, in their words, such as "Tell me more about St. Naum". + */ + emit: (event: string, label: string, payload?: Record) => void; + /** The conversation's host, for running the app's tools and opening links. */ + host: AIUIHost; + /** Whether the agent is still writing the reply's UI, so the arguments may still change. */ + isStreaming: boolean; + /** The reply the component is in. */ + messageId: string; +}; + +export type AIUIComponentProps = { + /** What the agent filled in: keys in camelCase, dates as `Date`s. */ + args: Arguments; + context: AIUIComponentContext; +}; + +/** + * One of the app's views that the agent can place in a reply's UI, in whichever format it + * writes, such as a map or a card over the app's own data. The counterpart of `AIUIComponent` + * in StreamChatAI. + */ +export type AIUIComponent = { + Component: ComponentType>; + /** Its description, the same on every platform and for the agent. */ + definition: AIUIComponentDefinition; +}; + +/** + * One of the app's components, with what its view reads from the agent's arguments. The view + * gets them only once they fit the definition's parameters, so it can rely on their types. + */ +export const defineAIUIComponent = ( + definition: AIUIComponentDefinition, + Component: ComponentType>, +): AIUIComponent => ({ + // The arguments are checked against `definition` before the view gets them. + Component: Component as unknown as ComponentType>, + definition, +}); + +/** + * An app's component for the arguments the agent wrote, coming in as it shows, or nothing + * while they don't fit its parameters, such as while they are still streaming in. Renderers + * place the app's components with it. + */ +export const AIUIComponentView = ({ + args, + component, + context, +}: { + args: Record; + component: AIUIComponent; + context: AIUIComponentContext; +}) => { + const decoded = decodeAIUIArguments(args, component.definition.parameters); + if (decoded === undefined) return null; + const { Component } = component; + return ( + + + + ); +}; diff --git a/src/plugins/AIComponents/GenerativeUI/AIUIComponentDefinition.ts b/src/plugins/AIComponents/GenerativeUI/AIUIComponentDefinition.ts new file mode 100644 index 000000000..a12a7da82 --- /dev/null +++ b/src/plugins/AIComponents/GenerativeUI/AIUIComponentDefinition.ts @@ -0,0 +1,167 @@ +/** A JSON Schema, as a component's parameters are described to the agent. */ +export type AIUIJSONSchema = Record; + +/** Something the agent fills in for a component. The counterpart of `AIUIParameter`. */ +export type AIUIParameter = { + /** Whether the agent must fill it in. */ + isRequired: boolean; + /** The name the agent fills it in by, such as `title`. */ + name: string; + /** What it holds, as a JSON Schema, such as `AIUIParameterType.string`. */ + type: AIUIJSONSchema; + /** What it is, for the agent. */ + description?: string; +}; + +/** Something a component reports when the person uses it, such as tapping a place. */ +export type AIUIComponentEvent = { + /** When it happens, for the agent. */ + description: string; + /** The name it is reported by, such as `ask_about`. */ + name: string; + /** What it carries, in order. */ + parameters: AIUIParameter[]; +}; + +/** + * An app's component as the agent knows it: its name, what it shows, what the agent fills in + * and what it reports. It is portable JSON, the same on every platform, so one description + * (such as a `UIComponents.json` shared with the iOS and Android apps) serves them all. The + * counterpart of `AIUIComponentDefinition` in StreamChatAI. + */ +export type AIUIComponentDefinition = { + /** What it shows and when to use it, for the agent. */ + description: string; + /** What it reports when the person uses it. */ + events: AIUIComponentEvent[]; + /** The name the agent places it by, such as `place_card`. */ + name: string; + /** + * What the agent fills in, in order: a format such as OpenUI Lang passes arguments by + * position. + */ + parameters: AIUIParameter[]; +}; + +/** The types a parameter commonly has, as JSON Schemas. */ +export const AIUIParameterType = { + /** A list of values of one type. */ + array: (items: AIUIJSONSchema): AIUIJSONSchema => ({ items, type: 'array' }), + boolean: { type: 'boolean' } as AIUIJSONSchema, + /** A day, such as `2026-11-14`. */ + date: { format: 'date', type: 'string' } as AIUIJSONSchema, + /** A moment, such as `2026-11-14T09:30:00Z`. */ + dateTime: { format: 'date-time', type: 'string' } as AIUIJSONSchema, + /** One of a fixed set of strings. */ + enumeration: (values: string[]): AIUIJSONSchema => ({ enum: values, type: 'string' }), + integer: { type: 'integer' } as AIUIJSONSchema, + number: { type: 'number' } as AIUIJSONSchema, + /** An object with named fields, in order. */ + object: (fields: AIUIParameter[]): AIUIJSONSchema => aiUIObjectSchema(fields), + string: { type: 'string' } as AIUIJSONSchema, + /** A link, such as an image's. */ + url: { format: 'uri', type: 'string' } as AIUIJSONSchema, +}; + +/** A parameter the agent must fill in. */ +export const requiredAIUIParameter = ( + name: string, + type: AIUIJSONSchema, + description?: string, +): AIUIParameter => ({ description, isRequired: true, name, type }); + +/** A parameter the agent may leave out. */ +export const optionalAIUIParameter = ( + name: string, + type: AIUIJSONSchema, + description?: string, +): AIUIParameter => ({ description, isRequired: false, name, type }); + +// The key that keeps the order of an object's fields, which JSON objects don't have. +const ORDER_KEY = 'x-order'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const strings = (value: unknown) => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; + +/** Parameters as a JSON Schema object, the way a tool's are described, keeping their order. */ +export const aiUIObjectSchema = (parameters: AIUIParameter[]): AIUIJSONSchema => ({ + [ORDER_KEY]: parameters.map(({ name }) => name), + properties: Object.fromEntries( + parameters.map(({ description, name, type }) => [ + name, + description === undefined ? type : { ...type, description }, + ]), + ), + required: parameters.filter(({ isRequired }) => isRequired).map(({ name }) => name), + type: 'object', +}); + +/** The fields of a JSON Schema object, in the order it keeps, then by name. */ +export const aiUIParametersOf = (schema: unknown): AIUIParameter[] => { + if (!isRecord(schema)) return []; + const properties = isRecord(schema.properties) ? schema.properties : {}; + const required = new Set(strings(schema.required)); + const ordered = strings(schema[ORDER_KEY]).filter((name) => name in properties); + const rest = Object.keys(properties) + .filter((name) => !ordered.includes(name)) + .sort(); + return [...ordered, ...rest].flatMap((name) => { + const field = properties[name]; + if (!isRecord(field)) return []; + const { description, ...type } = field; + return [ + { + description: typeof description === 'string' ? description : undefined, + isRequired: required.has(name), + name, + type, + }, + ]; + }); +}; + +/** + * Reads a component's portable JSON, such as one entry of a `UIComponents.json`, or returns + * `undefined` for one without a name. + */ +export const parseAIUIComponentDefinition = ( + json: unknown, +): AIUIComponentDefinition | undefined => { + if (!isRecord(json) || typeof json.name !== 'string' || json.name === '') { + return undefined; + } + const events = Array.isArray(json.events) ? json.events : []; + return { + description: typeof json.description === 'string' ? json.description : '', + events: events.flatMap((event) => + isRecord(event) && typeof event.name === 'string' + ? [ + { + description: typeof event.description === 'string' ? event.description : '', + name: event.name, + parameters: aiUIParametersOf(event.parameters), + }, + ] + : [], + ), + name: json.name, + parameters: aiUIParametersOf(json.parameters), + }; +}; + +/** A component's portable JSON, as `parseAIUIComponentDefinition` reads it back. */ +export const aiUIComponentDefinitionJSON = (definition: AIUIComponentDefinition) => ({ + description: definition.description, + events: definition.events.map(({ description, name, parameters }) => ({ + description, + name, + ...(parameters.length ? { parameters: aiUIObjectSchema(parameters) } : {}), + })), + name: definition.name, + parameters: aiUIObjectSchema(definition.parameters), +}); diff --git a/src/plugins/AIComponents/GenerativeUI/AIUIDocument.ts b/src/plugins/AIComponents/GenerativeUI/AIUIDocument.ts new file mode 100644 index 000000000..df59aa491 --- /dev/null +++ b/src/plugins/AIComponents/GenerativeUI/AIUIDocument.ts @@ -0,0 +1,69 @@ +import isEqual from 'react-fast-compare'; + +/** + * The format a reply's UI is written in, such as `a2ui` or `openui`. Formats are an open set: + * a renderer says which one it reads. + */ +export type AIUIFormat = 'a2ui' | 'openui' | (string & {}); + +/** + * The UI an agent wrote for a reply, which shows in place of the reply's text. The counterpart + * of `AIUIDocument` in StreamChatAI. + */ +export type AIUIDocument = { + /** The document in its format's own shape: a program's text, or JSON. */ + content: unknown; + /** The format it is written in. */ + format: AIUIFormat; + /** Whether the agent is still writing it. */ + isStreaming: boolean; + /** The format's version, when the agent names one. */ + version?: string; +}; + +/** The custom field of a message that holds its document. */ +export const AI_UI_CUSTOM_DATA_KEY = 'ai_ui'; +/** The newest version of the `ai_ui` field this SDK reads. */ +export const AI_UI_SUPPORTED_VERSION = 1; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Reads the document of a message's `ai_ui` field, or returns `undefined` when it carries none + * this SDK can read: no format, no content, or a field newer than this SDK, which can lay its + * documents out differently. The reply then shows its text. + */ +export const parseAIUIDocument = (field: unknown): AIUIDocument | undefined => { + if (!isRecord(field)) return undefined; + const { content, format, status, v, version } = field; + if (typeof format !== 'string' || format === '') return undefined; + if (content === undefined || content === null) return undefined; + if (typeof v === 'number' && v > AI_UI_SUPPORTED_VERSION) return undefined; + return { + content, + format, + isStreaming: status === 'streaming', + ...(typeof version === 'string' && version !== '' ? { version } : {}), + }; +}; + +/** The content of a document when it is text, such as an OpenUI program or A2UI's JSON lines. */ +export const aiUIDocumentText = (document: AIUIDocument) => + typeof document.content === 'string' ? document.content : undefined; + +/** + * Whether two documents are the same, content included. A message is a new object on every + * update while a reply streams, so a document is compared by what it holds. + */ +export const isSameAIUIDocument = ( + a: AIUIDocument | undefined, + b: AIUIDocument | undefined, +) => + a === b || + (!!a && + !!b && + a.format === b.format && + a.version === b.version && + a.isStreaming === b.isStreaming && + isEqual(a.content, b.content)); diff --git a/src/plugins/AIComponents/GenerativeUI/AIUIEntrance.tsx b/src/plugins/AIComponents/GenerativeUI/AIUIEntrance.tsx new file mode 100644 index 000000000..acf1d79eb --- /dev/null +++ b/src/plugins/AIComponents/GenerativeUI/AIUIEntrance.tsx @@ -0,0 +1,140 @@ +import React, { + createContext, + type ReactNode, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import clsx from 'clsx'; + +/** + * Spaces out the parts of a reply's UI that come in together, so they come in one after another + * rather than at once. The counterpart of `AIUICascade` in StreamChatAI. + */ +export class AIUICascade { + /** How long after the part before it a part comes in, in seconds. */ + static readonly step = 0.1; + /** The longest a part waits for the parts before it, in seconds. */ + static readonly longestWait = 0.4; + + private next = 0; + + /** How long a part that comes in at `now`, in seconds, waits for the parts before it. */ + delay( + now = (typeof performance === 'undefined' ? Date.now() : performance.now()) / 1000, + ) { + const start = Math.min(Math.max(now, this.next), now + AIUICascade.longestWait); + this.next = start + AIUICascade.step; + return start - now; + } +} + +type AIUIStage = { + cascade: AIUICascade; + /** Whether what shows now comes in with the part around it, rather than on its own. */ + isCarried: boolean; +}; + +const AIUIStageContext = createContext(undefined); + +const nextFrame = (callback: () => void) => { + if (typeof requestAnimationFrame === 'function') { + const frame = requestAnimationFrame(callback); + return () => cancelAnimationFrame(frame); + } + const timer = setTimeout(callback, 0); + return () => clearTimeout(timer); +}; + +// Shows its content, fading it in after the cascade's delay when it starts hidden. +const useEntrance = (startsHidden: boolean, cascade: AIUICascade | undefined) => { + const [entrance, setEntrance] = useState({ delay: 0, hasShown: !startsHidden }); + useEffect(() => { + if (entrance.hasShown) return; + const delay = cascade?.delay() ?? 0; + // A frame later, so the hidden state is painted and the change to shown transitions. + return nextFrame(() => setEntrance({ delay, hasShown: true })); + }, [cascade, entrance.hasShown]); + return entrance; +}; + +const EntranceBox = ({ + children, + className, + delay, + hasShown, +}: { + children: ReactNode; + delay: number; + hasShown: boolean; + className?: string; +}) => ( +
0 ? { transitionDelay: `${delay}s` } : undefined} + > + {children} +
+); + +/** + * A reply's generated UI as a whole: it comes in when it first shows while the agent writes it, + * and shows at once when it was written before, such as a reply from history. + */ +export const AIUIEntranceStage = ({ + children, + className, + isStreaming, +}: { + children: ReactNode; + /** Whether the agent is writing the UI as it first shows. */ + isStreaming: boolean; + className?: string; +}) => { + const [cascade] = useState(() => new AIUICascade()); + const { delay, hasShown } = useEntrance(isStreaming, cascade); + const stage = useMemo(() => ({ cascade, isCarried: !hasShown }), [cascade, hasShown]); + return ( + + + {children} + + + ); +}; + +/** + * Brings a part of a reply's generated UI in when it shows, such as when the agent writes it: + * the part fades in, rising into place and coming into focus, after the parts that came in just + * before it. What shows together with the reply's UI, or with a part around it, comes in with + * that, so a reply written before it showed shows at once. With reduced motion it only fades. + * + * Renderers give it to each part they place. Every app component has it. + */ +export const AIUIEntrance = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) => { + const stage = useContext(AIUIStageContext); + const { delay, hasShown } = useEntrance(!!stage && !stage.isCarried, stage?.cascade); + const inner = useMemo( + () => stage && { cascade: stage.cascade, isCarried: stage.isCarried || !hasShown }, + [hasShown, stage], + ); + return ( + + + {children} + + + ); +}; diff --git a/src/plugins/AIComponents/GenerativeUI/AIUIHost.ts b/src/plugins/AIComponents/GenerativeUI/AIUIHost.ts new file mode 100644 index 000000000..d533092a8 --- /dev/null +++ b/src/plugins/AIComponents/GenerativeUI/AIUIHost.ts @@ -0,0 +1,226 @@ +import type { ReactNode } from 'react'; + +import type { AIMessage } from '../Conversation/types'; +import { type AIUIAction, aiUIActionProblem } from './AIUIAction'; +import type { AIUIComponent } from './AIUIComponent'; +import { aiUIComponentDefinitionJSON } from './AIUIComponentDefinition'; +import { type AIUIDocument, isSameAIUIDocument } from './AIUIDocument'; +import { type AIUIEngine, type AIUIRenderer, aiUIRendererReads } from './AIUIRenderer'; + +/** + * One of the app's tools a reply's UI can run, such as to load data on this device. It runs + * whenever the UI asks, without asking the person, so give the host only tools that are safe to + * run that way. It is apart from the tools the agent calls. + */ +export type AIUIClientTool = { + name: string; + /** Runs the tool. What it returns is the output the UI gets. */ + run: (args: Record) => unknown; +}; + +export type AIUIHostOptions = { + /** The renderer of each format the agent writes, the first one listed asked first. */ + renderers: AIUIRenderer[]; + /** + * Sends what the person did to the agent, as their next message, such as + * `session.responses.create(action.label, { uiAction: aiUIActionJSON(action) })`. + */ + send: (action: AIUIAction) => Promise | unknown; + /** The app's views the agent can place. */ + components?: AIUIComponent[]; + /** Tells the app that an action could not be sent. It is logged by default. */ + onError?: (error: unknown, action: AIUIAction) => void; + /** + * Opens a link from a reply's UI. By default, web and email links open and others don't: the + * agent writes the links, so another could call someone or act in another app. + */ + open?: (url: URL) => void; + /** The app's tools a reply's UI can run. */ + tools?: AIUIClientTool[]; +}; + +const WEB_OR_EMAIL = new Set(['http:', 'https:', 'mailto:']); + +/** Whether a link is one a reply's UI may open by default: a web or email link. */ +export const isWebOrEmailLink = (url: URL) => + WEB_OR_EMAIL.has(url.protocol.toLowerCase()); + +const openWebOrEmailLink = (url: URL) => { + if (!isWebOrEmailLink(url)) { + console.warn( + `A reply's UI asked to open ${url.href}, which isn't a web or email link`, + ); + return; + } + if (typeof window === 'undefined') return; + if (url.protocol === 'mailto:') window.location.assign(url.href); + else window.open(url.href, '_blank', 'noopener,noreferrer'); +}; + +/** + * The generated UI of one conversation: the renderers of the formats the agent writes, the + * app's components and tools, what each reply's UI was left in, and where what the person does + * goes. Give it to `AIConversation` as `ui`; keep one per conversation, for as long as it shows. + * The counterpart of `AIUIHost` in StreamChatAI. + */ +export class AIUIHost { + readonly components: AIUIComponent[]; + readonly renderers: AIUIRenderer[]; + + private readonly engines = new Map(); + private lastSend: Promise = Promise.resolve(); + private readonly options: AIUIHostOptions; + private readonly received = new Map(); + private readonly states = new Map(); + private readonly tools: Map; + + constructor(options: AIUIHostOptions) { + this.options = options; + this.renderers = options.renderers; + this.components = options.components ?? []; + this.tools = new Map(); + for (const tool of options.tools ?? []) { + if (!this.tools.has(tool.name)) this.tools.set(tool.name, tool); + } + } + + /** The app's component the agent places by `name`. */ + component(name: string) { + return this.components.find((component) => component.definition.name === name); + } + + /** + * Whether one of its renderers reads `document`. The one that does can still show nothing of + * its own for a reply, which then shows its text, as `content` tells. + */ + canShow(document: AIUIDocument) { + return this.renderers.some((renderer) => aiUIRendererReads(renderer, document)); + } + + /** + * The view of a reply's document from the first renderer that reads its format and version, + * or `null` when none shows anything of its own, so the reply shows its text. `AIUIView` puts + * it on screen; `AIConversation` does it for every reply. + */ + content(document: AIUIDocument, messageId: string): ReactNode | null { + const renderer = this.renderers.find((candidate) => + aiUIRendererReads(candidate, document), + ); + return renderer ? (this.engineOf(renderer).view(document, messageId) ?? null) : null; + } + + /** + * Gives its renderers the UI of the conversation's replies, oldest first, as each arrives or + * changes, whether or not the reply is on screen, so a format whose later replies change an + * earlier one's UI follows them all. It also tells them when a reply leaves the conversation, + * or its UI does, such as when it is deleted. `AIConversation` does it: call it with the + * conversation's messages when you show replies yourself with `AIUIView`. + */ + receive(messages: AIMessage[]) { + const documents = new Map(); + for (const message of messages) { + if (message.role !== 'user' && message.uiDocument) { + documents.set(message.id, message.uiDocument); + } + } + for (const [messageId, document] of [...this.received]) { + if (documents.has(messageId)) continue; + this.received.delete(messageId); + this.states.delete(messageId); + const renderer = this.renderers.find((candidate) => + aiUIRendererReads(candidate, document), + ); + if (renderer) this.engineOf(renderer).remove?.(messageId); + } + for (const [messageId, document] of documents) { + if (isSameAIUIDocument(this.received.get(messageId), document)) continue; + this.received.set(messageId, document); + const renderer = this.renderers.find((candidate) => + aiUIRendererReads(candidate, document), + ); + if (renderer) this.engineOf(renderer).receive?.(document, messageId); + } + } + + /** + * Sends what the person did to the agent, after everything sent before it, so the agent gets + * a reply's actions in the order they happened. One the agent's backend would refuse, or that + * fails to send, goes to `onError` and does not hold up the ones after it. + */ + send(action: AIUIAction): Promise { + const report = (error: unknown) => { + if (this.options.onError) this.options.onError(error, action); + else console.error("Couldn't send what the person did in a reply's UI", error); + }; + const problem = aiUIActionProblem(action); + if (problem) { + report(new Error(problem)); + return this.lastSend; + } + this.lastSend = this.lastSend.then(async () => { + try { + await this.options.send(action); + } catch (error) { + report(error); + } + }); + return this.lastSend; + } + + /** Runs one of the app's tools for a reply's UI, such as to load data on this device. */ + async runTool(name: string, args: Record = {}): Promise { + const tool = this.tools.get(name); + if (!tool) throw new Error(`No tool named ${name}`); + return await tool.run(args); + } + + /** Opens a link from a reply's UI, as the host's `open` allows. */ + open(url: URL | string) { + let link: URL; + try { + link = typeof url === 'string' ? new URL(url) : url; + } catch { + console.warn(`A reply's UI asked to open ${String(url)}, which isn't a link`); + return; + } + (this.options.open ?? openWebOrEmailLink)(link); + } + + /** The state a reply's UI was left in, such as a form's values, kept while the host is. */ + state(messageId: string): unknown { + return this.states.get(messageId); + } + + /** Keeps the state a reply's UI was left in. `undefined` forgets it. */ + setState(messageId: string, state: unknown) { + if (state === undefined) this.states.delete(messageId); + else this.states.set(messageId, state); + } + + /** + * What the agent's backend needs to write UI this app shows: each format it reads, with what + * its renderer adds, and the app's components. + */ + get capabilities() { + return { + components: this.components.map(({ definition }) => + aiUIComponentDefinitionJSON(definition), + ), + renderers: this.renderers.map((renderer) => ({ + ...renderer.capabilities?.(this.components), + format: renderer.format, + })), + v: 1, + }; + } + + // The engine of a renderer, made the first time it is needed and kept as the host is. + private engineOf(renderer: AIUIRenderer) { + let engine = this.engines.get(renderer); + if (!engine) { + engine = renderer.createEngine(this); + this.engines.set(renderer, engine); + } + return engine; + } +} diff --git a/src/plugins/AIComponents/GenerativeUI/AIUIRenderer.ts b/src/plugins/AIComponents/GenerativeUI/AIUIRenderer.ts new file mode 100644 index 000000000..0d3af7036 --- /dev/null +++ b/src/plugins/AIComponents/GenerativeUI/AIUIRenderer.ts @@ -0,0 +1,54 @@ +import type { ReactNode } from 'react'; + +import type { AIUIComponent } from './AIUIComponent'; +import type { AIUIDocument, AIUIFormat } from './AIUIDocument'; +import type { AIUIHost } from './AIUIHost'; + +/** + * Shows the documents of one conversation in a renderer's format. A host makes one per + * renderer, the first time the renderer receives or shows something, and keeps it as long as + * the host lives, so what a reply's UI holds, such as a form's values, survives scrolling. The + * counterpart of `AIUIEngine` in StreamChatAI. + */ +export type AIUIEngine = { + /** + * The view of a reply's document, or `null` when it shows nothing of its own, such as a reply + * that only changed an earlier one's UI, so the reply's text shows. It is called while the + * conversation renders, so keep what it makes for a reply and return it again. + */ + view: (document: AIUIDocument, messageId: string) => ReactNode | null; + /** + * Takes a reply's document as it arrives or changes, oldest reply first, whether or not the + * reply is on screen, so a format whose later replies change an earlier one's UI can follow + * them all. It comes while the conversation renders, before any reply's view is asked for: + * change what is already on screen afterwards, such as in a microtask. + */ + receive?: (document: AIUIDocument, messageId: string) => void; + /** + * Forgets a reply that left the conversation, or whose UI did, such as one that was deleted, + * so the engine can let go of what it kept for it. + */ + remove?: (messageId: string) => void; +}; + +/** + * Reads one format of generated UI, such as A2UI. A plug-in of `AIUIHost`: the AI components + * know no format themselves. The counterpart of `AIUIRenderer` in StreamChatAI. + */ +export type AIUIRenderer = { + /** Makes what shows the documents of one conversation. */ + createEngine: (host: AIUIHost) => AIUIEngine; + /** The format it reads. */ + format: AIUIFormat; + /** + * What the agent's backend needs to have its model write UI this renderer shows with + * `components`, such as the model's instructions. None by default. + */ + capabilities?: (components: AIUIComponent[]) => Record; + /** Whether it reads documents of `version`, or of no version. Every version by default. */ + supports?: (version: string | undefined) => boolean; +}; + +/** Whether `renderer` reads `document`: its format, in a version it supports. */ +export const aiUIRendererReads = (renderer: AIUIRenderer, document: AIUIDocument) => + renderer.format === document.format && (renderer.supports?.(document.version) ?? true); diff --git a/src/plugins/AIComponents/GenerativeUI/AIUIView.tsx b/src/plugins/AIComponents/GenerativeUI/AIUIView.tsx new file mode 100644 index 000000000..6e48e8e5b --- /dev/null +++ b/src/plugins/AIComponents/GenerativeUI/AIUIView.tsx @@ -0,0 +1,42 @@ +import React, { type ReactNode } from 'react'; + +import type { AIUIDocument } from './AIUIDocument'; +import { AIUIEntranceStage } from './AIUIEntrance'; +import type { AIUIHost } from './AIUIHost'; + +/** + * A reply's generated UI as it shows in place of the reply's text: as wide as the reply, coming + * in as it first shows while the agent writes it. + */ +export const AIUIGenerated = ({ + children, + isStreaming, +}: { + children: ReactNode; + isStreaming: boolean; +}) => ( + + {children} + +); + +export type AIUIViewProps = { + document: AIUIDocument; + host: AIUIHost; + /** The reply the document belongs to. */ + messageId: string; +}; + +/** + * One reply's generated UI, for showing replies outside `AIConversation`. It shows nothing when + * none of the host's renderers shows the document, and the reply's text should show instead: + * `host.content(document, messageId) !== null` tells which beforehand. Give the host the + * conversation's messages with `host.receive(messages)` as they change. The counterpart of + * `AIUIView` in StreamChatAI. + */ +export const AIUIView = ({ document, host, messageId }: AIUIViewProps) => { + const content = host.content(document, messageId); + return content === null ? null : ( + {content} + ); +}; diff --git a/src/plugins/AIComponents/GenerativeUI/index.ts b/src/plugins/AIComponents/GenerativeUI/index.ts new file mode 100644 index 000000000..5b49b6dcf --- /dev/null +++ b/src/plugins/AIComponents/GenerativeUI/index.ts @@ -0,0 +1,9 @@ +export * from './AIUIAction'; +export * from './AIUIArguments'; +export * from './AIUIComponent'; +export * from './AIUIComponentDefinition'; +export * from './AIUIDocument'; +export { AIUICascade, AIUIEntrance, AIUIEntranceStage } from './AIUIEntrance'; +export * from './AIUIHost'; +export * from './AIUIRenderer'; +export * from './AIUIView'; diff --git a/src/plugins/AIComponents/README.md b/src/plugins/AIComponents/README.md index 918b1dd03..d33f9728e 100644 --- a/src/plugins/AIComponents/README.md +++ b/src/plugins/AIComponents/README.md @@ -122,6 +122,7 @@ const approver = { | `bottomInset` | Room in pixels under the latest turn for a composer floating over the conversation. The scroll-to-latest button sits above it. | | `UserMessage`, `AssistantMessage`, `ReplyActions` | Slots for the person's messages, the agent's replies and the actions under each finished reply (`null` for none). Default to `AIUserMessage`, `AIAssistantMessage` and `AIReplyActions`. | | `replyActions` | `(message, { isLatestReply }) => ({ rating?, onRate?, onRegenerate? })` for the default reply actions. | +| `ui` | An `AIUIHost`: shows the UI the agent writes for a reply (`AIMessage.uiDocument`) in place of its text. See [Generated UI](#generated-ui). | Behaviour: @@ -130,6 +131,34 @@ Behaviour: - A round button scrolls to the latest message while the end is out of view. - Steps render with `AIMessageParts`; the answer types out with `StreamingMessage` as it streams in, and a reply loaded from history shows at once. +### Generated UI + +An agent can answer with UI rather than text, such as a card, a form or a map, in a format such as [A2UI](https://a2ui.org) or OpenUI. The agents router puts it in the reply's `ai_ui` field (`{ v, format, version?, status, content }`) and keeps the words around it as the reply's text, which shows wherever the UI can't. The counterpart of StreamChatAI's generative UI; these components know no format themselves, and renderers plug one in. + +```tsx +const ui = useMemo( + () => + new AIUIHost({ + components: [placeCard], // the app's own views, which the agent can place + renderers: [a2uiRenderer], + send: (action) => + session.responses.create(action.label, { uiAction: aiUIActionJSON(action) }), + }), + [session], +); + + fromStreamMessage(m))} ui={ui} />; +``` + +- `fromStreamMessage` reads a reply's `ai_ui` into `uiDocument` (`parseAIUIDocument`). A person's message never has one. A document of a newer `v` than this SDK reads is ignored. +- The first renderer whose `format` matches and that `supports(version)` shows it, through the engine the host makes for it once and keeps (`AIUIRenderer`, `AIUIEngine`). When none does, or its engine shows nothing of its own (`view` returns `null`), the reply shows its text. +- A reply whose UI shows is no longer empty: the agent's thinking gives way to it as soon as it starts streaming in. A finished reply has its actions under it whether it answers in text or in UI, once the UI is written (`status` other than `streaming`). +- A live reply's UI fades and rises in as it first shows, and its parts after it, 0.1 s apart and at most 0.4 s later (`AIUIEntrance`); with reduced motion they only fade. A reply from history shows at once. +- What the person does is an `AIUIAction` (`messageId`, `format`, `name`, `label`, `payload`). `host.send` sends them in order; one the router would refuse (no reply, format or name, or over 8 KB as JSON) goes to `onError` instead. `label` is the person's message and `aiUIActionJSON(action)` the router's `ui_action`, which it keeps on that message as `ai_ui_action`. +- Links from a reply's UI open through `host.open`: web and email links by default, since the agent writes them. Pass `open` to decide yourself. +- The app's components are described once, in portable JSON shared with the iOS and Android apps (`parseAIUIComponentDefinition`: `name`, `description`, `parameters` as a JSON Schema object with `x-order`, `events`). `defineAIUIComponent(definition, Component)` gives the view `args` (camelCase keys, dates as `Date`s, once they fit the parameters) and `context.emit(event, label, payload)`. `host.capabilities` describes the formats and components to the agent's backend. +- `AIUIView` shows one reply's UI outside `AIConversation`; call `host.receive(messages)` as the messages change. + ### Steps, questions and sign-ins - `AIMessageParts` / `AIMessagePart`: a reply's steps, each as `AIReasoning`, `AIToolCall` (with `AIToolApproval` and `AIToolAuthorization` under it), or a neutral line for a step this SDK can't show. diff --git a/src/plugins/AIComponents/__tests__/AIConversationUI.test.tsx b/src/plugins/AIComponents/__tests__/AIConversationUI.test.tsx new file mode 100644 index 000000000..c8be665b2 --- /dev/null +++ b/src/plugins/AIComponents/__tests__/AIConversationUI.test.tsx @@ -0,0 +1,287 @@ +import React from 'react'; +import { act, fireEvent, screen } from '@testing-library/react'; + +import { + AIConversation, + type AIMessage, + AIUIComponentView, + AIUIHost, + type AIUIRenderer, + AIUIView, + defineAIUIComponent, + fromStreamMessage, + parseAIUIComponentDefinition, +} from '..'; +import { renderWithEnglish } from './renderWithEnglish'; + +const user = (id: string, text: string): AIMessage => ({ id, role: 'user', text }); +const reply = ( + id: string, + text: string, + ui?: Record, + extra: Record = {}, +): AIMessage => + fromStreamMessage({ + ai_generated: true, + ai_ui: ui, + attachments: [], + id, + text, + ...extra, + }); + +// Shows a document's text as a card, or nothing for the content `nothing`. +const cardRenderer: AIUIRenderer = { + createEngine: () => ({ + view: (document) => + document.content === 'nothing' ? null : ( +
{String(document.content)}
+ ), + }), + format: 'a2ui', + supports: (version) => version === undefined || version === '1.0', +}; + +const makeHost = () => new AIUIHost({ renderers: [cardRenderer], send: vi.fn() }); + +const flushFrames = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + +describe('AIConversation with generated UI', () => { + it('shows a reply’s UI in place of its text', () => { + renderWithEnglish( + , + ); + expect(screen.getByTestId('card')).toHaveTextContent('Ohrid plan'); + expect(screen.queryByText("Here's a weekend in Ohrid.")).not.toBeInTheDocument(); + }); + + it('shows the text of a reply whose UI no renderer shows', () => { + renderWithEnglish( + , + ); + for (const text of [ + 'In OpenUI', + 'A newer A2UI', + 'A newer field', + 'Shows nothing of its own', + ]) { + expect(screen.getByText(text)).toBeInTheDocument(); + } + expect(screen.queryByTestId('card')).not.toBeInTheDocument(); + }); + + it('shows the text without a host', () => { + renderWithEnglish( + , + ); + expect(screen.getByText('The words')).toBeInTheDocument(); + }); + + it('stops thinking once a reply has UI to show, and keeps thinking while it has none', () => { + const ui = makeHost(); + const question = user('u1', 'Plan'); + const { rerender } = renderWithEnglish( + , + ); + expect(screen.getByText('Thinking…')).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.queryByText('Thinking…')).not.toBeInTheDocument(); + expect(screen.getByTestId('card')).toHaveTextContent('Ohrid'); + }); + + it('has the reply’s actions under a finished UI-only reply, not while its UI is written', () => { + const ui = makeHost(); + const question = user('u1', 'Plan'); + const { rerender } = renderWithEnglish( + , + ); + expect(screen.queryByRole('button', { name: /copy/i })).not.toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument(); + }); + + it('brings a UI in when it first shows while written, and shows one from history at once', async () => { + const ui = makeHost(); + renderWithEnglish( + , + ); + const stageOf = (text: string) => screen.getByText(text).closest('.str-chat__ai-ui'); + expect(stageOf('From history')).toHaveAttribute('data-entrance', 'shown'); + expect(stageOf('Being written')).toHaveAttribute('data-entrance', 'hidden'); + await flushFrames(); + expect(stageOf('Being written')).toHaveAttribute('data-entrance', 'shown'); + }); + + it('hands the replies’ documents to the host as they change', () => { + const ui = makeHost(); + const receive = vi.spyOn(ui, 'receive'); + const messages = [ + user('u1', 'Plan'), + reply('a1', '', { content: 'Ohrid', format: 'a2ui' }), + ]; + renderWithEnglish(); + expect(receive).toHaveBeenCalledWith(messages); + }); +}); + +describe('AIUIView', () => { + it('shows one reply’s UI outside a conversation, or nothing when no renderer shows it', () => { + const ui = makeHost(); + const { rerender } = renderWithEnglish( + , + ); + expect(screen.getByTestId('card')).toHaveTextContent('Alone'); + rerender( + , + ); + expect(screen.queryByTestId('card')).not.toBeInTheDocument(); + }); +}); + +describe('AIUIComponentView', () => { + const definition = parseAIUIComponentDefinition({ + description: 'One place of a plan.', + events: [{ description: 'Asks about the place.', name: 'ask_about' }], + name: 'place_card', + parameters: { + properties: { place_note: { type: 'string' }, title: { type: 'string' } }, + required: ['title'], + 'x-order': ['title', 'place_note'], + }, + }); + if (!definition) throw new Error('no definition'); + const placeCard = defineAIUIComponent<{ placeNote?: string; title: string }>( + definition, + ({ args, context }) => ( + + ), + ); + + it('shows an app’s component once its arguments fit, and tells the agent what was done', () => { + const emit = vi.fn(); + const context = { emit, host: makeHost(), isStreaming: true, messageId: 'a1' }; + const { rerender } = renderWithEnglish( + , + ); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + + rerender( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Kaneo · Best at sunset' })); + expect(emit).toHaveBeenCalledWith('ask_about', 'Tell me more about Kaneo', { + title: 'Kaneo', + }); + }); +}); diff --git a/src/plugins/AIComponents/__tests__/generativeUI.test.ts b/src/plugins/AIComponents/__tests__/generativeUI.test.ts new file mode 100644 index 000000000..e4754a231 --- /dev/null +++ b/src/plugins/AIComponents/__tests__/generativeUI.test.ts @@ -0,0 +1,570 @@ +import { + AI_UI_ACTION_MAX_BYTES, + type AIUIAction, + aiUIActionJSON, + aiUIActionProblem, + aiUICamelCase, + AIUICascade, + aiUIComponentDefinitionJSON, + aiUIDate, + AIUIHost, + AIUIParameterType, + type AIUIRenderer, + decodeAIUIArguments, + defineAIUIComponent, + fromStreamMessage, + isWebOrEmailLink, + parseAIUIComponentDefinition, + parseAIUIDocument, + requiredAIUIParameter, +} from '..'; +import type { AIMessage } from '..'; + +// The components the Swift assistant describes once for every platform. +const uiComponents = [ + { + description: 'One place of a plan, with its photo and a short summary.', + events: [ + { description: 'Tapping the card asks you about the place.', name: 'ask_about' }, + ], + name: 'place_card', + parameters: { + properties: { + note: { description: 'One short line about it for this plan', type: 'string' }, + title: { + description: "The place's exact English Wikipedia title", + type: 'string', + }, + }, + required: ['title'], + type: 'object', + 'x-order': ['title', 'note'], + }, + }, + { + description: 'A map of a plan with a pin for each place.', + events: [ + { description: 'Tapping a pin asks you about that place.', name: 'select_place' }, + ], + name: 'trip_map', + parameters: { + properties: { + places: { + description: 'Every place of the plan, with its coordinates', + items: { + properties: { + latitude: { type: 'number' }, + longitude: { type: 'number' }, + name: { type: 'string' }, + }, + required: ['name', 'latitude', 'longitude'], + type: 'object', + 'x-order': ['name', 'latitude', 'longitude'], + }, + type: 'array', + }, + }, + required: ['places'], + type: 'object', + 'x-order': ['places'], + }, + }, + { + description: "The person's own calendar for the days of a plan.", + events: [], + name: 'calendar_check', + parameters: { + properties: { + end: { + description: 'The last day, as YYYY-MM-DD', + format: 'date', + type: 'string', + }, + start: { + description: 'The first day, as YYYY-MM-DD', + format: 'date', + type: 'string', + }, + }, + required: ['start', 'end'], + type: 'object', + 'x-order': ['start', 'end'], + }, + }, +]; + +const reply = (id: string, ui?: unknown): AIMessage => + fromStreamMessage({ + ai_generated: true, + ai_ui: ui, + attachments: [], + id, + text: 'Here.', + }); + +// A renderer that records what its engine was given, and shows a document's text. +const recordingRenderer = (format: string, supports?: (version?: string) => boolean) => { + const log: string[] = []; + const renderer: AIUIRenderer = { + createEngine: () => { + log.push('engine'); + return { + receive: (document, messageId) => + log.push(`receive ${messageId} ${document.content}`), + remove: (messageId) => log.push(`remove ${messageId}`), + view: (document) => + document.content === 'nothing' ? null : `${document.content}`, + }; + }, + format, + supports, + }; + return { log, renderer }; +}; + +describe('AIUIDocument', () => { + it('reads the format, version, status and content of a reply’s ai_ui', () => { + expect( + parseAIUIDocument({ + content: '{"version":"1.0"}', + format: 'a2ui', + status: 'streaming', + v: 1, + version: '1.0', + }), + ).toEqual({ + content: '{"version":"1.0"}', + format: 'a2ui', + isStreaming: true, + version: '1.0', + }); + }); + + it('treats only status streaming as being written', () => { + for (const status of ['complete', undefined, 'other']) { + expect( + parseAIUIDocument({ content: 'x', format: 'openui', status })?.isStreaming, + ).toBe(false); + } + }); + + it('keeps JSON content, as a backend can send A2UI messages as JSON', () => { + const content = [{ version: '1.0' }]; + expect(parseAIUIDocument({ content, format: 'a2ui' })?.content).toBe(content); + }); + + it('reads none without a format or content, or from a field newer than this SDK', () => { + expect(parseAIUIDocument(undefined)).toBeUndefined(); + expect(parseAIUIDocument({ content: 'x' })).toBeUndefined(); + expect(parseAIUIDocument({ content: 'x', format: '' })).toBeUndefined(); + expect(parseAIUIDocument({ content: null, format: 'a2ui' })).toBeUndefined(); + expect(parseAIUIDocument({ content: 'x', format: 'a2ui', v: 2 })).toBeUndefined(); + expect(parseAIUIDocument({ content: 'x', format: 'a2ui' })).toBeDefined(); + }); + + it('treats an empty version as none', () => { + expect( + parseAIUIDocument({ content: 'x', format: 'a2ui', version: '' }), + ).not.toHaveProperty('version'); + }); + + it('is read from a reply only, never from a person’s message', () => { + const ui = { content: 'x', format: 'a2ui' }; + expect(reply('a1', ui).uiDocument?.format).toBe('a2ui'); + expect( + fromStreamMessage({ ai_ui: ui, attachments: [], id: 'u1', text: 'Hi' }).uiDocument, + ).toBeUndefined(); + }); +}); + +describe('AIUIAction', () => { + const action: AIUIAction = { + format: 'a2ui', + label: 'Update the plan', + messageId: 'reply-1', + name: 'update_plan', + payload: { pace: 'relaxed' }, + }; + + it('goes to the agent as the JSON its backend reads, with the label as the text', () => { + expect(aiUIActionJSON(action)).toEqual({ + format: 'a2ui', + message_id: 'reply-1', + name: 'update_plan', + payload: { pace: 'relaxed' }, + v: 1, + }); + }); + + it('names what the agent’s backend would refuse before it is sent', () => { + expect(aiUIActionProblem(action)).toBeUndefined(); + expect(aiUIActionProblem({ ...action, messageId: '' })).toMatch(/reply/); + expect(aiUIActionProblem({ ...action, name: '' })).toMatch(/what happened/); + expect( + aiUIActionProblem({ + ...action, + payload: { notes: 'x'.repeat(AI_UI_ACTION_MAX_BYTES) }, + }), + ).toMatch(/at most 8192 bytes/); + }); +}); + +describe('AIUIComponentDefinition', () => { + it('reads the portable JSON every platform shares, keeping the parameters’ order', () => { + const [placeCard, tripMap] = uiComponents.map(parseAIUIComponentDefinition); + expect( + placeCard?.parameters.map(({ isRequired, name }) => [name, isRequired]), + ).toEqual([ + ['title', true], + ['note', false], + ]); + expect(placeCard?.parameters[0]?.description).toMatch(/Wikipedia/); + expect(placeCard?.events).toEqual([ + { + description: 'Tapping the card asks you about the place.', + name: 'ask_about', + parameters: [], + }, + ]); + expect(tripMap?.parameters[0]?.type.type).toBe('array'); + }); + + it('writes the JSON it reads', () => { + for (const json of uiComponents) { + const definition = parseAIUIComponentDefinition(json); + expect(definition).toBeDefined(); + if (!definition) continue; + expect( + parseAIUIComponentDefinition(aiUIComponentDefinitionJSON(definition)), + ).toEqual(definition); + } + }); + + it('orders the fields an object leaves unordered by name, after those it orders', () => { + const definition = parseAIUIComponentDefinition({ + name: 'card', + parameters: { + properties: { + a: { type: 'string' }, + b: { type: 'string' }, + c: { type: 'string' }, + }, + 'x-order': ['c'], + }, + }); + expect(definition?.parameters.map(({ name }) => name)).toEqual(['c', 'a', 'b']); + expect(definition?.description).toBe(''); + }); + + it('reads none without a name', () => { + expect(parseAIUIComponentDefinition({ description: 'x' })).toBeUndefined(); + }); +}); + +describe('decodeAIUIArguments', () => { + const [placeCard, tripMap, calendarCheck] = uiComponents.map( + (json) => parseAIUIComponentDefinition(json)?.parameters ?? [], + ); + + it('reads the snake_case the agent writes as camelCase', () => { + expect(aiUICamelCase('place_name')).toBe('placeName'); + expect(aiUICamelCase('_private_key_')).toBe('_privateKey_'); + expect(aiUICamelCase('title')).toBe('title'); + expect( + decodeAIUIArguments( + { best_time: 'sunset', note: 'Best at sunset', title: 'Kaneo' }, + placeCard ?? [], + ), + ).toEqual({ bestTime: 'sunset', note: 'Best at sunset', title: 'Kaneo' }); + }); + + it('waits while a required argument is missing or of another type, as while it streams in', () => { + expect( + decodeAIUIArguments({ note: 'Best at sunset' }, placeCard ?? []), + ).toBeUndefined(); + expect(decodeAIUIArguments({ title: 3 }, placeCard ?? [])).toBeUndefined(); + expect( + decodeAIUIArguments({ places: [{ latitude: 41.1, name: 'Kaneo' }] }, tripMap ?? []), + ).toBeUndefined(); + expect( + decodeAIUIArguments( + { places: [{ latitude: 41.1, longitude: 20.79, name: 'Kaneo' }] }, + tripMap ?? [], + ), + ).toEqual({ places: [{ latitude: 41.1, longitude: 20.79, name: 'Kaneo' }] }); + }); + + it('reads whole days as their start where the person is, and RFC 3339 moments', () => { + const decoded = decodeAIUIArguments<{ end: Date; start: Date }>( + { end: '2026-11-15T18:00:00Z', start: '2026-11-14' }, + calendarCheck ?? [], + ); + expect(decoded?.start).toEqual(new Date(2026, 10, 14)); + expect(decoded?.end.toISOString()).toBe('2026-11-15T18:00:00.000Z'); + }); + + it('refuses dates the calendar does not have, and loose strings', () => { + expect(aiUIDate('2026-02-30')).toBeUndefined(); + expect(aiUIDate('2026-2-3')).toBeUndefined(); + expect(aiUIDate('next Friday')).toBeUndefined(); + expect(aiUIDate('1700000000')).toBeUndefined(); + expect( + decodeAIUIArguments({ end: 'soon', start: '2026-11-14' }, calendarCheck ?? []), + ).toBe(undefined); + }); + + it('checks enumerations, integers and nested objects', () => { + const parameters = [ + requiredAIUIParameter('pace', AIUIParameterType.enumeration(['relaxed', 'busy'])), + requiredAIUIParameter('days', AIUIParameterType.integer), + requiredAIUIParameter( + 'stay', + AIUIParameterType.object([ + requiredAIUIParameter('hotel_name', AIUIParameterType.string), + ]), + ), + ]; + expect( + decodeAIUIArguments( + { days: 2, pace: 'relaxed', stay: { hotel_name: 'Tino' } }, + parameters, + ), + ).toEqual({ days: 2, pace: 'relaxed', stay: { hotelName: 'Tino' } }); + expect( + decodeAIUIArguments( + { days: 2, pace: 'lazy', stay: { hotel_name: 'Tino' } }, + parameters, + ), + ).toBeUndefined(); + expect( + decodeAIUIArguments( + { days: 2.5, pace: 'busy', stay: { hotel_name: 'Tino' } }, + parameters, + ), + ).toBeUndefined(); + }); +}); + +// Written in pieces, as a literal script link reads as eval to the linter. +const scriptLink = ['java', 'script:alert(1)'].join(''); + +describe('AIUIHost', () => { + const host = (renderers: AIUIRenderer[], send = vi.fn()) => + new AIUIHost({ renderers, send }); + + it('shows a document with the first renderer that reads its format and version', () => { + const old = recordingRenderer('a2ui', (version) => version === '0.9'); + const current = recordingRenderer('a2ui', (version) => version === '1.0'); + const ui = host([old.renderer, current.renderer]); + const document = { + content: 'card', + format: 'a2ui', + isStreaming: false, + version: '1.0', + }; + expect(ui.canShow(document)).toBe(true); + expect(ui.content(document, 'a1')).toBe('card'); + expect(old.log).toEqual([]); + expect(current.log).toEqual(['engine']); + }); + + it('shows nothing for a format or version no renderer reads, so the text shows', () => { + const { renderer } = recordingRenderer('a2ui', (version) => version === '1.0'); + const ui = host([renderer]); + for (const document of [ + { content: 'x', format: 'openui', isStreaming: false }, + { content: 'x', format: 'a2ui', isStreaming: false, version: '2.0' }, + ]) { + expect(ui.canShow(document)).toBe(false); + expect(ui.content(document, 'a1')).toBeNull(); + } + }); + + it('says a renderer can show what its engine then shows nothing of its own for', () => { + const { renderer } = recordingRenderer('a2ui'); + const ui = host([renderer]); + const document = { content: 'nothing', format: 'a2ui', isStreaming: false }; + expect(ui.canShow(document)).toBe(true); + expect(ui.content(document, 'a1')).toBeNull(); + }); + + it('keeps one engine per renderer for as long as it lives', () => { + const { log, renderer } = recordingRenderer('a2ui'); + const ui = host([renderer]); + ui.content({ content: 'a', format: 'a2ui', isStreaming: false }, 'a1'); + ui.content({ content: 'b', format: 'a2ui', isStreaming: false }, 'a2'); + expect(log.filter((line) => line === 'engine')).toHaveLength(1); + }); + + it('hands every reply’s document to its engine, oldest first, only as it arrives or changes', () => { + const { log, renderer } = recordingRenderer('a2ui'); + const ui = host([renderer]); + const person: AIMessage = { + id: 'u1', + role: 'user', + text: 'Plan', + uiDocument: undefined, + }; + const first = [ + reply('a1', { content: 'one', format: 'a2ui' }), + person, + reply('a2', { content: 'two', format: 'a2ui', status: 'streaming' }), + reply('a3'), + ]; + ui.receive(first); + // the same documents in new message objects, as while a reply streams + ui.receive(first.map((message) => ({ ...message }))); + ui.receive([ + first[0] as AIMessage, + person, + reply('a2', { content: 'two more', format: 'a2ui', status: 'complete' }), + ]); + expect(log).toEqual([ + 'engine', + 'receive a1 one', + 'receive a2 two', + 'receive a2 two more', + ]); + }); + + it('tells the engine when a reply or its UI leaves, and forgets its state', () => { + const { log, renderer } = recordingRenderer('a2ui'); + const ui = host([renderer]); + ui.receive([reply('a1', { content: 'one', format: 'a2ui' })]); + ui.setState('a1', { pace: 'relaxed' }); + expect(ui.state('a1')).toEqual({ pace: 'relaxed' }); + ui.receive([reply('a1')]); + expect(log).toContain('remove a1'); + expect(ui.state('a1')).toBeUndefined(); + }); + + it('sends a reply’s actions in the order they happened, past one that fails', async () => { + const sent: string[] = []; + let release: () => void = () => undefined; + const slow = new Promise((resolve) => { + release = resolve; + }); + const onError = vi.fn(); + const ui = new AIUIHost({ + onError, + renderers: [], + send: async (action) => { + if (action.name === 'first') await slow; + if (action.name === 'second') throw new Error('offline'); + sent.push(action.name); + }, + }); + const action = (name: string): AIUIAction => ({ + format: 'a2ui', + label: name, + messageId: 'a1', + name, + payload: {}, + }); + ui.send(action('first')); + ui.send(action('second')); + const last = ui.send(action('third')); + release(); + await last; + expect(sent).toEqual(['first', 'third']); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]?.[1]).toMatchObject({ name: 'second' }); + }); + + it('does not send an action the agent’s backend would refuse', async () => { + const send = vi.fn(); + const onError = vi.fn(); + const ui = new AIUIHost({ onError, renderers: [], send }); + await ui.send({ + format: 'a2ui', + label: 'Go', + messageId: '', + name: 'go', + payload: {}, + }); + expect(send).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('opens only web and email links by default, as the agent writes them', () => { + expect(isWebOrEmailLink(new URL('https://example.com'))).toBe(true); + expect(isWebOrEmailLink(new URL('mailto:hi@example.com'))).toBe(true); + for (const link of ['tel:+123', 'sms:+123', scriptLink, 'file:///etc/passwd']) { + expect(isWebOrEmailLink(new URL(link))).toBe(false); + } + const opened = vi.spyOn(window, 'open').mockReturnValue(null); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const ui = host([]); + ui.open(scriptLink); + ui.open('not a link'); + expect(opened).not.toHaveBeenCalled(); + ui.open('https://en.wikipedia.org/wiki/Ohrid'); + expect(opened).toHaveBeenCalledWith( + 'https://en.wikipedia.org/wiki/Ohrid', + '_blank', + 'noopener,noreferrer', + ); + opened.mockRestore(); + warn.mockRestore(); + }); + + it('opens links the app’s way when it says how', () => { + const open = vi.fn(); + const ui = new AIUIHost({ open, renderers: [], send: vi.fn() }); + ui.open('tel:+123'); + expect(open).toHaveBeenCalledWith(new URL('tel:+123')); + }); + + it('runs the app’s tools for a reply’s UI, and refuses one it does not have', async () => { + const ui = new AIUIHost({ + renderers: [], + send: vi.fn(), + tools: [{ name: 'weather', run: ({ city }) => ({ city, sky: 'clear' }) }], + }); + await expect(ui.runTool('weather', { city: 'Ohrid' })).resolves.toEqual({ + city: 'Ohrid', + sky: 'clear', + }); + await expect(ui.runTool('calendar')).rejects.toThrow('No tool named calendar'); + }); + + it('describes the formats it reads and the app’s components to the agent’s backend', () => { + const renderer: AIUIRenderer = { + capabilities: (components) => ({ + catalog_id: 'app', + components: components.length, + }), + createEngine: () => ({ view: () => null }), + format: 'a2ui', + }; + const definition = parseAIUIComponentDefinition(uiComponents[0]); + if (!definition) throw new Error('no definition'); + const ui = new AIUIHost({ + components: [defineAIUIComponent(definition, () => null)], + renderers: [renderer], + send: vi.fn(), + }); + expect(ui.capabilities).toEqual({ + components: [aiUIComponentDefinitionJSON(definition)], + renderers: [{ catalog_id: 'app', components: 1, format: 'a2ui' }], + v: 1, + }); + expect(ui.component('place_card')?.definition).toBe(definition); + }); +}); + +describe('AIUICascade', () => { + it('spaces out parts that come in together, waiting at most 0.4 seconds', () => { + const cascade = new AIUICascade(); + const delays = [0, 1, 2, 3, 4, 5].map(() => cascade.delay(10)); + expect(delays.map((delay) => Math.round(delay * 10) / 10)).toEqual([ + 0, 0.1, 0.2, 0.3, 0.4, 0.4, + ]); + }); + + it('lets a part that comes in alone in at once', () => { + const cascade = new AIUICascade(); + expect(cascade.delay(10)).toBe(0); + expect(cascade.delay(11)).toBe(0); + }); +}); diff --git a/src/plugins/AIComponents/__tests__/ssr.test.tsx b/src/plugins/AIComponents/__tests__/ssr.test.tsx index 11d826d00..8362c0051 100644 --- a/src/plugins/AIComponents/__tests__/ssr.test.tsx +++ b/src/plugins/AIComponents/__tests__/ssr.test.tsx @@ -8,6 +8,8 @@ import { AIMessageComposer, AIStateIndicator, AISuggestions, + AIUIHost, + fromStreamMessage, parseMessageParts, StreamingMessage, VoiceGlow, @@ -92,3 +94,36 @@ it('server-renders a conversation without touching browser globals', () => { expect(html).toContain(marker); } }); + +it('server-renders a reply’s generated UI without touching browser globals', () => { + const ui = new AIUIHost({ + renderers: [ + { + createEngine: () => ({ + view: (document) =>
{String(document.content)}
, + }), + format: 'a2ui', + }, + ], + send: () => undefined, + }); + const html = renderToString( + , + ); + expect(html).toContain('ssr-ui-marker'); + expect(html).not.toContain('ssr-hidden-text'); + expect(html).toContain('data-entrance="hidden"'); +}); diff --git a/src/plugins/AIComponents/index.ts b/src/plugins/AIComponents/index.ts index 49dc099c2..12cb1c3a4 100644 --- a/src/plugins/AIComponents/index.ts +++ b/src/plugins/AIComponents/index.ts @@ -2,6 +2,7 @@ export * from './AIMarkdown'; export * from './AIMessageComposer'; export * from './AIStateIndicator/AIStateIndicator'; export * from './Conversation'; +export * from './GenerativeUI'; export * from './hooks/useHeardText'; export * from './Message'; export * from './MessageParts'; diff --git a/src/plugins/AIComponents/styling/ai-ui.scss b/src/plugins/AIComponents/styling/ai-ui.scss new file mode 100644 index 000000000..cfd4b8538 --- /dev/null +++ b/src/plugins/AIComponents/styling/ai-ui.scss @@ -0,0 +1,34 @@ +// A reply's generated UI, and how its parts come in. + +.str-chat__ai-ui { + width: 100%; + min-width: 0; +} + +// Fades in, rising into place and coming into focus. The curve settles like the spring the +// other platforms use. Once shown, it keeps no transform or filter, which would otherwise +// become the containing block of a fixed overlay inside it, such as a modal. +.str-chat__ai-ui-entrance { + transform-origin: top center; + transition: + opacity 0.6s cubic-bezier(0.22, 1, 0.36, 1), + filter 0.6s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.6s cubic-bezier(0.22, 1, 0.36, 1); + + &--hidden { + opacity: 0; + filter: blur(8px); + transform: translateY(16px) scale(0.97); + } +} + +@media (prefers-reduced-motion: reduce) { + .str-chat__ai-ui-entrance { + transition: opacity 0.3s ease-out; + + &--hidden { + filter: none; + transform: none; + } + } +} diff --git a/src/plugins/AIComponents/styling/index.scss b/src/plugins/AIComponents/styling/index.scss index 4c60a7f1b..5722d53ed 100644 --- a/src/plugins/AIComponents/styling/index.scss +++ b/src/plugins/AIComponents/styling/index.scss @@ -6,5 +6,6 @@ @use './voice-glow-css.scss'; @use './ai-message-parts.scss'; @use './ai-conversation.scss'; +@use './ai-ui.scss'; // Intentionally declares no `--str-chat__ai-*` custom properties; see _tokens.scss. From af8624f46ecb49d7815a286c94a5d28a632ba811 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 3 Nov 2026 13:50:12 +0100 Subject: [PATCH 04/22] feat(a2ui): render A2UI replies on the official A2UI packages A new stream-chat-react/a2ui entry: createA2UIRenderer, an AIUIHost renderer for A2UI 1.0 and the counterpart of StreamChatA2UI, built on @a2ui/web_core and @a2ui/react 0.15.0 as optional, pinned peers. - Each reply has its own processor, fed each message as it completes; messages the agent rewrites start its surfaces again. Every spelling of 1.0 is read, and a surface naming no catalog gets the app's. - The app's components join the catalog from their portable JSON as on iOS (place_card is PlaceCard), under getstream.io:app-catalog and the basic catalog, with bound values worked out before they render. - Actions reach host.send with Swift's label and payload; openUrl goes through host.open from a tap only; Text renders with AIMarkdown. - Errors go to onError and the rest still shows; a batch one bad component spoils is fed again a component at a time. - The A2UI packages load in the browser when a reply first shows A2UI, so server rendering shows a placeholder. The entry is ESM only. - Stream's AI tokens theme the basic catalog's --a2ui-* variables. --- AGENTS.md | 3 +- package.json | 15 + src/plugins/A2UI/A2UIPlaceholder.tsx | 8 + src/plugins/A2UI/A2UIRenderer.tsx | 140 +++++ src/plugins/A2UI/A2UIStream.ts | 84 +++ .../A2UI/__tests__/A2UIRenderer.test.tsx | 412 ++++++++++++++ src/plugins/A2UI/__tests__/ohrid.ts | 107 ++++ src/plugins/A2UI/__tests__/ssr.test.tsx | 33 ++ src/plugins/A2UI/index.ts | 7 + src/plugins/A2UI/runtime.tsx | 516 ++++++++++++++++++ src/plugins/AIComponents/README.md | 26 + src/plugins/AIComponents/styling/a2ui.scss | 75 +++ src/plugins/AIComponents/styling/index.scss | 1 + vite.config.ts | 1 + yarn.lock | 228 +++++++- 15 files changed, 1654 insertions(+), 2 deletions(-) create mode 100644 src/plugins/A2UI/A2UIPlaceholder.tsx create mode 100644 src/plugins/A2UI/A2UIRenderer.tsx create mode 100644 src/plugins/A2UI/A2UIStream.ts create mode 100644 src/plugins/A2UI/__tests__/A2UIRenderer.test.tsx create mode 100644 src/plugins/A2UI/__tests__/ohrid.ts create mode 100644 src/plugins/A2UI/__tests__/ssr.test.tsx create mode 100644 src/plugins/A2UI/index.ts create mode 100644 src/plugins/A2UI/runtime.tsx create mode 100644 src/plugins/AIComponents/styling/a2ui.scss diff --git a/AGENTS.md b/AGENTS.md index 23afe6004..24381d834 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -314,7 +314,7 @@ Mock modules with `vi.mock('../../EmptyStateIndicator', () => ({ … }))`; use ` `yarn build` = `yarn clean` + 4 steps in parallel via `concurrently`, each writing to a separate `dist/` subdirectory: 1. **`build-translations`** — `i18next-cli extract` pulls `t()` calls from source into `src/i18n/*.json` -2. **`vite build`** — bundles 5 entry points as ESM (`dist/es/*.mjs`) + CJS (`dist/cjs/*.js`) +2. **`vite build`** — bundles 6 entry points as ESM (`dist/es/*.mjs`) + CJS (`dist/cjs/*.js`) 3. **`tsc -p tsconfig.lib.json`** — `.d.ts` only → `dist/types/` 4. **`build-styling`** — Sass → `dist/css/index.css`, `emoji-replacement.css`, `emoji-picker.css`, `channel-detail.css`, `ai-components.css`, plus `cp -r src/styling/assets dist/css/assets` @@ -326,6 +326,7 @@ Mock modules with `vi.mock('../../EmptyStateIndicator', () => ({ … }))`; use ` | `stream-chat-react/channel-detail` | `src/plugins/ChannelDetail/` | | `stream-chat-react/emojis` | `src/plugins/Emojis/` | | `stream-chat-react/ai-components` | `src/plugins/AIComponents/` | +| `stream-chat-react/a2ui` | `src/plugins/A2UI/` | | `stream-chat-react/mp3-encoder` | `src/plugins/encoders/mp3.ts` | | `stream-chat-react/css/*` | `dist/css/*` | diff --git a/package.json b/package.json index 7837b07af..89259b1cb 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,11 @@ "require": "./dist/cjs/ai-components.js", "default": "./dist/cjs/ai-components.js" }, + "./a2ui": { + "types": "./dist/types/plugins/A2UI/index.d.ts", + "import": "./dist/es/a2ui.mjs", + "default": "./dist/es/a2ui.mjs" + }, "./dist/css/*": { "default": "./dist/css/*" }, @@ -122,6 +127,8 @@ "@stream-io/transliterate": "^1.5.5" }, "peerDependencies": { + "@a2ui/react": "0.15.0", + "@a2ui/web_core": "0.15.0", "@breezystack/lamejs": "^1.2.7", "@emoji-mart/data": "^1.1.0", "emoji-mart": "^5.4.0", @@ -131,6 +138,12 @@ "stream-chat": "^9.54.0" }, "peerDependenciesMeta": { + "@a2ui/react": { + "optional": true + }, + "@a2ui/web_core": { + "optional": true + }, "@breezystack/lamejs": { "optional": true }, @@ -150,6 +163,8 @@ "README.md" ], "devDependencies": { + "@a2ui/react": "0.15.0", + "@a2ui/web_core": "0.15.0", "@breezystack/lamejs": "^1.2.7", "@commitlint/cli": "^21.0.1", "@commitlint/config-conventional": "^21.0.1", diff --git a/src/plugins/A2UI/A2UIPlaceholder.tsx b/src/plugins/A2UI/A2UIPlaceholder.tsx new file mode 100644 index 000000000..c52fc4df9 --- /dev/null +++ b/src/plugins/A2UI/A2UIPlaceholder.tsx @@ -0,0 +1,8 @@ +import React from 'react'; + +/** What shows while a reply's A2UI loads, or while its first surface is being written. */ +export const A2UIPlaceholder = () => ( +
+
+
+); diff --git a/src/plugins/A2UI/A2UIRenderer.tsx b/src/plugins/A2UI/A2UIRenderer.tsx new file mode 100644 index 000000000..18b809f3c --- /dev/null +++ b/src/plugins/A2UI/A2UIRenderer.tsx @@ -0,0 +1,140 @@ +import React, { useEffect, useState } from 'react'; + +import type { AIUIDocument } from '../AIComponents/GenerativeUI/AIUIDocument'; +import type { AIUIHost } from '../AIComponents/GenerativeUI/AIUIHost'; +import type { AIUIEngine, AIUIRenderer } from '../AIComponents/GenerativeUI/AIUIRenderer'; +import { A2UIPlaceholder } from './A2UIPlaceholder'; +import { A2UI_VERSION, a2uiSourceOf, supportsA2UIVersion } from './A2UIStream'; +// Types only: the runtime, with the A2UI packages, loads in the browser when a reply first +// shows A2UI. +import type { A2UIReplyState, A2UIRuntime } from './runtime'; + +/** The id of the catalog with the app's components, unless the renderer is given another. */ +export const A2UI_DEFAULT_CATALOG_ID = 'getstream.io:app-catalog'; + +/** What went wrong in a reply's A2UI, as the A2UI specification reports it to an agent. */ +export type A2UIError = { + /** A code such as `VALIDATION_FAILED` or `EXPRESSION_ERROR`. */ + code: string; + message: string; + /** Where in the message it went wrong, as a JSON pointer. */ + path?: string; + surfaceId?: string; +}; + +export type A2UIRendererOptions = { + /** + * The id the agent names the catalog with the app's components by. A surface naming the + * basic catalog shows them too, and a surface naming none is made with this one. + */ + catalogId?: string; + /** + * Tells the app what went wrong in a reply's A2UI, such as a message that isn't JSON or a + * component the catalog doesn't have. The rest of the reply's UI still shows. Logged by + * default. + */ + onError?: (error: A2UIError, messageId: string) => void; +}; + +/** + * Shows the A2UI of one conversation. Each reply's surfaces are its own, kept for as long as + * the host keeps the engine, so what the person entered survives scrolling. + */ +export class A2UIEngine implements AIUIEngine { + readonly catalogId: string; + readonly host: AIUIHost; + /** Each reply's surfaces, by the reply's id. */ + readonly replies = new Map(); + /** The runtime, once loaded. */ + runtime?: A2UIRuntime; + + private loading?: Promise; + private readonly options: A2UIRendererOptions; + + constructor(host: AIUIHost, options: A2UIRendererOptions) { + this.host = host; + this.options = options; + this.catalogId = options.catalogId ?? A2UI_DEFAULT_CATALOG_ID; + } + + /** Loads the runtime, once. */ + load() { + this.loading ??= import('./runtime').then((runtime) => { + this.runtime = runtime; + return runtime; + }); + return this.loading; + } + + report(error: A2UIError, messageId: string) { + if (this.options.onError) this.options.onError(error, messageId); + else + console.error(`A reply's A2UI can't show: ${error.code} ${error.message}`, error); + } + + remove(messageId: string) { + this.replies.get(messageId)?.dispose(); + this.replies.delete(messageId); + } + + view(document: AIUIDocument, messageId: string) { + if (a2uiSourceOf(document) === undefined) return null; + // A finished reply whose A2UI made no surface shows its text instead, once that is known. + if (this.runtime?.showsNothing(this, document, messageId)) return null; + return ; + } +} + +type A2UIReplyViewProps = { + document: AIUIDocument; + engine: A2UIEngine; + messageId: string; +}; + +// A reply's surfaces, once the runtime has loaded. Only in the browser: the A2UI packages +// define custom elements, which a server has no way to. +const A2UIReplyView = ({ document, engine, messageId }: A2UIReplyViewProps) => { + const [runtime, setRuntime] = useState(engine.runtime); + useEffect(() => { + if (runtime) return; + let live = true; + engine.load().then( + (loaded) => { + if (live) setRuntime(loaded); + }, + (error: unknown) => + engine.report( + { + code: 'EXECUTION_ERROR', + message: `The A2UI renderer couldn't load: ${String(error)}`, + }, + messageId, + ), + ); + return () => { + live = false; + }; + }, [engine, messageId, runtime]); + if (!runtime) return ; + const { A2UIReplyContent } = runtime; + return ; +}; + +/** + * The renderer of [A2UI](https://a2ui.org) 1.0 for `AIUIHost`, on the official A2UI packages + * (`@a2ui/web_core` and `@a2ui/react`, which the app installs). The app's components join its + * catalog, so the agent places them like the basic catalog's own. The counterpart of + * `A2UIRenderer` in StreamChatA2UI. + */ +export const createA2UIRenderer = (options: A2UIRendererOptions = {}): AIUIRenderer => { + const catalogId = options.catalogId ?? A2UI_DEFAULT_CATALOG_ID; + return { + capabilities: () => ({ + catalog_id: catalogId, + fence: `ui:a2ui@${A2UI_VERSION}`, + }), + createEngine: (host) => new A2UIEngine(host, options), + format: 'a2ui', + supports: supportsA2UIVersion, + }; +}; diff --git a/src/plugins/A2UI/A2UIStream.ts b/src/plugins/A2UI/A2UIStream.ts new file mode 100644 index 000000000..6ca3da1d7 --- /dev/null +++ b/src/plugins/A2UI/A2UIStream.ts @@ -0,0 +1,84 @@ +import type { AIUIDocument } from '../AIComponents/GenerativeUI/AIUIDocument'; + +/** The version of A2UI this renderer reads. */ +export const A2UI_VERSION = '1.0'; + +// The spellings of 1.0 an agent may write, such as from instructions written before its +// release, or by copying the fence's `ui:a2ui@1.0` in another form. +const VERSION_SPELLINGS = new Set(['1', '1.0', '1.0.0', 'v1', 'v1.0']); + +/** Whether a reply's A2UI of `version`, or of no version, is one this renderer reads. */ +export const supportsA2UIVersion = (version: string | undefined) => + version === undefined || VERSION_SPELLINGS.has(version); + +/** + * The complete A2UI messages in `text`, as written: every top-level JSON object, in order. + * One object per line, a pretty-printed one or a JSON array of them all read the same, and + * words around them are skipped. An object still being written is left out until it closes, so + * a reply's UI shows each message as it completes. + */ +export const a2uiMessagesIn = (text: string): string[] => { + const messages: string[] = []; + let depth = 0; + let start = 0; + let inString = false; + let escaped = false; + for (let index = 0; index < text.length; index++) { + const char = text[index]; + if (inString) { + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') inString = false; + continue; + } + if (char === '"' && depth > 0) { + inString = true; + } else if (char === '{') { + if (depth === 0) start = index; + depth++; + } else if (char === '}' && depth > 0) { + depth--; + if (depth === 0) messages.push(text.slice(start, index + 1)); + } + } + return messages; +}; + +/** + * The messages of a document as text: as the agent wrote them, or written out when a backend + * sent them as JSON. `undefined` for content that holds no messages. + */ +export const a2uiSourceOf = (document: AIUIDocument): string | undefined => { + const { content } = document; + if (typeof content === 'string') return content; + if (typeof content === 'object' && content !== null) { + try { + return JSON.stringify(content); + } catch { + return undefined; + } + } + return undefined; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * A message as the processor reads it: 1.0 in its own spelling, and a surface the agent made + * without naming a catalog made with `catalogId`, the catalog with the app's components. + */ +export const normalizeA2UIMessage = ( + message: Record, + catalogId: string, +): Record => { + let normalized = message; + if (typeof message.version === 'string' && VERSION_SPELLINGS.has(message.version)) { + normalized = { ...normalized, version: A2UI_VERSION }; + } + const { createSurface } = normalized; + if (isRecord(createSurface) && createSurface.catalogId === undefined) { + normalized = { ...normalized, createSurface: { ...createSurface, catalogId } }; + } + return normalized; +}; diff --git a/src/plugins/A2UI/__tests__/A2UIRenderer.test.tsx b/src/plugins/A2UI/__tests__/A2UIRenderer.test.tsx new file mode 100644 index 000000000..1470668ef --- /dev/null +++ b/src/plugins/A2UI/__tests__/A2UIRenderer.test.tsx @@ -0,0 +1,412 @@ +import React from 'react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; + +import { + AIConversation, + type AIMessage, + type AIUIAction, + AIUIHost, + defineAIUIComponent, + fromStreamMessage, + parseAIUIComponentDefinition, +} from '../../AIComponents'; +import { renderWithEnglish } from '../../AIComponents/__tests__/renderWithEnglish'; +import { + A2UI_DEFAULT_CATALOG_ID, + a2uiMessagesIn, + createA2UIRenderer, + supportsA2UIVersion, +} from '..'; +import { normalizeA2UIMessage } from '../A2UIStream'; +import { a2uiAppCatalogDocument, a2uiTypeName } from '../runtime'; +import { ohridLines, uiComponents } from './ohrid'; + +const definitionOf = (name: string) => { + const definition = parseAIUIComponentDefinition( + uiComponents.find((component) => component.name === name), + ); + if (!definition) throw new Error(`no ${name}`); + return definition; +}; + +const placeCard = defineAIUIComponent<{ note?: string; title: string }>( + definitionOf('place_card'), + ({ args, context }) => ( + + ), +); + +const tripMap = defineAIUIComponent<{ places: { name: string }[] }>( + definitionOf('trip_map'), + ({ args }) => ( +

{args.places.map(({ name }) => name).join(', ')}

+ ), +); + +const calendarCheck = defineAIUIComponent<{ end: Date; start: Date }>( + definitionOf('calendar_check'), + ({ args }) => ( +

+ {args.start.getDate()}–{args.end.getDate()} +

+ ), +); + +const setup = () => { + const sent: AIUIAction[] = []; + const errors: { code: string; message: string }[] = []; + const open = vi.fn(); + const ui = new AIUIHost({ + components: [placeCard, tripMap, calendarCheck], + open, + renderers: [createA2UIRenderer({ onError: (error) => errors.push(error) })], + send: (action) => { + sent.push(action); + }, + }); + return { errors, open, sent, ui }; +}; + +const question: AIMessage = { id: 'u1', role: 'user', text: 'Plan a weekend in Ohrid' }; +const replyWith = (lines: string[], status = 'complete', text = 'A weekend in Ohrid.') => + fromStreamMessage({ + ai_generated: true, + ai_ui: { content: lines.join('\n'), format: 'a2ui', status, v: 1, version: '1.0' }, + attachments: [], + id: 'a1', + text, + }); + +const line = (message: Record) => + JSON.stringify({ version: '1.0', ...message }); + +describe('a2uiMessagesIn', () => { + it('reads the messages one per line, in an array, pretty-printed, or among words', () => { + const one = '{"version":"1.0","deleteSurface":{"surfaceId":"a"}}'; + const two = '{"version":"1.0","deleteSurface":{"surfaceId":"b"}}'; + expect(a2uiMessagesIn(`${one}\n${two}`)).toEqual([one, two]); + expect(a2uiMessagesIn(`[${one},${two}]`)).toEqual([one, two]); + expect(a2uiMessagesIn(`Here it is:\n${one}\nand more.`)).toEqual([one]); + expect( + a2uiMessagesIn('{\n "version": "1.0",\n "deleteSurface": {"surfaceId": "a"}\n}'), + ).toHaveLength(1); + }); + + it('leaves out a message still being written', () => { + expect(a2uiMessagesIn('{"version":"1.0"}\n{"version":"1.0","updateComp')).toEqual([ + '{"version":"1.0"}', + ]); + }); + + it('is not misled by braces and quotes inside strings', () => { + const tricky = '{"text":"a } and a \\" and a {"}'; + expect(a2uiMessagesIn(tricky)).toEqual([tricky]); + }); +}); + +describe('the A2UI versions and catalog it reads', () => { + it('reads 1.0 in the spellings an agent writes, and no other version', () => { + for (const version of [undefined, '1', '1.0', '1.0.0', 'v1', 'v1.0']) { + expect(supportsA2UIVersion(version)).toBe(true); + } + for (const version of ['0.9', '2.0', 'v0.8']) { + expect(supportsA2UIVersion(version)).toBe(false); + } + }); + + it('writes every message as 1.0, and names the app’s catalog for a surface naming none', () => { + expect(normalizeA2UIMessage({ version: 'v1.0' }, 'app').version).toBe('1.0'); + expect( + normalizeA2UIMessage({ createSurface: { surfaceId: 's' }, version: '1.0' }, 'app'), + ).toEqual({ createSurface: { catalogId: 'app', surfaceId: 's' }, version: '1.0' }); + expect( + normalizeA2UIMessage( + { createSurface: { catalogId: 'other', surfaceId: 's' }, version: '1.0' }, + 'app', + ).createSurface, + ).toEqual({ catalogId: 'other', surfaceId: 's' }); + }); + + it('describes the app’s components to the agent as StreamChatA2UI does', () => { + expect(a2uiTypeName('place_card')).toBe('PlaceCard'); + const document = a2uiAppCatalogDocument(A2UI_DEFAULT_CATALOG_ID, [placeCard]); + expect(document.components.PlaceCard).toMatchObject({ + description: + 'One place of a plan, with its photo and a short summary, which the phone loads from Wikipedia, so it needs no image URL. Use one per sight, beach, monastery, village or day trip. Tapping the card asks you about the place.', + required: ['component', 'title'], + }); + }); + + it('tells the agent’s backend its fence and catalog', () => { + const { ui } = setup(); + expect(ui.capabilities.renderers).toEqual([ + { catalog_id: A2UI_DEFAULT_CATALOG_ID, fence: 'ui:a2ui@1.0', format: 'a2ui' }, + ]); + }); +}); + +describe('a reply written in A2UI', () => { + it('shows the Ohrid plan with the basic catalog and the app’s components', async () => { + const { errors, ui } = setup(); + renderWithEnglish( + , + ); + + await waitFor(() => + expect(screen.getByTestId('trip-map')).toHaveTextContent( + 'Church of St. John at Kaneo', + ), + ); + expect(screen.getByTestId('calendar-check')).toHaveTextContent('14–15'); + expect(screen.getAllByTestId('place-card')[0]).toHaveTextContent('Plaošnik (1 hour)'); + expect(document.body).toHaveTextContent('Two easy days by the lake'); + expect(screen.queryByText('A weekend in Ohrid.')).not.toBeInTheDocument(); + expect(errors).toEqual([]); + }); + + it('tells the agent what the person did, in their words', async () => { + const { sent, ui } = setup(); + const { container } = renderWithEnglish( + , + ); + + await waitFor(() => + expect(container.querySelector('a2ui-basic-button button')).not.toBeNull(), + ); + const update = [...container.querySelectorAll('a2ui-basic-button button')].find( + (button) => button.textContent?.includes('Update the plan'), + ); + if (!update) throw new Error('no Update the plan button'); + act(() => { + fireEvent.click(update); + }); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0]).toEqual({ + format: 'a2ui', + label: 'Update the plan', + messageId: 'a1', + name: 'update_plan', + payload: { + context: { interests: [], pace: ['relaxed'] }, + sourceComponentId: 'update', + surfaceId: 'ohrid-weekend', + }, + }); + + fireEvent.click(screen.getAllByTestId('place-card')[0] as HTMLElement); + await waitFor(() => expect(sent).toHaveLength(2)); + expect(sent[1]).toEqual({ + format: 'a2ui', + label: 'Tell me more about Plaošnik', + messageId: 'a1', + name: 'ask_about', + payload: { component: 'place_card', title: 'Plaošnik' }, + }); + }); + + it('names an action by what its button says when the agent gave it no words', async () => { + const { sent, ui } = setup(); + const lines = [ + line({ createSurface: { surfaceId: 's' } }), + line({ + updateComponents: { + components: [ + { + action: { event: { name: 'book' } }, + child: 'label', + component: 'Button', + id: 'root', + }, + { component: 'Text', id: 'label', text: 'Book the boat' }, + ], + surfaceId: 's', + }, + }), + ]; + const { container } = renderWithEnglish( + , + ); + await waitFor(() => + expect(container.querySelector('a2ui-basic-button button')).not.toBeNull(), + ); + act(() => { + fireEvent.click(container.querySelector('a2ui-basic-button button') as Element); + }); + await waitFor(() => expect(sent[0]?.label).toBe('Book the boat')); + }); + + it('follows a reply as the agent writes it, a message at a time', async () => { + const { ui } = setup(); + const { rerender } = renderWithEnglish( + , + ); + await waitFor(() => + expect(document.body).toHaveTextContent('Two easy days by the lake'), + ); + expect(screen.queryByTestId('trip-map')).not.toBeInTheDocument(); + + rerender( + , + ); + await waitFor(() => expect(screen.getByTestId('trip-map')).toBeInTheDocument()); + }); + + it('starts the surfaces again when the agent rewrites what it had written', async () => { + const { ui } = setup(); + const surface = line({ createSurface: { surfaceId: 's' } }); + const text = (words: string) => + line({ + updateComponents: { + components: [{ component: 'Text', id: 'root', text: words }], + surfaceId: 's', + }, + }); + const { rerender } = renderWithEnglish( + , + ); + await waitFor(() => expect(document.body).toHaveTextContent('First draft')); + rerender( + , + ); + await waitFor(() => expect(document.body).toHaveTextContent('Second draft')); + expect(document.body).not.toHaveTextContent('First draft'); + }); + + it('shows the rest of a batch that one bad component spoils, and says what went wrong', async () => { + const { errors, ui } = setup(); + const lines = [ + line({ createSurface: { surfaceId: 's' } }), + line({ + updateComponents: { + components: [ + { children: ['good', 'bad'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'good', text: 'Still here' }, + { component: 'Slider', id: 'bad', value: 'not a number' }, + ], + surfaceId: 's', + }, + }), + ]; + renderWithEnglish(); + await waitFor(() => expect(document.body).toHaveTextContent('Still here')); + expect(errors).toEqual([ + expect.objectContaining({ code: 'VALIDATION_FAILED', surfaceId: 's' }), + ]); + }); + + it('says a message is not JSON, and shows the rest', async () => { + const { errors, ui } = setup(); + const lines = [ + line({ createSurface: { surfaceId: 's' } }), + '{not json}', + line({ + updateComponents: { + components: [{ component: 'Text', id: 'root', text: 'After the bad one' }], + surfaceId: 's', + }, + }), + ]; + renderWithEnglish(); + await waitFor(() => expect(document.body).toHaveTextContent('After the bad one')); + expect(errors.map(({ message }) => message)).toContain( + 'A message isn’t a JSON object', + ); + }); + + it('opens links the way the host allows, from the person’s tap only', async () => { + const { open, ui } = setup(); + const lines = [ + line({ createSurface: { surfaceId: 's' } }), + line({ + updateComponents: { + components: [ + { + action: { + functionCall: { + '@call': 'openUrl', + args: { url: 'https://en.wikipedia.org/wiki/Ohrid' }, + }, + }, + child: 'label', + component: 'Button', + id: 'root', + }, + { component: 'Text', id: 'label', text: 'Read about Ohrid' }, + ], + surfaceId: 's', + }, + }), + ]; + const { container } = renderWithEnglish( + , + ); + await waitFor(() => + expect(container.querySelector('a2ui-basic-button button')).not.toBeNull(), + ); + expect(open).not.toHaveBeenCalled(); + act(() => { + fireEvent.click(container.querySelector('a2ui-basic-button button') as Element); + }); + await waitFor(() => + expect(open).toHaveBeenCalledWith(new URL('https://en.wikipedia.org/wiki/Ohrid')), + ); + }); + + it('shows the text of a finished reply whose A2UI made no surface', async () => { + const { ui } = setup(); + const { rerender } = renderWithEnglish( + , + ); + // once the renderer has loaded, which the first reply's view starts + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + rerender( + , + ); + await waitFor(() => expect(screen.getByText('Just words')).toBeInTheDocument()); + }); +}); diff --git a/src/plugins/A2UI/__tests__/ohrid.ts b/src/plugins/A2UI/__tests__/ohrid.ts new file mode 100644 index 000000000..b519b4372 --- /dev/null +++ b/src/plugins/A2UI/__tests__/ohrid.ts @@ -0,0 +1,107 @@ +// The Swift assistant's "Plan a weekend in Ohrid" example: the A2UI an agent is prompted to +// write (`a2ui/prompt.json`) and the app's components it places (`UIComponents.json`). +// Kept as the shared files write them, key order included: `x-order` aside, the order is what +// the agent reads. +/* eslint-disable sort-keys */ + +export const ohridLines = [ + '{"version":"1.0","createSurface":{"surfaceId":"ohrid-weekend","catalogId":"getstream.io:app-catalog","dataModel":{"pace":["relaxed"],"interests":[]}}}', + '{"version":"1.0","updateComponents":{"surfaceId":"ohrid-weekend","components":[{"id":"root","component":"Column","children":["title","intro","map","calendar","days","tip","refine","next"]},{"id":"title","component":"Text","text":"## A weekend in Ohrid"},{"id":"intro","component":"Text","text":"Two easy days by the lake: the old town on Saturday, a boat to St. Naum on Sunday."}]}}', + '{"version":"1.0","updateComponents":{"surfaceId":"ohrid-weekend","components":[{"id":"map","component":"TripMap","places":[{"name":"Church of St. John at Kaneo","latitude":41.1112,"longitude":20.7881},{"name":"Plaošnik","latitude":41.1131,"longitude":20.7903},{"name":"Samuel\'s Fortress, Ohrid","latitude":41.1162,"longitude":20.7928},{"name":"Monastery of Saint Naum","latitude":40.9139,"longitude":20.7406},{"name":"Bay of Bones","latitude":41.0118,"longitude":20.7988}]},{"id":"calendar","component":"CalendarCheck","start":"2026-11-14","end":"2026-11-15"}]}}', + '{"version":"1.0","updateComponents":{"surfaceId":"ohrid-weekend","components":[{"id":"days","component":"Tabs","tabs":[{"title":"Saturday","child":"saturday"},{"title":"Sunday","child":"sunday"}]},{"id":"saturday","component":"Column","children":["saturday_plan","plaosnik","fortress","kaneo"]},{"id":"saturday_plan","component":"Text","text":"**Morning:** coffee by the harbour, then up through the old town to Plaošnik.\\n\\n**Afternoon:** Samuel\'s Fortress for the view over the lake.\\n\\n**Sunset:** St. John at Kaneo, on the cliff above the water."},{"id":"plaosnik","component":"PlaceCard","title":"Plaošnik","note":"1 hour"},{"id":"fortress","component":"PlaceCard","title":"Samuel\'s Fortress, Ohrid","note":"2 hours"},{"id":"kaneo","component":"PlaceCard","title":"Church of St. John at Kaneo","note":"Best at sunset"}]}}', + '{"version":"1.0","updateComponents":{"surfaceId":"ohrid-weekend","components":[{"id":"sunday","component":"Column","children":["sunday_plan","naum","bones"]},{"id":"sunday_plan","component":"Text","text":"**Morning:** the boat from the harbour to St. Naum.\\n\\n**Afternoon:** the Bay of Bones on the way back."},{"id":"naum","component":"PlaceCard","title":"Monastery of Saint Naum","note":"Take the 10:00 boat"},{"id":"bones","component":"PlaceCard","title":"Bay of Bones","note":"1 hour"}]}}', + '{"version":"1.0","updateComponents":{"surfaceId":"ohrid-weekend","components":[{"id":"tip","component":"Text","text":"> **Good to know:** book the St. Naum boat the day before. It fills up at weekends."},{"id":"refine","component":"Card","child":"refine_form"},{"id":"refine_form","component":"Column","children":["pace","interests","update"]},{"id":"pace","component":"ChoicePicker","label":"Pace","variant":"mutuallyExclusive","displayStyle":"chips","options":[{"label":"Relaxed","value":"relaxed"},{"label":"Packed","value":"packed"}],"value":{"@path":"/pace"}},{"id":"interests","component":"ChoicePicker","label":"More of","variant":"multipleSelection","displayStyle":"chips","options":[{"label":"History","value":"history"},{"label":"Food","value":"food"},{"label":"Nature","value":"nature"},{"label":"Swimming","value":"swimming"}],"value":{"@path":"/interests"}},{"id":"update","component":"Button","variant":"primary","child":"update_label","action":{"event":{"name":"update_plan","userMessage":"Update the plan","context":{"pace":{"@path":"/pace"},"interests":{"@path":"/interests"}}}}},{"id":"update_label","component":"Text","text":"Update the plan"}]}}', + '{"version":"1.0","updateComponents":{"surfaceId":"ohrid-weekend","components":[{"id":"next","component":"Column","children":["hotel","eat","longer"]},{"id":"hotel","component":"Button","variant":"borderless","child":"hotel_label","action":{"event":{"name":"ask","userMessage":"Find a hotel by the lake"}}},{"id":"hotel_label","component":"Text","text":"Find a hotel by the lake"},{"id":"eat","component":"Button","variant":"borderless","child":"eat_label","action":{"event":{"name":"ask","userMessage":"Where should we eat?"}}},{"id":"eat_label","component":"Text","text":"Where should we eat?"},{"id":"longer","component":"Button","variant":"borderless","child":"longer_label","action":{"event":{"name":"ask","userMessage":"Make it three days"}}},{"id":"longer_label","component":"Text","text":"Make it three days"}]}}', +]; + +export const uiComponents = [ + { + name: 'place_card', + description: + 'One place of a plan, with its photo and a short summary, which the phone loads from Wikipedia, so it needs no image URL. Use one per sight, beach, monastery, village or day trip.', + parameters: { + type: 'object', + properties: { + title: { + type: 'string', + description: + 'The place\'s exact English Wikipedia article title, such as "Church of St. John at Kaneo"', + }, + note: { + type: 'string', + description: + 'One short line about it for this plan, such as "Best at sunset" or "2 hours"', + }, + }, + required: ['title'], + 'x-order': ['title', 'note'], + }, + events: [ + { + name: 'ask_about', + description: 'Tapping the card asks you about the place.', + }, + ], + }, + { + name: 'trip_map', + description: + 'A map of a plan with a pin for each place, drawn on the phone. Use one near the top of every trip plan.', + parameters: { + type: 'object', + properties: { + places: { + type: 'array', + description: 'Every place of the plan, with its coordinates', + items: { + type: 'object', + properties: { + name: { + type: 'string', + }, + latitude: { + type: 'number', + }, + longitude: { + type: 'number', + }, + }, + required: ['name', 'latitude', 'longitude'], + 'x-order': ['name', 'latitude', 'longitude'], + }, + }, + }, + required: ['places'], + 'x-order': ['places'], + }, + events: [ + { + name: 'select_place', + description: 'Tapping a pin asks you about that place.', + }, + ], + }, + { + name: 'calendar_check', + description: + "The person's own calendar for the days of a plan, read on the phone. You never see it: use it whenever a plan has dates, so they can see what the trip would clash with.", + parameters: { + type: 'object', + properties: { + start: { + type: 'string', + format: 'date', + description: 'The first day, as YYYY-MM-DD', + }, + end: { + type: 'string', + format: 'date', + description: 'The last day, as YYYY-MM-DD', + }, + }, + required: ['start', 'end'], + 'x-order': ['start', 'end'], + }, + events: [], + }, +]; diff --git a/src/plugins/A2UI/__tests__/ssr.test.tsx b/src/plugins/A2UI/__tests__/ssr.test.tsx new file mode 100644 index 000000000..0bb4562bc --- /dev/null +++ b/src/plugins/A2UI/__tests__/ssr.test.tsx @@ -0,0 +1,33 @@ +// @vitest-environment node +import React from 'react'; +import { renderToString } from 'react-dom/server'; + +import { AIConversation, AIUIHost, fromStreamMessage } from '../../AIComponents'; +import { createA2UIRenderer } from '..'; + +it('server-renders a reply’s A2UI as a placeholder, without loading the A2UI packages', () => { + expect(typeof window).toBe('undefined'); + expect(typeof HTMLElement).toBe('undefined'); + const ui = new AIUIHost({ renderers: [createA2UIRenderer()], send: () => undefined }); + const html = renderToString( + , + ); + expect(html).toContain('str-chat__a2ui--loading'); + expect(html).not.toContain('ssr-hidden-text'); +}); diff --git a/src/plugins/A2UI/index.ts b/src/plugins/A2UI/index.ts new file mode 100644 index 000000000..0c4641c5d --- /dev/null +++ b/src/plugins/A2UI/index.ts @@ -0,0 +1,7 @@ +export { + A2UI_DEFAULT_CATALOG_ID, + type A2UIError, + type A2UIRendererOptions, + createA2UIRenderer, +} from './A2UIRenderer'; +export { A2UI_VERSION, a2uiMessagesIn, supportsA2UIVersion } from './A2UIStream'; diff --git a/src/plugins/A2UI/runtime.tsx b/src/plugins/A2UI/runtime.tsx new file mode 100644 index 000000000..1cd0fcdfe --- /dev/null +++ b/src/plugins/A2UI/runtime.tsx @@ -0,0 +1,516 @@ +// The part of the A2UI renderer that runs the official A2UI packages. It loads in the browser +// when a reply first shows A2UI, because the packages define custom elements, which a server +// cannot, and are of no use to an app whose agent never writes A2UI. +import React, { + createContext, + useContext, + useEffect, + useLayoutEffect, + useMemo, +} from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { + A2uiSurface, + createBinderlessComponentImplementation, + MarkdownComponentContext, +} from '@a2ui/react'; +import { + type ActionPayload, + Catalog, + type ComponentApi, + type ComponentContext, + createFunctionImplementation, + effect, + getValue, + MessageProcessor, + peekValue, + type SurfaceModel, +} from '@a2ui/web_core'; +import { + basicCatalog, + createBasicCatalogFunctions, + OpenUrlApi, +} from '@a2ui/web_core/catalogs/basic/v1'; + +import { AIMarkdown } from '../AIComponents/AIMarkdown'; +import { + type AIUIComponent, + AIUIComponentView, +} from '../AIComponents/GenerativeUI/AIUIComponent'; +import type { AIUIJSONSchema } from '../AIComponents/GenerativeUI/AIUIComponentDefinition'; +import type { AIUIDocument } from '../AIComponents/GenerativeUI/AIUIDocument'; +import { A2UIPlaceholder } from './A2UIPlaceholder'; +import type { A2UIEngine, A2UIError } from './A2UIRenderer'; +import { + A2UI_VERSION, + a2uiMessagesIn, + a2uiSourceOf, + normalizeA2UIMessage, +} from './A2UIStream'; + +// A catalog entry, as web_core reads it. `@a2ui/react` types its entries against the Zod the app +// resolves, which can be another major than web_core's own; at runtime only web_core's runs. +type Entry = ComponentApi; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** The A2UI type of one of the app's components, named as the basic catalog names its own. */ +export const a2uiTypeName = (name: string) => + name + .split(/[_\- ]/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); + +const sentence = (text: string) => { + const trimmed = text.trim(); + return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`; +}; + +// A parameter as the agent may write it: the value its schema describes, bound to the data +// model, or worked out by a function. +const dynamic = (schema: AIUIJSONSchema) => ({ + anyOf: [ + schema, + { $ref: 'common_types.json#/$defs/DataBinding' }, + { $ref: 'common_types.json#/$defs/FunctionCall' }, + ], +}); + +/** + * The app's components as an A2UI catalog document: each under its A2UI type, its parameters + * as its properties and what its events do in its description, since it reports them itself + * rather than through an A2UI action the agent writes. The same as StreamChatA2UI's + * `PortableComponent`, so an agent writes for both alike. + */ +export const a2uiAppCatalogDocument = ( + catalogId: string, + components: AIUIComponent[], +) => ({ + catalogId, + components: Object.fromEntries( + components.map(({ definition }) => { + const type = a2uiTypeName(definition.name); + const notes = [ + definition.description, + ...definition.events.map((e) => e.description), + ] + .filter((note) => note.trim() !== '') + .map(sentence); + return [ + type, + { + ...(notes.length ? { description: notes.join(' ') } : {}), + properties: { + component: { const: type }, + ...Object.fromEntries( + definition.parameters.map(({ description, name, type: schema }) => [ + name, + dynamic(description === undefined ? schema : { ...schema, description }), + ]), + ), + }, + required: [ + 'component', + ...definition.parameters + .filter(({ isRequired }) => isRequired) + .map(({ name }) => name), + ], + type: 'object', + }, + ]; + }), + ), + protocolVersion: A2UI_VERSION, +}); + +type A2UIReplyContextValue = { isStreaming: boolean; messageId: string }; + +const A2UIReplyContext = createContext(undefined); + +// The declared parameters of an app's component as the agent wrote them, bindings and functions +// worked out, following the data model and the component's updates. +const useResolvedArguments = (context: ComponentContext, names: string[]) => { + const namesKey = names.join('\u0000'); + const store = useMemo(() => { + const declared = namesKey.split('\u0000'); + const signalOf = () => { + const { properties } = context.componentModel; + const written = Object.fromEntries( + declared + .filter((name) => name in properties) + .map((name) => [name, properties[name]]), + ); + return context.dataContext.resolveSignal>(written); + }; + let value: Record = {}; + try { + value = peekValue(signalOf()) ?? {}; + } catch { + value = {}; + } + return { + get: () => value, + subscribe: (onChange: () => void) => { + let stop = () => undefined as void; + const watch = () => { + stop(); + try { + const signal = signalOf(); + stop = effect(() => { + value = getValue(signal) ?? {}; + onChange(); + }); + } catch { + value = {}; + onChange(); + } + }; + watch(); + const updates = context.componentModel.onUpdated.subscribe(watch); + return () => { + stop(); + updates.unsubscribe(); + }; + }, + }; + }, [context, namesKey]); + return useSyncExternalStore(store.subscribe, store.get); +}; + +// One of the app's components where the agent placed it in a reply's surface. +const PlacedComponent = ({ + component, + context, + engine, +}: { + component: AIUIComponent; + context: ComponentContext; + engine: A2UIEngine; +}) => { + const reply = useContext(A2UIReplyContext); + const args = useResolvedArguments( + context, + component.definition.parameters.map(({ name }) => name), + ); + if (!reply) return null; + const { messageId } = reply; + const { name } = component.definition; + return ( + + void engine.host.send({ + format: 'a2ui', + label, + messageId, + name: event, + payload: { ...payload, component: name }, + }), + host: engine.host, + isStreaming: reply.isStreaming, + messageId, + }} + /> + ); +}; + +const catalogsByEngine = new WeakMap[]>(); + +/** + * The catalogs a reply's surfaces are made with: the basic catalog with the app's components, + * under the renderer's catalog id and under the basic catalog's own, so a surface naming either + * shows them. Links open the way the host allows. + */ +export const catalogsOf = (engine: A2UIEngine) => { + const made = catalogsByEngine.get(engine); + if (made) return made; + const functions = [ + ...createBasicCatalogFunctions().filter(({ name }) => name !== OpenUrlApi.name), + createFunctionImplementation(OpenUrlApi, ({ url }) => { + engine.host.open(String(url)); + }), + ]; + const appComponents: Entry[] = []; + for (const component of engine.host.components) { + const type = a2uiTypeName(component.definition.name); + try { + const api = Catalog.fromJson( + a2uiAppCatalogDocument(engine.catalogId, [component]), + A2UI_VERSION, + ).components.get(type); + if (!api) continue; + const implementation = createBinderlessComponentImplementation( + api as never, + ({ context }) => ( + + ), + ); + appComponents.push(implementation as unknown as Entry); + } catch (error) { + engine.report( + { + code: 'CATALOG_ERROR', + message: `The component ${component.definition.name} can't join the catalog: ${String(error)}`, + }, + '', + ); + } + } + const components: Entry[] = [...basicCatalog.components.values(), ...appComponents]; + const catalogs = [ + new Catalog(engine.catalogId, A2UI_VERSION, components, functions), + ...(engine.catalogId === basicCatalog.id + ? [] + : [new Catalog(basicCatalog.id, A2UI_VERSION, components, functions)]), + ]; + catalogsByEngine.set(engine, catalogs); + return catalogs; +}; + +type RendererError = { + code?: unknown; + message?: unknown; + path?: unknown; + surfaceId?: unknown; +}; + +const errorOf = (error: RendererError): A2UIError => ({ + code: typeof error.code === 'string' ? error.code : 'EXECUTION_ERROR', + message: typeof error.message === 'string' ? error.message : String(error.message), + ...(typeof error.path === 'string' ? { path: error.path } : {}), + ...(typeof error.surfaceId === 'string' ? { surfaceId: error.surfaceId } : {}), +}); + +/** + * The surfaces of one reply: its own processor, fed the messages of its document as they + * complete. Messages that changed, rather than grew, start its surfaces again. + */ +export class A2UIReplyState { + isStreaming = false; + + private applied: string[] = []; + private readonly engine: A2UIEngine; + private listeners = new Set<() => void>(); + private readonly messageId: string; + private processor: MessageProcessor; + private reported = new Set(); + private shown: SurfaceModel[] = []; + private stopWatching: () => void; + + constructor(engine: A2UIEngine, messageId: string) { + this.engine = engine; + this.messageId = messageId; + this.processor = this.makeProcessor(); + this.stopWatching = this.watch(); + } + + /** The reply's surfaces, in the order the agent made them. */ + surfaces = () => this.shown; + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + /** Applies the messages of `source` not applied yet. */ + update(source: string, isStreaming: boolean) { + this.isStreaming = isStreaming; + const messages = a2uiMessagesIn(source); + const grew = + messages.length >= this.applied.length && + this.applied.every((message, index) => messages[index] === message); + if (!grew) { + this.stopWatching(); + this.processor.dispose(); + this.processor = this.makeProcessor(); + this.stopWatching = this.watch(); + this.applied = []; + } + for (const raw of messages.slice(this.applied.length)) { + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + json = undefined; + } + if (!isRecord(json)) { + this.report({ + code: 'VALIDATION_FAILED', + message: 'A message isn’t a JSON object', + }); + continue; + } + this.feed(normalizeA2UIMessage(json, this.engine.catalogId)); + } + this.applied = messages; + this.refresh(); + } + + dispose() { + this.stopWatching(); + this.processor.dispose(); + this.listeners.clear(); + } + + // Feeds a message to the processor. A batch of components that one bad component spoils is + // fed again a component at a time, so the rest show, as they would while being written. + private feed(message: Record) { + let errors = this.processor.processMessagesWithErrors(message); + const { updateComponents } = message; + if ( + errors.length && + isRecord(updateComponents) && + Array.isArray(updateComponents.components) && + updateComponents.components.length > 1 + ) { + errors = updateComponents.components.flatMap((component) => + this.processor.processMessagesWithErrors({ + ...message, + updateComponents: { ...updateComponents, components: [component] }, + }), + ); + } + for (const { error } of errors) this.report(errorOf(error)); + } + + private makeProcessor() { + return new MessageProcessor(catalogsOf(this.engine), (action) => + this.act(action), + ); + } + + // Tells the agent what the person did, in their words: the action's own, else what its + // button says, else its name. + private act(action: ActionPayload) { + void this.engine.host.send({ + format: 'a2ui', + label: action.userMessage ?? this.buttonText(action) ?? action.name, + messageId: this.messageId, + name: action.name, + payload: { + context: action.context, + sourceComponentId: action.sourceComponentId, + surfaceId: action.surfaceId, + }, + }); + } + + private buttonText(action: ActionPayload) { + const components = this.processor.model.getSurface(action.surfaceId)?.componentsModel; + const child: unknown = components?.get(action.sourceComponentId)?.properties.child; + if (typeof child !== 'string') return undefined; + const text: unknown = components?.get(child)?.properties.text; + return typeof text === 'string' && text !== '' ? text : undefined; + } + + private watch() { + const subscriptions = [ + this.processor.onSurfaceCreated(() => this.refresh()), + this.processor.onSurfaceDeleted(() => this.refresh()), + this.processor.model.onError.subscribe((error) => this.report(errorOf(error))), + ]; + return () => subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + private refresh() { + const surfaces = [...this.processor.getSurfaces().values()]; + if ( + surfaces.length === this.shown.length && + surfaces.every((surface, index) => surface === this.shown[index]) + ) { + return; + } + this.shown = surfaces; + this.listeners.forEach((listener) => listener()); + } + + // Each error once: a reply's messages are fed again as it streams, and the processor tells + // the same error both as it fails a message, with the place in it, and to the surface. + private report(error: A2UIError) { + const key = [error.code, error.message, error.surfaceId].join('\u0000'); + if (this.reported.has(key)) return; + this.reported.add(key); + this.engine.report(error, this.messageId); + } +} + +/** The surfaces of a reply, made with its messages so far the first time they are asked for. */ +export const replyOf = ( + engine: A2UIEngine, + document: AIUIDocument, + messageId: string, +) => { + let reply = engine.replies.get(messageId); + if (!reply) { + reply = new A2UIReplyState(engine, messageId); + engine.replies.set(messageId, reply); + reply.update(a2uiSourceOf(document) ?? '', document.isStreaming); + } + return reply; +}; + +/** Whether a finished reply's A2UI made no surface, so its text shows instead. */ +export const showsNothing = ( + engine: A2UIEngine, + document: AIUIDocument, + messageId: string, +) => + !document.isStreaming && replyOf(engine, document, messageId).surfaces().length === 0; + +const A2UIMarkdown = ({ markdown }: { markdown: string }) => ( + {markdown} +); + +// The layout effect warns under server rendering; this module only runs in the browser, but +// tests may render it either way. +const useIsomorphicLayoutEffect = + typeof window === 'undefined' ? useEffect : useLayoutEffect; + +/** A reply's surfaces, following its document as the agent writes it. */ +export const A2UIReplyContent = ({ + document, + engine, + messageId, +}: { + document: AIUIDocument; + engine: A2UIEngine; + messageId: string; +}) => { + const reply = replyOf(engine, document, messageId); + const source = a2uiSourceOf(document) ?? ''; + const { isStreaming } = document; + useIsomorphicLayoutEffect(() => { + reply.update(source, isStreaming); + }, [isStreaming, reply, source]); + const surfaces = useSyncExternalStore(reply.subscribe, reply.surfaces); + const replyContext = useMemo( + () => ({ isStreaming, messageId }), + [isStreaming, messageId], + ); + if (surfaces.length === 0) return isStreaming ? : null; + return ( + + +
+ {surfaces.map((surface) => ( +
+ +
+ ))} +
+
+
+ ); +}; + +/** What the renderer's entry reaches once the runtime loads. */ +export type A2UIRuntime = { + A2UIReplyContent: typeof A2UIReplyContent; + showsNothing: typeof showsNothing; +}; diff --git a/src/plugins/AIComponents/README.md b/src/plugins/AIComponents/README.md index d33f9728e..007f4e572 100644 --- a/src/plugins/AIComponents/README.md +++ b/src/plugins/AIComponents/README.md @@ -159,6 +159,32 @@ const ui = useMemo( - The app's components are described once, in portable JSON shared with the iOS and Android apps (`parseAIUIComponentDefinition`: `name`, `description`, `parameters` as a JSON Schema object with `x-order`, `events`). `defineAIUIComponent(definition, Component)` gives the view `args` (camelCase keys, dates as `Date`s, once they fit the parameters) and `context.emit(event, label, payload)`. `host.capabilities` describes the formats and components to the agent's backend. - `AIUIView` shows one reply's UI outside `AIConversation`; call `host.receive(messages)` as the messages change. +### A2UI + +`stream-chat-react/a2ui` is the renderer of [A2UI](https://a2ui.org) 1.0, the counterpart of StreamChatA2UI. It is built on the official A2UI packages, which the app installs: they are optional peers of this package, pinned to the version it is tested with. + +```bash +npm install @a2ui/web_core@0.15.0 @a2ui/react@0.15.0 +``` + +```tsx +import { createA2UIRenderer } from 'stream-chat-react/a2ui'; + +const ui = new AIUIHost({ + components: [placeCard, tripMap], + renderers: [createA2UIRenderer({ onError: (error, messageId) => report(error) })], + send, +}); +``` + +- A reply's `content` is A2UI messages, one JSON object per line as the agents router passes them on (a JSON array, or JSON rather than text, reads the same). Each message shows as it completes, while the agent writes it. Messages the agent rewrites, rather than adds to, start the reply's surfaces again. +- Each reply's surfaces are its own, kept for as long as the host is, so what the person entered survives scrolling. A finished reply whose A2UI made no surface shows its text. +- The app's components join the catalog under their A2UI type (`place_card` is `PlaceCard`), in the catalog `getstream.io:app-catalog` (`catalogId` to name another) and in the basic catalog, as on iOS, so the same instructions serve both apps. A parameter takes the value its schema describes, or a binding (`@path`) or function (`@call`), which the component gets worked out. +- What the person does goes to `host.send` as an `AIUIAction`: an A2UI event with its `userMessage` as the label (else what its button says, else its name) and `{ surfaceId, sourceComponentId, context }` as its payload; an app component's event with its own label and payload, plus `component`. `openUrl` opens through `host.open`, from a tap only. Text renders with `AIMarkdown`. +- What goes wrong, such as a message that is not JSON or a component the catalog lacks, goes to `onError` with the A2UI error code, and the rest of the reply still shows. A batch of components that one bad component spoils shows the others. +- The basic catalog draws with A2UI's own elements, themed with the AI components' tokens in `ai-components.css` (`.str-chat__a2ui` sets the `--a2ui-*` variables); its `Icon` uses the Material Symbols font, which the app loads. +- The A2UI packages load in the browser the first time a reply shows A2UI, so server rendering shows a placeholder and an app whose agent never writes A2UI never loads them. The entry is ESM only and needs React 18 or later. + ### Steps, questions and sign-ins - `AIMessageParts` / `AIMessagePart`: a reply's steps, each as `AIReasoning`, `AIToolCall` (with `AIToolApproval` and `AIToolAuthorization` under it), or a neutral line for a step this SDK can't show. diff --git a/src/plugins/AIComponents/styling/a2ui.scss b/src/plugins/AIComponents/styling/a2ui.scss new file mode 100644 index 000000000..e281a724b --- /dev/null +++ b/src/plugins/AIComponents/styling/a2ui.scss @@ -0,0 +1,75 @@ +@use './tokens' as *; + +// A reply's A2UI: its surfaces, in Stream's look. The A2UI basic catalog reads `--a2ui-*` +// variables, which it defines for the whole page with no specificity, so setting them here +// themes every surface of a reply, and follows Stream's light and dark themes. The variants it +// works out from the colours are worked out here again, from these colours. + +.str-chat__a2ui { + --a2ui-color-background: #{ai-token(bg-primary)}; + --a2ui-color-on-background: #{ai-token(text-primary)}; + --a2ui-color-surface: #{ai-token(card-bg)}; + --a2ui-color-on-surface: #{ai-token(text-primary)}; + --a2ui-color-primary: #{ai-token(button-primary-bg)}; + --a2ui-color-on-primary: #{ai-token(button-primary-text)}; + --a2ui-color-primary-light: color-mix( + in oklab, + var(--a2ui-color-primary) 20%, + var(--a2ui-color-background) + ); + --a2ui-color-primary-dark: color-mix( + in oklab, + var(--a2ui-color-primary) 82%, + var(--a2ui-color-on-background) + ); + --a2ui-color-primary-hover: var(--a2ui-color-primary-dark); + --a2ui-color-secondary: #{ai-token(button-secondary-bg)}; + --a2ui-color-on-secondary: #{ai-token(button-secondary-text)}; + --a2ui-color-secondary-light: color-mix( + in oklab, + var(--a2ui-color-secondary) 60%, + var(--a2ui-color-background) + ); + --a2ui-color-secondary-dark: color-mix( + in oklab, + var(--a2ui-color-secondary) 90%, + var(--a2ui-color-on-background) + ); + --a2ui-color-secondary-hover: #{ai-token(surface-hover)}; + --a2ui-color-border: #{ai-token(border)}; + --a2ui-border: var(--a2ui-border-width, 1px) solid var(--a2ui-color-border); + --a2ui-border-radius: 0.75rem; + --a2ui-color-input: #{ai-token(bg-primary)}; + --a2ui-color-on-input: #{ai-token(text-primary)}; + --a2ui-font-family-title: inherit; + --a2ui-font-size: 0.9375rem; + + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; + min-width: 0; + color: ai-token(text-primary); + color-scheme: light; + + &__surface { + min-width: 0; + } + + &__placeholder { + height: 4rem; + border-radius: 0.75rem; + background-color: ai-token(bg-secondary); + animation: str-chat__ai-shimmer 1.6s ease-in-out infinite; + } +} + +.str-chat__theme-dark .str-chat__a2ui { + color-scheme: dark; +} + +@media (prefers-reduced-motion: reduce) { + .str-chat__a2ui__placeholder { + animation: none; + } +} diff --git a/src/plugins/AIComponents/styling/index.scss b/src/plugins/AIComponents/styling/index.scss index 5722d53ed..b8374e162 100644 --- a/src/plugins/AIComponents/styling/index.scss +++ b/src/plugins/AIComponents/styling/index.scss @@ -7,5 +7,6 @@ @use './ai-message-parts.scss'; @use './ai-conversation.scss'; @use './ai-ui.scss'; +@use './a2ui.scss'; // Intentionally declares no `--str-chat__ai-*` custom properties; see _tokens.scss. diff --git a/vite.config.ts b/vite.config.ts index 62e95464b..82c4ad9c3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ emojis: resolve(__dirname, './src/plugins/Emojis/index.ts'), 'mp3-encoder': resolve(__dirname, './src/plugins/encoders/mp3.ts'), 'ai-components': resolve(__dirname, './src/plugins/AIComponents/index.ts'), + a2ui: resolve(__dirname, './src/plugins/A2UI/index.ts'), }, }, // `yarn build` already wipes dist up front via `yarn clean` diff --git a/yarn.lock b/yarn.lock index 455e312a7..d0fe4545e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,50 @@ __metadata: version: 10 cacheKey: 10c0 +"@a2ui/markdown-it@npm:^0.2.2": + version: 0.2.2 + resolution: "@a2ui/markdown-it@npm:0.2.2" + dependencies: + dompurify: "npm:3.4.11" + markdown-it: "npm:^14.2.0" + peerDependencies: + "@a2ui/web_core": ^0.14.0 + checksum: 10c0/bd971ae89ef5ea61c319d2ec6e8813b1a4345809c595f35e18b209976ccd909c1451e4bb15e9c0e69002f1ba10c40d3c1764847fc5a213cdbc554c1120a85541 + languageName: node + linkType: hard + +"@a2ui/react@npm:0.15.0": + version: 0.15.0 + resolution: "@a2ui/react@npm:0.15.0" + dependencies: + "@a2ui/markdown-it": "npm:^0.2.2" + "@a2ui/web_core": "npm:^0.15.0" + clsx: "npm:^2.1.1" + markdown-it: "npm:^14.2.0" + zod: "npm:^3.25.76" + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + zod: ^3.25.76 + checksum: 10c0/f939119d5abf42be26155b452bd0f703a1d763f42f4b9360adcf09464b9640249c796978d1391ac607786ec03e02bff338b8be93ce065ba30e672b58e5cc2475 + languageName: node + linkType: hard + +"@a2ui/web_core@npm:0.15.0, @a2ui/web_core@npm:^0.15.0": + version: 0.15.0 + resolution: "@a2ui/web_core@npm:0.15.0" + dependencies: + "@lit/context": "npm:^1.1.6" + "@preact/signals-core": "npm:^1.14.2" + lit: "npm:^3.3.3" + redos-detector: "npm:^6.1.4" + validate-color: "npm:^2.2.4" + zod: "npm:^3.25.76" + zod-to-json-schema: "npm:^3.25.2" + checksum: 10c0/9c235ef60bf87003120d1f336dd5d6a9b04db5f3e018f86633c502d7eb5b6bc17b2267a9514f160d84591615210b79dcd3367e7377d0a4601eaec86fc06f589f + languageName: node + linkType: hard + "@actions/core@npm:^3.0.0": version: 3.0.1 resolution: "@actions/core@npm:3.0.1" @@ -1417,6 +1461,31 @@ __metadata: languageName: node linkType: hard +"@lit-labs/ssr-dom-shim@npm:^1.5.0": + version: 1.6.0 + resolution: "@lit-labs/ssr-dom-shim@npm:1.6.0" + checksum: 10c0/deffcfd765d268820beef600a1c1cf9d3cf1a5cb4bbe7e80843796ff33d1a122f4e5651ef4a44b33d36e5fa6538e6ee87a95b9d40b4a26cabf3c35b45194c87a + languageName: node + linkType: hard + +"@lit/context@npm:^1.1.6": + version: 1.1.6 + resolution: "@lit/context@npm:1.1.6" + dependencies: + "@lit/reactive-element": "npm:^1.6.2 || ^2.1.0" + checksum: 10c0/203f761eda19c8b37d77f01d9a0148535c5b28c47b76e28b321cb6e5ed0546c45332512e68b1647ce92ca67690d8c66de31972ec115410b080d69fa25a5d86f3 + languageName: node + linkType: hard + +"@lit/reactive-element@npm:^1.6.2 || ^2.1.0, @lit/reactive-element@npm:^2.1.0": + version: 2.1.2 + resolution: "@lit/reactive-element@npm:2.1.2" + dependencies: + "@lit-labs/ssr-dom-shim": "npm:^1.5.0" + checksum: 10c0/557069ce6ebbbafb1140e1e0a25ce73d3501bf455cda231d42bb131baa9065c54b6b7ca1655507eede397decd7ddde16c84192cb72a07d4edf41d54e07725933 + languageName: node + linkType: hard + "@napi-rs/wasm-runtime@npm:^1.1.6": version: 1.1.6 resolution: "@napi-rs/wasm-runtime@npm:1.1.6" @@ -2001,6 +2070,13 @@ __metadata: languageName: node linkType: hard +"@preact/signals-core@npm:^1.14.2": + version: 1.14.5 + resolution: "@preact/signals-core@npm:1.14.5" + checksum: 10c0/44ec9338275092e4dfee5f534f1cef54132c024395e73a7ec33239dac87d91bd6920f9c53c53409331c8a55386e98dd68b2ec87e72dac35c1d5486dbe228a501 + languageName: node + linkType: hard + "@react-aria/focus@npm:^3.22.0": version: 3.22.0 resolution: "@react-aria/focus@npm:3.22.0" @@ -2945,6 +3021,13 @@ __metadata: languageName: node linkType: hard +"@types/trusted-types@npm:^2.0.2, @types/trusted-types@npm:^2.0.7": + version: 2.0.7 + resolution: "@types/trusted-types@npm:2.0.7" + checksum: 10c0/4c4855f10de7c6c135e0d32ce462419d8abbbc33713b31d294596c0cc34ae1fa6112a2f9da729c8f7a20707782b0d69da3b1f8df6645b0366d08825ca1522e0c + languageName: node + linkType: hard + "@types/unist@npm:^2.0.6": version: 2.0.11 resolution: "@types/unist@npm:2.0.11" @@ -4616,6 +4699,18 @@ __metadata: languageName: node linkType: hard +"dompurify@npm:3.4.11": + version: 3.4.11 + resolution: "dompurify@npm:3.4.11" + dependencies: + "@types/trusted-types": "npm:^2.0.7" + dependenciesMeta: + "@types/trusted-types": + optional: true + checksum: 10c0/31439481c7e8fc3805d40c376936fd66936620fb1b1a31a2ec097f6165412c37f2d868e082c9ceba62bb37661c1ea132a5db4d5213434317e30df68d4aca9cc9 + languageName: node + linkType: hard + "dot-prop@npm:^5.1.0": version: 5.3.0 resolution: "dot-prop@npm:5.3.0" @@ -4696,6 +4791,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^4.5.0": + version: 4.5.0 + resolution: "entities@npm:4.5.0" + checksum: 10c0/5b039739f7621f5d1ad996715e53d964035f75ad3b9a4d38c6b3804bb226e282ffeae2443624d8fdd9c47d8e926ae9ac009c54671243f0c3294c26af7cc85250 + languageName: node + linkType: hard + "entities@npm:^8.0.0": version: 8.0.0 resolution: "entities@npm:8.0.0" @@ -6875,7 +6977,7 @@ __metadata: languageName: node linkType: hard -"jsesc@npm:^3.0.2": +"jsesc@npm:^3.0.2, jsesc@npm:~3.1.0": version: 3.1.0 resolution: "jsesc@npm:3.1.0" bin: @@ -7330,6 +7432,15 @@ __metadata: languageName: node linkType: hard +"linkify-it@npm:^5.0.2": + version: 5.0.2 + resolution: "linkify-it@npm:5.0.2" + dependencies: + uc.micro: "npm:^2.0.0" + checksum: 10c0/dd70b1735a13d41a2cff0a058ac3771166038f23f6aff004dd53873cf985c64b107902fe0b544a5b3d1ff6e63249cf9c648fb3ae9f285481db48f56887adb0d6 + languageName: node + linkType: hard + "linkifyjs@npm:^4.3.3": version: 4.3.3 resolution: "linkifyjs@npm:4.3.3" @@ -7368,6 +7479,37 @@ __metadata: languageName: node linkType: hard +"lit-element@npm:^4.2.0": + version: 4.2.2 + resolution: "lit-element@npm:4.2.2" + dependencies: + "@lit-labs/ssr-dom-shim": "npm:^1.5.0" + "@lit/reactive-element": "npm:^2.1.0" + lit-html: "npm:^3.3.0" + checksum: 10c0/114ab5837f1f9e03a30b1ed1c055fa0e31f01e444464e5ab0453ef88be12d778508235533267c42614d323e3048ee58f865b5c612948a53bd6219abca404c710 + languageName: node + linkType: hard + +"lit-html@npm:^3.3.0": + version: 3.3.3 + resolution: "lit-html@npm:3.3.3" + dependencies: + "@types/trusted-types": "npm:^2.0.2" + checksum: 10c0/84e57314412ff385cc67fc482d26b77057370e2cfe3c6495cc45f692bdad840d5ed0519ddddcfae1d043bb098c66daa7e807593d5a21ac72094da14274497879 + languageName: node + linkType: hard + +"lit@npm:^3.3.3": + version: 3.3.3 + resolution: "lit@npm:3.3.3" + dependencies: + "@lit/reactive-element": "npm:^2.1.0" + lit-element: "npm:^4.2.0" + lit-html: "npm:^3.3.0" + checksum: 10c0/11b6175ebffce92f4cf835ddd8a198d49707c27f34b0a7c538d2cb294f6c2fb7ff40aabd3589fcf0c2ed162404faaf2bb7c966fad9024bb9e02d013e2d5aa0b5 + languageName: node + linkType: hard + "load-json-file@npm:^4.0.0": version: 4.0.0 resolution: "load-json-file@npm:4.0.0" @@ -7668,6 +7810,22 @@ __metadata: languageName: node linkType: hard +"markdown-it@npm:^14.2.0": + version: 14.3.3 + resolution: "markdown-it@npm:14.3.3" + dependencies: + argparse: "npm:^2.0.1" + entities: "npm:^4.5.0" + linkify-it: "npm:^5.0.2" + mdurl: "npm:^2.0.0" + punycode.js: "npm:^2.3.1" + uc.micro: "npm:^2.1.0" + bin: + markdown-it: bin/markdown-it.mjs + checksum: 10c0/d6075ad320731d7fb9f127e496677f4e36cbdba839d7387dabf1f0d07b5c02bdffb46fb3969033817c04e60017e05d4269e4aa74bee58cc0a562a17cc0b6b8ff + languageName: node + linkType: hard + "markdown-table@npm:^3.0.0": version: 3.0.4 resolution: "markdown-table@npm:3.0.4" @@ -7932,6 +8090,13 @@ __metadata: languageName: node linkType: hard +"mdurl@npm:^2.0.0": + version: 2.1.0 + resolution: "mdurl@npm:2.1.0" + checksum: 10c0/aed11a75b791d596c7104fa6a3d84680e87cd2bfdb32ecfe512e795481d40a64b21d627b325ffd58aadcd15faf7498ac06130b8f198ebec437aceeead24bd1b1 + languageName: node + linkType: hard + "memoize-one@npm:^5.1.1": version: 5.2.1 resolution: "memoize-one@npm:5.2.1" @@ -9585,6 +9750,13 @@ __metadata: languageName: node linkType: hard +"punycode.js@npm:^2.3.1": + version: 2.3.1 + resolution: "punycode.js@npm:2.3.1" + checksum: 10c0/1d12c1c0e06127fa5db56bd7fdf698daf9a78104456a6b67326877afc21feaa821257b171539caedd2f0524027fa38e67b13dd094159c8d70b6d26d2bea4dfdb + languageName: node + linkType: hard + "punycode@npm:^2.1.0, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" @@ -9910,6 +10082,17 @@ __metadata: languageName: node linkType: hard +"redos-detector@npm:^6.1.4": + version: 6.1.4 + resolution: "redos-detector@npm:6.1.4" + dependencies: + regjsparser: "npm:0.13.0" + bin: + redos-detector: bin.js + checksum: 10c0/5119a875985da41a83883f533cd5117048ba2f418a0f4f00aa8825489ee3bdc391c47ead0d1936e64edc584c47a4997fe85d671037a539ee926f883a67a44c60 + languageName: node + linkType: hard + "reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9": version: 1.0.10 resolution: "reflect.getprototypeof@npm:1.0.10" @@ -9961,6 +10144,17 @@ __metadata: languageName: node linkType: hard +"regjsparser@npm:0.13.0": + version: 0.13.0 + resolution: "regjsparser@npm:0.13.0" + dependencies: + jsesc: "npm:~3.1.0" + bin: + regjsparser: bin/parser + checksum: 10c0/4702f85cda09f67747c1b2fb673a0f0e5d1ba39d55f177632265a0be471ba59e3f320623f411649141f752b126b8126eac3ff4c62d317921e430b0472bfc6071 + languageName: node + linkType: hard + "remark-gfm@npm:^4.0.1": version: 4.0.1 resolution: "remark-gfm@npm:4.0.1" @@ -10748,6 +10942,8 @@ __metadata: version: 0.0.0-use.local resolution: "stream-chat-react@workspace:." dependencies: + "@a2ui/react": "npm:0.15.0" + "@a2ui/web_core": "npm:0.15.0" "@braintree/sanitize-url": "npm:^7.1.2" "@breezystack/lamejs": "npm:^1.2.7" "@commitlint/cli": "npm:^21.0.1" @@ -11576,6 +11772,13 @@ __metadata: languageName: node linkType: hard +"uc.micro@npm:^2.0.0, uc.micro@npm:^2.1.0": + version: 2.1.0 + resolution: "uc.micro@npm:2.1.0" + checksum: 10c0/8862eddb412dda76f15db8ad1c640ccc2f47cdf8252a4a30be908d535602c8d33f9855dfcccb8b8837855c1ce1eaa563f7fa7ebe3c98fd0794351aab9b9c55fa + languageName: node + linkType: hard + "uglify-js@npm:^3.1.4": version: 3.19.3 resolution: "uglify-js@npm:3.19.3" @@ -11839,6 +12042,13 @@ __metadata: languageName: node linkType: hard +"validate-color@npm:^2.2.4": + version: 2.2.4 + resolution: "validate-color@npm:2.2.4" + checksum: 10c0/597a88adff8d1a235e6f2597df3ea5c1aaa1a6b6d3ea725135c6557c55a0960cb022858ecd7b1b53820cb939263fe095fbcc343aec8a2a66a1063a41dc92e23c + languageName: node + linkType: hard + "validate-npm-package-license@npm:^3.0.4": version: 3.0.4 resolution: "validate-npm-package-license@npm:3.0.4" @@ -12385,6 +12595,15 @@ __metadata: languageName: node linkType: hard +"zod-to-json-schema@npm:^3.25.2": + version: 3.25.2 + resolution: "zod-to-json-schema@npm:3.25.2" + peerDependencies: + zod: ^3.25.28 || ^4 + checksum: 10c0/dd300554393903022487688af14fbda5c719ba8179702bb55b3aa86318830467f0f7beb7d654036975ac963dc4843b72e59636448bfff9a0608f277bb6a14939 + languageName: node + linkType: hard + "zod-validation-error@npm:^3.5.0 || ^4.0.0": version: 4.0.2 resolution: "zod-validation-error@npm:4.0.2" @@ -12401,6 +12620,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^3.25.76": + version: 3.25.76 + resolution: "zod@npm:3.25.76" + checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c + languageName: node + linkType: hard + "zod@npm:^4.1.12": version: 4.6.5 resolution: "zod@npm:4.6.5" From 9b2129d770f1d65520a3dd244181777e3bd2ba25 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 3 Nov 2026 14:11:49 +0100 Subject: [PATCH 05/22] fix(ai-components): show a reply's UI where no frame is painted A reply's UI being written faded in a frame after it mounted, and a page that paints no frames, such as one in the background or in some web views, never got one, so the UI stayed hidden. It now shows at most 50 ms later either way, and still fades in where frames are painted. --- .../GenerativeUI/AIUIEntrance.tsx | 25 ++++++++++++++----- .../__tests__/AIConversationUI.test.tsx | 22 ++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/plugins/AIComponents/GenerativeUI/AIUIEntrance.tsx b/src/plugins/AIComponents/GenerativeUI/AIUIEntrance.tsx index acf1d79eb..4f6e0aa0f 100644 --- a/src/plugins/AIComponents/GenerativeUI/AIUIEntrance.tsx +++ b/src/plugins/AIComponents/GenerativeUI/AIUIEntrance.tsx @@ -38,13 +38,26 @@ type AIUIStage = { const AIUIStageContext = createContext(undefined); +// How long a part waits for a frame before it shows anyway, in milliseconds. +const LONGEST_FRAME_WAIT = 50; + +// Calls back on the next frame, or a moment later where no frame comes: a page painting no +// frames, such as one in the background or in some web views, still shows its UI. const nextFrame = (callback: () => void) => { - if (typeof requestAnimationFrame === 'function') { - const frame = requestAnimationFrame(callback); - return () => cancelAnimationFrame(frame); - } - const timer = setTimeout(callback, 0); - return () => clearTimeout(timer); + let done = false; + const run = () => { + if (done) return; + done = true; + callback(); + }; + const frame = + typeof requestAnimationFrame === 'function' ? requestAnimationFrame(run) : undefined; + const timer = setTimeout(run, LONGEST_FRAME_WAIT); + return () => { + done = true; + if (frame !== undefined) cancelAnimationFrame(frame); + clearTimeout(timer); + }; }; // Shows its content, fading it in after the cascade's delay when it starts hidden. diff --git a/src/plugins/AIComponents/__tests__/AIConversationUI.test.tsx b/src/plugins/AIComponents/__tests__/AIConversationUI.test.tsx index c8be665b2..384769f54 100644 --- a/src/plugins/AIComponents/__tests__/AIConversationUI.test.tsx +++ b/src/plugins/AIComponents/__tests__/AIConversationUI.test.tsx @@ -197,6 +197,28 @@ describe('AIConversation with generated UI', () => { expect(stageOf('Being written')).toHaveAttribute('data-entrance', 'shown'); }); + it('shows a UI being written even where no frame is painted', async () => { + const frames = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0); + renderWithEnglish( + , + ); + const stage = screen.getByText('In the background').closest('.str-chat__ai-ui'); + expect(stage).toHaveAttribute('data-entrance', 'hidden'); + await flushFrames(); + expect(stage).toHaveAttribute('data-entrance', 'shown'); + frames.mockRestore(); + }); + it('hands the replies’ documents to the host as they change', () => { const ui = makeHost(); const receive = vi.spyOn(ui, 'receive'); From 867f136b01cfe19751c4d008cd550c7a31e8ab03 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 3 Nov 2026 14:36:42 +0100 Subject: [PATCH 06/22] fix(a2ui): follow links by the host's policy, open Modals as dialogs - A link in A2UI's Text, which the agent writes, opens through host.open, as openUrl does, rather than wherever it points. - A Modal opens as a native dialog: focus stays in it while it is open, Escape and the backdrop close it, focus goes back to what opened it, and it shows above a transformed layout. A trigger other than a Button opens it from the keyboard. - require('stream-chat-react/a2ui') says it is an ES module only, as the A2UI packages it runs are, rather than failing in one of them. --- package.json | 1 + scripts/validate-cjs-node-bundle.cjs | 8 + .../A2UI/__tests__/A2UIRenderer.test.tsx | 110 ++++++++++++++ src/plugins/A2UI/esmOnly.ts | 7 + src/plugins/A2UI/runtime.tsx | 142 ++++++++++++++++-- src/plugins/AIComponents/README.md | 6 +- src/plugins/AIComponents/styling/a2ui.scss | 64 ++++++++ vite.config.ts | 2 + 8 files changed, 324 insertions(+), 16 deletions(-) create mode 100644 src/plugins/A2UI/esmOnly.ts diff --git a/package.json b/package.json index 89259b1cb..c65778be8 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "./a2ui": { "types": "./dist/types/plugins/A2UI/index.d.ts", "import": "./dist/es/a2ui.mjs", + "require": "./dist/cjs/a2ui-require.js", "default": "./dist/es/a2ui.mjs" }, "./dist/css/*": { diff --git a/scripts/validate-cjs-node-bundle.cjs b/scripts/validate-cjs-node-bundle.cjs index 9e2c82613..c17a674a7 100644 --- a/scripts/validate-cjs-node-bundle.cjs +++ b/scripts/validate-cjs-node-bundle.cjs @@ -3,3 +3,11 @@ require('../dist/cjs/index.js'); require('../dist/cjs/ai-components.js'); + +// The A2UI renderer is an ES module only, and requiring it says so. +try { + require('stream-chat-react/a2ui'); + throw new Error('stream-chat-react/a2ui loaded with require'); +} catch (error) { + if (!/an ES module only/.test(error.message)) throw error; +} diff --git a/src/plugins/A2UI/__tests__/A2UIRenderer.test.tsx b/src/plugins/A2UI/__tests__/A2UIRenderer.test.tsx index 1470668ef..36bb05a9f 100644 --- a/src/plugins/A2UI/__tests__/A2UIRenderer.test.tsx +++ b/src/plugins/A2UI/__tests__/A2UIRenderer.test.tsx @@ -382,6 +382,116 @@ describe('a reply written in A2UI', () => { ); }); + it('opens the links of its Markdown the way the host allows, not wherever they point', async () => { + const { open, ui } = setup(); + const lines = [ + line({ createSurface: { surfaceId: 's' } }), + line({ + updateComponents: { + components: [ + { + component: 'Text', + id: 'root', + text: 'Read [about Ohrid](https://en.wikipedia.org/wiki/Ohrid) first.', + }, + ], + surfaceId: 's', + }, + }), + ]; + renderWithEnglish(); + const link = await screen.findByRole('link', { name: 'about Ohrid' }); + const followed = fireEvent.click(link); + expect(followed).toBe(false); + expect(open).toHaveBeenCalledWith(new URL('https://en.wikipedia.org/wiki/Ohrid')); + }); + + it('opens a Modal as a dialog from its trigger, and closes it from Escape and the backdrop', async () => { + const { sent, ui } = setup(); + const lines = [ + line({ createSurface: { surfaceId: 's' } }), + line({ + updateComponents: { + components: [ + { component: 'Modal', content: 'details', id: 'root', trigger: 'open' }, + { + action: { event: { name: 'opened_details' } }, + child: 'open_label', + component: 'Button', + id: 'open', + }, + { component: 'Text', id: 'open_label', text: 'Opening times' }, + { component: 'Text', id: 'details', text: 'Open daily, 9 to 5.' }, + ], + surfaceId: 's', + }, + }), + ]; + const { container } = renderWithEnglish( + , + ); + await waitFor(() => + expect(container.querySelector('a2ui-basic-button button')).not.toBeNull(), + ); + const dialog = container.querySelector( + 'dialog.str-chat__a2ui-modal', + ) as HTMLDialogElement; + expect(dialog.open).toBe(false); + expect(document.body).not.toHaveTextContent('Open daily, 9 to 5.'); + + act(() => { + fireEvent.click(container.querySelector('a2ui-basic-button button') as Element); + }); + await waitFor(() => expect(dialog.open).toBe(true)); + await waitFor(() => expect(dialog).toHaveTextContent('Open daily, 9 to 5.')); + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + // the trigger's own action still runs + await waitFor(() => expect(sent[0]?.name).toBe('opened_details')); + + act(() => { + fireEvent(dialog, new Event('cancel', { cancelable: true })); + }); + await waitFor(() => expect(dialog.open).toBe(false)); + + act(() => { + fireEvent.click(container.querySelector('a2ui-basic-button button') as Element); + }); + await waitFor(() => expect(dialog.open).toBe(true)); + act(() => { + fireEvent.click(dialog); + }); + await waitFor(() => expect(dialog.open).toBe(false)); + }); + + it('opens a Modal whose trigger is not a button from the keyboard', async () => { + const { ui } = setup(); + const lines = [ + line({ createSurface: { surfaceId: 's' } }), + line({ + updateComponents: { + components: [ + { component: 'Modal', content: 'details', id: 'root', trigger: 'label' }, + { component: 'Text', id: 'label', text: 'See the opening times' }, + { component: 'Text', id: 'details', text: 'Open daily, 9 to 5.' }, + ], + surfaceId: 's', + }, + }), + ]; + const { container } = renderWithEnglish( + , + ); + const trigger = await screen.findByRole('button', { name: /See the opening times/ }); + expect(trigger).toHaveAttribute('tabindex', '0'); + act(() => { + fireEvent.keyDown(trigger, { key: 'Enter' }); + }); + const dialog = container.querySelector( + 'dialog.str-chat__a2ui-modal', + ) as HTMLDialogElement; + await waitFor(() => expect(dialog.open).toBe(true)); + }); + it('shows the text of a finished reply whose A2UI made no surface', async () => { const { ui } = setup(); const { rerender } = renderWithEnglish( diff --git a/src/plugins/A2UI/esmOnly.ts b/src/plugins/A2UI/esmOnly.ts new file mode 100644 index 000000000..7b4519a15 --- /dev/null +++ b/src/plugins/A2UI/esmOnly.ts @@ -0,0 +1,7 @@ +// What `require('stream-chat-react/a2ui')` reaches. The A2UI packages the renderer runs are ES +// modules only, so it is too: this says so, rather than failing deep in a dependency. +throw new Error( + 'stream-chat-react/a2ui is an ES module only, as the A2UI packages it runs (@a2ui/web_core and @a2ui/react) are: import it rather than require it.', +); + +export {}; diff --git a/src/plugins/A2UI/runtime.tsx b/src/plugins/A2UI/runtime.tsx index 1cd0fcdfe..ccc77bb41 100644 --- a/src/plugins/A2UI/runtime.tsx +++ b/src/plugins/A2UI/runtime.tsx @@ -3,16 +3,22 @@ // cannot, and are of no use to an app whose agent never writes A2UI. import React, { createContext, + type KeyboardEvent, + type MouseEvent, useContext, useEffect, useLayoutEffect, useMemo, + useRef, + useState, } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { A2uiSurface, createBinderlessComponentImplementation, + createComponentImplementation, MarkdownComponentContext, + type ReactA2uiComponentProps, } from '@a2ui/react'; import { type ActionPayload, @@ -32,6 +38,7 @@ import { OpenUrlApi, } from '@a2ui/web_core/catalogs/basic/v1'; +import { useTranslationContext } from '../../context/TranslationContext'; import { AIMarkdown } from '../AIComponents/AIMarkdown'; import { type AIUIComponent, @@ -129,6 +136,81 @@ type A2UIReplyContextValue = { isStreaming: boolean; messageId: string }; const A2UIReplyContext = createContext(undefined); +// The engine of the surfaces below, for what in them reaches the host, such as links. +const A2UIEngineContext = createContext(undefined); + +/** + * A Modal that opens as a dialog: focus moves into it and stays there while it is open, Escape + * and the backdrop close it, focus goes back to what opened it, and it shows above everything, + * whatever the reply's layout. A tap on its trigger opens it, and a Button's own action still + * runs; another trigger opens from the keyboard too. + */ +const A2UIModal = ({ + buildChild, + context, + props, +}: ReactA2uiComponentProps<{ content: string; trigger: string }>) => { + const { t } = useTranslationContext(); + const dialog = useRef(null); + const [isOpen, setOpen] = useState(false); + useEffect(() => { + const element = dialog.current; + if (!element || isOpen === element.open) return; + if (isOpen) { + if (typeof element.showModal === 'function') element.showModal(); + else element.setAttribute('open', ''); + } else if (typeof element.close === 'function') element.close(); + else element.removeAttribute('open'); + }, [isOpen]); + const opensItself = context.surfaceComponents.get(props.trigger)?.type === 'Button'; + const open = () => setOpen(true); + return ( + <> +
{ + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + open(); + }, + role: 'button', + tabIndex: 0, + })} + > + {buildChild(props.trigger)} +
+ { + event.preventDefault(); + setOpen(false); + }} + onClick={(event) => { + if (event.target === event.currentTarget) setOpen(false); + }} + onClose={() => setOpen(false)} + ref={dialog} + > +
+ + {isOpen && buildChild(props.content)} +
+
+ + ); +}; + // The declared parameters of an app's component as the agent wrote them, bindings and functions // worked out, following the data model and the component's updates. const useResolvedArguments = (context: ComponentContext, names: string[]) => { @@ -260,7 +342,19 @@ export const catalogsOf = (engine: A2UIEngine) => { ); } } - const components: Entry[] = [...basicCatalog.components.values(), ...appComponents]; + const basicModal = basicCatalog.components.get('Modal'); + const components: Entry[] = [ + ...[...basicCatalog.components.values()].filter((entry) => entry !== basicModal), + ...(basicModal + ? [ + createComponentImplementation( + basicModal as never, + A2UIModal, + ) as unknown as Entry, + ] + : []), + ...appComponents, + ]; const catalogs = [ new Catalog(engine.catalogId, A2UI_VERSION, components, functions), ...(engine.catalogId === basicCatalog.id @@ -463,9 +557,29 @@ export const showsNothing = ( ) => !document.isStreaming && replyOf(engine, document, messageId).surfaces().length === 0; -const A2UIMarkdown = ({ markdown }: { markdown: string }) => ( - {markdown} -); +/** + * The Markdown of A2UI's Text, rendered as replies' is. Its links open the way the host allows, + * since the agent writes them, rather than wherever they point. + */ +const A2UIMarkdown = ({ markdown }: { markdown: string }) => { + const engine = useContext(A2UIEngineContext); + const follow = (event: MouseEvent) => { + const link = (event.target as Element | null)?.closest?.('a[href]'); + if (!link) return; + event.preventDefault(); + event.stopPropagation(); + engine?.host.open(link.getAttribute('href') ?? ''); + }; + return ( +
+ {markdown} +
+ ); +}; // The layout effect warns under server rendering; this module only runs in the browser, but // tests may render it either way. @@ -496,15 +610,17 @@ export const A2UIReplyContent = ({ if (surfaces.length === 0) return isStreaming ? : null; return ( - -
- {surfaces.map((surface) => ( -
- -
- ))} -
-
+ + +
+ {surfaces.map((surface) => ( +
+ +
+ ))} +
+
+
); }; diff --git a/src/plugins/AIComponents/README.md b/src/plugins/AIComponents/README.md index 007f4e572..e0ec60e6c 100644 --- a/src/plugins/AIComponents/README.md +++ b/src/plugins/AIComponents/README.md @@ -180,10 +180,10 @@ const ui = new AIUIHost({ - A reply's `content` is A2UI messages, one JSON object per line as the agents router passes them on (a JSON array, or JSON rather than text, reads the same). Each message shows as it completes, while the agent writes it. Messages the agent rewrites, rather than adds to, start the reply's surfaces again. - Each reply's surfaces are its own, kept for as long as the host is, so what the person entered survives scrolling. A finished reply whose A2UI made no surface shows its text. - The app's components join the catalog under their A2UI type (`place_card` is `PlaceCard`), in the catalog `getstream.io:app-catalog` (`catalogId` to name another) and in the basic catalog, as on iOS, so the same instructions serve both apps. A parameter takes the value its schema describes, or a binding (`@path`) or function (`@call`), which the component gets worked out. -- What the person does goes to `host.send` as an `AIUIAction`: an A2UI event with its `userMessage` as the label (else what its button says, else its name) and `{ surfaceId, sourceComponentId, context }` as its payload; an app component's event with its own label and payload, plus `component`. `openUrl` opens through `host.open`, from a tap only. Text renders with `AIMarkdown`. +- What the person does goes to `host.send` as an `AIUIAction`: an A2UI event with its `userMessage` as the label (else what its button says, else its name) and `{ surfaceId, sourceComponentId, context }` as its payload; an app component's event with its own label and payload, plus `component`. `openUrl` opens through `host.open`, from a tap only. Text renders with `AIMarkdown`, and its links open through `host.open` too, since the agent writes them. - What goes wrong, such as a message that is not JSON or a component the catalog lacks, goes to `onError` with the A2UI error code, and the rest of the reply still shows. A batch of components that one bad component spoils shows the others. -- The basic catalog draws with A2UI's own elements, themed with the AI components' tokens in `ai-components.css` (`.str-chat__a2ui` sets the `--a2ui-*` variables); its `Icon` uses the Material Symbols font, which the app loads. -- The A2UI packages load in the browser the first time a reply shows A2UI, so server rendering shows a placeholder and an app whose agent never writes A2UI never loads them. The entry is ESM only and needs React 18 or later. +- The basic catalog draws with A2UI's own elements, themed with the AI components' tokens in `ai-components.css` (`.str-chat__a2ui` sets the `--a2ui-*` variables); its `Icon` uses the Material Symbols font, which the app loads. Its Modal is this package's own: a native dialog, which keeps focus in it while open, closes on Escape or its backdrop, gives focus back to what opened it, and shows above the reply's layout. A trigger other than a Button opens it from the keyboard too. +- The A2UI packages load in the browser the first time a reply shows A2UI, so server rendering shows a placeholder and an app whose agent never writes A2UI never loads them. The entry is ESM only, and `require` of it throws saying so; it needs React 18 or later. ### Steps, questions and sign-ins diff --git a/src/plugins/AIComponents/styling/a2ui.scss b/src/plugins/AIComponents/styling/a2ui.scss index e281a724b..8a7ed559f 100644 --- a/src/plugins/AIComponents/styling/a2ui.scss +++ b/src/plugins/AIComponents/styling/a2ui.scss @@ -64,6 +64,70 @@ } } +// Text's Markdown, wrapped only to follow its links: it takes no box of its own. +.str-chat__a2ui-markdown { + display: contents; +} + +// A Modal opens as a dialog, in the top layer above everything. +.str-chat__a2ui-modal { + width: min(32rem, calc(100vw - 2rem)); + max-height: calc(100dvh - 4rem); + padding: 0; + border: none; + border-radius: 1rem; + background-color: var(--a2ui-color-surface); + color: var(--a2ui-color-on-surface); + box-shadow: 0 1rem 3rem + color-mix(in srgb, var(--a2ui-color-on-background) 22%, transparent); + + &::backdrop { + background-color: color-mix( + in srgb, + var(--a2ui-color-on-background) 40%, + transparent + ); + } + + // A block, not `display: contents`, which takes a trigger that opens from the keyboard out of + // the focus order in some browsers. + &__trigger { + display: block; + } + + &__content { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + } + + &__close { + all: unset; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + align-self: flex-end; + width: 2rem; + height: 2rem; + border-radius: 9999px; + color: ai-token(text-secondary); + font-size: 1.25rem; + line-height: 1; + cursor: pointer; + + &:hover { + background-color: ai-token(surface-hover); + color: ai-token(text-primary); + } + + &:focus-visible { + @include ai-focus-ring; + } + } +} + .str-chat__theme-dark .str-chat__a2ui { color-scheme: dark; } diff --git a/vite.config.ts b/vite.config.ts index 82c4ad9c3..636d22f72 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,6 +24,8 @@ export default defineConfig({ 'mp3-encoder': resolve(__dirname, './src/plugins/encoders/mp3.ts'), 'ai-components': resolve(__dirname, './src/plugins/AIComponents/index.ts'), a2ui: resolve(__dirname, './src/plugins/A2UI/index.ts'), + // What `require` reaches: the renderer is an ES module only, and says so. + 'a2ui-require': resolve(__dirname, './src/plugins/A2UI/esmOnly.ts'), }, }, // `yarn build` already wipes dist up front via `yarn clean` From 99f451dc24be52db3e5f746b73956fb8eabe9888 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 3 Nov 2026 14:59:46 +0100 Subject: [PATCH 07/22] feat(a2ui): draw the basic catalog's main parts as Stream's components Button, Text, Card, TextField, ChoicePicker and Icon render with Stream's components and theme; the rest of the basic catalog stays A2UI's own elements, themed. A Button's words keep the agent's case. A live reply's parts come in as the agent writes them, through a new useAIUIEntrance hook that puts the entrance on a part's own element. A reply from history now shows whole, its app components included. Icon draws Stream's icon for about half of A2UI's names; the renderer's `icons` and `iconFont` options cover the rest, and an icon none of them draws shows nothing rather than its name. A Modal whose trigger is a Button no longer wraps it in a second button: it looks the trigger up by the id the agent wrote, and follows it when it arrives later. --- src/plugins/A2UI/A2UIRenderer.tsx | 21 +- .../A2UI/__tests__/A2UIRenderer.test.tsx | 333 +++++++++- src/plugins/A2UI/basicComponents.tsx | 583 ++++++++++++++++++ src/plugins/A2UI/runtime.tsx | 152 +---- .../GenerativeUI/AIUIEntrance.tsx | 50 +- .../AIComponents/GenerativeUI/index.ts | 7 +- src/plugins/AIComponents/README.md | 4 +- src/plugins/AIComponents/styling/a2ui.scss | 146 ++++- 8 files changed, 1118 insertions(+), 178 deletions(-) create mode 100644 src/plugins/A2UI/basicComponents.tsx diff --git a/src/plugins/A2UI/A2UIRenderer.tsx b/src/plugins/A2UI/A2UIRenderer.tsx index 18b809f3c..dc354d267 100644 --- a/src/plugins/A2UI/A2UIRenderer.tsx +++ b/src/plugins/A2UI/A2UIRenderer.tsx @@ -1,4 +1,9 @@ -import React, { useEffect, useState } from 'react'; +import React, { + type ComponentPropsWithoutRef, + type ComponentType, + useEffect, + useState, +} from 'react'; import type { AIUIDocument } from '../AIComponents/GenerativeUI/AIUIDocument'; import type { AIUIHost } from '../AIComponents/GenerativeUI/AIUIHost'; @@ -28,6 +33,16 @@ export type A2UIRendererOptions = { * basic catalog shows them too, and a surface naming none is made with this one. */ catalogId?: string; + /** + * The icon font the page loads, such as `'Material Symbols Outlined'`, for the icons of A2UI's + * Icon that neither `icons` nor Stream draws. Without one, such an icon shows nothing. + */ + iconFont?: string; + /** + * The app's icons for the names A2UI's Icon takes, such as `shoppingCart`, over Stream's. + * Stream draws about 30 of the 60 names. + */ + icons?: Partial>>>; /** * Tells the app what went wrong in a reply's A2UI, such as a message that isn't JSON or a * component the catalog doesn't have. The rest of the reply's UI still shows. Logged by @@ -43,6 +58,8 @@ export type A2UIRendererOptions = { export class A2UIEngine implements AIUIEngine { readonly catalogId: string; readonly host: AIUIHost; + readonly iconFont?: string; + readonly icons?: A2UIRendererOptions['icons']; /** Each reply's surfaces, by the reply's id. */ readonly replies = new Map(); /** The runtime, once loaded. */ @@ -55,6 +72,8 @@ export class A2UIEngine implements AIUIEngine { this.host = host; this.options = options; this.catalogId = options.catalogId ?? A2UI_DEFAULT_CATALOG_ID; + this.iconFont = options.iconFont; + this.icons = options.icons; } /** Loads the runtime, once. */ diff --git a/src/plugins/A2UI/__tests__/A2UIRenderer.test.tsx b/src/plugins/A2UI/__tests__/A2UIRenderer.test.tsx index 36bb05a9f..b083c22f5 100644 --- a/src/plugins/A2UI/__tests__/A2UIRenderer.test.tsx +++ b/src/plugins/A2UI/__tests__/A2UIRenderer.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'; import { AIConversation, @@ -14,6 +14,7 @@ import { renderWithEnglish } from '../../AIComponents/__tests__/renderWithEnglis import { A2UI_DEFAULT_CATALOG_ID, a2uiMessagesIn, + type A2UIRendererOptions, createA2UIRenderer, supportsA2UIVersion, } from '..'; @@ -63,14 +64,16 @@ const calendarCheck = defineAIUIComponent<{ end: Date; start: Date }>( ), ); -const setup = () => { +const setup = (options: A2UIRendererOptions = {}) => { const sent: AIUIAction[] = []; const errors: { code: string; message: string }[] = []; const open = vi.fn(); const ui = new AIUIHost({ components: [placeCard, tripMap, calendarCheck], open, - renderers: [createA2UIRenderer({ onError: (error) => errors.push(error) })], + renderers: [ + createA2UIRenderer({ ...options, onError: (error) => errors.push(error) }), + ], send: (action) => { sent.push(action); }, @@ -177,17 +180,11 @@ describe('a reply written in A2UI', () => { it('tells the agent what the person did, in their words', async () => { const { sent, ui } = setup(); - const { container } = renderWithEnglish( + renderWithEnglish( , ); - await waitFor(() => - expect(container.querySelector('a2ui-basic-button button')).not.toBeNull(), - ); - const update = [...container.querySelectorAll('a2ui-basic-button button')].find( - (button) => button.textContent?.includes('Update the plan'), - ); - if (!update) throw new Error('no Update the plan button'); + const update = await screen.findByRole('button', { name: 'Update the plan' }); act(() => { fireEvent.click(update); }); @@ -234,14 +231,10 @@ describe('a reply written in A2UI', () => { }, }), ]; - const { container } = renderWithEnglish( - , - ); - await waitFor(() => - expect(container.querySelector('a2ui-basic-button button')).not.toBeNull(), - ); + renderWithEnglish(); + const book = await screen.findByRole('button', { name: 'Book the boat' }); act(() => { - fireEvent.click(container.querySelector('a2ui-basic-button button') as Element); + fireEvent.click(book); }); await waitFor(() => expect(sent[0]?.label).toBe('Book the boat')); }); @@ -367,15 +360,11 @@ describe('a reply written in A2UI', () => { }, }), ]; - const { container } = renderWithEnglish( - , - ); - await waitFor(() => - expect(container.querySelector('a2ui-basic-button button')).not.toBeNull(), - ); + renderWithEnglish(); + const read = await screen.findByRole('button', { name: 'Read about Ohrid' }); expect(open).not.toHaveBeenCalled(); act(() => { - fireEvent.click(container.querySelector('a2ui-basic-button button') as Element); + fireEvent.click(read); }); await waitFor(() => expect(open).toHaveBeenCalledWith(new URL('https://en.wikipedia.org/wiki/Ohrid')), @@ -430,9 +419,7 @@ describe('a reply written in A2UI', () => { const { container } = renderWithEnglish( , ); - await waitFor(() => - expect(container.querySelector('a2ui-basic-button button')).not.toBeNull(), - ); + const trigger = await screen.findByRole('button', { name: 'Opening times' }); const dialog = container.querySelector( 'dialog.str-chat__a2ui-modal', ) as HTMLDialogElement; @@ -440,7 +427,7 @@ describe('a reply written in A2UI', () => { expect(document.body).not.toHaveTextContent('Open daily, 9 to 5.'); act(() => { - fireEvent.click(container.querySelector('a2ui-basic-button button') as Element); + fireEvent.click(trigger); }); await waitFor(() => expect(dialog.open).toBe(true)); await waitFor(() => expect(dialog).toHaveTextContent('Open daily, 9 to 5.')); @@ -454,7 +441,7 @@ describe('a reply written in A2UI', () => { await waitFor(() => expect(dialog.open).toBe(false)); act(() => { - fireEvent.click(container.querySelector('a2ui-basic-button button') as Element); + fireEvent.click(trigger); }); await waitFor(() => expect(dialog.open).toBe(true)); act(() => { @@ -520,3 +507,289 @@ describe('a reply written in A2UI', () => { await waitFor(() => expect(screen.getByText('Just words')).toBeInTheDocument()); }); }); + +const surfaceWith = (components: Record[], dataModel = {}) => [ + line({ createSurface: { dataModel, surfaceId: 's' } }), + line({ updateComponents: { components, surfaceId: 's' } }), +]; + +describe('the basic catalog in Stream’s look', () => { + it('draws Buttons as Stream’s, with their label as words', async () => { + const { ui } = setup(); + const lines = surfaceWith([ + { children: ['go', 'later'], component: 'Row', id: 'root' }, + { + action: { event: { name: 'go' } }, + child: 'go_label', + component: 'Button', + id: 'go', + variant: 'primary', + }, + { component: 'Text', id: 'go_label', text: '**Book** it' }, + { + action: { event: { name: 'later' } }, + child: 'later_label', + component: 'Button', + id: 'later', + variant: 'borderless', + }, + { component: 'Text', id: 'later_label', text: 'Later' }, + ]); + renderWithEnglish(); + + const book = await screen.findByRole('button', { name: '**Book** it' }); + expect(book).toHaveClass('str-chat__button--primary', 'str-chat__button--solid'); + expect(book.querySelector('p, strong')).toBeNull(); + expect(screen.getByRole('button', { name: 'Later' })).toHaveClass( + 'str-chat__button--ghost', + ); + }); + + it('draws Markdown and captions, in a Card', async () => { + const { ui } = setup(); + const lines = surfaceWith([ + { child: 'column', component: 'Card', id: 'root' }, + { children: ['title', 'body', 'note'], component: 'Column', id: 'column' }, + { component: 'Text', id: 'title', text: '### Saturday' }, + { component: 'Text', id: 'body', text: 'Coffee by the **harbour**.' }, + { component: 'Text', id: 'note', text: 'Opens at 8', variant: 'caption' }, + ]); + const { container } = renderWithEnglish( + , + ); + + const title = await screen.findByRole('heading', { level: 3, name: 'Saturday' }); + expect(title.closest('.str-chat__a2ui-card')).not.toBeNull(); + expect(screen.getByText('harbour').tagName).toBe('STRONG'); + expect(screen.getByText('Opens at 8')).toHaveClass('str-chat__a2ui-text--caption'); + expect(container.querySelector('a2ui-card, a2ui-basic-text')).toBeNull(); + }); + + it('keeps what the person types in the data model, and says what its checks found', async () => { + const { sent, ui } = setup(); + const lines = surfaceWith( + [ + { children: ['name', 'notes', 'send'], component: 'Column', id: 'root' }, + { + checks: [ + { + condition: { '@call': 'required', args: { value: { '@path': '/name' } } }, + message: 'Say who is coming', + }, + ], + component: 'TextField', + id: 'name', + label: 'Who is coming', + value: { '@path': '/name' }, + }, + { + component: 'TextField', + id: 'notes', + label: 'Anything else', + value: { '@path': '/notes' }, + variant: 'longText', + }, + { + action: { + event: { + context: { name: { '@path': '/name' }, notes: { '@path': '/notes' } }, + name: 'book', + }, + }, + child: 'send_label', + component: 'Button', + id: 'send', + }, + { component: 'Text', id: 'send_label', text: 'Book' }, + ], + { name: '', notes: '' }, + ); + renderWithEnglish(); + + const name = await screen.findByRole('textbox', { name: 'Who is coming' }); + expect(name).toHaveAttribute('aria-invalid', 'true'); + expect(name).toHaveAccessibleDescription('Say who is coming'); + act(() => { + fireEvent.change(name, { target: { value: 'Ana and Marko' } }); + }); + await waitFor(() => expect(name).toHaveAttribute('aria-invalid', 'false')); + expect(screen.queryByText('Say who is coming')).not.toBeInTheDocument(); + + const notes = screen.getByRole('textbox', { name: 'Anything else' }); + expect(notes.tagName).toBe('TEXTAREA'); + act(() => { + fireEvent.change(notes, { target: { value: 'A room by the lake' } }); + }); + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Book' })); + }); + await waitFor(() => + expect(sent[0]?.payload).toMatchObject({ + context: { name: 'Ana and Marko', notes: 'A room by the lake' }, + }), + ); + }); + + it('chooses one option or several, as chips or check marks, and filters them', async () => { + const { sent, ui } = setup(); + const lines = surfaceWith( + [ + { children: ['pace', 'interests', 'send'], component: 'Column', id: 'root' }, + { + component: 'ChoicePicker', + displayStyle: 'chips', + id: 'pace', + label: 'Pace', + options: [ + { label: 'Relaxed', value: 'relaxed' }, + { label: 'Packed', value: 'packed' }, + ], + value: { '@path': '/pace' }, + }, + { + component: 'ChoicePicker', + filterable: true, + id: 'interests', + label: 'More of', + options: [ + { label: 'History', value: 'history' }, + { label: 'Food', value: 'food' }, + { label: 'Nature', value: 'nature' }, + ], + value: { '@path': '/interests' }, + variant: 'multipleSelection', + }, + { + action: { + event: { + context: { + interests: { '@path': '/interests' }, + pace: { '@path': '/pace' }, + }, + name: 'update_plan', + }, + }, + child: 'send_label', + component: 'Button', + id: 'send', + }, + { component: 'Text', id: 'send_label', text: 'Update the plan' }, + ], + { interests: [], pace: ['relaxed'] }, + ); + renderWithEnglish(); + + const pace = await screen.findByRole('group', { name: 'Pace' }); + const relaxed = within(pace).getByRole('button', { name: 'Relaxed' }); + const packed = within(pace).getByRole('button', { name: 'Packed' }); + expect(relaxed).toHaveAttribute('aria-pressed', 'true'); + act(() => { + fireEvent.click(packed); + }); + await waitFor(() => expect(packed).toHaveAttribute('aria-pressed', 'true')); + expect(relaxed).toHaveAttribute('aria-pressed', 'false'); + + const interests = screen.getByRole('group', { name: 'More of' }); + act(() => { + fireEvent.click(within(interests).getByRole('checkbox', { name: 'History' })); + }); + act(() => { + fireEvent.click(within(interests).getByRole('checkbox', { name: 'Food' })); + }); + act(() => { + fireEvent.change(within(interests).getByRole('searchbox', { name: 'Search' }), { + target: { value: 'nat' }, + }); + }); + expect(within(interests).queryByRole('checkbox', { name: 'Food' })).toBeNull(); + expect(within(interests).getByRole('checkbox', { name: 'Nature' })).not.toBeChecked(); + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Update the plan' })); + }); + await waitFor(() => + expect(sent[0]?.payload).toMatchObject({ + context: { interests: ['history', 'food'], pace: ['packed'] }, + }), + ); + }); + + it('draws icons as Stream’s or the app’s, in its icon font, or as nothing', async () => { + const Cart = () => ; + const icons = (names: string[]) => + surfaceWith([ + { children: names, component: 'Row', id: 'root' }, + ...names.map((name) => ({ component: 'Icon', id: name, name })), + ]); + const names = ['search', 'shoppingCart', 'payment']; + + const withFont = setup({ + iconFont: 'Material Symbols Outlined', + icons: { shoppingCart: Cart }, + }); + const { unmount } = renderWithEnglish( + , + ); + const search = await screen.findByRole('img', { name: 'search' }); + expect(search.querySelector('svg.str-chat__icon--search')).not.toBeNull(); + expect( + within(screen.getByRole('img', { name: 'shopping cart' })).getByTestId('cart'), + ).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'payment' })).toHaveTextContent('payment'); + unmount(); + + const { ui } = setup(); + renderWithEnglish( + , + ); + await screen.findByRole('img', { name: 'search' }); + expect(screen.getByRole('img', { name: 'payment' })).toHaveTextContent(''); + expect(screen.getByRole('img', { name: 'shopping cart' })).toHaveTextContent(''); + }); +}); + +describe('a reply’s parts coming in', () => { + const text = (id: string, words: string) => ({ component: 'Text', id, text: words }); + const column = (children: string[]) => ({ children, component: 'Column', id: 'root' }); + + it('shows a reply from history whole, its parts with it', async () => { + const { ui } = setup(); + const lines = surfaceWith([ + column(['a', 'b']), + text('a', 'First'), + text('b', 'Second'), + ]); + renderWithEnglish(); + const second = await screen.findByText('Second'); + expect(second.closest('[data-entrance]')).toHaveAttribute('data-entrance', 'shown'); + expect(screen.getByText('First').closest('[data-entrance]')).toHaveAttribute( + 'data-entrance', + 'shown', + ); + }); + + it('brings in a part the agent writes once the reply has come in', async () => { + const { ui } = setup(); + const first = surfaceWith([column(['a']), text('a', 'First')]); + const { rerender } = renderWithEnglish( + , + ); + const stage = (await screen.findByText('First')).closest('.str-chat__ai-ui'); + await waitFor(() => expect(stage).toHaveAttribute('data-entrance', 'shown')); + const more = [ + ...first, + line({ + updateComponents: { + components: [column(['a', 'b']), text('b', 'Second')], + surfaceId: 's', + }, + }), + ]; + rerender( + , + ); + const second = (await screen.findByText('Second')).closest('[data-entrance]'); + expect(second).toHaveAttribute('data-entrance', 'hidden'); + await waitFor(() => expect(second).toHaveAttribute('data-entrance', 'shown')); + }); +}); diff --git a/src/plugins/A2UI/basicComponents.tsx b/src/plugins/A2UI/basicComponents.tsx new file mode 100644 index 000000000..eb9b4760d --- /dev/null +++ b/src/plugins/A2UI/basicComponents.tsx @@ -0,0 +1,583 @@ +// The basic catalog's components a person sees most, in Stream's look: the ones Stream has a +// component for are Stream's, rather than the A2UI elements themed. The rest of the catalog, +// its layouts and media, stays A2UI's own. +import React, { + type ComponentPropsWithoutRef, + createContext, + type CSSProperties, + type KeyboardEvent, + type MouseEvent, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import clsx from 'clsx'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import type { ReactA2uiComponentProps } from '@a2ui/react'; +import type { ComponentContext } from '@a2ui/web_core'; + +import { Button } from '../../components/Button'; +import { Checkbox, FieldError, TextInput } from '../../components/Form'; +import type { IconSlots } from '../../components/Icons/slots'; +import { useStableId } from '../../components/UtilityComponents/useStableId'; +import { useComponentContextIcons } from '../../context/useComponentContextIcons'; +import { useTranslationContext } from '../../context/TranslationContext'; +import { AIMarkdown } from '../AIComponents/AIMarkdown'; +import { useAIUIEntrance } from '../AIComponents/GenerativeUI/AIUIEntrance'; +import type { A2UIEngine } from './A2UIRenderer'; + +/** The engine of the surfaces below, for what in them reaches the host, such as links. */ +export const A2UIEngineContext = createContext(undefined); + +// Whether a Text is the label of a Button, where it shows as words rather than as Markdown. +const A2UILabelContext = createContext(false); + +type Weighted = { weight?: unknown }; + +// A part's flex weight in its Row or Column, as the A2UI elements apply theirs. +const weightOf = ({ weight }: Weighted): CSSProperties | undefined => + typeof weight === 'number' ? { flex: weight } : undefined; + +// A part that comes in when it shows, carrying its weight: the props of its element, and the +// scope of what shows with it. +const usePart = (props: Weighted) => { + const { props: entrance, scope } = useAIUIEntrance(); + return { + part: { ...entrance, style: { ...entrance.style, ...weightOf(props) } }, + scope, + }; +}; + +const textOf = (value: unknown) => + typeof value === 'string' ? value : value == null ? '' : String(value); + +const errorsOf = ({ isValid, validationErrors }: Checked) => + isValid === false && Array.isArray(validationErrors) + ? validationErrors.filter((error): error is string => typeof error === 'string') + : []; + +type Checked = { isValid?: unknown; validationErrors?: unknown }; + +// What a part's checks found, under it. +const Problems = ({ errors, id }: { errors: string[]; id: string }) => + errors.length ? ( + + ) : null; + +type ButtonProps = Checked & + Weighted & { + action?: unknown; + child?: unknown; + variant?: unknown; + }; + +/** A2UI's Button as Stream's: primary for the main call to action, ghost when borderless. */ +export const A2UIButton = ({ + buildChild, + props, +}: ReactA2uiComponentProps) => { + const { part, scope } = usePart(props); + const isDisabled = props.isValid === false; + const look = + props.variant === 'primary' + ? ({ appearance: 'solid', variant: 'primary' } as const) + : props.variant === 'borderless' + ? ({ appearance: 'ghost', variant: 'secondary' } as const) + : ({ appearance: 'outline', variant: 'secondary' } as const); + return ( + + ); +}; + +type CardProps = Weighted & { child?: unknown }; + +/** A2UI's Card: a box of its own in the reply, as Stream's cards are. */ +export const A2UICard = ({ buildChild, props }: ReactA2uiComponentProps) => { + const { part, scope } = usePart(props); + return ( +
+ {scope(typeof props.child === 'string' ? buildChild(props.child) : null)} +
+ ); +}; + +type TextProps = Weighted & { text?: unknown; variant?: unknown }; + +/** + * A2UI's Text: Markdown rendered as replies' is, or a caption. Its links open the + * way the host allows, since the agent writes them, rather than wherever they point. As a + * Button's label it is the button's words. + */ +export const A2UIText = ({ props }: ReactA2uiComponentProps) => + useContext(A2UILabelContext) ? ( + + {textOf(props.text)} + + ) : ( + + ); + +// A Text that is a part of its own, coming in when it shows. +const A2UITextPart = (props: TextProps) => { + const engine = useContext(A2UIEngineContext); + const { part } = usePart(props); + const text = textOf(props.text); + const variant = typeof props.variant === 'string' ? props.variant : 'body'; + const classes = clsx( + 'str-chat__a2ui-text', + `str-chat__a2ui-text--${variant}`, + part.className, + ); + if (variant === 'caption') { + return ( +

+ {text} +

+ ); + } + const follow = (event: MouseEvent) => { + const link = (event.target as Element | null)?.closest?.('a[href]'); + if (!link) return; + event.preventDefault(); + event.stopPropagation(); + engine?.host.open(link.getAttribute('href') ?? ''); + }; + return ( +
+ {text} +
+ ); +}; + +type TextFieldProps = Checked & + Weighted & { + label?: unknown; + placeholder?: unknown; + setValue?: unknown; + value?: unknown; + variant?: unknown; + }; + +/** A2UI's TextField as Stream's text input, or a text area for long text. */ +export const A2UITextField = ({ props }: ReactA2uiComponentProps) => { + const { part } = usePart(props); + const id = useStableId(); + const problemsId = `${id}-problems`; + const errors = errorsOf(props); + const label = textOf(props.label); + const placeholder = textOf(props.placeholder) || undefined; + const value = textOf(props.value); + const change = (next: string) => { + if (typeof props.setValue === 'function') props.setValue(next); + }; + const field = + props.variant === 'longText' ? ( +
0 && 'str-chat__form-text-input--error', + )} + > + {label && ( + + )} +
+
+