From b4e479d9d07ccc9f14da65909b0fd0ba82abff7e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 8 Sep 2026 11:19:07 +0300 Subject: [PATCH] feat: persist AI conversations and add chat history (#661) * feat: persist AI conversations and add chat history Store transcripts and attachment previews in IndexedDB, separate conversation history from transport lifetime, and add document-aware switching with shared Storybook coverage. Preserve interrupted activity and guard stale writes and async switches. * test: group chat history tests by domain * feat: simplify chat history navigation and diagnostics Use a compact header with searchable document-scoped history and a correctly anchored conversation menu. Move diagnostic copying into the menu with copy-result feedback, and separate Storybook fixtures from composition. * fix: address chat history review findings * test: cover harness shutdown and restored chat scrolling * fix: preserve Portless proxy port in MCP routes * docs: clarify UI animation conventions * feat: configure reasoning display and animate disclosure * fix: separate transcript following from reasoning disclosure * fix: restore selected chat after document recovery * refactor: separate chat history persistence and sessions * style: format reasoning story imports --- AGENTS.md | 8 + CHANGELOG.md | 2 + packages/vue/src/i18n/locales/de/ai.json | 34 +- packages/vue/src/i18n/locales/es/ai.json | 34 +- packages/vue/src/i18n/locales/fr/ai.json | 34 +- packages/vue/src/i18n/locales/it/ai.json | 34 +- packages/vue/src/i18n/locales/ja/ai.json | 34 +- packages/vue/src/i18n/locales/pl/ai.json | 34 +- packages/vue/src/i18n/locales/ru/ai.json | 34 +- packages/vue/src/i18n/locales/zh-cn/ai.json | 34 +- packages/vue/src/i18n/messages/ai.ts | 34 ++ src/app/ai/chat/history/continuation.ts | 24 ++ src/app/ai/chat/history/controller.ts | 210 +++++++++++++ src/app/ai/chat/history/document.ts | 37 +++ src/app/ai/chat/history/idb.ts | 154 +++++++++ src/app/ai/chat/history/messages.ts | 44 +++ src/app/ai/chat/history/persistence.ts | 55 ++++ src/app/ai/chat/history/session.ts | 63 ++++ src/app/ai/chat/history/types.ts | 51 +++ src/app/ai/chat/preferences.ts | 10 + src/app/ai/chat/submission/use.ts | 6 +- src/app/ai/chat/transports.ts | 46 +-- src/app/ai/chat/use.ts | 18 +- src/app/ai/harness/transport.ts | 18 +- src/app/automation/bridge/portless-route.ts | 2 +- src/app/settings/preferences/store.ts | 12 + src/app/storage/idb/database-names.ts | 1 + src/components/ChatPanel.vue | 297 ++++++++---------- src/components/chat/ChatComposer.vue | 97 ++++++ src/components/chat/ChatHistory.vue | 226 +++++++++++++ src/components/chat/ChatInput.vue | 246 ++++++--------- src/components/chat/ChatMessage.vue | 14 +- src/components/chat/ChatPlayground.stories.ts | 120 +++++++ src/components/chat/ChatTranscript.vue | 113 +++++++ src/components/chat/ReasoningBlock.stories.ts | 35 +++ src/components/chat/ReasoningBlock.vue | 37 ++- src/components/chat/stories/fixtures.ts | 120 +++++++ src/components/chat/stories/useAttachments.ts | 49 +++ .../chat/stories/useConversations.ts | 102 ++++++ .../chat/transcript/useScrollFollowing.ts | 78 +++++ src/components/settings/SettingsDialog.vue | 2 + .../settings/chat/ChatSettingsSection.vue | 32 ++ src/theme/chat/history.ts | 27 ++ tests/e2e/chat/composer.spec.ts | 2 +- tests/e2e/chat/diagnostics.spec.ts | 66 ++++ tests/e2e/chat/file-identity.spec.ts | 33 ++ tests/e2e/chat/history.spec.ts | 55 ++++ tests/e2e/chat/reasoning.spec.ts | 91 ++++++ tests/e2e/chat/recovery.spec.ts | 30 ++ tests/e2e/chat/restore.spec.ts | 33 ++ tests/engine/app/ai/harness/transport.test.ts | 70 +++++ .../app/automation/portless-route.test.ts | 6 + .../app/chat/history/continuation.test.ts | 48 +++ .../app/chat/history/controller.test.ts | 180 +++++++++++ tests/engine/app/chat/history/idb.test.ts | 123 ++++++++ tests/helpers/chat/reasoning.ts | 45 +++ tools/i18n/mixed-script-baseline.txt | 3 + tools/i18n/translation-baseline.txt | 4 + 58 files changed, 3078 insertions(+), 373 deletions(-) create mode 100644 src/app/ai/chat/history/continuation.ts create mode 100644 src/app/ai/chat/history/controller.ts create mode 100644 src/app/ai/chat/history/document.ts create mode 100644 src/app/ai/chat/history/idb.ts create mode 100644 src/app/ai/chat/history/messages.ts create mode 100644 src/app/ai/chat/history/persistence.ts create mode 100644 src/app/ai/chat/history/session.ts create mode 100644 src/app/ai/chat/history/types.ts create mode 100644 src/app/ai/chat/preferences.ts create mode 100644 src/components/chat/ChatComposer.vue create mode 100644 src/components/chat/ChatHistory.vue create mode 100644 src/components/chat/ChatPlayground.stories.ts create mode 100644 src/components/chat/ChatTranscript.vue create mode 100644 src/components/chat/ReasoningBlock.stories.ts create mode 100644 src/components/chat/stories/fixtures.ts create mode 100644 src/components/chat/stories/useAttachments.ts create mode 100644 src/components/chat/stories/useConversations.ts create mode 100644 src/components/chat/transcript/useScrollFollowing.ts create mode 100644 src/components/settings/chat/ChatSettingsSection.vue create mode 100644 src/theme/chat/history.ts create mode 100644 tests/e2e/chat/diagnostics.spec.ts create mode 100644 tests/e2e/chat/file-identity.spec.ts create mode 100644 tests/e2e/chat/history.spec.ts create mode 100644 tests/e2e/chat/reasoning.spec.ts create mode 100644 tests/e2e/chat/recovery.spec.ts create mode 100644 tests/e2e/chat/restore.spec.ts create mode 100644 tests/engine/app/ai/harness/transport.test.ts create mode 100644 tests/engine/app/chat/history/continuation.test.ts create mode 100644 tests/engine/app/chat/history/controller.test.ts create mode 100644 tests/engine/app/chat/history/idb.test.ts create mode 100644 tests/helpers/chat/reasoning.ts diff --git a/AGENTS.md b/AGENTS.md index c6919ec00..2ffdfcba2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,6 +220,14 @@ Keep responsibilities distinct: engine tests cover state contracts, Playwright b - Binding-aware fields detach/mutate only on the first value change; opening/focusing is non-destructive. - Preserve nearby interaction gotchas when refactoring: splitter handles, NumberField pointer ownership, section dragging, panel containment, and number-spinner styling. +### Animations + +- Use Tailwind transitions and `tw-animate-css` for simple visual state changes and enter/exit animations. Use the existing `motion-v` dependency for gesture-driven motion, coordinated layout changes, and springs; do not add another animation library. +- Use Reka state attributes and measured CSS variables for collapsibles. The utilities are `animate-collapsible-down` and `animate-collapsible-up`; keep padding and borders inside the animated height wrapper so they do not snap during collapse. +- Respect `prefers-reduced-motion` in both CSS and Motion. Disable or simplify nonessential motion while preserving state changes and interaction feedback. +- Keep reusable animation styling in the owning theme and share repeated duration/easing values rather than scattering timing constants across components. +- Verify opening and closing, interrupted transitions, reduced motion, and scroll behavior. Expanding historical chat content must not force the transcript to the bottom. + ## File format - Kiwi schema/runtime/codec/container helpers live in `@open-pencil/kiwi`; complete archive parsing and SceneGraph conversion live in `@open-pencil/fig`; Core owns format-neutral orchestration, runtime fonts/workers, and thumbnails. diff --git a/CHANGELOG.md b/CHANGELOG.md index 31a51cbfa..02217ee84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Save AI conversations and attachment previews locally, switch between chats, rename or delete them, and browse saved transcripts across documents. Choose whether reasoning stays collapsed, expands while thinking, or stays expanded, with animated disclosure controls that respect reduced motion. + - Add a searchable command palette for editor and application actions. - Search current AI provider catalogs from model pickers, with curated recommendations, recent compatible models, and offline fallbacks. - Render triangle and line arrow stroke caps on lines and open vector paths, and choose them from the stroke cap picker. diff --git a/packages/vue/src/i18n/locales/de/ai.json b/packages/vue/src/i18n/locales/de/ai.json index 68e4adc90..f0d420b32 100644 --- a/packages/vue/src/i18n/locales/de/ai.json +++ b/packages/vue/src/i18n/locales/de/ai.json @@ -115,5 +115,37 @@ "recommendedModels": "Empfohlen", "latestModels": "Neueste", "allModels": "Alle Modelle", - "latest": "Neu" + "latest": "Neu", + "chatHistory": "Chatverlauf", + "newChat": "Neuer Chat", + "allChats": "Alle Chats", + "renameChat": "Umbenennen", + "deleteChat": "Löschen", + "chatTitle": "Chatname", + "saveChatTitle": "Speichern", + "cancelChatAction": "Abbrechen", + "deleteChatConfirmation": "Diesen Chat endgültig löschen?", + "chatDocumentHint": "Öffne das Dokument des Chats, um fortzufahren.", + "chatReadOnly": "Dieser Chat gehört zu einem anderen Dokument. Öffne es, um fortzufahren.", + "chatAgentReadOnly": "Der Agentenverlauf wurde gespeichert, aber die Sitzung kann hier noch nicht fortgesetzt werden. Starte einen neuen Chat.", + "chatStorageFailed": "Der Chatverlauf konnte nicht gespeichert werden. Lass dieses Fenster geöffnet und versuche es erneut.", + "chatHistoryFailed": "Der Chatverlauf konnte nicht aktualisiert werden. Versuche es erneut.", + "chatInterrupted": "Diese Antwort wurde unterbrochen.", + "searchChats": "Unterhaltungen suchen…", + "chatScope": "Unterhaltungsbereich", + "thisDocument": "Dieses Dokument", + "allDocuments": "Alle Dokumente", + "noChatsFound": "Keine Unterhaltungen gefunden.", + "chatActions": "Unterhaltungsaktionen", + "copyDiagnosticLog": "Diagnoseprotokoll kopieren", + "diagnosticCopied": "Diagnoseprotokoll kopiert.", + "diagnosticCopyFailed": "Das Diagnoseprotokoll konnte nicht kopiert werden. Prüfe die Berechtigungen für die Zwischenablage und versuche es erneut.", + "continueChat": "Fortfahren", + "copyACPLog": "ACP-Protokoll kopieren", + "chatSettings": "Chat", + "reasoningDisplay": "Anzeige der Überlegungen", + "reasoningCollapsed": "Standardmäßig eingeklappt", + "reasoningWhileThinking": "Beim Nachdenken ausklappen", + "reasoningExpanded": "Standardmäßig ausgeklappt", + "jumpToLatest": "Zum neuesten Beitrag" } diff --git a/packages/vue/src/i18n/locales/es/ai.json b/packages/vue/src/i18n/locales/es/ai.json index 53379edcd..ccb906d30 100644 --- a/packages/vue/src/i18n/locales/es/ai.json +++ b/packages/vue/src/i18n/locales/es/ai.json @@ -115,5 +115,37 @@ "connectProvider": "Conecta un proveedor de IA para empezar a chatear.", "getProviderAPIKey": "Obtener una clave API de {provider} →", "provider": "Proveedor de IA", - "modelsTitle": "Modelos" + "modelsTitle": "Modelos", + "chatHistory": "Historial de chats", + "newChat": "Nuevo chat", + "allChats": "Todos los chats", + "renameChat": "Renombrar", + "deleteChat": "Eliminar", + "chatTitle": "Título del chat", + "saveChatTitle": "Guardar", + "cancelChatAction": "Cancelar", + "deleteChatConfirmation": "¿Eliminar este chat permanentemente?", + "chatDocumentHint": "Abre el documento del chat para continuar.", + "chatReadOnly": "Este chat pertenece a otro documento. Ábrelo para continuar.", + "chatAgentReadOnly": "La conversación del agente está guardada, pero todavía no se puede reanudar su sesión aquí. Inicia un nuevo chat.", + "chatStorageFailed": "No se pudo guardar el historial. Mantén esta ventana abierta e inténtalo de nuevo.", + "chatHistoryFailed": "No se pudo actualizar el historial. Inténtalo de nuevo.", + "chatInterrupted": "Esta respuesta se interrumpió.", + "searchChats": "Buscar conversaciones…", + "chatScope": "Ámbito de conversaciones", + "thisDocument": "Este documento", + "allDocuments": "Todos los documentos", + "noChatsFound": "No se encontraron conversaciones.", + "chatActions": "Acciones de la conversación", + "copyDiagnosticLog": "Copiar registro de diagnóstico", + "diagnosticCopied": "Registro de diagnóstico copiado.", + "diagnosticCopyFailed": "No se pudo copiar el registro de diagnóstico. Comprueba los permisos del portapapeles e inténtalo de nuevo.", + "continueChat": "Continuar", + "copyACPLog": "Copiar registro ACP", + "chatSettings": "Chat", + "reasoningDisplay": "Visualización del razonamiento", + "reasoningCollapsed": "Contraído por defecto", + "reasoningWhileThinking": "Expandir al razonar", + "reasoningExpanded": "Expandido por defecto", + "jumpToLatest": "Ir al último mensaje" } diff --git a/packages/vue/src/i18n/locales/fr/ai.json b/packages/vue/src/i18n/locales/fr/ai.json index a7fae1d14..e300292a4 100644 --- a/packages/vue/src/i18n/locales/fr/ai.json +++ b/packages/vue/src/i18n/locales/fr/ai.json @@ -115,5 +115,37 @@ "recommendedModels": "Recommandés", "latestModels": "Plus récents", "allModels": "Tous les modèles", - "latest": "Nouveau" + "latest": "Nouveau", + "chatHistory": "Historique des discussions", + "newChat": "Nouvelle discussion", + "allChats": "Toutes les discussions", + "renameChat": "Renommer", + "deleteChat": "Supprimer", + "chatTitle": "Titre de la discussion", + "saveChatTitle": "Enregistrer", + "cancelChatAction": "Annuler", + "deleteChatConfirmation": "Supprimer définitivement cette discussion ?", + "chatDocumentHint": "Ouvrez le document de la discussion pour continuer.", + "chatReadOnly": "Cette discussion appartient à un autre document. Ouvrez-le pour continuer.", + "chatAgentReadOnly": "La conversation avec l’agent est enregistrée, mais sa session ne peut pas encore reprendre ici. Commencez une nouvelle discussion.", + "chatStorageFailed": "Impossible d’enregistrer l’historique. Gardez cette fenêtre ouverte et réessayez.", + "chatHistoryFailed": "Impossible de mettre à jour l’historique. Réessayez.", + "chatInterrupted": "Cette réponse a été interrompue.", + "searchChats": "Rechercher des discussions…", + "chatScope": "Portée des discussions", + "thisDocument": "Ce document", + "allDocuments": "Tous les documents", + "noChatsFound": "Aucune discussion trouvée.", + "chatActions": "Actions de la discussion", + "copyDiagnosticLog": "Copier le journal de diagnostic", + "diagnosticCopied": "Journal de diagnostic copié.", + "diagnosticCopyFailed": "Impossible de copier le journal de diagnostic. Vérifiez les autorisations du presse-papiers et réessayez.", + "continueChat": "Continuer", + "copyACPLog": "Copier le journal ACP", + "chatSettings": "Discussion", + "reasoningDisplay": "Affichage du raisonnement", + "reasoningCollapsed": "Replié par défaut", + "reasoningWhileThinking": "Déplier pendant le raisonnement", + "reasoningExpanded": "Déplié par défaut", + "jumpToLatest": "Aller au dernier message" } diff --git a/packages/vue/src/i18n/locales/it/ai.json b/packages/vue/src/i18n/locales/it/ai.json index 80dc5887e..640744aca 100644 --- a/packages/vue/src/i18n/locales/it/ai.json +++ b/packages/vue/src/i18n/locales/it/ai.json @@ -115,5 +115,37 @@ "connectProvider": "Collega un provider AI per iniziare a chattare.", "getProviderAPIKey": "Ottieni una chiave API {provider} →", "provider": "Provider AI", - "modelsTitle": "Modelli" + "modelsTitle": "Modelli", + "chatHistory": "Cronologia chat", + "newChat": "Nuova chat", + "allChats": "Tutte le chat", + "renameChat": "Rinomina", + "deleteChat": "Elimina", + "chatTitle": "Titolo della chat", + "saveChatTitle": "Salva", + "cancelChatAction": "Annulla", + "deleteChatConfirmation": "Eliminare definitivamente questa chat?", + "chatDocumentHint": "Apri il documento della chat per continuare.", + "chatReadOnly": "Questa chat appartiene a un altro documento. Aprilo per continuare.", + "chatAgentReadOnly": "La conversazione con l’agente è salvata, ma non è ancora possibile riprendere la sessione qui. Avvia una nuova chat.", + "chatStorageFailed": "Impossibile salvare la cronologia. Tieni aperta questa finestra e riprova.", + "chatHistoryFailed": "Impossibile aggiornare la cronologia. Riprova.", + "chatInterrupted": "Questa risposta è stata interrotta.", + "searchChats": "Cerca conversazioni…", + "chatScope": "Ambito delle conversazioni", + "thisDocument": "Questo documento", + "allDocuments": "Tutti i documenti", + "noChatsFound": "Nessuna conversazione trovata.", + "chatActions": "Azioni della conversazione", + "copyDiagnosticLog": "Copia registro diagnostico", + "diagnosticCopied": "Registro diagnostico copiato.", + "diagnosticCopyFailed": "Impossibile copiare il registro diagnostico. Controlla i permessi degli appunti e riprova.", + "continueChat": "Continua", + "copyACPLog": "Copia registro ACP", + "chatSettings": "Chat", + "reasoningDisplay": "Visualizzazione del ragionamento", + "reasoningCollapsed": "Compresso per impostazione predefinita", + "reasoningWhileThinking": "Espandi durante il ragionamento", + "reasoningExpanded": "Espanso per impostazione predefinita", + "jumpToLatest": "Vai all’ultimo messaggio" } diff --git a/packages/vue/src/i18n/locales/ja/ai.json b/packages/vue/src/i18n/locales/ja/ai.json index 5610c1904..6f70b23b5 100644 --- a/packages/vue/src/i18n/locales/ja/ai.json +++ b/packages/vue/src/i18n/locales/ja/ai.json @@ -115,5 +115,37 @@ "connectProvider": "チャットを始めるには、AIプロバイダーを接続してください。", "getProviderAPIKey": "{provider} からAPIキーを取得 →", "provider": "AIプロバイダー", - "modelsTitle": "モデル" + "modelsTitle": "モデル", + "chatHistory": "チャット履歴", + "newChat": "新しいチャット", + "allChats": "すべてのチャット", + "renameChat": "名前を変更", + "deleteChat": "削除", + "chatTitle": "チャットのタイトル", + "saveChatTitle": "保存", + "cancelChatAction": "キャンセル", + "deleteChatConfirmation": "このチャットを完全に削除しますか?", + "chatDocumentHint": "続けるにはチャットのドキュメントを開いてください。", + "chatReadOnly": "このチャットは別のドキュメントに属しています。続けるにはそのドキュメントを開いてください。", + "chatAgentReadOnly": "エージェントとの会話は保存されていますが、ここではまだセッションを再開できません。新しいチャットを開始してください。", + "chatStorageFailed": "チャット履歴を保存できませんでした。このウィンドウを開いたまま、もう一度お試しください。", + "chatHistoryFailed": "チャット履歴を更新できませんでした。もう一度お試しください。", + "chatInterrupted": "この応答は中断されました。", + "searchChats": "会話を検索…", + "chatScope": "会話の範囲", + "thisDocument": "このドキュメント", + "allDocuments": "すべてのドキュメント", + "noChatsFound": "会話が見つかりません。", + "chatActions": "会話の操作", + "copyDiagnosticLog": "診断ログをコピー", + "diagnosticCopied": "診断ログをコピーしました。", + "diagnosticCopyFailed": "診断ログをコピーできませんでした。クリップボードの権限を確認して、もう一度お試しください。", + "continueChat": "続ける", + "copyACPLog": "ACPログをコピー", + "chatSettings": "チャット", + "reasoningDisplay": "推論の表示", + "reasoningCollapsed": "既定で折りたたむ", + "reasoningWhileThinking": "思考中に展開", + "reasoningExpanded": "既定で展開", + "jumpToLatest": "最新へ移動" } diff --git a/packages/vue/src/i18n/locales/pl/ai.json b/packages/vue/src/i18n/locales/pl/ai.json index 4bc722cc0..5b04b2fa0 100644 --- a/packages/vue/src/i18n/locales/pl/ai.json +++ b/packages/vue/src/i18n/locales/pl/ai.json @@ -115,5 +115,37 @@ "recommendedModels": "Polecane", "latestModels": "Najnowsze", "allModels": "Wszystkie modele", - "latest": "Nowy" + "latest": "Nowy", + "chatHistory": "Historia czatów", + "newChat": "Nowy czat", + "allChats": "Wszystkie czaty", + "renameChat": "Zmień nazwę", + "deleteChat": "Usuń", + "chatTitle": "Tytuł czatu", + "saveChatTitle": "Zapisz", + "cancelChatAction": "Anuluj", + "deleteChatConfirmation": "Trwale usunąć ten czat?", + "chatDocumentHint": "Otwórz dokument czatu, aby kontynuować.", + "chatReadOnly": "Ten czat należy do innego dokumentu. Otwórz go, aby kontynuować.", + "chatAgentReadOnly": "Rozmowa z agentem została zapisana, ale nie można tu jeszcze wznowić jego sesji. Rozpocznij nowy czat.", + "chatStorageFailed": "Nie udało się zapisać historii. Nie zamykaj tego okna i spróbuj ponownie.", + "chatHistoryFailed": "Nie udało się zaktualizować historii. Spróbuj ponownie.", + "chatInterrupted": "Ta odpowiedź została przerwana.", + "searchChats": "Szukaj rozmów…", + "chatScope": "Zakres rozmów", + "thisDocument": "Ten dokument", + "allDocuments": "Wszystkie dokumenty", + "noChatsFound": "Nie znaleziono rozmów.", + "chatActions": "Działania rozmowy", + "copyDiagnosticLog": "Kopiuj dziennik diagnostyczny", + "diagnosticCopied": "Skopiowano dziennik diagnostyczny.", + "diagnosticCopyFailed": "Nie udało się skopiować dziennika diagnostycznego. Sprawdź uprawnienia schowka i spróbuj ponownie.", + "continueChat": "Kontynuuj", + "copyACPLog": "Kopiuj dziennik ACP", + "chatSettings": "Czat", + "reasoningDisplay": "Wyświetlanie rozumowania", + "reasoningCollapsed": "Domyślnie zwinięte", + "reasoningWhileThinking": "Rozwijaj podczas rozumowania", + "reasoningExpanded": "Domyślnie rozwinięte", + "jumpToLatest": "Przejdź do najnowszej wiadomości" } diff --git a/packages/vue/src/i18n/locales/ru/ai.json b/packages/vue/src/i18n/locales/ru/ai.json index abe76ef25..fe94798ad 100644 --- a/packages/vue/src/i18n/locales/ru/ai.json +++ b/packages/vue/src/i18n/locales/ru/ai.json @@ -115,5 +115,37 @@ "recommendedModels": "Рекомендуемые", "latestModels": "Новые", "allModels": "Все модели", - "latest": "Новая" + "latest": "Новая", + "chatHistory": "История чатов", + "newChat": "Новый чат", + "allChats": "Все чаты", + "renameChat": "Переименовать", + "deleteChat": "Удалить", + "chatTitle": "Название чата", + "saveChatTitle": "Сохранить", + "cancelChatAction": "Отмена", + "deleteChatConfirmation": "Удалить этот чат навсегда?", + "chatDocumentHint": "Откройте документ чата, чтобы продолжить.", + "chatReadOnly": "Этот чат относится к другому документу. Откройте его, чтобы продолжить.", + "chatAgentReadOnly": "Переписка с агентом сохранена, но восстановление его сессии пока недоступно. Начните новый чат.", + "chatStorageFailed": "Не удалось сохранить историю чата. Не закрывайте окно и попробуйте ещё раз.", + "chatHistoryFailed": "Не удалось обновить историю чатов. Попробуйте ещё раз.", + "chatInterrupted": "Этот ответ был прерван.", + "searchChats": "Поиск по чатам…", + "chatScope": "Область поиска чатов", + "thisDocument": "Этот документ", + "allDocuments": "Все документы", + "noChatsFound": "Чаты не найдены.", + "chatActions": "Действия с чатом", + "copyDiagnosticLog": "Копировать диагностический журнал", + "diagnosticCopied": "Диагностический журнал скопирован.", + "diagnosticCopyFailed": "Не удалось скопировать диагностический журнал. Проверьте разрешения буфера обмена и повторите попытку.", + "continueChat": "Продолжить", + "copyACPLog": "Копировать журнал ACP", + "chatSettings": "Чат", + "reasoningDisplay": "Отображение рассуждений", + "reasoningCollapsed": "Свёрнуто по умолчанию", + "reasoningWhileThinking": "Разворачивать во время размышлений", + "reasoningExpanded": "Развёрнуто по умолчанию", + "jumpToLatest": "К последнему сообщению" } diff --git a/packages/vue/src/i18n/locales/zh-cn/ai.json b/packages/vue/src/i18n/locales/zh-cn/ai.json index 45ca1967f..b4b056a37 100644 --- a/packages/vue/src/i18n/locales/zh-cn/ai.json +++ b/packages/vue/src/i18n/locales/zh-cn/ai.json @@ -115,5 +115,37 @@ "connectProvider": "连接 AI 提供商以开始聊天。", "getProviderAPIKey": "获取 {provider} API 密钥 →", "provider": "AI 提供商", - "modelsTitle": "模型" + "modelsTitle": "模型", + "chatHistory": "聊天记录", + "newChat": "新建聊天", + "allChats": "所有聊天", + "renameChat": "重命名", + "deleteChat": "删除", + "chatTitle": "聊天标题", + "saveChatTitle": "保存", + "cancelChatAction": "取消", + "deleteChatConfirmation": "永久删除此聊天?", + "chatDocumentHint": "打开聊天所属的文档以继续。", + "chatReadOnly": "此聊天属于另一个文档。打开该文档以继续。", + "chatAgentReadOnly": "与代理的对话已保存,但暂时无法在此恢复会话。请新建聊天。", + "chatStorageFailed": "无法保存聊天记录。请保持此窗口打开并重试。", + "chatHistoryFailed": "无法更新聊天记录。请重试。", + "chatInterrupted": "此回复已中断。", + "searchChats": "搜索对话…", + "chatScope": "对话范围", + "thisDocument": "此文档", + "allDocuments": "所有文档", + "noChatsFound": "未找到对话。", + "chatActions": "对话操作", + "copyDiagnosticLog": "复制诊断日志", + "diagnosticCopied": "诊断日志已复制。", + "diagnosticCopyFailed": "无法复制诊断日志。请检查剪贴板权限并重试。", + "continueChat": "继续", + "copyACPLog": "复制 ACP 日志", + "chatSettings": "聊天", + "reasoningDisplay": "推理显示", + "reasoningCollapsed": "默认折叠", + "reasoningWhileThinking": "思考时展开", + "reasoningExpanded": "默认展开", + "jumpToLatest": "跳转到最新消息" } diff --git a/packages/vue/src/i18n/messages/ai.ts b/packages/vue/src/i18n/messages/ai.ts index 0a3205932..14fddb2c9 100644 --- a/packages/vue/src/i18n/messages/ai.ts +++ b/packages/vue/src/i18n/messages/ai.ts @@ -3,6 +3,40 @@ import { params } from '@nanostores/i18n' import { i18n } from '#vue/i18n/create' export const aiMessageDefaults = { + jumpToLatest: 'Jump to latest', + chatSettings: 'Chat', + reasoningDisplay: 'Reasoning display', + reasoningCollapsed: 'Collapsed by default', + reasoningWhileThinking: 'Expand while thinking', + reasoningExpanded: 'Expanded by default', + continueChat: 'Continue', + copyACPLog: 'Copy ACP log', + diagnosticCopied: 'Diagnostic log copied.', + diagnosticCopyFailed: + 'Could not copy the diagnostic log. Check clipboard permissions and try again.', + copyDiagnosticLog: 'Copy diagnostic log', + searchChats: 'Search conversations…', + chatScope: 'Conversation scope', + thisDocument: 'This document', + allDocuments: 'All documents', + noChatsFound: 'No conversations found.', + chatActions: 'Conversation actions', + chatHistory: 'Conversation history', + newChat: 'New chat', + allChats: 'All chats', + renameChat: 'Rename', + deleteChat: 'Delete', + chatTitle: 'Conversation title', + saveChatTitle: 'Save', + cancelChatAction: 'Cancel', + deleteChatConfirmation: 'Delete this conversation permanently?', + chatDocumentHint: 'Open a chat’s document to continue it.', + chatReadOnly: 'This conversation belongs to another document. Open its document to continue.', + chatAgentReadOnly: + 'This agent transcript is saved, but its session cannot be resumed here yet. Start a new chat to continue working.', + chatStorageFailed: 'Chat history could not be saved. Keep this window open and try again.', + chatHistoryFailed: 'Could not update chat history. Try again.', + chatInterrupted: 'This response was interrupted.', connectProvider: 'Connect an AI provider to start chatting.', connectionTestSuccess: 'Connected successfully. Model is reachable.', connectionTestMissingAPIKey: 'Enter an API key before testing.', diff --git a/src/app/ai/chat/history/continuation.ts b/src/app/ai/chat/history/continuation.ts new file mode 100644 index 000000000..315208402 --- /dev/null +++ b/src/app/ai/chat/history/continuation.ts @@ -0,0 +1,24 @@ +import type { ChatTransport, UIMessage } from 'ai' + +/** Preserve partial tool activity in history but omit unpaired calls from new requests. */ +export function resumableTransport(transport: ChatTransport): ChatTransport { + return { + reconnectToStream: (options) => transport.reconnectToStream(options), + sendMessages: (options) => + transport.sendMessages({ + ...options, + messages: options.messages + .map((message) => ({ + ...message, + parts: message.parts.filter( + (part) => + !('toolCallId' in part) || + part.state === 'output-available' || + part.state === 'output-error' || + part.state === 'output-denied' + ) + })) + .filter((message) => message.parts.length > 0) + }) + } +} diff --git a/src/app/ai/chat/history/controller.ts b/src/app/ai/chat/history/controller.ts new file mode 100644 index 000000000..c7d1228a6 --- /dev/null +++ b/src/app/ai/chat/history/controller.ts @@ -0,0 +1,210 @@ +import type { UIMessage } from 'ai' +import { ref, shallowRef } from 'vue' + +import { chatDocumentId, resolveChatDocumentId, type ChatDocumentEditor } from './document' +import { createConversationStore } from './idb' +import { restoreMessages } from './messages' +import { createHistoryPersistence } from './persistence' +import { createHistorySession } from './session' +import type { + Conversation, + ConversationMeta, + ConversationStore, + HistoryChat, + HistoryRuntime +} from './types' + +/** Coordinates document ownership and serialized public conversation actions. */ +export function createConversationHistory( + runtime: HistoryRuntime, + store: ConversationStore = createConversationStore() +) { + const current = shallowRef(null) + const messages = shallowRef([]) + const conversations = ref([]) + const readOnly = ref(false) + const busy = ref(false) + let ownerRecoveryId: string | null = null + let owner: ChatDocumentEditor | null = null + let operation: Promise = Promise.resolve() + const session = createHistorySession(() => runtime.resetChat()) + const { flush, storageError } = createHistoryPersistence({ + store, + current, + messages, + readOnly, + refresh, + content: () => ({ + messages: session.chat?.messages ?? messages.value, + interrupted: session.interrupted + }) + }) + + function serialize(run: () => Promise): Promise { + const next = operation.then(async () => { + busy.value = true + try { + return await run() + } finally { + busy.value = false + } + }) + operation = next.catch(() => undefined) + return next + } + + async function refresh() { + conversations.value = await store.list() + } + function detach() { + return session.detach(flush) + } + + async function activate(conversation: Conversation) { + owner = runtime.getEditor() + ownerRecoveryId = owner.getRecoveryId() + session.restoreInterrupted(conversation.interrupted) + readOnly.value = conversation.documentId !== chatDocumentId(owner) + current.value = conversation + messages.value = restoreMessages(conversation.messages) + if (conversation.messages.length || conversation.titleSource !== 'fallback') { + await store.select(conversation.documentId, conversation.id) + } + } + + async function createDraft() { + const editor = runtime.getEditor() + const now = new Date().toISOString() + await activate({ + id: crypto.randomUUID(), + documentId: await resolveChatDocumentId(editor, store), + documentName: editor.state.documentName, + title: '', + titleSource: 'fallback', + createdAt: now, + updatedAt: now, + profileId: runtime.profileId(), + backend: runtime.backend(), + interrupted: false, + messages: [] + }) + await refresh() + } + + function isIdentityChange(editor: ChatDocumentEditor, documentId: string) { + return ( + owner === editor && + ownerRecoveryId === editor.getRecoveryId() && + current.value && + !readOnly.value && + current.value.documentId !== documentId + ) + } + + async function reassignDocument(editor: ChatDocumentEditor, documentId: string) { + if (!current.value) return + await flush() + await store.reassignDocument(current.value.documentId, documentId, editor.state.documentName) + current.value = { ...current.value, documentId, documentName: editor.state.documentName } + await refresh() + } + + async function loadDocument() { + const editor = runtime.getEditor() + const documentId = await resolveChatDocumentId(editor, store) + if (isIdentityChange(editor, documentId)) await reassignDocument(editor, documentId) + if (current.value?.documentId === documentId) { + readOnly.value = false + owner = editor + ownerRecoveryId = editor.getRecoveryId() + return + } + await detach() + const id = await store.getSelected(documentId) + const conversation = id ? await store.read(id) : null + if (conversation?.documentId === documentId) await activate(conversation) + else await createDraft() + await refresh() + } + + function ensureChat() { + return serialize(async () => { + if (readOnly.value && owner === runtime.getEditor()) return null + await loadDocument() + // A restored transcript does not restore an external agent session. + if ( + current.value?.messages.length && + !session.chat && + (runtime.backend() !== 'direct' || current.value.backend !== 'direct') + ) + return null + const editor = runtime.getEditor() + const next = await runtime.ensureChat(messages.value, current.value?.id) + if (runtime.getEditor() !== editor) { + await next?.stop() + await runtime.resetChat() + return null + } + if (next) session.attach(next, flush) + return next + }) + } + + function initialize() { + return serialize(loadDocument) + } + function newChat() { + return serialize(async () => { + await detach() + await createDraft() + }) + } + function open(id: string) { + return serialize(async () => { + if (current.value?.id === id) return + await detach() + const conversation = await store.read(id) + if (conversation) await activate(conversation) + }) + } + function rename(id: string, title: string) { + return serialize(async () => { + if (current.value?.id === id && current.value.messages.length === 0 && title.trim()) { + current.value = { ...current.value, title: title.trim(), titleSource: 'manual' } + await flush() + } + await store.rename(id, title) + if (current.value?.id === id && title.trim()) + current.value = { ...current.value, title: title.trim(), titleSource: 'manual' } + await refresh() + }) + } + function remove(id: string) { + return serialize(async () => { + if (current.value?.id === id) { + await detach() + current.value = null + messages.value = [] + } + await store.remove(id) + if (!current.value) await createDraft() + await refresh() + }) + } + + return { + current, + messages, + conversations, + readOnly, + busy, + storageError, + initialize, + ensureChat, + flush, + newChat, + open, + rename, + remove + } +} diff --git a/src/app/ai/chat/history/document.ts b/src/app/ai/chat/history/document.ts new file mode 100644 index 000000000..6eea5835d --- /dev/null +++ b/src/app/ai/chat/history/document.ts @@ -0,0 +1,37 @@ +import type { EditorStore } from '@/app/editor/active-store' + +import type { ConversationStore } from './types' + +export type ChatDocumentEditor = Pick< + EditorStore, + 'getSourceIdentity' | 'getStorageBinding' | 'getDocumentFilePath' | 'getRecoveryId' +> & { state: { documentName: string } } + +const fileIds = new WeakMap() + +export async function resolveChatDocumentId( + editor: ChatDocumentEditor, + store: ConversationStore +): Promise { + const handle = editor.getSourceIdentity().handle + if (handle && !editor.getStorageBinding() && !editor.getDocumentFilePath()) { + const id = fileIds.get(handle) ?? (await store.resolveFile(handle)) + fileIds.set(handle, id) + return id + } + return chatDocumentId(editor) +} + +/** Runtime tab IDs are not document identities. Recovery IDs survive untitled recovery. */ +export function chatDocumentId(editor: ChatDocumentEditor): string { + const binding = editor.getStorageBinding() + if (binding) return `storage:${JSON.stringify([binding.providerId, binding.documentId])}` + const path = editor.getDocumentFilePath() + if (path) return `file:${path}` + const handle = editor.getSourceIdentity().handle + if (handle) { + const id = fileIds.get(handle) + if (id) return id + } + return `recovery:${editor.getRecoveryId()}` +} diff --git a/src/app/ai/chat/history/idb.ts b/src/app/ai/chat/history/idb.ts new file mode 100644 index 000000000..809e4215b --- /dev/null +++ b/src/app/ai/chat/history/idb.ts @@ -0,0 +1,154 @@ +import type { DBSchema } from 'idb' + +import { APP_DATABASE_NAMES, defineAppDatabase, openAppDatabase } from '@/app/storage/idb' + +import type { Conversation, ConversationMeta, ConversationStore } from './types' + +interface ChatDatabase extends DBSchema { + conversations: { key: string; value: ConversationMeta; indexes: { document: string } } + messages: { key: string; value: Conversation['messages'] } + files: { key: string; value: FileSystemFileHandle } + deleted: { key: string; value: true } + selected: { key: string; value: string } +} + +const definition = defineAppDatabase({ + name: APP_DATABASE_NAMES.chats, + version: 3, + callbacks: { + upgrade(database, oldVersion) { + if (oldVersion < 1) { + database + .createObjectStore('conversations', { keyPath: 'id' }) + .createIndex('document', 'documentId') + database.createObjectStore('messages') + database.createObjectStore('selected') + } + if (oldVersion < 2) database.createObjectStore('deleted') + if (oldVersion < 3) database.createObjectStore('files') + } + } +}) + +/** Transcript snapshots and Blob previews are committed atomically with their list metadata. */ +export function createConversationStore(): ConversationStore { + let database: ReturnType> | undefined + const getDatabase = () => (database ??= openAppDatabase(definition)) + return { + async list(documentId) { + const db = await getDatabase() + const rows = + documentId === undefined + ? await db.getAll('conversations') + : await db.getAllFromIndex('conversations', 'document', documentId) + return rows.toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + }, + async read(id) { + const transaction = (await getDatabase()).transaction(['conversations', 'messages']) + const meta = await transaction.objectStore('conversations').get(id) + const messages = await transaction.objectStore('messages').get(id) + await transaction.done + return meta ? { ...meta, messages: messages ?? [] } : null + }, + async write(conversation) { + const { messages, ...meta } = conversation + const transaction = (await getDatabase()).transaction( + ['conversations', 'messages', 'deleted'], + 'readwrite' + ) + if (await transaction.objectStore('deleted').get(meta.id)) { + await transaction.done + throw new Error('Conversation was deleted') + } + const existing = await transaction.objectStore('conversations').get(meta.id) + if (existing) { + meta.documentId = existing.documentId + meta.documentName = existing.documentName + } + // A delayed streaming snapshot must not undo a manual rename. + if (existing?.titleSource === 'manual') { + meta.title = existing.title + meta.titleSource = 'manual' + } + await transaction.objectStore('conversations').put(meta) + await transaction.objectStore('messages').put(messages, meta.id) + await transaction.done + }, + async rename(id, title) { + const trimmed = title.trim() + if (!trimmed) return + const transaction = (await getDatabase()).transaction('conversations', 'readwrite') + const meta = await transaction.store.get(id) + if (meta) await transaction.store.put({ ...meta, title: trimmed, titleSource: 'manual' }) + await transaction.done + }, + async remove(id) { + const transaction = (await getDatabase()).transaction( + ['conversations', 'messages', 'selected', 'deleted'], + 'readwrite' + ) + await transaction.objectStore('deleted').put(true, id) + await transaction.objectStore('conversations').delete(id) + await transaction.objectStore('messages').delete(id) + let cursor = await transaction.objectStore('selected').openCursor() + while (cursor) { + if (cursor.value === id) await cursor.delete() + cursor = await cursor.continue() + } + await transaction.done + }, + async resolveFile(handle) { + return navigator.locks.request('open-pencil-chat-file-identity', async () => { + const db = await getDatabase() + // isSameEntry is asynchronous and must run outside an IDB transaction. + const transaction = db.transaction('files') + const keys = await transaction.store.getAllKeys() + const handles = await transaction.store.getAll() + await transaction.done + for (const [index, saved] of handles.entries()) { + try { + if (await saved.isSameEntry(handle)) return String(keys[index]) + } catch { + console.warn('[Chat history] Could not compare a saved file handle') + } + } + const id = `browser-file:${crypto.randomUUID()}` + await db.put('files', handle, id) + return id + }) + }, + async reassignDocument(from, to, name) { + const transaction = (await getDatabase()).transaction( + ['conversations', 'selected'], + 'readwrite' + ) + const rows = await transaction.objectStore('conversations').index('document').getAll(from) + for (const row of rows) { + await transaction + .objectStore('conversations') + .put({ ...row, documentId: to, documentName: name }) + } + const selected = await transaction.objectStore('selected').get(from) + if (selected) await transaction.objectStore('selected').put(selected, to) + if (from !== to) await transaction.objectStore('selected').delete(from) + await transaction.done + }, + async getSelected(documentId) { + return (await (await getDatabase()).get('selected', documentId)) ?? null + }, + async select(documentId, id) { + const transaction = (await getDatabase()).transaction( + ['conversations', 'selected'], + 'readwrite' + ) + const meta = await transaction.objectStore('conversations').get(id) + if (!meta || meta.documentId !== documentId) { + transaction.abort() + await transaction.done.catch(() => undefined) + throw new Error('Conversation belongs to a different document') + } + await transaction.objectStore('selected').put(id, documentId) + await transaction.done + } + } +} diff --git a/src/app/ai/chat/history/messages.ts b/src/app/ai/chat/history/messages.ts new file mode 100644 index 000000000..aadcbed5e --- /dev/null +++ b/src/app/ai/chat/history/messages.ts @@ -0,0 +1,44 @@ +import type { UIMessage } from 'ai' +import { toRaw } from 'vue' + +import { + attachmentsForMessage, + setMessageAttachments +} from '@/app/ai/attachment/presentation/store' +import { visibleMessageText, setVisibleMessageText } from '@/app/ai/chat/presentation' + +import type { ConversationMessage } from './types' + +export function snapshotMessages(messages: UIMessage[]): ConversationMessage[] { + return messages.map((message) => { + const text = message.parts + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join('') + return { + message: structuredClone(toRaw(message)), + displayText: visibleMessageText(message.id, text), + attachments: attachmentsForMessage(message.id).value.map((attachment) => + structuredClone(toRaw(attachment)) + ) + } + }) +} + +export function restoreMessages(messages: ConversationMessage[]): UIMessage[] { + for (const row of messages) { + if (row.displayText !== undefined) setVisibleMessageText(row.message.id, row.displayText) + setMessageAttachments(row.message.id, row.attachments) + } + return messages.map((row) => structuredClone(row.message)) +} + +export function fallbackTitle(messages: UIMessage[]): string { + const first = messages.find((message) => message.role === 'user') + if (!first) return '' + const text = first.parts + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join(' ') + return visibleMessageText(first.id, text).replace(/\s+/g, ' ').trim().slice(0, 120) +} diff --git a/src/app/ai/chat/history/persistence.ts b/src/app/ai/chat/history/persistence.ts new file mode 100644 index 000000000..fac1898f7 --- /dev/null +++ b/src/app/ai/chat/history/persistence.ts @@ -0,0 +1,55 @@ +import type { UIMessage } from 'ai' +import { ref, type Ref } from 'vue' + +import { fallbackTitle, snapshotMessages } from './messages' +import type { Conversation, ConversationStore } from './types' + +interface PersistenceOptions { + store: ConversationStore + current: Ref + messages: Ref + readOnly: Ref + content(): { messages: UIMessage[]; interrupted: boolean } + refresh(): Promise +} + +/** Captures snapshots before queuing writes; failures remain visible and retryable. */ +export function createHistoryPersistence(options: PersistenceOptions) { + const storageError = ref(false) + let writes: Promise = Promise.resolve() + + function flush(): Promise { + const conversation = options.current.value + if (!conversation || options.readOnly.value) return writes + const content = options.content() + if (!content.messages.length && conversation.titleSource === 'fallback') return writes + const snapshot: Conversation = { + ...conversation, + title: + conversation.titleSource === 'fallback' + ? fallbackTitle(content.messages) + : conversation.title, + updatedAt: new Date().toISOString(), + interrupted: content.interrupted, + messages: snapshotMessages(content.messages) + } + options.current.value = snapshot + options.messages.value = content.messages + writes = writes + .catch(() => undefined) + .then(async () => { + await options.store.write(snapshot) + await options.store.select(snapshot.documentId, snapshot.id) + storageError.value = false + await options.refresh() + return undefined + }) + .catch((error) => { + storageError.value = true + throw error + }) + return writes + } + + return { flush, storageError } +} diff --git a/src/app/ai/chat/history/session.ts b/src/app/ai/chat/history/session.ts new file mode 100644 index 000000000..566d10b83 --- /dev/null +++ b/src/app/ai/chat/history/session.ts @@ -0,0 +1,63 @@ +import { watchThrottled } from '@vueuse/core' +import { watch } from 'vue' + +import type { HistoryChat } from './types' + +/** Owns live subscriptions; a failed final save deliberately leaves the session attached. */ +export function createHistorySession(reset: () => Promise) { + let live: HistoryChat | null = null + let interrupted = false + let generation = 0 + let stopWatch: (() => void) | undefined + + function attach(next: HistoryChat, flush: () => Promise): void { + if (live === next) return + stopWatch?.() + live = next + const activeGeneration = ++generation + function save() { + if (live === next && generation === activeGeneration) void flush().catch(() => undefined) + } + const stopMessages = watchThrottled(() => next.messages, save, { + deep: true, + throttle: 500, + trailing: true + }) + const stopStatus = watch( + () => next.status, + () => { + if (next.status === 'submitted') interrupted = false + save() + } + ) + stopWatch = () => { + stopMessages() + stopStatus() + } + } + + async function detach(flush: () => Promise): Promise { + interrupted ||= live?.status === 'submitted' || live?.status === 'streaming' + await live?.stop() + await flush() + generation++ + stopWatch?.() + stopWatch = undefined + await reset() + live = null + } + + return { + attach, + detach, + get chat() { + return live + }, + get interrupted() { + return interrupted || live?.status === 'submitted' || live?.status === 'streaming' + }, + restoreInterrupted(value: boolean) { + interrupted = value + } + } +} diff --git a/src/app/ai/chat/history/types.ts b/src/app/ai/chat/history/types.ts new file mode 100644 index 000000000..43eb2b26d --- /dev/null +++ b/src/app/ai/chat/history/types.ts @@ -0,0 +1,51 @@ +import type { Chat } from '@ai-sdk/vue' +import type { UIMessage } from 'ai' + +import type { AttachmentPresentation } from '@/app/ai/attachment/presentation/types' + +import type { ChatDocumentEditor } from './document' + +export type HistoryChat = Pick, 'messages' | 'status' | 'stop'> + +export interface HistoryRuntime { + getEditor(): ChatDocumentEditor + ensureChat(messages?: UIMessage[], sessionId?: string): Promise + resetChat(): Promise + backend(): ConversationMeta['backend'] + profileId(): string | null +} + +export interface ConversationMeta { + id: string + documentId: string + documentName: string + title: string + titleSource: 'fallback' | 'manual' | 'generated' + createdAt: string + updatedAt: string + profileId: string | null + backend: 'direct' | 'acp' | 'harness' + interrupted: boolean +} + +export interface ConversationMessage { + message: UIMessage + displayText?: string + attachments: AttachmentPresentation[] +} + +export interface Conversation extends ConversationMeta { + messages: ConversationMessage[] +} + +export interface ConversationStore { + list(documentId?: string): Promise + read(id: string): Promise + write(conversation: Conversation): Promise + rename(id: string, title: string): Promise + remove(id: string): Promise + resolveFile(handle: FileSystemFileHandle): Promise + reassignDocument(from: string, to: string, name: string): Promise + getSelected(documentId: string): Promise + select(documentId: string, id: string): Promise +} diff --git a/src/app/ai/chat/preferences.ts b/src/app/ai/chat/preferences.ts new file mode 100644 index 000000000..a6ba96f6f --- /dev/null +++ b/src/app/ai/chat/preferences.ts @@ -0,0 +1,10 @@ +import { computed } from 'vue' + +import { appPreferences, type ReasoningDisplay } from '@/app/settings/preferences/store' + +export const reasoningDisplay = computed({ + get: () => appPreferences.value.chat.reasoningDisplay, + set: (reasoningDisplay: ReasoningDisplay) => { + appPreferences.value = { ...appPreferences.value, chat: { reasoningDisplay } } + } +}) diff --git a/src/app/ai/chat/submission/use.ts b/src/app/ai/chat/submission/use.ts index 58d451843..afee50bd2 100644 --- a/src/app/ai/chat/submission/use.ts +++ b/src/app/ai/chat/submission/use.ts @@ -29,6 +29,7 @@ interface SubmissionMessages { interface SubmissionOptions { chat: Ref | null> ensureChat: () => Promise | null> + flush?: () => Promise clearFailure: () => void getEditor: () => EditorStore messages: Ref @@ -123,8 +124,8 @@ export function useChatSubmission(options: SubmissionOptions) { isPreparingAttachments.value = submission.images.length > 0 options.clearFailure() try { - const currentChat = options.chat.value ?? (await options.ensureChat()) - if (currentChat) options.chat.value = markRaw(currentChat) + const currentChat = await options.ensureChat() + if (currentChat && version === operationVersion) options.chat.value = markRaw(currentChat) if (!currentChat || version !== operationVersion) { for (const image of submission.images) revokeImagePreviewURL(image.previewURL) if (submission.images.length > 0) options.reportError(options.messages.value.requestFailed) @@ -138,6 +139,7 @@ export function useChatSubmission(options: SubmissionOptions) { } catch (error) { reportSubmissionError(error) } finally { + await options.flush?.().catch(() => undefined) if (version === operationVersion) isPreparingAttachments.value = false } } diff --git a/src/app/ai/chat/transports.ts b/src/app/ai/chat/transports.ts index 277e6375c..a3d08583d 100644 --- a/src/app/ai/chat/transports.ts +++ b/src/app/ai/chat/transports.ts @@ -20,6 +20,8 @@ import { } from '@/app/diagnostics/events' import type { getActiveEditorStore } from '@/app/editor/active-store' +import { resumableTransport } from './history/continuation' + type EditorStore = ReturnType type ChatSessionOptions = { @@ -117,13 +119,15 @@ export function createToolLoopTransport({ } }) - return new DirectChatTransport({ - agent, - onError: (error) => { - onError?.(error) - return 'The provider rejected the request.' - } - }) as ChatTransport + return resumableTransport( + new DirectChatTransport({ + agent, + onError: (error) => { + onError?.(error) + return 'The provider rejected the request.' + } + }) as ChatTransport + ) } export function createChatSessionManager({ @@ -137,10 +141,10 @@ export function createChatSessionManager({ const failure = ref(null) let transportDirty = false let currentChatStore: EditorStore | null = null - let currentChatMessages = new WeakMap() + const currentChatMessages = new WeakMap() let chat: Chat | null = null let acpTransportInstance: { destroy(): Promise } | null = null - let harnessTransportInstance: { destroy(): Promise } | null = null + let harnessTransportInstance: { stop(): Promise } | null = null let overrideTransport: (() => ChatTransport) | null = null let activeProviderError: unknown = null @@ -171,8 +175,6 @@ export function createChatSessionManager({ function markTransportDirty() { transportDirty = true - currentChatStore = null - currentChatMessages = new WeakMap() } async function destroyAgentTransports(): Promise { @@ -180,7 +182,7 @@ export function createChatSessionManager({ const harness = harnessTransportInstance acpTransportInstance = null harnessTransportInstance = null - const results = await Promise.allSettled([acp?.destroy(), harness?.destroy()]) + const results = await Promise.allSettled([acp?.destroy(), harness?.stop()]) const errors = results .filter((result) => result.status === 'rejected') .map((result) => result.reason) @@ -194,18 +196,19 @@ export function createChatSessionManager({ return transport as ChatTransport } - async function createActiveHarnessTransport() { + async function createActiveHarnessTransport(sessionId: string) { await destroyAgentTransports() const runtime = await createAIModelRuntime('design') if (runtime?.kind !== 'harness') throw new Error('The Design agent is not configured for Pi') - const [{ HarnessChatTransport }, { buildPiMCPServers }, { getActiveTabId }] = await Promise.all( - [import('@/app/ai/harness/transport'), import('@/app/integrations/mcp'), import('@/app/tabs')] - ) + const [{ HarnessChatTransport }, { buildPiMCPServers }] = await Promise.all([ + import('@/app/ai/harness/transport'), + import('@/app/integrations/mcp') + ]) const apiKey = await resolveModelConnectionAPIKey(runtime.role.connection.id) if (!apiKey) throw new Error('Credential is unavailable for the Pi agent') const model = runtime.role.profile.customModelID || runtime.role.profile.modelID const transport = new HarnessChatTransport( - `tab-${getActiveTabId()}-${runtime.role.profile.id}`, + sessionId, { adapter: 'pi', sandbox: 'just-bash', @@ -247,7 +250,10 @@ export function createChatSessionManager({ }) } - async function ensureChat(): Promise | null> { + async function ensureChat( + initialMessages?: UIMessage[], + sessionId = crypto.randomUUID() + ): Promise | null> { await credentialsReady if (!isConfigured.value) return null @@ -257,10 +263,10 @@ export function createChatSessionManager({ } if (!chat || transportDirty || currentChatStore !== store) { - const messages = currentChatMessages.get(store) + const messages = initialMessages ?? currentChatMessages.get(store) let transport: ChatTransport if (isACPProvider.value) transport = await createActiveACPTransport() - else if (isHarnessProvider.value) transport = await createActiveHarnessTransport() + else if (isHarnessProvider.value) transport = await createActiveHarnessTransport(sessionId) else transport = await createTransport(store) chat = new Chat({ transport, diff --git a/src/app/ai/chat/use.ts b/src/app/ai/chat/use.ts index a1802e5a6..a2d4c7937 100644 --- a/src/app/ai/chat/use.ts +++ b/src/app/ai/chat/use.ts @@ -2,6 +2,7 @@ import { ref } from 'vue' import { IS_BROWSER } from '@open-pencil/core/constants' +import { createConversationHistory } from '@/app/ai/chat/history/controller' import { apiKeyStatus, browserCredentialsRemembered, @@ -21,6 +22,7 @@ import { setAPIKey } from '@/app/ai/chat/storage' import { createChatSessionManager } from '@/app/ai/chat/transports' +import { designModelProfile } from '@/app/ai/models' import { exposeChatTransportOverride } from '@/app/browser-bridge' import { getActiveEditorStore } from '@/app/editor/active-store' import { @@ -42,6 +44,17 @@ const chatSession = createChatSessionManager({ getActiveEditorStore }) +const history = createConversationHistory({ + profileId: () => designModelProfile.value?.id ?? null, + getEditor: getActiveEditorStore, + ensureChat: chatSession.ensureChat, + resetChat: chatSession.resetChat, + backend: () => { + if (isACPProvider.value) return 'acp' + return isHarnessProvider.value ? 'harness' : 'direct' + } +}) + registerAIChatEffects(chatSession.markTransportDirty) if (IS_BROWSER) { @@ -70,8 +83,9 @@ export function useAIChat() { setUnsplashKey, activeTab, isConfigured, - ensureChat: chatSession.ensureChat, - resetChat: chatSession.resetChat, + history, + ensureChat: history.ensureChat, + resetChat: history.newChat, chatFailure: chatSession.failure, clearChatFailure: chatSession.clearFailure } diff --git a/src/app/ai/harness/transport.ts b/src/app/ai/harness/transport.ts index c45e477b4..2e9dc4241 100644 --- a/src/app/ai/harness/transport.ts +++ b/src/app/ai/harness/transport.ts @@ -70,7 +70,8 @@ export class HarnessChatTransport implements ChatTransport { constructor( private readonly sessionId: string, private readonly configuration: HarnessSessionConfiguration, - private readonly environment: Record + private readonly environment: Record, + private readonly spawnProcess: typeof spawnHarnessProcess = spawnHarnessProcess ) {} async sendMessages({ @@ -135,6 +136,19 @@ export class HarnessChatTransport implements ChatTransport { return null } + async stop(): Promise { + if (this.destroyed) return + try { + if (this.sessionCreated) { + await this.request({ method: 'session.stop', params: { sessionId: this.sessionId } }) + } + } finally { + // Stopping must never delete resume state, including when saving it fails. + this.sessionCreated = false + await this.destroy() + } + } + async destroy(): Promise { if (this.destroyed) return this.destroyed = true @@ -190,7 +204,7 @@ export class HarnessChatTransport implements ChatTransport { if (this.process) return this.process this.destroyed = false const environment = this.environment - const process = await spawnHarnessProcess({ + const process = await this.spawnProcess({ environment, onUnexpectedClose: () => this.failAll(new Error('Harness process exited unexpectedly')) }) diff --git a/src/app/automation/bridge/portless-route.ts b/src/app/automation/bridge/portless-route.ts index 4d952a873..6858037a6 100644 --- a/src/app/automation/bridge/portless-route.ts +++ b/src/app/automation/bridge/portless-route.ts @@ -29,7 +29,7 @@ export function devAutomationRoute( const suffix = appURL.hostname.slice(markerIndex + APP_NAME.length) const mcpHostname = `${prefix}${MCP_SERVICE_NAME}${suffix}` return { - browserURL: `wss://${mcpHostname}`, + browserURL: `wss://${mcpHostname}${appURL.port ? `:${appURL.port}` : ''}`, corsOrigin: appURL.origin, portlessServiceName: MCP_SERVICE_NAME, runtimeId: mcpHostname diff --git a/src/app/settings/preferences/store.ts b/src/app/settings/preferences/store.ts index 2a97d26ad..3710cef51 100644 --- a/src/app/settings/preferences/store.ts +++ b/src/app/settings/preferences/store.ts @@ -2,9 +2,12 @@ import { useLocalStorage } from '@vueuse/core' import { DEFAULT_SNAPPING_PREFERENCES, type SnappingPreferences } from '@open-pencil/core/editor' +export type ReasoningDisplay = 'collapsed' | 'while-thinking' | 'expanded' + export type CanvasRenderingMode = 'retained' | 'tiled' export interface AppPreferences { + chat: { reasoningDisplay: ReasoningDisplay } version: 1 recovery: { enabled: boolean @@ -18,6 +21,7 @@ export interface AppPreferences { } export const DEFAULT_APP_PREFERENCES: Readonly = { + chat: { reasoningDisplay: 'collapsed' }, version: 1, recovery: { enabled: true }, editing: { @@ -39,6 +43,7 @@ interface StoredSnappingPreferences { } interface StoredAppPreferences { + chat?: { reasoningDisplay?: unknown } recovery?: { enabled?: unknown } editing?: { snapping?: StoredSnappingPreferences } rendering?: { canvasMode?: unknown } @@ -53,6 +58,13 @@ function normalizePreferences(value: unknown): AppPreferences { const snapping = stored?.editing?.snapping return { + chat: { + reasoningDisplay: + stored?.chat?.reasoningDisplay === 'expanded' || + stored?.chat?.reasoningDisplay === 'while-thinking' + ? stored.chat.reasoningDisplay + : 'collapsed' + }, version: 1, recovery: { enabled: booleanOrDefault(stored?.recovery?.enabled, DEFAULT_APP_PREFERENCES.recovery.enabled) diff --git a/src/app/storage/idb/database-names.ts b/src/app/storage/idb/database-names.ts index 72ed77280..5e015157f 100644 --- a/src/app/storage/idb/database-names.ts +++ b/src/app/storage/idb/database-names.ts @@ -1,5 +1,6 @@ /** Stable names for the app's independent IndexedDB databases. */ export const APP_DATABASE_NAMES = { + chats: 'open-pencil-chats', credentials: 'open-pencil-credentials', libraries: 'open-pencil-libraries', localCanvas: 'open-pencil-cloud-local', diff --git a/src/components/ChatPanel.vue b/src/components/ChatPanel.vue index 37a90791d..f0ba2ddd1 100644 --- a/src/components/ChatPanel.vue +++ b/src/components/ChatPanel.vue @@ -1,21 +1,18 @@